authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 05:09:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 02:09:16-07:00
log23f0a1ae38cb1fc510a499ef2b3602f5ae98d0c9
treea127f8df24dd8c82fef28b0ae345e7abf79774ae
parent85b1734d4d4914e743044f0ea8cb38a796de7da5
signature Signed by PGP key B5690EEEBB952194

fix(web): reading a thread clears Done; Woke is dismissible (#5579)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 101 insertions(+), 20 deletions(-)

apps/web/src/components/ChatView.tsx+75-1
......@@ -1604,6 +1604,24 @@ function ChatViewContent(props: ChatViewProps) {
16041604 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
16051605 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
16061606 const activeLatestTurn = activeThread?.latestTurn ?? null;
1607 // Reading a finished thread clears the sidebar's Done badge. The visit is
1608 // stamped at the turn's completion time — not now/updatedAt — so it clears
1609 // exactly the completion the user is looking at: a wake or completion that
1610 // lands later still gets its signal (markThreadVisited never moves the
1611 // timestamp backwards).
1612 useEffect(() => {
1613 const completedAt = serverThread?.latestTurn?.completedAt;
1614 if (!serverThread?.id || !completedAt) return;
1615 markThreadVisited(
1616 scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
1617 completedAt,
1618 );
1619 }, [
1620 markThreadVisited,
1621 serverThread?.environmentId,
1622 serverThread?.id,
1623 serverThread?.latestTurn?.completedAt,
1624 ]);
16071625 useEffect(() => {
16081626 setMountedTerminalThreadKeys((currentThreadIds) => {
16091627 const nextThreadIds = reconcileMountedTerminalThreadIds({
......@@ -4046,6 +4064,37 @@ function ChatViewContent(props: ChatViewProps) {
40464064 if (activeThreadRef === null || activeThreadWokeAt === null) return;
40474065 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
40484066 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
4067 // Mirror of the sidebar's Woke pill for the open thread: same visit
4068 // comparison, same merged/closed-PR suppression (finished work needs no
4069 // wake-up call). Drives the dismissible composer banner below.
4070 const activeThreadLastVisitedAt = useUiStateStore((store) =>
4071 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
4072 );
4073 const activeThreadWokeVisible = useMemo(() => {
4074 if (activeThreadWokeAt === null) return false;
4075 if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false;
4076 const wokeAtMs = Date.parse(activeThreadWokeAt);
4077 if (Number.isNaN(wokeAtMs)) return false;
4078 // Having the thread open counts as a visit at completedAt (the effect
4079 // above stamps it); folding that floor in here keeps a completion-
4080 // triggered wake from flashing a banner for one frame before the stamp
4081 // lands. An unparseable stored visit counts as never-visited: corrupt
4082 // local data must not eat the wake signal.
4083 const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
4084 const completedAtMs = activeLatestTurn?.completedAt
4085 ? Date.parse(activeLatestTurn.completedAt)
4086 : NaN;
4087 const lastVisitedMs = Math.max(
4088 Number.isNaN(storedVisitMs) ? -Infinity : storedVisitMs,
4089 Number.isNaN(completedAtMs) ? -Infinity : completedAtMs,
4090 );
4091 return lastVisitedMs < wokeAtMs;
4092 }, [
4093 activeLatestTurn?.completedAt,
4094 activeThreadLastVisitedAt,
4095 activeThreadPr?.state,
4096 activeThreadWokeAt,
4097 ]);
40494098 const activeThreadSettled = useMemo(() => {
40504099 if (activeThreadShell === null || !supportsSettlement) return false;
40514100 return effectiveSettled(activeThreadShell, {
......@@ -4305,6 +4354,23 @@ function ChatViewContent(props: ChatViewProps) {
43054354 handleStopBackgroundWork,
43064355 isStoppingBackgroundWork,
43074356 ]);
4357 // A woken thread announces itself in the open view, not just the sidebar
4358 // pill. Dismissing marks the wake as seen (same acknowledgment as the
4359 // pill); sending a message clears it as a side effect of the send path.
4360 const wokeThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4361 if (!activeThreadWokeVisible) {
4362 return null;
4363 }
4364 return {
4365 id: `thread-woke:${activeThread?.id ?? "unknown"}`,
4366 variant: "info",
4367 icon: <AlarmClockIcon />,
4368 title: "This thread woke from snooze",
4369 description: "Dismiss to clear the Woke indicator, or send a message to keep going.",
4370 dismissLabel: "Dismiss Woke notification",
4371 onDismiss: acknowledgeActiveThreadWoke,
4372 };
4373 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
43084374 // The stack renders items[0] front-most and tucks the rest behind hover, so
43094375 // ordering is priority: system banners, then the branch-mismatch notice,
43104376 // and the informational parked-thread banner last — it must never cover another.
......@@ -4359,13 +4425,20 @@ function ChatViewContent(props: ChatViewProps) {
43594425 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
43604426 const backgroundLivenessItems =
43614427 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
4428 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
43624429 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
43634430 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
4364 return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems];
4431 return [
4432 ...systemComposerBannerItems,
4433 ...backgroundLivenessItems,
4434 ...wokeThreadItems,
4435 ...parkedThreadItems,
4436 ];
43654437 }
43664438 return [
43674439 ...systemComposerBannerItems,
43684440 ...backgroundLivenessItems,
4441 ...wokeThreadItems,
43694442 {
43704443 id: `branch-mismatch:${activeBranchMismatchKey}`,
43714444 variant: "info",
......@@ -4416,6 +4489,7 @@ function ChatViewContent(props: ChatViewProps) {
44164489 parkedThreadBannerItem,
44174490 showBranchMismatchBanner,
44184491 systemComposerBannerItems,
4492 wokeThreadBannerItem,
44194493 ]);
44204494
44214495 useEffect(() => {
apps/web/src/components/SidebarV2.tsx+26-19
......@@ -470,19 +470,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
470470 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
471471 const terminalProcessCount = runningTerminalIds.length;
472472
473 const gitCwd = thread.worktreePath ?? props.projectCwd;
474 const gitStatus = useEnvironmentQuery(
475 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
476 ? vcsEnvironment.status({
477 environmentId: thread.environmentId,
478 input: { cwd: gitCwd },
479 })
480 : null,
481 );
482 const pr = resolveThreadPr({
483 threadBranch: thread.branch,
484 gitStatus: gitStatus.data,
485 });
486 const prState = pr?.state ?? null;
487
473488 // Same semantics as v1 (never-visited counts as read): flipping the beta
474489 // flag must not light up every historical thread as unread.
475490 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
476491 const status = resolveSidebarV2Status(thread);
477492 // A woken thread reappears at its original position (the sort is
478493 // deliberately static), so the pill has to carry the weight. Snoozing is
479 // an explicit act, so unlike Done, a never-visited woke thread still
480 // shows the pill; visiting clears it. An unparseable visit timestamp
481 // counts as never-visited — corrupt local data must not eat the wake
482 // signal.
494 // an explicit act, so the pill clears only when the user re-engages:
495 // reading a completion-triggered wake, clicking the pill, sending a
496 // message, settling, archiving — or finishing the work outright (merged
497 // or closed PR). Timer wakes survive a mere visit. An unparseable visit
498 // timestamp counts as never-visited — corrupt local data must not eat
499 // the wake signal.
483500 const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
484501 const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
485 const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate);
502 const isWoke =
503 wokeAtDate !== null &&
504 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
505 prState !== "merged" &&
506 prState !== "closed";
486507 // In-flight rows (working, or waiting on approval/input) fade as a whole:
487508 // there is nothing for the user to do yet, so prominence is reserved for
488509 // rows that need a human — done (unread), read-but-unsettled, failed, and
......@@ -550,30 +571,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
550571 : null;
551572 const isWokeStatus = topStatus?.icon === "woke";
552573
553 const gitCwd = thread.worktreePath ?? props.projectCwd;
554 const gitStatus = useEnvironmentQuery(
555 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
556 ? vcsEnvironment.status({
557 environmentId: thread.environmentId,
558 input: { cwd: gitCwd },
559 })
560 : null,
561 );
562574 const branchMismatch = resolveLocalCheckoutBranchMismatch({
563575 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
564576 activeWorktreePath: thread.worktreePath,
565577 activeThreadBranch: thread.branch,
566578 currentGitBranch: gitStatus.data?.refName ?? null,
567579 });
568 const pr = resolveThreadPr({
569 threadBranch: thread.branch,
570 gitStatus: gitStatus.data,
571 });
572580 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
573581 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
574582 // Report the PR state up: the parent partitions rows with effectiveSettled,
575583 // and a merged/closed PR auto-settles a thread — data only rows have.
576 const prState = pr?.state ?? null;
577584 useEffect(() => {
578585 onChangeRequestState(threadKey, prState);
579586 }, [onChangeRequestState, prState, threadKey]);