authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-06 20:08:35+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 14:08:35-04:00
log4f5834ba72c5905a318c00456dd21271b2fa9d6f
tree5cfb1fbd93415b50cb6dcbea5704a6605e3b8428
parentaa16c180e5850ab94c022d3e41e18350c2117deb
signature Signed by PGP key B5690EEEBB952194

fix(web): clear woke state on explicit thread actions (#5486)


3 files changed, 129 insertions(+), 68 deletions(-)

apps/web/src/components/ChatView.tsx+19-25
...@@ -26,7 +26,11 @@ import {...@@ -26,7 +26,11 @@ import {
26 connectionStatusTitle,26 connectionStatusTitle,
27 type EnvironmentConnectionPresentation,27 type EnvironmentConnectionPresentation,
28} from "@t3tools/client-runtime/connection";28} from "@t3tools/client-runtime/connection";
29import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";29import {
30 effectiveSettled,
31 effectiveSnoozed,
32 threadWokeAt,
33} from "@t3tools/client-runtime/state/thread-settled";
30import {34import {
31 parseScopedThreadKey,35 parseScopedThreadKey,
32 scopedThreadKey,36 scopedThreadKey,
...@@ -1231,9 +1235,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1231,9 +1235,6 @@ function ChatViewContent(props: ChatViewProps) {
1231 );1235 );
1232 const activeServerThread = serverThread ?? loadingServerThread;1236 const activeServerThread = serverThread ?? loadingServerThread;
1233 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);1237 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1234 const activeThreadLastVisitedAt = useUiStateStore(
1235 (store) => store.threadLastVisitedAtById[routeThreadKey],
1236 );
1237 const settings = useEnvironmentSettings(environmentId);1238 const settings = useEnvironmentSettings(environmentId);
1238 // New-thread defaults live in the primary environment's settings.json (the1239 // New-thread defaults live in the primary environment's settings.json (the
1239 // settings UI never writes to remote environments), so read them from the1240 // settings UI never writes to remote environments), so read them from the
...@@ -1850,25 +1851,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1850,25 +1851,6 @@ function ChatViewContent(props: ChatViewProps) {
1850 [openOrReuseProjectDraftThread],1851 [openOrReuseProjectDraftThread],
1851 );1852 );
18521853
1853 useEffect(() => {
1854 if (!serverThread?.id) return;
1855 const threadUpdatedAt = Date.parse(serverThread.updatedAt);
1856 if (Number.isNaN(threadUpdatedAt)) return;
1857 const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
1858 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;
1859
1860 markThreadVisited(
1861 scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
1862 serverThread.updatedAt,
1863 );
1864 }, [
1865 activeThreadLastVisitedAt,
1866 markThreadVisited,
1867 serverThread?.environmentId,
1868 serverThread?.id,
1869 serverThread?.updatedAt,
1870 ]);
1871
1872 const selectedProviderByThreadId = composerActiveProvider ?? null;1854 const selectedProviderByThreadId = composerActiveProvider ?? null;
1873 const threadProvider =1855 const threadProvider =
1874 activeThread?.modelSelection.instanceId ??1856 activeThread?.modelSelection.instanceId ??
...@@ -4006,13 +3988,18 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4006,13 +3988,18 @@ function ChatViewContent(props: ChatViewProps) {
4006 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;3988 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
4007 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;3989 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
4008 const nowMinute = useNowMinute();3990 const nowMinute = useNowMinute();
3991 const snoozeNow = new Date().toISOString();
4009 const activeThreadSnoozed =3992 const activeThreadSnoozed =
4010 activeThreadShell !== null &&3993 activeThreadShell !== null &&
4011 supportsSnooze &&3994 supportsSnooze &&
4012 effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });3995 effectiveSnoozed(activeThreadShell, { now: snoozeNow });
4013 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);3996 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
3997 void snoozeWakeTick;
3998 const activeThreadWokeAt =
3999 activeThreadShell !== null && supportsSnooze
4000 ? threadWokeAt(activeThreadShell, { now: snoozeNow })
4001 : null;
4014 useEffect(() => {4002 useEffect(() => {
4015 void snoozeWakeTick;
4016 if (!activeThreadSnoozed) return;4003 if (!activeThreadSnoozed) return;
4017 const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");4004 const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
4018 if (!Number.isFinite(wakeAtMs)) return;4005 if (!Number.isFinite(wakeAtMs)) return;
...@@ -4022,6 +4009,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4022,6 +4009,10 @@ function ChatViewContent(props: ChatViewProps) {
4022 );4009 );
4023 return () => window.clearTimeout(id);4010 return () => window.clearTimeout(id);
4024 }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);4011 }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
4012 const acknowledgeActiveThreadWoke = useCallback(() => {
4013 if (activeThreadRef === null || activeThreadWokeAt === null) return;
4014 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
4015 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
4025 const activeThreadSettled = useMemo(() => {4016 const activeThreadSettled = useMemo(() => {
4026 if (activeThreadShell === null || !supportsSettlement) return false;4017 if (activeThreadShell === null || !supportsSettlement) return false;
4027 return effectiveSettled(activeThreadShell, {4018 return effectiveSettled(activeThreadShell, {
...@@ -5063,6 +5054,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5063,6 +5054,7 @@ function ChatViewContent(props: ChatViewProps) {
5063 failure = startResult;5054 failure = startResult;
5064 } else {5055 } else {
5065 turnStartSucceeded = true;5056 turnStartSucceeded = true;
5057 acknowledgeActiveThreadWoke();
5066 }5058 }
5067 }5059 }
50685060
...@@ -5422,6 +5414,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5422,6 +5414,7 @@ function ChatViewContent(props: ChatViewProps) {
5422 }5414 }
54235415
5424 if (failure === null) {5416 if (failure === null) {
5417 acknowledgeActiveThreadWoke();
5425 // Optimistically open the plan sidebar when implementing (not refining).5418 // Optimistically open the plan sidebar when implementing (not refining).
5426 // "default" mode here means the agent is executing the plan, which produces5419 // "default" mode here means the agent is executing the plan, which produces
5427 // step-tracking activities that the sidebar will display.5420 // step-tracking activities that the sidebar will display.
...@@ -5451,6 +5444,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5451,6 +5444,7 @@ function ChatViewContent(props: ChatViewProps) {
5451 [5444 [
5452 activeThread,5445 activeThread,
5453 activeProposedPlan,5446 activeProposedPlan,
5447 acknowledgeActiveThreadWoke,
5454 beginLocalDispatch,5448 beginLocalDispatch,
5455 isConnecting,5449 isConnecting,
5456 isSendBusy,5450 isSendBusy,
apps/web/src/components/SidebarV2.tsx+92-39
...@@ -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";
545548
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 were875 // 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. PR881 // 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 <span883 <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 Woke891 <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 reclaim1000 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 absolute1003 {/* Read-only status labels yield to the hover actions. Woke is
976 + opacity-0, which paints it ABOVE the in-flow settle/snooze1004 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 <span1006 <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 <span1016 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 make1030 ) : (
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 label1062 // would keep the controls pinned over the status label
1018 // once the pointer moves away (e.g. after a failed1063 // 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 );
apps/web/src/hooks/useThreadActions.ts+18-4
...@@ -2,9 +2,10 @@ import {...@@ -2,9 +2,10 @@ import {
2 parseScopedThreadKey,2 parseScopedThreadKey,
3 scopeProjectRef,3 scopeProjectRef,
4 scopeThreadRef,4 scopeThreadRef,
5 scopedThreadKey,
5} from "@t3tools/client-runtime/environment";6} from "@t3tools/client-runtime/environment";
6import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
7import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled";8import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
8import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";9import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";10import * as Cause from "effect/Cause";
10import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
...@@ -29,6 +30,7 @@ import {...@@ -29,6 +30,7 @@ import {
29 readThreadShell,30 readThreadShell,
30} from "../state/entities";31} from "../state/entities";
31import { useTerminalUiStateStore } from "../terminalUiStateStore";32import { useTerminalUiStateStore } from "../terminalUiStateStore";
33import { useUiStateStore } from "../uiStateStore";
32import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";34import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
33import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";35import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
34import { stackedThreadToast, toastManager } from "../components/ui/toast";36import { stackedThreadToast, toastManager } from "../components/ui/toast";
...@@ -150,6 +152,7 @@ export function useThreadActions() {...@@ -150,6 +152,7 @@ export function useThreadActions() {
150 (store) => store.clearProjectDraftThreadById,152 (store) => store.clearProjectDraftThreadById,
151 );153 );
152 const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState);154 const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState);
155 const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
153 const router = useRouter();156 const router = useRouter();
154 const handleNewThread = useNewThreadHandler();157 const handleNewThread = useNewThreadHandler();
155 // Keep a ref so archiveThread can call handleNewThread without appearing in158 // Keep a ref so archiveThread can call handleNewThread without appearing in
...@@ -201,6 +204,10 @@ export function useThreadActions() {...@@ -201,6 +204,10 @@ export function useThreadActions() {
201 if (archiveResult._tag === "Failure") {204 if (archiveResult._tag === "Failure") {
202 return archiveResult;205 return archiveResult;
203 }206 }
207 const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() });
208 if (wokeAt !== null) {
209 markThreadVisited(scopedThreadKey(threadRef), wokeAt);
210 }
204 refreshArchivedThreadsForEnvironment(threadRef.environmentId);211 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
205 opts.onArchived?.();212 opts.onArchived?.();
206213
...@@ -216,7 +223,7 @@ export function useThreadActions() {...@@ -216,7 +223,7 @@ export function useThreadActions() {
216223
217 return archiveResult;224 return archiveResult;
218 },225 },
219 [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget],226 [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget],
220 );227 );
221228
222 const unarchiveThread = useCallback(229 const unarchiveThread = useCallback(
...@@ -459,14 +466,21 @@ export function useThreadActions() {...@@ -459,14 +466,21 @@ export function useThreadActions() {
459 ),466 ),
460 );467 );
461 }468 }
469 const wokeAt = resolved
470 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })
471 : null;
462 // Settle is a high-frequency lifecycle action and stays silent — no472 // Settle is a high-frequency lifecycle action and stays silent — no
463 // toast.473 // toast.
464 return settleThreadMutation({474 const result = await settleThreadMutation({
465 environmentId: target.environmentId,475 environmentId: target.environmentId,
466 input: { threadId: target.threadId },476 input: { threadId: target.threadId },
467 });477 });
478 if (result._tag === "Success" && wokeAt !== null) {
479 markThreadVisited(scopedThreadKey(target), wokeAt);
480 }
481 return result;
468 },482 },
469 [resolveThreadTarget, settleThreadMutation],483 [markThreadVisited, resolveThreadTarget, settleThreadMutation],
470 );484 );
471485
472 const unsettleThread = useCallback(486 const unsettleThread = useCallback(