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 {
2626 connectionStatusTitle,
2727 type EnvironmentConnectionPresentation,
2828} 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";
3034import {
3135 parseScopedThreadKey,
3236 scopedThreadKey,
......@@ -1231,9 +1235,6 @@ function ChatViewContent(props: ChatViewProps) {
12311235 );
12321236 const activeServerThread = serverThread ?? loadingServerThread;
12331237 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1234 const activeThreadLastVisitedAt = useUiStateStore(
1235 (store) => store.threadLastVisitedAtById[routeThreadKey],
1236 );
12371238 const settings = useEnvironmentSettings(environmentId);
12381239 // New-thread defaults live in the primary environment's settings.json (the
12391240 // settings UI never writes to remote environments), so read them from the
......@@ -1850,25 +1851,6 @@ function ChatViewContent(props: ChatViewProps) {
18501851 [openOrReuseProjectDraftThread],
18511852 );
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
18721854 const selectedProviderByThreadId = composerActiveProvider ?? null;
18731855 const threadProvider =
18741856 activeThread?.modelSelection.instanceId ??
......@@ -4006,13 +3988,18 @@ function ChatViewContent(props: ChatViewProps) {
40063988 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
40073989 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
40083990 const nowMinute = useNowMinute();
3991 const snoozeNow = new Date().toISOString();
40093992 const activeThreadSnoozed =
40103993 activeThreadShell !== null &&
40113994 supportsSnooze &&
4012 effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
3995 effectiveSnoozed(activeThreadShell, { now: snoozeNow });
40133996 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
3997 void snoozeWakeTick;
3998 const activeThreadWokeAt =
3999 activeThreadShell !== null && supportsSnooze
4000 ? threadWokeAt(activeThreadShell, { now: snoozeNow })
4001 : null;
40144002 useEffect(() => {
4015 void snoozeWakeTick;
40164003 if (!activeThreadSnoozed) return;
40174004 const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
40184005 if (!Number.isFinite(wakeAtMs)) return;
......@@ -4022,6 +4009,10 @@ function ChatViewContent(props: ChatViewProps) {
40224009 );
40234010 return () => window.clearTimeout(id);
40244011 }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
4012 const acknowledgeActiveThreadWoke = useCallback(() => {
4013 if (activeThreadRef === null || activeThreadWokeAt === null) return;
4014 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
4015 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
40254016 const activeThreadSettled = useMemo(() => {
40264017 if (activeThreadShell === null || !supportsSettlement) return false;
40274018 return effectiveSettled(activeThreadShell, {
......@@ -5063,6 +5054,7 @@ function ChatViewContent(props: ChatViewProps) {
50635054 failure = startResult;
50645055 } else {
50655056 turnStartSucceeded = true;
5057 acknowledgeActiveThreadWoke();
50665058 }
50675059 }
50685060
......@@ -5422,6 +5414,7 @@ function ChatViewContent(props: ChatViewProps) {
54225414 }
54235415
54245416 if (failure === null) {
5417 acknowledgeActiveThreadWoke();
54255418 // Optimistically open the plan sidebar when implementing (not refining).
54265419 // "default" mode here means the agent is executing the plan, which produces
54275420 // step-tracking activities that the sidebar will display.
......@@ -5451,6 +5444,7 @@ function ChatViewContent(props: ChatViewProps) {
54515444 [
54525445 activeThread,
54535446 activeProposedPlan,
5447 acknowledgeActiveThreadWoke,
54545448 beginLocalDispatch,
54555449 isConnecting,
54565450 isSendBusy,
apps/web/src/components/SidebarV2.tsx+92-39
......@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
431431 onUnsettle: (threadRef: ScopedThreadRef) => void;
432432 onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
433433 onUnsnooze: (threadRef: ScopedThreadRef) => void;
434 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
434435 onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
435436}) {
436437 const {
......@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
439440 onCancelRename,
440441 onCommitRename,
441442 onContextMenu,
443 onAcknowledgeWoke,
442444 onRenameTitleChange,
443445 onSettle,
444446 onSnooze,
......@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
542544 className: "text-emerald-700 dark:text-emerald-300",
543545 }
544546 : null;
547 const isWokeStatus = topStatus?.icon === "woke";
545548
546549 const gitCwd = thread.worktreePath ?? props.projectCwd;
547550 const gitStatus = useEnvironmentQuery(
......@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
605608 },
606609 [onThreadClick, threadRef],
607610 );
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 );
608620 const handleContextMenu = useCallback(
609621 (event: ReactMouseEvent) => {
610622 event.preventDefault();
......@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
853865 the time/jump label yields to the settle affordance. */}
854866 {prBadge}
855867 <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 >
857874 {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
858875 // Snoozed rows show when they come BACK, not when they were
859876 // last touched — the return ticket is the row's whole story.
......@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
863880 ) : isWoke ? (
864881 // A wake can land straight in the settled tail (e.g. PR
865882 // 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"
870889 >
871890 <AlarmClockIcon aria-hidden className="size-3" />
872 Woke
873 </span>
891 <span role="status">Woke</span>
892 </button>
874893 ) : (
875894 <span className="text-xs">
876895 {variantAction === "unsettle"
......@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
885904 type="button"
886905 aria-label="Wake thread now"
887906 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 )}
889911 >
890912 <AlarmClockOffIcon className="size-3" />
891913 </button>
......@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
895917 type="button"
896918 aria-label="Un-settle thread"
897919 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 )}
899924 >
900925 <Undo2Icon className="mb-px size-3.5" />
901926 </button>
......@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
904929 type="button"
905930 aria-label="Settle thread"
906931 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 )}
908936 >
909937 <CheckIcon className="size-3" />
910938 </button>
......@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
9721000 the hidden state out of flow lets the project label reclaim
9731001 space without either state overlapping it. */}
9741002 <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. */}
9781006 <span
9791007 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",
9821013 )}
9831014 >
9841015 {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 >
9961027 <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 )
10081053 ) : (
10091054 threadTimeLabel(thread)
10101055 )}
......@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
10171062 // would keep the controls pinned over the status label
10181063 // once the pointer moves away (e.g. after a failed
10191064 // 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",
10221067 )}
10231068 >
10241069 {showSnoozeButton ? (
......@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
12881333 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
12891334 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
12901335 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 );
12911343 const routeTarget = useParams({
12921344 strict: false,
12931345 select: (params) => resolveThreadRouteTarget(params),
......@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
30223074 onUnsettle={attemptUnsettle}
30233075 onSnooze={attemptSnooze}
30243076 onUnsnooze={attemptUnsnooze}
3077 onAcknowledgeWoke={acknowledgeWoke}
30253078 onChangeRequestState={handleChangeRequestState}
30263079 />
30273080 );
apps/web/src/hooks/useThreadActions.ts+18-4
......@@ -2,9 +2,10 @@ import {
22 parseScopedThreadKey,
33 scopeProjectRef,
44 scopeThreadRef,
5 scopedThreadKey,
56} from "@t3tools/client-runtime/environment";
67import { 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";
89import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
910import * as Cause from "effect/Cause";
1011import * as Schema from "effect/Schema";
......@@ -29,6 +30,7 @@ import {
2930 readThreadShell,
3031} from "../state/entities";
3132import { useTerminalUiStateStore } from "../terminalUiStateStore";
33import { useUiStateStore } from "../uiStateStore";
3234import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
3335import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
3436import { stackedThreadToast, toastManager } from "../components/ui/toast";
......@@ -150,6 +152,7 @@ export function useThreadActions() {
150152 (store) => store.clearProjectDraftThreadById,
151153 );
152154 const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState);
155 const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
153156 const router = useRouter();
154157 const handleNewThread = useNewThreadHandler();
155158 // Keep a ref so archiveThread can call handleNewThread without appearing in
......@@ -201,6 +204,10 @@ export function useThreadActions() {
201204 if (archiveResult._tag === "Failure") {
202205 return archiveResult;
203206 }
207 const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() });
208 if (wokeAt !== null) {
209 markThreadVisited(scopedThreadKey(threadRef), wokeAt);
210 }
204211 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
205212 opts.onArchived?.();
206213
......@@ -216,7 +223,7 @@ export function useThreadActions() {
216223
217224 return archiveResult;
218225 },
219 [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget],
226 [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget],
220227 );
221228
222229 const unarchiveThread = useCallback(
......@@ -459,14 +466,21 @@ export function useThreadActions() {
459466 ),
460467 );
461468 }
469 const wokeAt = resolved
470 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })
471 : null;
462472 // Settle is a high-frequency lifecycle action and stays silent — no
463473 // toast.
464 return settleThreadMutation({
474 const result = await settleThreadMutation({
465475 environmentId: target.environmentId,
466476 input: { threadId: target.threadId },
467477 });
478 if (result._tag === "Success" && wokeAt !== null) {
479 markThreadVisited(scopedThreadKey(target), wokeAt);
480 }
481 return result;
468482 },
469 [resolveThreadTarget, settleThreadMutation],
483 [markThreadVisited, resolveThreadTarget, settleThreadMutation],
470484 );
471485
472486 const unsettleThread = useCallback(