| ... | ... | @@ -1604,6 +1604,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 1604 | 1604 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| 1605 | 1605 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); |
| 1606 | 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 | 1625 | useEffect(() => { |
| 1608 | 1626 | setMountedTerminalThreadKeys((currentThreadIds) => { |
| 1609 | 1627 | const nextThreadIds = reconcileMountedTerminalThreadIds({ |
| ... | ... | @@ -4046,6 +4064,37 @@ function ChatViewContent(props: ChatViewProps) { |
| 4046 | 4064 | if (activeThreadRef === null || activeThreadWokeAt === null) return; |
| 4047 | 4065 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); |
| 4048 | 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 | 4098 | const activeThreadSettled = useMemo(() => { |
| 4050 | 4099 | if (activeThreadShell === null || !supportsSettlement) return false; |
| 4051 | 4100 | return effectiveSettled(activeThreadShell, { |
| ... | ... | @@ -4305,6 +4354,23 @@ function ChatViewContent(props: ChatViewProps) { |
| 4305 | 4354 | handleStopBackgroundWork, |
| 4306 | 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 | 4374 | // The stack renders items[0] front-most and tucks the rest behind hover, so |
| 4309 | 4375 | // ordering is priority: system banners, then the branch-mismatch notice, |
| 4310 | 4376 | // and the informational parked-thread banner last — it must never cover another. |
| ... | ... | @@ -4359,13 +4425,20 @@ function ChatViewContent(props: ChatViewProps) { |
| 4359 | 4425 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 4360 | 4426 | const backgroundLivenessItems = |
| 4361 | 4427 | backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; |
| 4428 | const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; |
| 4362 | 4429 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 4363 | 4430 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 4364 | | return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems]; |
| 4431 | return [ |
| 4432 | ...systemComposerBannerItems, |
| 4433 | ...backgroundLivenessItems, |
| 4434 | ...wokeThreadItems, |
| 4435 | ...parkedThreadItems, |
| 4436 | ]; |
| 4365 | 4437 | } |
| 4366 | 4438 | return [ |
| 4367 | 4439 | ...systemComposerBannerItems, |
| 4368 | 4440 | ...backgroundLivenessItems, |
| 4441 | ...wokeThreadItems, |
| 4369 | 4442 | { |
| 4370 | 4443 | id: `branch-mismatch:${activeBranchMismatchKey}`, |
| 4371 | 4444 | variant: "info", |
| ... | ... | @@ -4416,6 +4489,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4416 | 4489 | parkedThreadBannerItem, |
| 4417 | 4490 | showBranchMismatchBanner, |
| 4418 | 4491 | systemComposerBannerItems, |
| 4492 | wokeThreadBannerItem, |
| 4419 | 4493 | ]); |
| 4420 | 4494 | |
| 4421 | 4495 | useEffect(() => { |