From 7f3bbd1ddda3848c07df5627051d9f264bfac114 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:32:34 +0200 Subject: [PATCH 1/4] fix(web): clear woke state when threads are visited --- .../web/src/components/ChatView.logic.test.ts | 27 +++++ apps/web/src/components/ChatView.logic.ts | 17 +++ apps/web/src/components/ChatView.tsx | 60 ++++++---- apps/web/src/components/SidebarV2.tsx | 105 ++++++++++++------ apps/web/src/hooks/useThreadActions.ts | 11 +- 5 files changed, 163 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1a..527e239ccbc 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -25,6 +25,7 @@ import { reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, + resolveThreadVisitedAt, resolveSendEnvMode, startNewThreadForProject, shouldShowBranchMismatchBanner, @@ -36,6 +37,32 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("resolveThreadVisitedAt", () => { + it("uses the wake timestamp when it is newer than the thread projection", () => { + expect( + resolveThreadVisitedAt({ + threadUpdatedAt: "2026-03-29T00:00:00.000Z", + wokeAt: "2026-03-29T01:00:00.000Z", + }), + ).toBe("2026-03-29T01:00:00.000Z"); + }); + + it("keeps the latest valid thread timestamp otherwise", () => { + expect( + resolveThreadVisitedAt({ + threadUpdatedAt: "2026-03-29T01:00:00.000Z", + wokeAt: "2026-03-29T00:00:00.000Z", + }), + ).toBe("2026-03-29T01:00:00.000Z"); + expect( + resolveThreadVisitedAt({ + threadUpdatedAt: "2026-03-29T01:00:00.000Z", + wokeAt: "not-a-date", + }), + ).toBe("2026-03-29T01:00:00.000Z"); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd4551..94e2b8fa33c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -38,6 +38,23 @@ export function startNewThreadForProject( return true; } +/** + * Visits use server-backed timestamps so local clock skew cannot clear unread + * state. A completed snooze wake is also server-backed and may be newer than + * the thread projection's updatedAt. + */ +export function resolveThreadVisitedAt(input: { + readonly threadUpdatedAt: string; + readonly wokeAt: string | null; +}): string { + if (input.wokeAt === null) return input.threadUpdatedAt; + const threadUpdatedAtMs = Date.parse(input.threadUpdatedAt); + const wokeAtMs = Date.parse(input.wokeAt); + if (Number.isNaN(wokeAtMs)) return input.threadUpdatedAt; + if (Number.isNaN(threadUpdatedAtMs) || wokeAtMs > threadUpdatedAtMs) return input.wokeAt; + return input.threadUpdatedAt; +} + export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9621f5f1674..ab3e24c70e1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,7 +26,11 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -283,6 +287,7 @@ import { readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, + resolveThreadVisitedAt, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -1851,25 +1856,6 @@ function ChatViewContent(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLastVisitedAt, - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.updatedAt, - ]); - const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -3997,13 +3983,18 @@ function ChatViewContent(props: ChatViewProps) { const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); + const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = activeThreadShell !== null && supportsSnooze && - effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + effectiveSnoozed(activeThreadShell, { now: snoozeNow }); const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + void snoozeWakeTick; + const activeThreadWokeAt = + activeThreadShell !== null && supportsSnooze + ? threadWokeAt(activeThreadShell, { now: snoozeNow }) + : null; useEffect(() => { - void snoozeWakeTick; if (!activeThreadSnoozed) return; const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); if (!Number.isFinite(wakeAtMs)) return; @@ -4013,6 +4004,31 @@ function ChatViewContent(props: ChatViewProps) { ); return () => window.clearTimeout(id); }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); + useEffect(() => { + if (!serverThread?.id) return; + const visitedAt = resolveThreadVisitedAt({ + threadUpdatedAt: serverThread.updatedAt, + wokeAt: activeThreadWokeAt, + }); + const visitedAtMs = Date.parse(visitedAt); + if (Number.isNaN(visitedAtMs)) return; + const lastVisitedAtMs = activeThreadLastVisitedAt + ? Date.parse(activeThreadLastVisitedAt) + : Number.NaN; + if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= visitedAtMs) return; + + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + visitedAt, + ); + }, [ + activeThreadLastVisitedAt, + activeThreadWokeAt, + markThreadVisited, + serverThread?.environmentId, + serverThread?.id, + serverThread?.updatedAt, + ]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 1444d72e60c..896cf1a75c4 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { @@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onCancelRename, onCommitRename, onContextMenu, + onAcknowledgeWoke, onRenameTitleChange, onSettle, onSnooze, @@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { className: "text-emerald-700 dark:text-emerald-300", } : null; + const isWokeStatus = topStatus?.icon === "woke"; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onThreadClick, threadRef], ); + const handleAcknowledgeWokeClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (props.wokeAt === null) return; + onAcknowledgeWoke(threadRef, props.wokeAt); + }, + [onAcknowledgeWoke, props.wokeAt, threadRef], + ); const handleContextMenu = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -863,14 +875,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : isWoke ? ( // A wake can land straight in the settled tail (e.g. PR // merged while snoozed); the signal must survive the trip. - - Woke - + Woke + ) : ( {variantAction === "unsettle" @@ -972,39 +986,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { the hidden state out of flow lets the project label reclaim space without either state overlapping it. */} - {/* pointer-events-none: while hovered this label is absolute - + opacity-0, which paints it ABOVE the in-flow settle/snooze - buttons; without it the invisible label eats their clicks. */} + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} {topStatus ? ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : topStatus.icon === "woke" ? ( + isWokeStatus ? ( + + ) : ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) ) : ( threadTimeLabel(thread) )} @@ -1288,6 +1319,13 @@ export default function SidebarV2() { const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); + const acknowledgeWoke = useCallback( + (threadRef: ScopedThreadRef, visitedAt: string) => { + markThreadVisited(scopedThreadKey(threadRef), visitedAt); + }, + [markThreadVisited], + ); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), @@ -3022,6 +3060,7 @@ export default function SidebarV2() { onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} + onAcknowledgeWoke={acknowledgeWoke} onChangeRequestState={handleChangeRequestState} /> ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 96ec645551a..87bf4680b44 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -2,9 +2,10 @@ import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef, + scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -29,6 +30,7 @@ import { readThreadShell, } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useUiStateStore } from "../uiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -150,6 +152,7 @@ export function useThreadActions() { (store) => store.clearProjectDraftThreadById, ); const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); + const markThreadVisited = useUiStateStore((state) => state.markThreadVisited); const router = useRouter(); const handleNewThread = useNewThreadHandler(); // Keep a ref so archiveThread can call handleNewThread without appearing in @@ -201,6 +204,10 @@ export function useThreadActions() { if (archiveResult._tag === "Failure") { return archiveResult; } + const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() }); + if (wokeAt !== null) { + markThreadVisited(scopedThreadKey(threadRef), wokeAt); + } refreshArchivedThreadsForEnvironment(threadRef.environmentId); opts.onArchived?.(); @@ -216,7 +223,7 @@ export function useThreadActions() { return archiveResult; }, - [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], + [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget], ); const unarchiveThread = useCallback( From e9c57ee9a068a3799fccdb2691c53855ac2669b9 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:44:18 +0200 Subject: [PATCH 2/4] fix(web): acknowledge woke on explicit thread actions --- .../web/src/components/ChatView.logic.test.ts | 27 -------------- apps/web/src/components/ChatView.logic.ts | 17 --------- apps/web/src/components/ChatView.tsx | 36 ++++--------------- apps/web/src/hooks/useThreadActions.ts | 11 ++++-- 4 files changed, 16 insertions(+), 75 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 527e239ccbc..39285438d1a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -25,7 +25,6 @@ import { reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, - resolveThreadVisitedAt, resolveSendEnvMode, startNewThreadForProject, shouldShowBranchMismatchBanner, @@ -37,32 +36,6 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; -describe("resolveThreadVisitedAt", () => { - it("uses the wake timestamp when it is newer than the thread projection", () => { - expect( - resolveThreadVisitedAt({ - threadUpdatedAt: "2026-03-29T00:00:00.000Z", - wokeAt: "2026-03-29T01:00:00.000Z", - }), - ).toBe("2026-03-29T01:00:00.000Z"); - }); - - it("keeps the latest valid thread timestamp otherwise", () => { - expect( - resolveThreadVisitedAt({ - threadUpdatedAt: "2026-03-29T01:00:00.000Z", - wokeAt: "2026-03-29T00:00:00.000Z", - }), - ).toBe("2026-03-29T01:00:00.000Z"); - expect( - resolveThreadVisitedAt({ - threadUpdatedAt: "2026-03-29T01:00:00.000Z", - wokeAt: "not-a-date", - }), - ).toBe("2026-03-29T01:00:00.000Z"); - }); -}); - function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 94e2b8fa33c..04b35fd4551 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -38,23 +38,6 @@ export function startNewThreadForProject( return true; } -/** - * Visits use server-backed timestamps so local clock skew cannot clear unread - * state. A completed snooze wake is also server-backed and may be newer than - * the thread projection's updatedAt. - */ -export function resolveThreadVisitedAt(input: { - readonly threadUpdatedAt: string; - readonly wokeAt: string | null; -}): string { - if (input.wokeAt === null) return input.threadUpdatedAt; - const threadUpdatedAtMs = Date.parse(input.threadUpdatedAt); - const wokeAtMs = Date.parse(input.wokeAt); - if (Number.isNaN(wokeAtMs)) return input.threadUpdatedAt; - if (Number.isNaN(threadUpdatedAtMs) || wokeAtMs > threadUpdatedAtMs) return input.wokeAt; - return input.threadUpdatedAt; -} - export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab3e24c70e1..c12e2a1ea48 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -287,7 +287,6 @@ import { readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, - resolveThreadVisitedAt, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -1237,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) { ); const activeServerThread = serverThread ?? loadingServerThread; const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[routeThreadKey], - ); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the @@ -4004,31 +4000,10 @@ function ChatViewContent(props: ChatViewProps) { ); return () => window.clearTimeout(id); }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); - useEffect(() => { - if (!serverThread?.id) return; - const visitedAt = resolveThreadVisitedAt({ - threadUpdatedAt: serverThread.updatedAt, - wokeAt: activeThreadWokeAt, - }); - const visitedAtMs = Date.parse(visitedAt); - if (Number.isNaN(visitedAtMs)) return; - const lastVisitedAtMs = activeThreadLastVisitedAt - ? Date.parse(activeThreadLastVisitedAt) - : Number.NaN; - if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= visitedAtMs) return; - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - visitedAt, - ); - }, [ - activeThreadLastVisitedAt, - activeThreadWokeAt, - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.updatedAt, - ]); + const acknowledgeActiveThreadWoke = useCallback(() => { + if (activeThreadRef === null || activeThreadWokeAt === null) return; + markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); + }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { @@ -5070,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + acknowledgeActiveThreadWoke(); } } @@ -5429,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { + acknowledgeActiveThreadWoke(); // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces // step-tracking activities that the sidebar will display. @@ -5458,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) { [ activeThread, activeProposedPlan, + acknowledgeActiveThreadWoke, beginLocalDispatch, isConnecting, isSendBusy, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 87bf4680b44..e47fce1d3bc 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -466,14 +466,21 @@ export function useThreadActions() { ), ); } + const wokeAt = resolved + ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) + : null; // Settle is a high-frequency lifecycle action and stays silent — no // toast. - return settleThreadMutation({ + const result = await settleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); + if (result._tag === "Success" && wokeAt !== null) { + markThreadVisited(scopedThreadKey(target), wokeAt); + } + return result; }, - [resolveThreadTarget, settleThreadMutation], + [markThreadVisited, resolveThreadTarget, settleThreadMutation], ); const unsettleThread = useCallback( From 0924788a0a4e67d68a298fe353f286121c104777 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:09:27 +0200 Subject: [PATCH 3/4] fix(web): keep slim woke action clickable --- apps/web/src/components/SidebarV2.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 896cf1a75c4..f65db9d3d9e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -865,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { the time/jump label yields to the settle affordance. */} {prBadge} - + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( // Snoozed rows show when they come BACK, not when they were // last touched — the return ticket is the row's whole story. @@ -899,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Wake thread now" onClick={handleUnsnoozeClick} - 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" + className={cn( + "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", + isWokeStatus && "group-hover/v2-row:static", + )} > @@ -909,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Un-settle thread" onClick={handleUnsettleClick} - 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" + className={cn( + "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", + isWokeStatus && "group-hover/v2-row:static", + )} > @@ -918,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - 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" + className={cn( + "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", + isWokeStatus && "group-hover/v2-row:static", + )} > From 05c6acde84355133b4584fb2964d8b699d14c2a4 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:21:37 +0200 Subject: [PATCH 4/4] fix(web): keep woke dismiss controls clickable --- apps/web/src/components/SidebarV2.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f65db9d3d9e..462bfde13b7 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -868,7 +868,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( @@ -905,8 +905,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { aria-label="Wake thread now" onClick={handleUnsnoozeClick} className={cn( - "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", - isWokeStatus && "group-hover/v2-row:static", + "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", + isWoke && "group-hover/v2-row:static", )} > @@ -918,8 +918,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { aria-label="Un-settle thread" onClick={handleUnsettleClick} className={cn( - "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", - isWokeStatus && "group-hover/v2-row:static", + "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", + isWoke && "group-hover/v2-row:static", )} > @@ -930,8 +930,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { aria-label="Settle thread" onClick={handleSettleClick} className={cn( - "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", - isWokeStatus && "group-hover/v2-row:static", + "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", + isWoke && "group-hover/v2-row:static", )} > @@ -1062,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // would keep the controls pinned over the status label // once the pointer moves away (e.g. after a failed // settle) instead of cross-fading back. - "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", - snoozeMenuOpen && "static opacity-100", + "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", + snoozeMenuOpen && "pointer-events-auto static opacity-100", )} > {showSnoozeButton ? (