| ... | ... | @@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 431 | 431 | onUnsettle: (threadRef: ScopedThreadRef) => void; |
| 432 | 432 | onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; |
| 433 | 433 | onUnsnooze: (threadRef: ScopedThreadRef) => void; |
| 434 | onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; |
| 434 | 435 | onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; |
| 435 | 436 | }) { |
| 436 | 437 | const { |
| ... | ... | @@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 439 | 440 | onCancelRename, |
| 440 | 441 | onCommitRename, |
| 441 | 442 | onContextMenu, |
| 443 | onAcknowledgeWoke, |
| 442 | 444 | onRenameTitleChange, |
| 443 | 445 | onSettle, |
| 444 | 446 | onSnooze, |
| ... | ... | @@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 542 | 544 | className: "text-emerald-700 dark:text-emerald-300", |
| 543 | 545 | } |
| 544 | 546 | : null; |
| 547 | const isWokeStatus = topStatus?.icon === "woke"; |
| 545 | 548 | |
| 546 | 549 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 547 | 550 | const gitStatus = useEnvironmentQuery( |
| ... | ... | @@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 605 | 608 | }, |
| 606 | 609 | [onThreadClick, threadRef], |
| 607 | 610 | ); |
| 611 | const handleAcknowledgeWokeClick = useCallback( |
| 612 | (event: ReactMouseEvent) => { |
| 613 | event.preventDefault(); |
| 614 | event.stopPropagation(); |
| 615 | if (props.wokeAt === null) return; |
| 616 | onAcknowledgeWoke(threadRef, props.wokeAt); |
| 617 | }, |
| 618 | [onAcknowledgeWoke, props.wokeAt, threadRef], |
| 619 | ); |
| 608 | 620 | const handleContextMenu = useCallback( |
| 609 | 621 | (event: ReactMouseEvent) => { |
| 610 | 622 | event.preventDefault(); |
| ... | ... | @@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 853 | 865 | the time/jump label yields to the settle affordance. */} |
| 854 | 866 | {prBadge} |
| 855 | 867 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> |
| 856 | | <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0"> |
| 868 | <span |
| 869 | className={cn( |
| 870 | "inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity", |
| 871 | !isWoke && "group-hover/v2-row:opacity-0", |
| 872 | )} |
| 873 | > |
| 857 | 874 | {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( |
| 858 | 875 | // Snoozed rows show when they come BACK, not when they were |
| 859 | 876 | // last touched — the return ticket is the row's whole story. |
| ... | ... | @@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 863 | 880 | ) : isWoke ? ( |
| 864 | 881 | // A wake can land straight in the settled tail (e.g. PR |
| 865 | 882 | // merged while snoozed); the signal must survive the trip. |
| 866 | | <span |
| 867 | | role="status" |
| 868 | | aria-label="Woke from snooze" |
| 869 | | className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300" |
| 883 | <button |
| 884 | type="button" |
| 885 | aria-label="Dismiss Woke notification" |
| 886 | title="Dismiss Woke notification" |
| 887 | onClick={handleAcknowledgeWokeClick} |
| 888 | className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300" |
| 870 | 889 | > |
| 871 | 890 | <AlarmClockIcon aria-hidden className="size-3" /> |
| 872 | | Woke |
| 873 | | </span> |
| 891 | <span role="status">Woke</span> |
| 892 | </button> |
| 874 | 893 | ) : ( |
| 875 | 894 | <span className="text-xs"> |
| 876 | 895 | {variantAction === "unsettle" |
| ... | ... | @@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 885 | 904 | type="button" |
| 886 | 905 | aria-label="Wake thread now" |
| 887 | 906 | onClick={handleUnsnoozeClick} |
| 888 | | className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" |
| 907 | className={cn( |
| 908 | "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", |
| 909 | isWoke && "group-hover/v2-row:static", |
| 910 | )} |
| 889 | 911 | > |
| 890 | 912 | <AlarmClockOffIcon className="size-3" /> |
| 891 | 913 | </button> |
| ... | ... | @@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 895 | 917 | type="button" |
| 896 | 918 | aria-label="Un-settle thread" |
| 897 | 919 | onClick={handleUnsettleClick} |
| 898 | | className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" |
| 920 | className={cn( |
| 921 | "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", |
| 922 | isWoke && "group-hover/v2-row:static", |
| 923 | )} |
| 899 | 924 | > |
| 900 | 925 | <Undo2Icon className="mb-px size-3.5" /> |
| 901 | 926 | </button> |
| ... | ... | @@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 904 | 929 | type="button" |
| 905 | 930 | aria-label="Settle thread" |
| 906 | 931 | onClick={handleSettleClick} |
| 907 | | className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" |
| 932 | className={cn( |
| 933 | "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", |
| 934 | isWoke && "group-hover/v2-row:static", |
| 935 | )} |
| 908 | 936 | > |
| 909 | 937 | <CheckIcon className="size-3" /> |
| 910 | 938 | </button> |
| ... | ... | @@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 972 | 1000 | the hidden state out of flow lets the project label reclaim |
| 973 | 1001 | space without either state overlapping it. */} |
| 974 | 1002 | <span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs"> |
| 975 | | {/* pointer-events-none: while hovered this label is absolute |
| 976 | | + opacity-0, which paints it ABOVE the in-flow settle/snooze |
| 977 | | buttons; without it the invisible label eats their clicks. */} |
| 1003 | {/* Read-only status labels yield to the hover actions. Woke is |
| 1004 | itself an action, so it stays pointer-enabled and visible |
| 1005 | while the other controls appear beside it. */} |
| 978 | 1006 | <span |
| 979 | 1007 | className={cn( |
| 980 | | "pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0", |
| 981 | | snoozeMenuOpen && "absolute right-0 opacity-0", |
| 1008 | isWokeStatus |
| 1009 | ? "pointer-events-auto" |
| 1010 | : "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0", |
| 1011 | "self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity", |
| 1012 | snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", |
| 982 | 1013 | )} |
| 983 | 1014 | > |
| 984 | 1015 | {topStatus ? ( |
| 985 | | <span |
| 986 | | className={cn( |
| 987 | | "inline-flex items-center gap-1 font-medium", |
| 988 | | topStatus.className, |
| 989 | | )} |
| 990 | | > |
| 991 | | {topStatus.icon === "working" ? ( |
| 992 | | <CircleDashedIcon aria-hidden className="size-4 shrink-0" /> |
| 993 | | ) : topStatus.icon === "done" ? ( |
| 994 | | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> |
| 995 | | ) : topStatus.icon === "woke" ? ( |
| 1016 | isWokeStatus ? ( |
| 1017 | <button |
| 1018 | type="button" |
| 1019 | aria-label="Dismiss Woke notification" |
| 1020 | title="Dismiss Woke notification" |
| 1021 | onClick={handleAcknowledgeWokeClick} |
| 1022 | className={cn( |
| 1023 | "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", |
| 1024 | topStatus.className, |
| 1025 | )} |
| 1026 | > |
| 996 | 1027 | <AlarmClockIcon aria-hidden className="size-4 shrink-0" /> |
| 997 | | ) : null} |
| 998 | | {/* The label alone is the live region: a role="status" |
| 999 | | wrapper around the ticking duration would make |
| 1000 | | screen readers announce every second. */} |
| 1001 | | <span role="status">{topStatus.label}</span> |
| 1002 | | {status === "working" ? ( |
| 1003 | | <span aria-hidden> |
| 1004 | | <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} /> |
| 1005 | | </span> |
| 1006 | | ) : null} |
| 1007 | | </span> |
| 1028 | <span role="status">{topStatus.label}</span> |
| 1029 | </button> |
| 1030 | ) : ( |
| 1031 | <span |
| 1032 | className={cn( |
| 1033 | "inline-flex items-center gap-1 font-medium", |
| 1034 | topStatus.className, |
| 1035 | )} |
| 1036 | > |
| 1037 | {topStatus.icon === "working" ? ( |
| 1038 | <CircleDashedIcon aria-hidden className="size-4 shrink-0" /> |
| 1039 | ) : topStatus.icon === "done" ? ( |
| 1040 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> |
| 1041 | ) : null} |
| 1042 | {/* The label alone is the live region: a role="status" |
| 1043 | wrapper around the ticking duration would make |
| 1044 | screen readers announce every second. */} |
| 1045 | <span role="status">{topStatus.label}</span> |
| 1046 | {status === "working" ? ( |
| 1047 | <span aria-hidden> |
| 1048 | <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} /> |
| 1049 | </span> |
| 1050 | ) : null} |
| 1051 | </span> |
| 1052 | ) |
| 1008 | 1053 | ) : ( |
| 1009 | 1054 | threadTimeLabel(thread) |
| 1010 | 1055 | )} |
| ... | ... | @@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 1017 | 1062 | // would keep the controls pinned over the status label |
| 1018 | 1063 | // once the pointer moves away (e.g. after a failed |
| 1019 | 1064 | // settle) instead of cross-fading back. |
| 1020 | | "absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100", |
| 1021 | | snoozeMenuOpen && "static opacity-100", |
| 1065 | "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100", |
| 1066 | snoozeMenuOpen && "pointer-events-auto static opacity-100", |
| 1022 | 1067 | )} |
| 1023 | 1068 | > |
| 1024 | 1069 | {showSnoozeButton ? ( |
| ... | ... | @@ -1288,6 +1333,13 @@ export default function SidebarV2() { |
| 1288 | 1333 | const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); |
| 1289 | 1334 | const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); |
| 1290 | 1335 | const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); |
| 1336 | const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); |
| 1337 | const acknowledgeWoke = useCallback( |
| 1338 | (threadRef: ScopedThreadRef, visitedAt: string) => { |
| 1339 | markThreadVisited(scopedThreadKey(threadRef), visitedAt); |
| 1340 | }, |
| 1341 | [markThreadVisited], |
| 1342 | ); |
| 1291 | 1343 | const routeTarget = useParams({ |
| 1292 | 1344 | strict: false, |
| 1293 | 1345 | select: (params) => resolveThreadRouteTarget(params), |
| ... | ... | @@ -3022,6 +3074,7 @@ export default function SidebarV2() { |
| 3022 | 3074 | onUnsettle={attemptUnsettle} |
| 3023 | 3075 | onSnooze={attemptSnooze} |
| 3024 | 3076 | onUnsnooze={attemptUnsnooze} |
| 3077 | onAcknowledgeWoke={acknowledgeWoke} |
| 3025 | 3078 | onChangeRequestState={handleChangeRequestState} |
| 3026 | 3079 | /> |
| 3027 | 3080 | ); |