From b41a39802bbbef02246e8c174de6b1f0e6abad74 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 00:26:39 -0700 Subject: [PATCH] fix(web): reading a thread clears Done; Woke is dismissible Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 76 ++++++++++++++++++++++++++- apps/web/src/components/SidebarV2.tsx | 45 +++++++++------- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a4c9bcc7c54..2db971a2b67 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1599,6 +1599,24 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; + // Reading a finished thread clears the sidebar's Done badge. The visit is + // stamped at the turn's completion time — not now/updatedAt — so it clears + // exactly the completion the user is looking at: a wake or completion that + // lands later still gets its signal (markThreadVisited never moves the + // timestamp backwards). + useEffect(() => { + const completedAt = serverThread?.latestTurn?.completedAt; + if (!serverThread?.id || !completedAt) return; + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + completedAt, + ); + }, [ + markThreadVisited, + serverThread?.environmentId, + serverThread?.id, + serverThread?.latestTurn?.completedAt, + ]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -4041,6 +4059,37 @@ function ChatViewContent(props: ChatViewProps) { if (activeThreadRef === null || activeThreadWokeAt === null) return; markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); + // Mirror of the sidebar's Woke pill for the open thread: same visit + // comparison, same merged/closed-PR suppression (finished work needs no + // wake-up call). Drives the dismissible composer banner below. + const activeThreadLastVisitedAt = useUiStateStore((store) => + activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], + ); + const activeThreadWokeVisible = useMemo(() => { + if (activeThreadWokeAt === null) return false; + if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false; + const wokeAtMs = Date.parse(activeThreadWokeAt); + if (Number.isNaN(wokeAtMs)) return false; + // Having the thread open counts as a visit at completedAt (the effect + // above stamps it); folding that floor in here keeps a completion- + // triggered wake from flashing a banner for one frame before the stamp + // lands. An unparseable stored visit counts as never-visited: corrupt + // local data must not eat the wake signal. + const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; + const completedAtMs = activeLatestTurn?.completedAt + ? Date.parse(activeLatestTurn.completedAt) + : NaN; + const lastVisitedMs = Math.max( + Number.isNaN(storedVisitMs) ? -Infinity : storedVisitMs, + Number.isNaN(completedAtMs) ? -Infinity : completedAtMs, + ); + return lastVisitedMs < wokeAtMs; + }, [ + activeLatestTurn?.completedAt, + activeThreadLastVisitedAt, + activeThreadPr?.state, + activeThreadWokeAt, + ]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { @@ -4300,6 +4349,23 @@ function ChatViewContent(props: ChatViewProps) { handleStopBackgroundWork, isStoppingBackgroundWork, ]); + // A woken thread announces itself in the open view, not just the sidebar + // pill. Dismissing marks the wake as seen (same acknowledgment as the + // pill); sending a message clears it as a side effect of the send path. + const wokeThreadBannerItem = useMemo(() => { + if (!activeThreadWokeVisible) { + return null; + } + return { + id: `thread-woke:${activeThread?.id ?? "unknown"}`, + variant: "info", + icon: , + title: "This thread woke from snooze", + description: "Dismiss to clear the Woke indicator, or send a message to keep going.", + dismissLabel: "Dismiss Woke notification", + onDismiss: acknowledgeActiveThreadWoke, + }; + }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); // The stack renders items[0] front-most and tucks the rest behind hover, so // ordering is priority: system banners, then the branch-mismatch notice, // and the informational parked-thread banner last — it must never cover another. @@ -4354,13 +4420,20 @@ function ChatViewContent(props: ChatViewProps) { const composerBannerItems = useMemo(() => { const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; + const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems]; + return [ + ...systemComposerBannerItems, + ...backgroundLivenessItems, + ...wokeThreadItems, + ...parkedThreadItems, + ]; } return [ ...systemComposerBannerItems, ...backgroundLivenessItems, + ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, variant: "info", @@ -4411,6 +4484,7 @@ function ChatViewContent(props: ChatViewProps) { parkedThreadBannerItem, showBranchMismatchBanner, systemComposerBannerItems, + wokeThreadBannerItem, ]); useEffect(() => { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f918480e1a5..e87bfde6bd5 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -470,19 +470,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + }); + const prState = pr?.state ?? null; + // Same semantics as v1 (never-visited counts as read): flipping the beta // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is - // an explicit act, so unlike Done, a never-visited woke thread still - // shows the pill; visiting clears it. An unparseable visit timestamp - // counts as never-visited — corrupt local data must not eat the wake - // signal. + // an explicit act, so the pill clears only when the user re-engages: + // reading a completion-triggered wake, clicking the pill, sending a + // message, settling, archiving — or finishing the work outright (merged + // or closed PR). Timer wakes survive a mere visit. An unparseable visit + // timestamp counts as never-visited — corrupt local data must not eat + // the wake signal. const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); - const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); + const isWoke = + wokeAtDate !== null && + (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && + prState !== "merged" && + prState !== "closed"; // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -546,30 +567,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { : null; const isWokeStatus = topStatus?.icon === "woke"; - const gitCwd = thread.worktreePath ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const pr = resolveThreadPr({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; // Report the PR state up: the parent partitions rows with effectiveSettled, // and a merged/closed PR auto-settles a thread — data only rows have. - const prState = pr?.state ?? null; useEffect(() => { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]);