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) {...@@ -1604,6 +1604,24 @@ function ChatViewContent(props: ChatViewProps) {
1604 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));1604 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
1605 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);1605 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
1606 const activeLatestTurn = activeThread?.latestTurn ?? null;1606 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 ]);
1607 useEffect(() => {1625 useEffect(() => {
1608 setMountedTerminalThreadKeys((currentThreadIds) => {1626 setMountedTerminalThreadKeys((currentThreadIds) => {
1609 const nextThreadIds = reconcileMountedTerminalThreadIds({1627 const nextThreadIds = reconcileMountedTerminalThreadIds({
...@@ -4046,6 +4064,37 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4046,6 +4064,37 @@ function ChatViewContent(props: ChatViewProps) {
4046 if (activeThreadRef === null || activeThreadWokeAt === null) return;4064 if (activeThreadRef === null || activeThreadWokeAt === null) return;
4047 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);4065 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
4048 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);4066 }, [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 ]);
4049 const activeThreadSettled = useMemo(() => {4098 const activeThreadSettled = useMemo(() => {
4050 if (activeThreadShell === null || !supportsSettlement) return false;4099 if (activeThreadShell === null || !supportsSettlement) return false;
4051 return effectiveSettled(activeThreadShell, {4100 return effectiveSettled(activeThreadShell, {
...@@ -4305,6 +4354,23 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4305,6 +4354,23 @@ function ChatViewContent(props: ChatViewProps) {
4305 handleStopBackgroundWork,4354 handleStopBackgroundWork,
4306 isStoppingBackgroundWork,4355 isStoppingBackgroundWork,
4307 ]);4356 ]);
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]);
4308 // The stack renders items[0] front-most and tucks the rest behind hover, so4374 // The stack renders items[0] front-most and tucks the rest behind hover, so
4309 // ordering is priority: system banners, then the branch-mismatch notice,4375 // ordering is priority: system banners, then the branch-mismatch notice,
4310 // and the informational parked-thread banner last — it must never cover another.4376 // and the informational parked-thread banner last — it must never cover another.
...@@ -4359,13 +4425,20 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4359,13 +4425,20 @@ function ChatViewContent(props: ChatViewProps) {
4359 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {4425 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
4360 const backgroundLivenessItems =4426 const backgroundLivenessItems =
4361 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];4427 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
4428 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
4362 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];4429 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
4363 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {4430 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
4364 return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems];4431 return [
4432 ...systemComposerBannerItems,
4433 ...backgroundLivenessItems,
4434 ...wokeThreadItems,
4435 ...parkedThreadItems,
4436 ];
4365 }4437 }
4366 return [4438 return [
4367 ...systemComposerBannerItems,4439 ...systemComposerBannerItems,
4368 ...backgroundLivenessItems,4440 ...backgroundLivenessItems,
4441 ...wokeThreadItems,
4369 {4442 {
4370 id: `branch-mismatch:${activeBranchMismatchKey}`,4443 id: `branch-mismatch:${activeBranchMismatchKey}`,
4371 variant: "info",4444 variant: "info",
...@@ -4416,6 +4489,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4416,6 +4489,7 @@ function ChatViewContent(props: ChatViewProps) {
4416 parkedThreadBannerItem,4489 parkedThreadBannerItem,
4417 showBranchMismatchBanner,4490 showBranchMismatchBanner,
4418 systemComposerBannerItems,4491 systemComposerBannerItems,
4492 wokeThreadBannerItem,
4419 ]);4493 ]);
44204494
4421 useEffect(() => {4495 useEffect(() => {
apps/web/src/components/SidebarV2.tsx+26-19
...@@ -470,19 +470,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -470,19 +470,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
470 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);470 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
471 const terminalProcessCount = runningTerminalIds.length;471 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
473 // Same semantics as v1 (never-visited counts as read): flipping the beta488 // Same semantics as v1 (never-visited counts as read): flipping the beta
474 // flag must not light up every historical thread as unread.489 // flag must not light up every historical thread as unread.
475 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });490 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
476 const status = resolveSidebarV2Status(thread);491 const status = resolveSidebarV2Status(thread);
477 // A woken thread reappears at its original position (the sort is492 // A woken thread reappears at its original position (the sort is
478 // deliberately static), so the pill has to carry the weight. Snoozing is493 // deliberately static), so the pill has to carry the weight. Snoozing is
479 // an explicit act, so unlike Done, a never-visited woke thread still494 // an explicit act, so the pill clears only when the user re-engages:
480 // shows the pill; visiting clears it. An unparseable visit timestamp495 // reading a completion-triggered wake, clicking the pill, sending a
481 // counts as never-visited — corrupt local data must not eat the wake496 // message, settling, archiving — or finishing the work outright (merged
482 // signal.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.
483 const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);500 const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
484 const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);501 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";
486 // In-flight rows (working, or waiting on approval/input) fade as a whole:507 // In-flight rows (working, or waiting on approval/input) fade as a whole:
487 // there is nothing for the user to do yet, so prominence is reserved for508 // there is nothing for the user to do yet, so prominence is reserved for
488 // rows that need a human — done (unread), read-but-unsettled, failed, and509 // rows that need a human — done (unread), read-but-unsettled, failed, and
...@@ -550,30 +571,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -550,30 +571,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
550 : null;571 : null;
551 const isWokeStatus = topStatus?.icon === "woke";572 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 );
562 const branchMismatch = resolveLocalCheckoutBranchMismatch({574 const branchMismatch = resolveLocalCheckoutBranchMismatch({
563 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",575 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
564 activeWorktreePath: thread.worktreePath,576 activeWorktreePath: thread.worktreePath,
565 activeThreadBranch: thread.branch,577 activeThreadBranch: thread.branch,
566 currentGitBranch: gitStatus.data?.refName ?? null,578 currentGitBranch: gitStatus.data?.refName ?? null,
567 });579 });
568 const pr = resolveThreadPr({
569 threadBranch: thread.branch,
570 gitStatus: gitStatus.data,
571 });
572 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);580 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
573 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;581 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
574 // Report the PR state up: the parent partitions rows with effectiveSettled,582 // Report the PR state up: the parent partitions rows with effectiveSettled,
575 // and a merged/closed PR auto-settles a thread — data only rows have.583 // and a merged/closed PR auto-settles a thread — data only rows have.
576 const prState = pr?.state ?? null;
577 useEffect(() => {584 useEffect(() => {
578 onChangeRequestState(threadKey, prState);585 onChangeRequestState(threadKey, prState);
579 }, [onChangeRequestState, prState, threadKey]);586 }, [onChangeRequestState, prState, threadKey]);