| ... | @@ -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, so | 4374 | // 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 | ]); |
| 4420 | | 4494 | |
| 4421 | useEffect(() => { | 4495 | useEffect(() => { |