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