From d0e621e9f9db38816211c3fbb423a68af7e7e427 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:40:07 +0530 Subject: [PATCH 01/91] fix(mobile): the question card uses the whole screen while the keyboard is closed (#24363) Co-authored-by: Claude Opus 5.5 --- .../features/threads/PendingUserInputCard.tsx | 39 ++++++------------ .../features/threads/ThreadDetailScreen.tsx | 41 ++++++++++--------- .../threads/pendingUserInputLayout.ts | 1 + 3 files changed, 36 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 6be28f295d638..1086422789a60 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -8,10 +8,9 @@ import Animated, { Easing, FadeInUp, FadeOutDown, - LinearTransition, useAnimatedStyle, useSharedValue, - withTiming, + type DerivedValue, type SharedValue, } from "react-native-reanimated"; @@ -33,7 +32,7 @@ import { export interface PendingUserInputCardProps { readonly canOperateThread: boolean; readonly pendingUserInput: PendingUserInput; - readonly maxHeight: number; + readonly maxHeight: DerivedValue; readonly collapsed: boolean; readonly onToggleCollapsed: () => void; readonly onStopThread?: () => void; @@ -64,8 +63,6 @@ export interface PendingUserInputCardProps { */ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; -const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); - function QuestionText(props: { readonly markdown: string }) { const styles = useMarkdownPreviewStyles(); const openLink = useOpenPullRequest(); @@ -92,18 +89,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { if (!cardCoverage) { return; } - const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); - if (coverage === cardCoverage.value) { - return; - } - if (cardCoverage.value === 0) { - cardCoverage.value = coverage; - return; - } - cardCoverage.value = withTiming(coverage, { - duration: USER_INPUT_TOGGLE_DURATION_MS, - easing: Easing.out(Easing.cubic), - }); + // The card resizes with the keyboard every frame, so easing each sample would leave the + // feed inset and floating controls trailing it. + cardCoverage.value = Math.max(0, cardHeightRef.current - barHeightRef.current); }, [cardCoverage]); const handleBarLayout = useCallback( (event: LayoutChangeEvent) => { @@ -121,12 +109,16 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { [cardHeight, notifyCoverage], ); const cardProgress = props.cardProgress; + const maxHeight = props.maxHeight; const cardAnimatedStyle = useAnimatedStyle(() => { const progress = cardProgress === undefined ? 1 : cardProgress.value; return { + maxHeight: maxHeight.value, transform: [{ translateY: (1 - progress) * cardHeight.value }], }; }); + const cardMaxHeightStyle = useAnimatedStyle(() => ({ maxHeight: maxHeight.value })); + const overlayHeightStyle = useAnimatedStyle(() => ({ height: maxHeight.value })); // On iOS the card stays MOUNTED while collapsed (hidden via the animated // style): expanding animates existing views on the UI thread the same @@ -192,13 +184,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ? undefined : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) } - layout={CARD_LAYOUT_TRANSITION} className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4" - style={ - EXPANDED_CARD_IS_OVERLAY - ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle] - : { maxHeight: props.maxHeight } - } + style={EXPANDED_CARD_IS_OVERLAY ? cardAnimatedStyle : cardMaxHeightStyle} > {bar} {EXPANDED_CARD_IS_OVERLAY ? ( - {card} - + ) : ( card )} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 70d7949d035a2..3a8cb765b6e8b 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -99,6 +99,7 @@ import { KeyboardController, KeyboardStickyView, useKeyboardState, + useReanimatedKeyboardAnimation, } from "react-native-keyboard-controller"; import Animated, { Easing, @@ -956,10 +957,8 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai ); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; - // The card's height RESERVES keyboard space at all times instead of tracking the keyboard: - // transforms (the sticky translation) apply same-frame on the UI thread while layout props lag - // a Yoga pass behind, so any height that follows the keyboard flashes the card over the nav - // header on the way up. + // A height React sets from keyboard state lags the sticky translation by a Yoga pass and + // flashes over the nav header on the way up, so the usage panel reserves the keyboard instead. const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); useEffect(() => { if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { @@ -968,12 +967,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai }, [lastKnownKeyboardHeight, liveKeyboardHeight]); const reservedKeyboardHeight = lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT; - const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ - windowHeight, - keyboardHeight: reservedKeyboardHeight, - navigationHeaderHeight, - composerOverlapHeight: composerBottomInset, - }); + // Waiting for a visibility render misses the first keyboard frames. + const keyboardStickyEnabled = + Platform.OS === "ios" + ? deviceType !== "duo-unfolded" || (composerOwnsKeyboard && !sidebarSearchActive) + : !keyboardStateSuspect; const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -1000,6 +998,18 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai setScreenHeight(event.nativeEvent.layout.height); }, []); const screenAreaHeight = screenHeight ?? windowHeight; + // The question card reads the keyboard frame on the UI thread, in the same frame the sticky + // view translates, so it gets the full height while the keyboard is closed and never crosses + // the header while the keyboard moves. + const { height: keyboardTranslation } = useReanimatedKeyboardAnimation(); + const pendingUserInputMaxHeight = useDerivedValue(() => + derivePendingUserInputMaxHeight({ + windowHeight: screenAreaHeight, + keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0, + navigationHeaderHeight, + composerOverlapHeight: composerBottomInset, + }), + ); // The usage panel stacks with the composer and any approval card, so it gets // whatever room they leave below the header. Measured only while it is open // so typing in the composer does not re-render the screen. @@ -1024,9 +1034,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai usageLimitsLayout.overlay > 0 ? usageLimitsLayout.overlay - usageLimitsLayout.panel : composerOverlapHeight; - // The keyboard is reserved even while closed, like the question card, so the - // panel never grows over the header while the keyboard animates. The floor - // keeps the close row on screen when the stack below leaves no room at all. + // The floor keeps the close row on screen when the stack below leaves no room at all. const usageLimitsMaxHeight = Math.max( USAGE_LIMITS_PANEL_MIN_HEIGHT, Math.min( @@ -1536,12 +1544,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai {showContent ? ( Date: Sat, 24 Oct 2026 17:43:56 +0530 Subject: [PATCH 02/91] fix(web): screen readers hear which background thread finished, even with the sidebar collapsed (#23839) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/AppSidebarLayout.tsx | 2 + .../components/BackgroundThreadAnnouncer.tsx | 64 ++++++ .../backgroundThreadAnnouncement.test.ts | 189 ++++++++++++++++++ .../backgroundThreadAnnouncement.ts | 88 ++++++++ .../chat/ThreadActivityAnnouncer.tsx | 24 +-- .../chat/threadActivityAnnouncement.ts | 21 +- .../components/sidebar/SidebarThreadRow.tsx | 12 +- apps/web/src/hooks/useLiveRegion.ts | 41 ++++ apps/web/src/state/shell.ts | 17 ++ 9 files changed, 428 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/BackgroundThreadAnnouncer.tsx create mode 100644 apps/web/src/components/backgroundThreadAnnouncement.test.ts create mode 100644 apps/web/src/components/backgroundThreadAnnouncement.ts create mode 100644 apps/web/src/hooks/useLiveRegion.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index bc3474e514f82..e3213614c68ef 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -37,6 +37,7 @@ import { usePanelAnimationSettings, usePanelNavigationSuppression, } from "../panelAnimations"; +import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; @@ -434,6 +435,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + {isOnSettings ? null : } diff --git a/apps/web/src/components/BackgroundThreadAnnouncer.tsx b/apps/web/src/components/BackgroundThreadAnnouncer.tsx new file mode 100644 index 0000000000000..40e0ebf3a0ba8 --- /dev/null +++ b/apps/web/src/components/BackgroundThreadAnnouncer.tsx @@ -0,0 +1,64 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { useEffect, useRef } from "react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { useLiveRegion } from "../hooks/useLiveRegion"; +import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; +import { useQuietThreadShells } from "../state/entities"; +import { liveShellEnvironmentIdsAtom } from "../state/shell"; +import { resolveActiveThreadRouteRef } from "../threadRoutes"; +import { + type BackgroundThreadMemory, + type BackgroundThreadSnapshot, + nextBackgroundThreadAnnouncement, + resolveBackgroundThreadRequest, +} from "./backgroundThreadAnnouncement"; + +/** + * Speaks status changes for threads other than the open one. It lives outside + * the sidebar because a collapsed sidebar is inert, which silences its rows. + */ +export function BackgroundThreadAnnouncer() { + const threads = useQuietThreadShells(); + const liveEnvironmentIds = useAtomValue(liveShellEnvironmentIdsAtom); + const routeTarget = useThreadRouteTarget(); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const openThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); + const openThreadKey = openThreadRef === null ? null : scopedThreadKey(openThreadRef); + const { regionRef, announce } = useLiveRegion(); + const memoryRef = useRef(new Map()); + + useEffect(() => { + const now = new Date().toISOString(); + const snapshots: BackgroundThreadSnapshot[] = []; + for (const thread of threads) { + // Subagents have no row of their own; their requests surface on the parent. + if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") + continue; + if (!liveEnvironmentIds.has(thread.environmentId)) continue; + snapshots.push({ + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + title: thread.title, + ...resolveBackgroundThreadRequest(thread), + runId: thread.latestRun?.runId ?? null, + runStatus: thread.latestRun?.status ?? null, + activeRunId: thread.runtime?.activeRunId ?? null, + snoozed: effectiveSnoozed(thread, { now }), + }); + } + const { memory, announcement } = nextBackgroundThreadAnnouncement( + memoryRef.current, + snapshots, + openThreadKey, + ); + memoryRef.current = memory; + if (announcement !== null) announce(announcement); + }, [threads, liveEnvironmentIds, openThreadKey, announce]); + + // No role="status": Orca 50 maps it to a status bar in Chromium and drops its updates. + return
; +} diff --git a/apps/web/src/components/backgroundThreadAnnouncement.test.ts b/apps/web/src/components/backgroundThreadAnnouncement.test.ts new file mode 100644 index 0000000000000..b2b6195d8f288 --- /dev/null +++ b/apps/web/src/components/backgroundThreadAnnouncement.test.ts @@ -0,0 +1,189 @@ +import { RunId, RuntimeRequestId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { + type BackgroundThreadMemory, + type BackgroundThreadSnapshot, + nextBackgroundThreadAnnouncement, + resolveBackgroundThreadRequest, +} from "./backgroundThreadAnnouncement"; + +const firstRun = RunId.make("run-1"); +const secondRun = RunId.make("run-2"); + +function thread( + key: string, + title: string, + state: Partial> = {}, +): BackgroundThreadSnapshot { + return { + key, + title, + request: null, + requestId: null, + runId: firstRun, + runStatus: "running", + activeRunId: null, + snoozed: false, + ...state, + }; +} +const login = (state?: Partial) => + thread("env:login", "Fix login redirect", state); +const docs = (state?: Partial) => + thread("env:docs", "Update docs", state); + +function speak( + frames: ReadonlyArray<{ + readonly threads: ReadonlyArray; + readonly open?: string; + }>, +) { + let memory: BackgroundThreadMemory = new Map(); + const spoken: string[] = []; + for (const frame of frames) { + const result = nextBackgroundThreadAnnouncement(memory, frame.threads, frame.open ?? null); + memory = result.memory; + if (result.announcement !== null) spoken.push(result.announcement); + } + return spoken; +} + +describe("nextBackgroundThreadAnnouncement", () => { + it("names the thread with the same words the open thread uses", () => { + expect( + speak([ + { threads: [login()] }, + { threads: [login({ request: "approval" })] }, + { threads: [login()] }, + { threads: [login({ request: "input" })] }, + { threads: [login({ runStatus: "completed" })] }, + { threads: [login({ runId: secondRun, runStatus: "failed" })] }, + { threads: [login({ runId: RunId.make("run-3"), runStatus: "interrupted" })] }, + ]), + ).toEqual([ + "Needs approval: Fix login redirect", + "Needs your input: Fix login redirect", + "Done: Fix login redirect", + "Failed: Fix login redirect", + "Stopped: Fix login redirect", + ]); + }); + + it("announces each run's ending once", () => { + expect( + speak([ + { threads: [login()] }, + { threads: [login({ runStatus: "completed" })] }, + // Marking the thread unread or renaming it changes nothing about the run. + { threads: [login({ runStatus: "completed", title: "Fix the login redirect" })] }, + // The next run can finish between two updates, so it never looks busy here. + { threads: [login({ runId: secondRun, runStatus: "completed" })] }, + { threads: [login({ runId: RunId.make("run-3"), runStatus: "rolled_back" })] }, + ]), + ).toEqual(["Done: Fix login redirect", "Done: Fix login redirect"]); + }); + + it("announces a second request of the same kind", () => { + expect( + speak([ + { threads: [login()] }, + { threads: [login({ request: "approval", requestId: "request-1" })] }, + { threads: [login({ request: "approval", requestId: "request-2" })] }, + ]), + ).toEqual(["Needs approval: Fix login redirect", "Needs approval: Fix login redirect"]); + }); + + it("doesn't call a thread stopped when a queued message is cancelled while it works", () => { + expect( + speak([ + { threads: [login({ activeRunId: firstRun })] }, + { + threads: [login({ runId: secondRun, runStatus: "cancelled", activeRunId: firstRun })], + }, + { threads: [login({ runId: secondRun, runStatus: "cancelled" })] }, + ]), + ).toEqual([]); + }); + + it("stays quiet for threads it sees for the first time, like on launch or reconnect", () => { + expect( + speak([ + { threads: [login({ runStatus: "completed" }), docs({ request: "approval" })] }, + { threads: [login({ runStatus: "completed" }), docs({ request: "approval" })] }, + // The environment drops out while it resyncs, then comes back changed. + { threads: [] }, + { + threads: [ + login({ runId: secondRun, runStatus: "failed" }), + docs({ runStatus: "completed" }), + ], + }, + ]), + ).toEqual([]); + }); + + it("leaves the open thread to its own announcer", () => { + expect( + speak([ + { threads: [login(), docs()], open: "env:login" }, + { threads: [login({ runStatus: "completed" }), docs()], open: "env:login" }, + // Switching away from a thread doesn't replay its ending. + { threads: [login({ runStatus: "completed" }), docs()], open: "env:docs" }, + { + threads: [login({ runStatus: "completed" }), docs({ request: "input" })], + open: "env:login", + }, + ]), + ).toEqual(["Needs your input: Update docs"]); + }); + + it("keeps a snoozed thread quiet, even after it wakes", () => { + expect( + speak([ + { threads: [login({ snoozed: true })] }, + { threads: [login({ snoozed: true, runStatus: "completed" })] }, + { threads: [login({ runStatus: "completed" })] }, + ]), + ).toEqual([]); + }); + + it("speaks threads that change together in one announcement", () => { + expect( + speak([ + { threads: [login(), docs()] }, + { threads: [login({ runStatus: "completed" }), docs({ request: "approval" })] }, + ]), + ).toEqual(["Done: Fix login redirect. Needs approval: Update docs"]); + }); +}); + +describe("resolveBackgroundThreadRequest", () => { + const question = (id: string) => ({ + id: RuntimeRequestId.make(id), + kind: "user_input" as const, + createdAt: DateTime.makeUnsafe(0), + }); + const childApproval = (parentQuestion: string | null) => + resolveBackgroundThreadRequest({ + hasPendingApprovals: false, + hasPendingUserInput: parentQuestion !== null, + pendingSubagentRequest: "approval", + source: { pendingRuntimeRequest: parentQuestion === null ? null : question(parentQuestion) }, + }); + + it("raises a subagent's request on the parent without borrowing the parent's request id", () => { + expect(childApproval(null)).toEqual({ request: "approval", requestId: null }); + // The parent's own questions come and go while the child's approval waits. + expect( + speak([ + { threads: [login()] }, + { threads: [login(childApproval(null))] }, + { threads: [login(childApproval("question-1"))] }, + { threads: [login(childApproval("question-2"))] }, + { threads: [login(childApproval(null))] }, + ]), + ).toEqual(["Needs approval: Fix login redirect"]); + }); +}); diff --git a/apps/web/src/components/backgroundThreadAnnouncement.ts b/apps/web/src/components/backgroundThreadAnnouncement.ts new file mode 100644 index 0000000000000..c04d363f2bd94 --- /dev/null +++ b/apps/web/src/components/backgroundThreadAnnouncement.ts @@ -0,0 +1,88 @@ +import type { OrchestrationV2RunStatus, RunId } from "@t3tools/contracts"; + +import type { SidebarThreadSummary } from "../types"; +import { runOutcomeText, THREAD_STATUS_TEXT } from "./chat/threadActivityAnnouncement"; + +export interface BackgroundThreadSnapshot { + readonly key: string; + readonly title: string; + readonly request: "approval" | "input" | null; + /** Null for a subagent's request, which the parent's shell only knows by kind. */ + readonly requestId: string | null; + readonly runId: RunId | null; + readonly runStatus: OrchestrationV2RunStatus | null; + /** The run doing work now, which can be older than a queued run cancelled while it ran. */ + readonly activeRunId: RunId | null; + /** A snoozed thread stays hidden until its timer, so it stays quiet too. */ + readonly snoozed: boolean; +} + +/** The request and its id come from the same source, so a subagent's request never takes the parent's id. */ +export function resolveBackgroundThreadRequest( + thread: Pick< + SidebarThreadSummary, + "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" + > & { + readonly source: Pick; + }, +): Pick { + const ownRequestId = thread.source.pendingRuntimeRequest?.id ?? null; + if (thread.hasPendingApprovals) return { request: "approval", requestId: ownRequestId }; + if (thread.pendingSubagentRequest === "approval") return { request: "approval", requestId: null }; + if (thread.hasPendingUserInput) return { request: "input", requestId: ownRequestId }; + if (thread.pendingSubagentRequest === "user_input") return { request: "input", requestId: null }; + return { request: null, requestId: null }; +} + +interface BackgroundThreadMemoryEntry { + readonly request: BackgroundThreadSnapshot["request"]; + readonly requestId: string | null; + /** The last run whose ending is known, so each run's ending is announced once. */ + readonly settledRunId: RunId | null; +} + +export type BackgroundThreadMemory = ReadonlyMap; + +/** + * Decides what a screen reader hears when a thread other than the open one + * asks for something or ends a run. `threads` holds only threads whose + * environment is live, so a thread is silent the first time it's seen: + * opening the app or reconnecting takes a baseline instead of reading out + * every thread. + */ +export function nextBackgroundThreadAnnouncement( + previous: BackgroundThreadMemory, + threads: ReadonlyArray, + openThreadKey: string | null, +): { readonly memory: BackgroundThreadMemory; readonly announcement: string | null } { + const memory = new Map(); + const announcements: string[] = []; + for (const thread of threads) { + const before = previous.get(thread.key); + const outcome = runOutcomeText(thread.runStatus); + const settled = outcome !== null || thread.runStatus === "rolled_back"; + memory.set(thread.key, { + request: thread.request, + requestId: thread.requestId, + settledRunId: settled ? thread.runId : (before?.settledRunId ?? null), + }); + if (before === undefined || thread.key === openThreadKey || thread.snoozed) continue; + if ( + thread.request !== null && + (thread.request !== before.request || thread.requestId !== before.requestId) + ) { + announcements.push(`${THREAD_STATUS_TEXT[thread.request]}: ${thread.title}`); + } + // While another run still works, the latest run's ending (a queued message + // cancelled before it started) isn't the thread finishing. The shell then + // only reports the latest run, so the working run's own ending stays quiet. + const anotherRunWorking = thread.activeRunId !== null && thread.activeRunId !== thread.runId; + if (outcome !== null && thread.runId !== before.settledRunId && !anotherRunWorking) { + announcements.push(`${outcome}: ${thread.title}`); + } + } + return { + memory, + announcement: announcements.length === 0 ? null : announcements.join(". "), + }; +} diff --git a/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx b/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx index 0a1f13fcbc0cf..d82ec08f41cf9 100644 --- a/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx +++ b/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx @@ -1,15 +1,14 @@ import { memo, useEffect, useRef } from "react"; +import { useLiveRegion } from "../../hooks/useLiveRegion"; + import { nextThreadAnnouncement, type ThreadActivitySnapshot, type ThreadAnnouncementMemory, } from "./threadActivityAnnouncement"; -/** - * One polite live region for the open thread. Text is written straight to the - * DOM so an announcement never re-renders the timeline. - */ +/** One polite live region for the open thread. */ export const ThreadActivityAnnouncer = memo(function ThreadActivityAnnouncer({ threadKey, status, @@ -23,14 +22,13 @@ export const ThreadActivityAnnouncer = memo(function ThreadActivityAnnouncer({ /** See isDetailBehindShell. */ readonly detailBehind: boolean; }) { - const regionRef = useRef(null); + const { regionRef, announce, clear } = useLiveRegion(); const memoryRef = useRef<{ threadKey: string; memory: ThreadAnnouncementMemory } | null>(null); useEffect(() => { - const region = regionRef.current; if (threadKey === null) { memoryRef.current = null; - if (region !== null) region.textContent = ""; + clear(); return; } if (detailBehind) return; @@ -42,15 +40,9 @@ export const ThreadActivityAnnouncer = memo(function ThreadActivityAnnouncer({ runStatus, }); memoryRef.current = { threadKey, memory }; - if (region === null) return; - if (previous === null) { - region.textContent = ""; - } else if (announcement !== null) { - // Screen readers ignore a write that leaves the text unchanged, so a - // repeated "Done" gets a trailing non-breaking space to count as new. - region.textContent = region.textContent === announcement ? `${announcement} ` : announcement; - } - }, [threadKey, status, requestId, runId, runStatus, detailBehind]); + if (previous === null) clear(); + else if (announcement !== null) announce(announcement); + }, [threadKey, status, requestId, runId, runStatus, detailBehind, announce, clear]); // No role="status": Orca 50 maps it to a status bar in Chromium and drops its updates. return
; diff --git a/apps/web/src/components/chat/threadActivityAnnouncement.ts b/apps/web/src/components/chat/threadActivityAnnouncement.ts index 5e658748dbe6c..270f4c9896403 100644 --- a/apps/web/src/components/chat/threadActivityAnnouncement.ts +++ b/apps/web/src/components/chat/threadActivityAnnouncement.ts @@ -3,6 +3,15 @@ import { latestExecutedRun } from "@t3tools/shared/orchestrationV2ThreadError"; export type ThreadActivityStatus = "working" | "approval" | "input" | "idle"; +/** Background threads say these too, so a status sounds the same wherever it's heard. */ +export const THREAD_STATUS_TEXT = { + approval: "Needs approval", + input: "Needs your input", + done: "Done", + failed: "Failed", + stopped: "Stopped", +} as const; + export interface ThreadActivitySnapshot { readonly status: ThreadActivityStatus; /** The pending approval or question, when there is one. */ @@ -37,15 +46,15 @@ const STARTING_RUN_STATUSES: ReadonlySet = new "starting", ]); -function runOutcomeText(status: OrchestrationV2RunStatus | null): string | null { +export function runOutcomeText(status: OrchestrationV2RunStatus | null): string | null { switch (status) { case "completed": - return "Done"; + return THREAD_STATUS_TEXT.done; case "failed": - return "Failed"; + return THREAD_STATUS_TEXT.failed; case "interrupted": case "cancelled": - return "Stopped"; + return THREAD_STATUS_TEXT.stopped; default: return null; } @@ -129,11 +138,11 @@ export function nextThreadAnnouncement( next.status === "approval" ? previous.snapshot.status === "approval" && previous.snapshot.requestId === next.requestId ? null - : "Needs approval" + : THREAD_STATUS_TEXT.approval : next.status === "input" ? previous.snapshot.status === "input" && previous.snapshot.requestId === next.requestId ? null - : "Needs your input" + : THREAD_STATUS_TEXT.input : turnStarted ? "Working" : null; diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 51d913f61b994..4a276b0ee730c 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1342,14 +1342,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : topStatus.icon === "done" ? ( ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. The open - thread announces its own status, so its row stays - quiet instead of saying everything twice. */} - - {topStatus.label} - + {/* Not a live region: a collapsed sidebar is inert, so + BackgroundThreadAnnouncer and the open thread's + timeline speak status changes instead. */} + {topStatus.label} {status === "working" ? ( diff --git a/apps/web/src/hooks/useLiveRegion.ts b/apps/web/src/hooks/useLiveRegion.ts new file mode 100644 index 0000000000000..edc43cf69cee6 --- /dev/null +++ b/apps/web/src/hooks/useLiveRegion.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react"; + +// NVDA reads a polite region's leftover text again when focus returns from a +// dialog, so an announcement is cleared once it has had time to be spoken. +// Clearing doesn't cut off speech that already started. +const CLEAR_AFTER_MS = 7_000; + +/** A polite live region written straight to the DOM, so speaking never re-renders. */ +export function useLiveRegion() { + const regionRef = useRef(null); + const clearTimerRef = useRef | null>(null); + + const cancelClear = () => { + if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + }; + + const clear = () => { + cancelClear(); + if (regionRef.current !== null) regionRef.current.textContent = ""; + }; + + const announce = (text: string) => { + const region = regionRef.current; + if (region === null) return; + cancelClear(); + // Screen readers ignore a write that leaves the text unchanged, so a repeat + // gets a trailing non-breaking space to count as new. + region.textContent = region.textContent === text ? `${text} ` : text; + clearTimerRef.current = setTimeout(clear, CLEAR_AFTER_MS); + }; + + useEffect( + () => () => { + if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current); + }, + [], + ); + + return { regionRef, announce, clear }; +} diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index 38269987e7513..6620630824db6 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -49,6 +49,23 @@ export function useEnvironmentShellHasSnapshot(environmentId: EnvironmentId | nu ); } +let previousLiveEnvironmentIds: ReadonlySet = new Set(); +/** Environments whose threads reflect the server now, not a cache or a resync in flight. */ +export const liveShellEnvironmentIdsAtom = Atom.make((get) => { + const next = new Set(); + for (const environmentId of enabledEnvironmentIds(get(environmentCatalog.catalogValueAtom))) { + if (get(environmentShell.statusAtom(environmentId)) === "live") next.add(environmentId); + } + if ( + next.size === previousLiveEnvironmentIds.size && + [...next].every((environmentId) => previousLiveEnvironmentIds.has(environmentId)) + ) { + return previousLiveEnvironmentIds; + } + previousLiveEnvironmentIds = next; + return next; +}).pipe(Atom.withLabel("web-live-shell-environment-ids")); + export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); if (Option.isNone(catalog)) { From 7f7daad98503dc049b7230465ae3863d71f197e4 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:06:42 +0530 Subject: [PATCH 03/91] fix(web): screen readers say which thread a shortcut switched to (#23676) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 3 +- .../components/chat/ModelPickerContent.tsx | 32 ++++++---- .../chat/OpenInPicker.browser.test.tsx | 36 ++++++++++- apps/web/src/components/chat/OpenInPicker.tsx | 31 +++++----- .../components/chat/OpenInPickerShortcut.ts | 5 ++ .../components/chat/ProviderModelPicker.tsx | 8 ++- .../SidebarThreadUndoNotice.browser.test.tsx | 37 +++++++++++ .../sidebar/SidebarThreadUndoNotice.tsx | 24 ++++--- apps/web/src/lib/announce.browser.test.ts | 62 +++++++++++++++++++ apps/web/src/lib/announce.ts | 54 ++++++++++++++++ 10 files changed, 255 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarThreadUndoNotice.browser.test.tsx create mode 100644 apps/web/src/lib/announce.browser.test.ts create mode 100644 apps/web/src/lib/announce.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index eaf958fa2f5cf..6df3e967eafdb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3765,7 +3765,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + ): string | null => { if (getModelDisabledReason?.(instanceId, modelSlug)) { - return; + return null; } const options = modelOptionsByInstance.get(instanceId); if (!options) { - return; + return null; } const entry = instanceEntries.find((candidate) => candidate.instanceId === instanceId); if (!entry) { - return; + return null; } const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); - if (resolvedModel) { - if (additive && onToggleModel) { - onToggleModel(instanceId, resolvedModel); - } else { - onInstanceModelChange(instanceId, resolvedModel); - } + if (!resolvedModel) return null; + if (additive && onToggleModel) { + onToggleModel(instanceId, resolvedModel); + } else { + onInstanceModelChange(instanceId, resolvedModel); } + return resolvedModel; }; const modelJumpShortcutContext = { @@ -773,6 +774,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }); setSearchQuery(""); handleSelectInstance(next); + announce( + next === "favorites" ? "Favorites" : (entryByInstanceId.get(next)?.displayName ?? next), + "model-picker", + ); return; } const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); @@ -790,7 +795,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return; } - handleModelSelect(model.slug, model.instanceId); + const selectedSlug = handleModelSelect(model.slug, model.instanceId); + if (selectedSlug === null) return; + const name = flatModels.find( + (item) => item.slug === selectedSlug && item.instanceId === model.instanceId, + )?.name; + announce(`Model: ${name ?? selectedSlug}`, "model-picker"); }; return ( diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index bb59ca99fe078..eafb73eb5cd24 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -1,9 +1,10 @@ import { AuthOrchestrationOperateScope, EnvironmentId, type EditorId } from "@t3tools/contracts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { Cause } from "effect"; import { AsyncResult } from "effect/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; -import { act, render as renderUi } from "../../../test/render"; +import { act, fakeTimers, render as renderUi } from "../../../test/render"; import type { RemoteOpenState } from "../../remoteOpen"; const state = vi.hoisted(() => ({ @@ -181,3 +182,36 @@ describe("host editor access", () => { expect((await pressOpenFavorite()).defaultPrevented).toBe(false); }); }); + +describe("open in editor announcement", () => { + it("speaks only after the shortcut opened the editor, from the picker or the fallback", async () => { + await using fixture = await setup(); + const { screen, renderPicker, pressOpenFavorite, FavoriteEditorFallback } = fixture; + state.allowed.add(selected); + await renderPicker(); + using _timers = fakeTimers(); + const regions = () => [...document.querySelectorAll('body > [aria-live="polite"]')]; + const spoken = async () => { + await act(async () => vi.runAllTimers()); + return regions() + .map((region) => region.textContent) + .join(""); + }; + const refuse = () => + state.run.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("No editor")))); + + refuse(); + await pressOpenFavorite(); + expect(await spoken()).toBe(""); + await pressOpenFavorite(); + expect(await spoken()).toBe("Opened in VS Code"); + + for (const region of regions()) region.textContent = ""; + await act(async () => screen.rerender()); + refuse(); + await pressOpenFavorite(); + expect(await spoken()).toBe(""); + await pressOpenFavorite(); + expect(await spoken()).toBe("Opened in VS Code"); + }); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 900879ae049b4..8bc6a884fa32b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -58,6 +58,7 @@ import { RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; +import { announce } from "~/lib/announce"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -240,11 +241,11 @@ export const OpenInPicker = memo(function OpenInPicker({ const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); - const openInEditor = (editorId: EditorId | null) => { - if (!openInCwd) return; + const openInEditor = async (editorId: EditorId | null): Promise => { + if (!openInCwd) return false; const editor = editorId ?? preferredEditor; - if (!editor) return; - if (remote.mode === "remote-unavailable") return; + if (!editor) return false; + if (remote.mode === "remote-unavailable") return false; if (remote.mode === "remote-links") { const url = buildRemoteOpenUrl({ editor, @@ -252,17 +253,16 @@ export const OpenInPicker = memo(function OpenInPicker({ absolutePath: openInCwd, isFile: openInCwdIsFile, }); - if (url === undefined) return; + if (url === undefined) return false; // Only record hint-seen/preferred when the shell actually accepted // the URL (an older desktop build can refuse the editor scheme). - void openRemoteEditorUrl(url).then((opened) => { - if (!opened) return; - markRemoteHintSeen(); - setPreferredEditor(editor); - }); - return; + const opened = await openRemoteEditorUrl(url); + if (!opened) return false; + markRemoteHintSeen(); + setPreferredEditor(editor); + return true; } - if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; const result = openInEditorMutation({ environmentId, input: { @@ -272,7 +272,7 @@ export const OpenInPicker = memo(function OpenInPicker({ }, }); setPreferredEditor(editor); - return result; + return (await result)._tag === "Success"; }; const openFavoriteEditorShortcutLabel = shortcutLabelForCommand( @@ -293,7 +293,10 @@ export const OpenInPicker = memo(function OpenInPicker({ return; e.preventDefault(); - void openInEditor(preferredEditor); + const label = editorLabelForPlatform(preferredEditor, navigator.platform); + void openInEditor(preferredEditor).then((opened) => { + if (opened) announce(`Opened in ${label}`); + }); }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 6d567b160d7d4..7cd000e83dca9 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -2,7 +2,9 @@ import type { EditorId, EnvironmentId, ResolvedKeybindingsConfig } from "@t3tool import { useEffect } from "react"; import { usePreferredEditor } from "../../editorPreferences"; +import { editorLabelForPlatform } from "../../editorLabels"; import { isOpenFavoriteEditorShortcut } from "../../keybindings"; +import { announce } from "../../lib/announce"; import { useRemoteOpenState } from "../../remoteOpen"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -31,12 +33,15 @@ export function useOpenFavoriteEditorShortcut({ if (!openInCwd || !preferredEditor) return; event.preventDefault(); + const label = editorLabelForPlatform(preferredEditor, navigator.platform); void openInEditorMutation({ environmentId, input: { cwd: openInCwd, editor: preferredEditor, }, + }).then((result) => { + if (result._tag === "Success") announce(`Opened in ${label}`); }); }; window.addEventListener("keydown", handler); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index c169ac10e6b1b..e836a92f58714 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -300,9 +300,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { terminalOpen={props.terminalOpen ?? false} onRequestClose={() => setIsMenuOpen(false)} {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})} - {...(props.getModelDisabledReason - ? { getModelDisabledReason: props.getModelDisabledReason } - : {})} + getModelDisabledReason={(instanceId, model) => + props.disabled + ? "Model picker is disabled" + : (props.getModelDisabledReason?.(instanceId, model) ?? null) + } onInstanceModelChange={handleInstanceModelChange} /> {props.selectedModels === undefined ? ( diff --git a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.browser.test.tsx b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.browser.test.tsx new file mode 100644 index 0000000000000..049ef07ad8056 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.browser.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render } from "../../../test/render"; +import { useThreadUndoNotice } from "../../hooks/showThreadUndoNotice"; +import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; + +const spoken = () => + [...document.querySelectorAll('body > [aria-live="polite"]')] + .map((region) => region.textContent) + .join(""); + +describe("SidebarThreadUndoNotice", () => { + it("speaks the notice, and drops it when the notice clears before it is spoken", async () => { + using _notice = { [Symbol.dispose]: () => useThreadUndoNotice.setState({ notice: null }) }; + await using _screen = await render(); + using _timers = fakeTimers(); + const showSettled = () => + act(async () => + useThreadUndoNotice.setState({ + notice: { action: "Settled", count: 1, undo: async () => {} }, + }), + ); + + await showSettled(); + await act(async () => vi.advanceTimersByTime(300)); + expect(spoken()).toMatch(/^Settled 1 thread, /); + + await act(async () => useThreadUndoNotice.setState({ notice: null })); + for (const region of document.querySelectorAll('body > [aria-live="polite"]')) { + region.textContent = ""; + } + await showSettled(); + await act(async () => useThreadUndoNotice.setState({ notice: null })); + await act(async () => vi.advanceTimersByTime(300)); + expect(spoken()).toBe(""); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx index 7f3f5bfe77ec0..71818e154a1c1 100644 --- a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx @@ -1,7 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; +import { useEffect } from "react"; import { undoLatestThreadAction, useThreadUndoNotice } from "../../hooks/showThreadUndoNotice"; import { shortcutLabelForCommand } from "../../keybindings"; +import { announce, cancelAnnouncement } from "../../lib/announce"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { Alert, AlertDescription } from "../ui/alert"; import { InlineButton } from "../ui/button"; @@ -10,17 +12,25 @@ export function SidebarThreadUndoNotice() { const notice = useThreadUndoNotice((state) => state.notice); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - if (!notice) return null; const shortcut = shortcutLabelForCommand(keybindings, "thread.undo"); - const noun = `${notice.action === "Discarded" ? "draft" : "thread"}${notice.count === 1 ? "" : "s"}`; + const undoLabel = shortcut ? `${shortcut} to undo` : "Undo"; + const summary = notice + ? `${notice.action} ${notice.count} ${notice.action === "Discarded" ? "draft" : "thread"}${notice.count === 1 ? "" : "s"}` + : null; + + // A live region that mounts already filled is skipped by screen readers, and settling + // usually moves focus to the next thread at the same moment. + useEffect(() => { + if (summary) announce(`${summary}, ${undoLabel}`, "thread-undo-notice"); + else cancelAnnouncement("thread-undo-notice"); + }, [summary, undoLabel]); + + if (!summary) return null; return ( - + - {notice.action} {notice.count} {noun},{" "} - - {shortcut ? `${shortcut} to undo` : "Undo"} - + {summary}, {undoLabel} ); diff --git a/apps/web/src/lib/announce.browser.test.ts b/apps/web/src/lib/announce.browser.test.ts new file mode 100644 index 0000000000000..72406754e2bbb --- /dev/null +++ b/apps/web/src/lib/announce.browser.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { fakeTimers } from "../../test/render"; +import { announce, cancelAnnouncement } from "./announce"; + +function spokenText() { + return Array.from(document.querySelectorAll('[aria-live="polite"]'), (region) => + region.textContent?.trim(), + ).filter(Boolean); +} + +describe("announce", () => { + it("waits for focus to settle, then speaks waiting messages together", () => { + using _timers = fakeTimers(); + const composer = document.createElement("textarea"); + document.body.append(composer); + using _composer = { [Symbol.dispose]: () => composer.remove() }; + + announce("Settled 1 thread, Ctrl+Z to undo"); + announce("Model: Claude Sonnet 5.5"); + vi.advanceTimersByTime(200); + composer.focus(); + vi.advanceTimersByTime(200); + expect(spokenText()).toEqual([]); + + vi.advanceTimersByTime(50); + expect(spokenText()).toEqual(["Settled 1 thread, Ctrl+Z to undo. Model: Claude Sonnet 5.5"]); + }); + + it("speaks the same message twice in a row through the other region", () => { + using _timers = fakeTimers(); + const regionTexts = () => + Array.from(document.querySelectorAll('[aria-live="polite"]'), (region) => region.textContent); + + announce("Claude"); + vi.runAllTimers(); + const first = regionTexts(); + announce("Claude"); + vi.runAllTimers(); + + expect(regionTexts()).toEqual([...first].reverse()); + expect(regionTexts()).toContain("Claude"); + }); + + it("replaces a waiting message from the same source", () => { + using _timers = fakeTimers(); + + announce("Settled 1 thread, Ctrl+Z to undo", "undo"); + announce("Opening in VS Code"); + announce("Settled 2 threads, Ctrl+Z to undo", "undo"); + vi.runAllTimers(); + + expect(spokenText()).toEqual(["Opening in VS Code. Settled 2 threads, Ctrl+Z to undo"]); + + announce("Settled 1 thread, Ctrl+Z to undo", "undo"); + announce("Opening in Zed"); + cancelAnnouncement("undo"); + vi.runAllTimers(); + + expect(spokenText()).toEqual(["Opening in Zed"]); + }); +}); diff --git a/apps/web/src/lib/announce.ts b/apps/web/src/lib/announce.ts new file mode 100644 index 0000000000000..20036b82a6d73 --- /dev/null +++ b/apps/web/src/lib/announce.ts @@ -0,0 +1,54 @@ +// Screen readers skip a live region that appears already filled, and moving focus cuts off +// whatever they were saying. So the regions exist before the first message lands, and +// messages wait until focus has stayed put for a moment, then are spoken together. +const FOCUS_SETTLE_MS = 250; + +let regions: readonly [HTMLElement, HTMLElement] | null = null; +let pending: { message: string; source: string | undefined }[] = []; +let flushTimer: ReturnType | undefined; + +function scheduleFlush() { + clearTimeout(flushTimer); + flushTimer = setTimeout(flush, FOCUS_SETTLE_MS); +} + +function flush() { + if (!regions || pending.length === 0) return; + const [first, second] = regions; + // Alternating between two regions lets the same message be spoken twice in a row. + const target = first.textContent ? second : first; + first.textContent = ""; + second.textContent = ""; + target.textContent = pending.map((entry) => entry.message).join(". "); + pending = []; +} + +function createRegion() { + const region = document.createElement("div"); + region.className = "sr-only"; + region.setAttribute("aria-live", "polite"); + region.setAttribute("aria-atomic", "true"); + document.body.append(region); + return region; +} + +/** + * Tells screen reader users what an action did when focus alone doesn't show it. A message + * replaces one still waiting from the same `source`, so a superseded status isn't read out. + */ +export function announce(message: string, source?: string) { + if (!regions) { + regions = [createRegion(), createRegion()]; + document.addEventListener("focusin", () => { + if (pending.length > 0) scheduleFlush(); + }); + } + if (source !== undefined) pending = pending.filter((entry) => entry.source !== source); + if (pending.at(-1)?.message !== message) pending.push({ message, source }); + scheduleFlush(); +} + +/** Drops a message from `source` that hasn't been spoken yet, once it no longer applies. */ +export function cancelAnnouncement(source: string) { + pending = pending.filter((entry) => entry.source !== source); +} From d53873b6a4799e87afd63c8d288ef0089f5a3748 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:16:50 +0530 Subject: [PATCH 04/91] fix(mobile): Back from a sub-agent opened from its delegator returns to the delegator (#23795) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/components/ComposerEditor.tsx | 6 ++--- .../features/threads/ThreadDetailScreen.tsx | 15 ++++------- .../src/features/threads/ThreadFeed.tsx | 11 +++++--- .../features/threads/useOpenLinkedThread.ts | 16 ++++++++++++ .../lib/android-workspace-navigation.test.ts | 25 +++++++++++++++++++ .../src/lib/android-workspace-navigation.ts | 21 ++++++++++++++++ 6 files changed, 77 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/threads/useOpenLinkedThread.ts diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index f68c5396d8414..8dfbb5587b621 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -3,7 +3,6 @@ import { ComposerContextId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { useNavigation } from "@react-navigation/native"; import { useEffect, useRef, useState } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; @@ -24,6 +23,7 @@ import { } from "../state/use-composer-drafts"; import { importComposerContextClipboard } from "../lib/composerContextClipboard"; import { mobilePreferencesAtom } from "../state/preferences"; +import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./ui/AppText"; import { @@ -154,7 +154,7 @@ export function ComposerEditor({ }), }) : ""; - const navigation = useNavigation(); + const openLinkedThread = useOpenLinkedThread(); const selectedReference = selected ? collectComposerContextReferences(selected.source)[0] : undefined; @@ -236,7 +236,7 @@ export function ComposerEditor({ : {})} onOpenThread={(thread) => { setSelected(null); - navigation.navigate("Thread", { + openLinkedThread({ environmentId: String(thread.environmentId), threadId: String(thread.threadId), }); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3a8cb765b6e8b..83fd54f2ac04a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -147,7 +147,6 @@ import { pendingThreadMessageJump, subscribeThreadMessageJump, } from "./threadMessageJump"; -import { parentThreadIsBelow } from "../../lib/android-workspace-navigation"; import { clearThreadComposerError, threadComposerErrorsAtom, @@ -191,6 +190,7 @@ import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; +import { useOpenLinkedThread } from "./useOpenLinkedThread"; import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; export interface ThreadDetailScreenProps { @@ -513,6 +513,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const [composerOwnsKeyboard, setComposerOwnsKeyboard] = useState(false); if (sidebarSearchActive && composerOwnsKeyboard) setComposerOwnsKeyboard(false); const navigation = useNavigation(); + const openLinkedThread = useOpenLinkedThread(); const { session: voiceInputSession } = useGlobalVoiceInput(); const reportedModelSelection = useThreadReportedModelSelection(props.selectedThreadDetailRef); const deviceState = useEnvironmentQuery( @@ -1747,17 +1748,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai onOpenParent={ props.selectedThread.lineage.parentThreadId === null ? null - : () => { - const parent = { + : () => + openLinkedThread({ environmentId: String(props.environmentId), threadId: String(props.selectedThread.lineage.parentThreadId), - }; - navigation.dispatch((state) => - parentThreadIsBelow(state, parent) - ? StackActions.pop() - : StackActions.push("Thread", parent), - ); - } + }) } /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 5d29e519917ff..48b1346646acf 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -270,6 +270,7 @@ import { waitForThreadShellReady } from "./threadForkNavigation"; import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; import { fileChipClipboardPath, fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; import { useFileChipShare } from "./useFileChipShare"; +import { useOpenLinkedThread } from "./useOpenLinkedThread"; import { MarkdownImageAvailableWidthContext, ThreadMarkdownImage, @@ -1055,13 +1056,13 @@ function OpenSenderThreadButton(props: { readonly environmentId: EnvironmentId; readonly senderThreadId: ThreadId; }) { - const navigation = useNavigation(); + const openLinkedThread = useOpenLinkedThread(); return ( - navigation.navigate("Thread", { + openLinkedThread({ environmentId: String(props.environmentId), threadId: String(props.senderThreadId), }) @@ -1797,6 +1798,7 @@ type UserMessageContentProps = { function UserMessageContent(props: UserMessageContentProps) { const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null); const navigation = useNavigation(); + const openLinkedThread = useOpenLinkedThread(); const { selectedThread } = useThreadSelection(); const liveText = useLiveThreadLinkLabels(props.text, props.environmentId); const threadIcons = useThreadChipIcons(props.environmentId, liveText, props.context?.records); @@ -1815,7 +1817,7 @@ function UserMessageContent(props: UserMessageContentProps) { return; } if (record?.kind === "thread" && "threadId" in record) { - navigation.navigate("Thread", { + openLinkedThread({ environmentId: String(record.environmentId), threadId: String(record.threadId), }); @@ -2026,6 +2028,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType); const openPullRequest = useOpenPullRequest(); const navigation = useNavigation(); + const openLinkedThread = useOpenLinkedThread(); const onOpenTurnDiff = useCallback( (checkpoint: ThreadCheckpointSummary) => { setReviewSelectedSectionId( @@ -2210,7 +2213,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } const target = resolveLinkedThread(href); if (target) { - navigation.navigate("Thread", { + openLinkedThread({ environmentId: String(target.environmentId), threadId: String(target.threadId), }); diff --git a/apps/mobile/src/features/threads/useOpenLinkedThread.ts b/apps/mobile/src/features/threads/useOpenLinkedThread.ts new file mode 100644 index 0000000000000..a3ecd7f685a03 --- /dev/null +++ b/apps/mobile/src/features/threads/useOpenLinkedThread.ts @@ -0,0 +1,16 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { useCallback } from "react"; +import { linkedThreadStep } from "../../lib/android-workspace-navigation"; + +export function useOpenLinkedThread() { + const navigation = useNavigation(); + return useCallback( + (target: { readonly environmentId: string; readonly threadId: string }) => { + const state = navigation.getState(); + const step = state ? linkedThreadStep(state, target) : "push"; + if (step === "pop") navigation.dispatch(StackActions.pop()); + else if (step === "push") navigation.dispatch(StackActions.push("Thread", target)); + }, + [navigation], + ); +} diff --git a/apps/mobile/src/lib/android-workspace-navigation.test.ts b/apps/mobile/src/lib/android-workspace-navigation.test.ts index ac163d7721490..88c4e945fe363 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.test.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.test.ts @@ -7,6 +7,7 @@ import { } from "../features/workspace/android-workspace-tabs"; import { androidWorkspaceNavigationState, + linkedThreadStep, parentThreadIsBelow, resetAndroidWorkspace, returnToAndroidWorkspaceList, @@ -202,6 +203,30 @@ describe("Android workspace navigation", () => { }); }); + it("opens a linked thread above the current one so Back returns to it", () => { + const parent = select(router.getInitialState(options), thread); + const child = { ...thread.params, threadId: "child-a" }; + const open = ( + state: ReturnType, + target: ReactNavigation.RootParamList["Thread"], + ) => { + const step = linkedThreadStep(state, target); + if (step === "stay") return state; + return apply( + state, + step === "pop" ? StackActions.pop() : StackActions.push("Thread", target), + ); + }; + + const inChild = open(parent, child); + expect(inChild.routes.map((route) => route.params)).toEqual([undefined, thread.params, child]); + expect(open(inChild, child)).toBe(inChild); + + const backInParent = open(inChild, thread.params); + expect(backInParent.routes).toEqual(parent.routes); + expect(backInParent.index).toBe(parent.index); + }); + it("recognizes a parent only when its environment and thread are immediately below", () => { const parent = select(router.getInitialState(options), thread); const child = apply( diff --git a/apps/mobile/src/lib/android-workspace-navigation.ts b/apps/mobile/src/lib/android-workspace-navigation.ts index 138704a03e677..af7ff01440f14 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.ts @@ -92,6 +92,27 @@ export function parentThreadIsBelow( ); } +/** + * How a thread link opened from inside a thread reaches its target. `navigate` would reuse the + * current Thread route, so Back would skip the thread the link was in and land on the list. + */ +export function linkedThreadStep( + state: NavigationState, + target: ReactNavigation.RootParamList["Thread"], +): "stay" | "pop" | "push" { + const params = state.routes[state.index]?.params as + | ReactNavigation.RootParamList["Thread"] + | undefined; + if ( + state.routes[state.index]?.name === "Thread" && + params?.environmentId === target?.environmentId && + params?.threadId === target?.threadId + ) { + return "stay"; + } + return parentThreadIsBelow(state, target) ? "pop" : "push"; +} + export function returnToAndroidWorkspaceList( navigation: WorkspaceNavigation & { readonly goBack: () => void }, selectTab: () => void, From 79014e6c41e99344c59470c3b315da4ce3ba162d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 11:49:29 -0700 Subject: [PATCH 05/91] fix(server): migrate-dev-db no longer trips over webhook tables (#24404) Co-authored-by: Claude Opus 5.5 --- apps/server/scripts/migrate-dev-db.test.ts | 17 +++++++++++++++-- apps/server/scripts/migrate-dev-db.ts | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 35d193b82db46..c9a8f801755eb 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -87,6 +87,12 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio created_at, updated_at, last_run_status, run_count) VALUES ('task-1', 'Nightly', 'Run it', 1, '{}', 'project-kept', '{}', '{}', 'full-access', 'default', 'user', 'user', '2026-08-01', '2026-08-01', 'never', 0)`; + yield* sql`INSERT INTO scheduled_task_webhook_replay_keys + (task_id, replay_key, delivery_id, expires_at) + VALUES ('task-1', 'replay-1', 'delivery-1', 1900000000000)`; + yield* sql`INSERT INTO scheduled_task_webhook_pending + (delivery_id, task_id, coalesce_key, due_at, task_json, request_json, delivery_ids_json) + VALUES ('delivery-1', 'task-1', 'key-1', 1900000000000, '{}', '{}', '[]')`; yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`; }), @@ -119,10 +125,17 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { SELECT stream_id FROM orchestration_events ORDER BY stream_id`; const sessions = yield* sql<{ provider_session_id: string }>` SELECT provider_session_id FROM orchestration_v2_projection_provider_sessions`; - const [leftovers] = yield* sql<{ auth: number; tasks: number; transfers: number }>` + const [leftovers] = yield* sql<{ + auth: number; + tasks: number; + webhooks: number; + transfers: number; + }>` SELECT (SELECT COUNT(*) FROM auth_sessions) AS auth, (SELECT COUNT(*) FROM scheduled_tasks) AS tasks, + (SELECT COUNT(*) FROM scheduled_task_webhook_replay_keys) + + (SELECT COUNT(*) FROM scheduled_task_webhook_pending) AS webhooks, (SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers`; return { threads, events, sessions, leftovers }; }), @@ -139,7 +152,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { kept.sessions.map((row) => row.provider_session_id), ["session-shared"], ); - assert.deepStrictEqual(kept.leftovers, { auth: 0, tasks: 0, transfers: 0 }); + assert.deepStrictEqual(kept.leftovers, { auth: 0, tasks: 0, webhooks: 0, transfers: 0 }); }), ); diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 57c8023d62588..68aa0ff782a13 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -250,6 +250,21 @@ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: ); const tables = schema.filter((entry) => entry.type === "table" && !isShadowTable(entry.name)); const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads"); + // A row that points into a cleared table cannot be copied, so whatever references one is + // cleared with it, however far down the chain a newer migration added it. + const references = yield* sql<{ name: string; parent: string }>` + SELECT m.name, f."table" AS parent FROM src.sqlite_master m, pragma_foreign_key_list(m.name, 'src') f + WHERE m.type = 'table'`; + const cleared = new Set(CLEARED_TABLES); + for (let grew = true; grew;) { + grew = false; + for (const { name, parent } of references) { + if (cleared.has(parent) && !cleared.has(name)) { + cleared.add(name); + grew = true; + } + } + } yield* sql.withTransaction( Effect.gen(function* () { @@ -263,7 +278,7 @@ const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: WHERE ${rootIsVisible(sql)}`; } for (const { name } of tables) { - if (CLEARED_TABLES.includes(name) || virtualTables.includes(name)) continue; + if (cleared.has(name) || virtualTables.includes(name)) continue; const nullable = threadIdNullable.get(name); const filter = !filtered ? "" From 71df573f2d6910048e304282b960981cb45672b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 11:49:56 -0700 Subject: [PATCH 06/91] fix(clients): GitCafe links show GitCafe's mark on mobile (#24405) Co-authored-by: Claude Opus 5.5 --- packages/client-runtime/src/sourceControlClients.test.ts | 1 + packages/source-control-gitcafe/src/shared/definition.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index a47b6ddfe53f9..6954dd979cc8d 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -357,6 +357,7 @@ describe("sourceControlClients.findByPublicDomain", () => { expect(kindOf("gist.github.com")).toBe("github"); expect(kindOf("GitLab.com")).toBe("gitlab"); expect(kindOf("codeberg.org")).toBe("forgejo"); + expect(kindOf("git.cafe")).toBe("gitcafe"); expect(kindOf("notgithub.com")).toBeUndefined(); expect(kindOf("github.com.example")).toBeUndefined(); }); diff --git a/packages/source-control-gitcafe/src/shared/definition.ts b/packages/source-control-gitcafe/src/shared/definition.ts index 7984dced627c4..5192c23720d26 100644 --- a/packages/source-control-gitcafe/src/shared/definition.ts +++ b/packages/source-control-gitcafe/src/shared/definition.ts @@ -50,7 +50,7 @@ export const definition = defineSourceControl({ plural: "pull requests", }, repositoryPathHint: "owner/repo", - publicHost: null, + publicHost: "git.cafe", publishDescription: "git.cafe", publishHost: () => "git.cafe", newRepositoryOwner: (account) => (account ? { owner: account } : null), From 0345e852ed009cc17ba1f0eec56d0c431897ba7f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 12:37:56 -0700 Subject: [PATCH 07/91] feat: compactions T3 Code starts read as one working row, and stay quiet (#24334) Co-authored-by: Claude Opus 5.5 --- .../src/features/threads/ThreadFeed.tsx | 65 ++- .../threads/pending-thread-feed.test.ts | 31 +- .../features/threads/pending-thread-feed.ts | 9 + apps/mobile/src/lib/threadActivity.test.ts | 139 +++++++ apps/mobile/src/lib/threadActivity.ts | 86 +++- .../Adapters/ClaudeAdapterV2.test.ts | 95 ++++- .../Adapters/ClaudeAdapterV2.ts | 28 +- .../orchestration-v2/IdleCompactionService.ts | 4 +- .../src/orchestration-v2/Orchestrator.ts | 15 + .../orchestration-v2/ProjectionStore.test.ts | 144 +++++++ .../src/orchestration-v2/ProjectionStore.ts | 41 ++ ...ficationCoordinator.badge.browser.test.tsx | 23 ++ .../ThreadNotificationCoordinator.tsx | 1 + .../chat/MessagesTimeline.browser.test.tsx | 77 ++++ .../chat/MessagesTimeline.logic.test.ts | 373 +++++++++++++++--- .../components/chat/MessagesTimeline.logic.ts | 161 +++++++- .../src/components/chat/MessagesTimeline.tsx | 41 +- .../components/chat/TimelineWorkGroupRows.tsx | 11 +- .../src/components/chat/useComposerSend.ts | 8 +- apps/web/src/session-logic.ts | 1 + apps/web/src/types.ts | 1 + .../client-runtime/src/operations/commands.ts | 7 + packages/client-runtime/src/userMessage.ts | 13 + packages/contracts/src/orchestrationV2.ts | 42 ++ packages/shared/src/agentAwareness.test.ts | 14 + packages/shared/src/agentAwareness.ts | 6 + packages/shared/src/claudeCompaction.ts | 21 +- 27 files changed, 1375 insertions(+), 82 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 48b1346646acf..11857f0e2b197 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -28,13 +28,17 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; -import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; +import { + IDLE_COMPACTING_LABELS, + resolveUserMessagePresentation, +} from "@t3tools/client-runtime/user-message"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { AuthFilesystemReadScope, + idleCompactionOf, type OrchestrationMessageContext, ThreadId, type ChatAttachment, @@ -195,6 +199,7 @@ import { } from "../../lib/markdownLinks"; import { createThreadFeedCache, + threadFeedCompactionLabels, failedFeedRunIds, deriveThreadFeedPresentation, threadFeedRunIsUnsettled, @@ -224,7 +229,11 @@ import { ThreadWorkLog, WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; -import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; +import { + appendPendingThreadMessages, + isCompactionCommandFeedEntry, + type PendingThreadFeedEntry, +} from "./pending-thread-feed"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; @@ -1192,6 +1201,13 @@ function ThreadAutomationMessageRow(props: { ); } +/** Leads with why T3 Code compacted, when it did so without being asked. */ +function compactionLabel(label: string, title: string | undefined) { + return title !== undefined && label.startsWith("Context compacted") + ? title + label.slice("Context compacted".length) + : label; +} + function renderFeedEntry( info: { item: PendingThreadFeedEntry; index: number }, props: Pick< @@ -1210,6 +1226,7 @@ function renderFeedEntry( readonly workGroupScrollPositions: Map; readonly terminalAssistantMessageIds: ReadonlySet; readonly unsettledTurnId: RunId | null; + readonly compactionLabelsByEntryId: ReadonlyMap; readonly failedRunIds: ReadonlySet; readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void; @@ -1361,16 +1378,29 @@ function renderFeedEntry( if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) { const label = entry.activities[0]!.summary; + const item = entry.activities[0]!.projectedItem.item; const active = props.unsettledTurnId !== null && entry.runId === props.unsettledTurnId && - entry.activities[0]!.projectedItem.item.status === "running"; + item.status === "running"; + const labels = props.compactionLabelsByEntryId.get(entry.id); + const failure = + item.type === "compaction" && item.status === "failed" ? item.summary?.trim() : undefined; return ( ); } @@ -1409,6 +1439,25 @@ function renderFeedEntry( /> ); } + if (isCompactionCommandFeedEntry(entry)) { + // Stands in for the compaction item until the provider reports it. + const active = props.unsettledTurnId !== null && message.runId === props.unsettledTurnId; + const reason = idleCompactionOf(message); + return ( + + ); + } if (isUser && presentation.attribution === "automation") { const rowId = `automation:${message.id}`; return ( @@ -2505,6 +2554,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const reviewCommentColors = useReviewCommentColors(); const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null; + const compactionLabelsByEntryId = useMemo( + () => threadFeedCompactionLabels(props.feed), + [props.feed], + ); // LegendList does not invalidate visible rows when only the renderItem closure changes. // Include turn completion so unchanged message rows reveal their footer and spacing // even when the final message update arrives before the turn settles. @@ -2987,6 +3040,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { workGroupScrollPositions, terminalAssistantMessageIds, unsettledTurnId, + compactionLabelsByEntryId, failedRunIds, onCopyWorkRow, onToggleWorkGroup, @@ -3124,6 +3178,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { keyExtractor={(entry) => entry.id} getItemType={(entry) => { if (entry.type !== "message") return entry.type; + if (isCompactionCommandFeedEntry(entry)) return "message:compaction"; const { attribution } = resolveUserMessagePresentation(entry.message); return attribution === "automation" || attribution === "agent" ? `message:${attribution}` diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts index 92c39ab1b8246..be82e4b6510ad 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts @@ -8,7 +8,7 @@ import { ThreadId, } from "@t3tools/contracts"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; -import { appendPendingThreadMessages } from "./pending-thread-feed"; +import { appendPendingThreadMessages, isCompactionCommandFeedEntry } from "./pending-thread-feed"; const pending = (id: string): QueuedThreadMessage => ({ environmentId: EnvironmentId.make("env"), @@ -21,6 +21,35 @@ const pending = (id: string): QueuedThreadMessage => ({ }); describe("pending timeline messages", () => { + it.each([false, true])( + "keeps a queued compact command in the pending message row (attachment: %s)", + (withAttachment) => { + const queued = { + ...pending("compact"), + text: "/compact", + attachments: withAttachment + ? [ + { + id: "image-1", + type: "image" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + fileUri: "file:///documents/photo.png", + previewUri: "file:///documents/photo.png", + }, + ] + : [], + }; + const entry = appendPendingThreadMessages([], [], [queued])[0]!; + expect(isCompactionCommandFeedEntry(entry)).toBe(false); + expect(entry.pendingMessage).toEqual(queued); + if (!withAttachment) { + expect(isCompactionCommandFeedEntry({ ...entry, pendingMessage: undefined })).toBe(true); + } + }, + ); + it("marks the synthetic creation prompt pending without duplicating it", () => { const queued = { ...pending("creation"), diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts index 6df93566286f6..80ed9798b98f7 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.ts @@ -1,3 +1,4 @@ +import { isCompactionCommandMessage } from "@t3tools/contracts"; import type { ThreadFeedEntry } from "../../lib/threadActivity"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; @@ -45,3 +46,11 @@ export function appendPendingThreadMessages( })), ]; } + +export function isCompactionCommandFeedEntry(entry: PendingThreadFeedEntry): boolean { + return ( + entry.type === "message" && + entry.pendingMessage === undefined && + isCompactionCommandMessage(entry.message) + ); +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index c2740dd00069f..d64763aa2788e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -24,6 +24,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadFeed, + threadFeedCompactionLabels, createThreadFeedCache, deriveThreadFeedPresentation, messageFoldRunId, @@ -76,6 +77,144 @@ it("keeps historical plan detail accessible from its paged turn item", () => { expect(presented[2]).toMatchObject({ activities: [{ projectedItem: { item } }] }); }); +it("shows a compact command as one mobile divider before, during, and after compaction", () => { + const startedAt = "2026-06-20T00:00:01.000Z"; + const prompt = projected({ ...userMessage(), text: "/compact", idleCompaction: "onReturn" }, 0); + const latestRun = { runId, status: "running" as const, startedAt, completedAt: null }; + const present = (items: OrchestrationV2ProjectedTurnItem[]) => + deriveThreadFeedPresentation( + buildThreadFeed(items), + latestRun, + new Set(), + new Set(), + startedAt, + ); + + const waiting = present([prompt]); + expect(waiting.map((entry) => entry.type)).toEqual(["message"]); + expect(waiting[0]?.type === "message" ? waiting[0].message.idleCompaction : null).toBe( + "onReturn", + ); + for (const status of ["running", "completed", "failed"] as const) { + const compaction = projected( + { + ...base("compaction", "2026-06-20T00:00:02.000Z", 1), + type: "compaction", + driver: ProviderDriverKind.make("claude"), + status, + beforeTokenCount: 100_000, + ...(status === "completed" ? { afterTokenCount: 20_000 } : {}), + ...(status === "failed" ? { summary: "Not enough messages to compact." } : {}), + }, + 1, + ); + const rows = present([prompt, compaction]); + expect(rows.map((entry) => entry.type)).toEqual(["activity-group"]); + expect( + rows[0]?.type === "activity-group" ? rows[0].activities[0]?.projectedItem.item : null, + ).toMatchObject({ type: "compaction", status }); + } +}); + +it.each(["/compact", "Continue"])( + "keeps a completed compaction divider visible while other settled work folds for %s", + (promptText) => { + const rows = deriveThreadFeedPresentation( + buildThreadFeed([ + projected({ ...userMessage(), text: promptText }, 0), + projected(command(), 1), + projected( + { + ...base("compaction", "2026-06-20T00:00:03.000Z", 2), + type: "compaction", + driver: ProviderDriverKind.make("claude"), + status: "completed", + beforeTokenCount: 100_000, + afterTokenCount: 20_000, + }, + 2, + ), + ]), + { + runId, + status: "completed", + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:03.000Z", + }, + new Set(), + ); + expect(rows.filter((entry) => entry.type === "run-fold")).toHaveLength(1); + const activities = rows.flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(activities.map((activity) => activity.projectedItem.item)).toMatchObject([ + { + type: "compaction", + status: "completed", + beforeTokenCount: 100_000, + afterTokenCount: 20_000, + }, + ]); + if (promptText === "/compact") { + expect(rows.some((entry) => entry.type === "message")).toBe(false); + } + }, +); + +it("binds a persisted resume answer only to the next compaction in its run", () => { + const requestId = RuntimeRequestId.make("resume-compaction"); + const question = projected( + { + ...base("resume-question", "2026-06-20T00:00:02.000Z", 1), + type: "user_input_request", + requestId, + questions: [], + questionAnswer: { + requestId, + questionTextById: { + resume: + "This session is 2h 10m old and uses 200,000 tokens. Compact it before continuing?", + }, + answers: { resume: "Compact and continue" }, + attachmentsByQuestionId: {}, + }, + }, + 1, + ); + const compaction = (id: string, position: number, status: "running" | "completed") => + projected( + { + ...base(id, `2026-06-20T00:00:0${position + 1}.000Z`, position), + type: "compaction", + driver: ProviderDriverKind.make("claude"), + status, + beforeTokenCount: 200_000, + }, + position, + ); + for (const status of ["running", "completed"] as const) { + // Rebuild from persisted items, without a live question or prior feed cache. + const feed = buildThreadFeed([ + projected(userMessage(), 0), + question, + compaction("resume-compact", 2, "completed"), + compaction("auto-compact", 3, status), + ]); + const labels = threadFeedCompactionLabels(feed); + const compactRows = feed.filter( + (entry) => + entry.type === "activity-group" && + entry.activities[0]?.projectedItem.item.type === "compaction", + ); + expect(compactRows).toHaveLength(2); + expect(labels.get(compactRows[0]!.id)).toEqual({ + title: "Context compacted after 2h 10m idle", + compacting: "Compacting after 2h 10m idle…", + }); + expect(labels.get(compactRows[1]!.id)).toBeUndefined(); + } +}); + function base(id: string, updatedAt: string, ordinal: number) { const timestamp = DateTime.makeUnsafe(updatedAt); return { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cfa562711cf1a..485c245818974 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -53,7 +53,7 @@ import type { ScheduledTaskId, TurnItemId, } from "@t3tools/contracts"; -import { RunId, ThreadId } from "@t3tools/contracts"; +import { idleCompactionOf, isCompactionCommandMessage, RunId, ThreadId } from "@t3tools/contracts"; import { classifyToolActivity, collectToolFilePaths, @@ -62,6 +62,7 @@ import { formatReadToolLabel, formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; +import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { truncate } from "@t3tools/shared/String"; import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; @@ -72,6 +73,10 @@ import { mcpAppFromToolItem, } from "@t3tools/shared/toolOutput"; import * as DateTime from "effect/DateTime"; +import { + IDLE_COMPACTION_TITLES, + IDLE_COMPACTING_LABELS, +} from "@t3tools/client-runtime/user-message"; export type PendingApproval = ThreadPendingApproval; export type PendingUserInput = ThreadPendingUserInput; @@ -141,6 +146,7 @@ export interface ThreadFeedMessage { readonly creationSource?: OrchestrationV2CreationSource; readonly scheduledTaskId?: ScheduledTaskId; readonly senderThreadId?: ThreadId; + readonly idleCompaction?: import("@t3tools/contracts").OrchestrationV2IdleCompaction; readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"]; readonly sourceThreadId: ThreadId; readonly createdAt: string; @@ -352,6 +358,49 @@ export function isContextCompactionActivityGroup(entry: ThreadFeedActivityGroup) ); } +export function threadFeedCompactionLabels(feed: ReadonlyArray) { + const labelsByRunId = new Map(); + const labelsByEntryId = new Map(); + const pendingResumeAgeByRunId = new Map(); + for (const entry of feed) { + const reason = entry.type === "message" ? idleCompactionOf(entry.message) : undefined; + if (entry.type === "message" && entry.message.runId !== null && reason !== undefined) { + labelsByRunId.set(entry.message.runId, { + title: IDLE_COMPACTION_TITLES[reason], + compacting: IDLE_COMPACTING_LABELS[reason], + }); + } else if (entry.type === "activity-group" && entry.runId !== null) { + if (isContextCompactionActivityGroup(entry)) { + const age = pendingResumeAgeByRunId.get(entry.runId); + const labels = + labelsByRunId.get(entry.runId) ?? + (age === undefined + ? undefined + : { + title: `Context compacted after ${age} idle`, + compacting: `Compacting after ${age} idle…`, + }); + if (labels) labelsByEntryId.set(entry.id, labels); + pendingResumeAgeByRunId.delete(entry.runId); + } + for (const activity of entry.activities) { + const age = activity.workEntry.questionAnswer + ? claudeResumeCompactionAge(activity.workEntry.questionAnswer) + : null; + if (age !== null) pendingResumeAgeByRunId.set(entry.runId, age); + } + } + } + return labelsByEntryId; +} + +function isResumeCompactionAnswerGroup(entry: ThreadFeedActivityGroup): boolean { + return entry.activities.every((activity) => { + const answer = activity.workEntry.questionAnswer; + return answer !== undefined && claudeResumeCompactionAge(answer) !== null; + }); +} + export function isContextHandoffActivityGroup(entry: ThreadFeedActivityGroup): boolean { return ( entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "handoff" @@ -1183,6 +1232,7 @@ function deriveThreadFeedRunFolds( entry.id !== terminalAssistantId && entry.type !== "html-render" && entry.type !== "mcp-app" && + !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) && !( entry.type === "activity-group" && entry.activities.some( @@ -1396,6 +1446,26 @@ export function deriveThreadFeedPresentation( const failedRunIds = failedFeedRunIds(sourceFeed, latestRun); const activeTailGroup = sourceFeed.at(-1); const activeRunId = unsettledRunId(latestRun); + // A bare `/compact` turn is the compaction itself: its compaction row stands in for the prompt + // and the live activity row. An answered "compact before continuing?" question folds into the + // compaction it asked for. + const compactionCommandRunIds = new Set(); + const compactionItemRunIds = new Set(); + for (const entry of feed) { + if ( + entry.type === "message" && + entry.message.runId !== null && + isCompactionCommandMessage(entry.message) + ) { + compactionCommandRunIds.add(entry.message.runId); + } else if ( + entry.type === "activity-group" && + entry.runId !== null && + isContextCompactionActivityGroup(entry) + ) { + compactionItemRunIds.add(entry.runId); + } + } const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; const foldsByAnchorId = deriveThreadFeedRunFolds( sourceFeed, @@ -1450,6 +1520,18 @@ export function deriveThreadFeedPresentation( } result.push(row); } + if ( + (entry.type === "message" && + entry.message.runId !== null && + compactionItemRunIds.has(entry.message.runId) && + isCompactionCommandMessage(entry.message)) || + (entry.type === "activity-group" && + entry.runId !== null && + compactionItemRunIds.has(entry.runId) && + isResumeCompactionAnswerGroup(entry)) + ) { + continue; + } if (!collapsedEntryIds.has(entry.id)) { if ( entry.type === "activity-group" && @@ -1487,6 +1569,7 @@ export function deriveThreadFeedPresentation( isWorking && activeWorkStartedAt !== null && latestRun?.turnEnded !== true && + (activeRunId === null || !compactionCommandRunIds.has(activeRunId)) && !result.some( (row) => row.id === LIVE_ACTIVITY_ROW_ID || @@ -2177,6 +2260,7 @@ export function buildThreadFeed( creationSource: item.creationSource, ...(item.scheduledTaskId ? { scheduledTaskId: item.scheduledTaskId } : {}), ...(item.senderThreadId ? { senderThreadId: item.senderThreadId } : {}), + ...(item.idleCompaction ? { idleCompaction: item.idleCompaction } : {}), } : {}), visibility: row.visibility, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0c8b2ad588acc..0b6232479c40a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4293,15 +4293,35 @@ describe("ClaudeAdapterV2 background wake turns", () => { const [running, settled] = updates; assert.equal(running?.status, "running"); assert.equal(settled?.status, compaction === "success" ? "completed" : "failed"); + assert.equal( + settled?.type === "compaction" ? settled.summary : undefined, + compaction === "failed" + ? limited + ? compactError + : "Not enough messages to compact." + : undefined, + ); assert.equal(settled?.id, running?.id); assert.equal(settled?.ordinal, running?.ordinal); assert.deepEqual(settled?.startedAt, running?.startedAt); assert.isNotNull(settled?.completedAt); assert.isEmpty(harness.terminalEvents()); - yield* Queue.offer( + yield* Queue.offerAll( harness.sdkMessages, - compact ? claudeSdkFrame({ ...result, local_command: "compact" }) : result, + compact + ? [ + // Claude Code echoes the command's stdout as a synthetic assistant message. + claudeSdkFrame({ + ...makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000615", + text: "Compacted.", + }), + local_command_source: "Compacted.", + }), + claudeSdkFrame({ ...result, local_command: "compact" }), + ] + : [result], ); const terminal = yield* Queue.take(harness.terminalReceipts); assert.deepEqual( @@ -4313,6 +4333,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { updates, ); assert.equal(terminal.status, status); + assert.equal( + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ), + !compact, + ); if (terminal.status !== "failed") return; assert.equal(terminal.failure.class, "usage_limit"); assert.equal(terminal.failure.message, compactError); @@ -4327,6 +4354,70 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("keeps an unreported /compact failure's reason on the compaction, not as a reply", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-compaction-unreported"), + text: "/compact", + attachments: [], + }), + ); + // Claude can refuse a /compact without a compact_result: the only reason is the result text. + yield* Queue.offerAll(harness.sdkMessages, [ + claudeSdkFrame({ + type: "system", + subtype: "status", + status: "compacting", + uuid: "00000000-0000-4000-8000-000000000620", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + ...makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000622", + text: "Not enough messages to compact.", + }), + local_command_source: + "Not enough messages to compact.", + }), + claudeSdkFrame({ + ...makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000621", + result: "Not enough messages to compact.", + numTurns: 0, + terminalReason: null, + }), + local_command: "compact", + }), + ]); + yield* Queue.take(harness.terminalReceipts); + const settled = harness.events + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(settled?.status, "failed"); + assert.equal(settled?.summary, "Not enough messages to compact."); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each([ { name: "parent limit", parentErrors: ["rate_limit"], expectedLimit: true }, { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0e0868918eaa4..792195ebfa80e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -58,6 +58,7 @@ import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { + CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, formatClaudeResumeCompactionQuestion, } from "@t3tools/shared/claudeCompaction"; @@ -66,6 +67,7 @@ import { ClaudeSettings, TrimmedNonEmptyString, defaultInstanceIdForDriver, + isCompactionCommandMessage, type ModelSelection, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, @@ -6641,6 +6643,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); if (input.context.compaction?.status === "running") { + const reason = input.result?.subtype === "success" ? input.result.result.trim() : ""; yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -6651,6 +6654,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.status === "completed" || input.status === "failed" ? "Context compaction failed" : "Context compaction stopped", + ...(reason === "" ? {} : { summary: reason }), completedAt: input.completedAt, updatedAt: input.completedAt, }, @@ -7767,6 +7771,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : message.compact_result === "failed" ? "Context compaction failed" : "Context compaction stopped", + ...(message.compact_result === "failed" && message.compact_error + ? { summary: message.compact_error } + : {}), completedAt: now, updatedAt: now, }; @@ -8484,6 +8491,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return; } + // Claude Code echoes /compact's stdout ("Not enough messages to compact.") as a + // synthetic assistant message. The compaction item reports that outcome; it is not a + // reply. + if ( + message.type === "assistant" && + "local_command_source" in message && + isCompactionCommandMessage({ role: "user", ...context.input.message }) + ) { + return; + } if (assistantText !== null && assistantText.text.length > 0) { yield* emitAssistantTextArtifacts({ context, @@ -8535,6 +8552,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? null : resultTextFromSdkMessage(message); if ( + // A /compact's result text reports the compaction ("Not enough messages to + // compact."), which its compaction item already carries; it is not a reply. + !( + message.type === "result" && + message.subtype === "success" && + message.local_command === "compact" + ) && context.assistant.emittedNativeItemIds.size === 0 && context.assistant.fallbackText.length === 0 && resultText !== null && @@ -9108,7 +9132,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( question, options: [ { - label: "Compact and continue", + label: CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, description: "Resume with a summary and use fewer tokens.", }, { @@ -9142,7 +9166,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return { behavior: "completed" as const, result: - selection === "Compact and continue" + selection === CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER ? ("compact" as const) : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER ? ("never" as const) diff --git a/apps/server/src/orchestration-v2/IdleCompactionService.ts b/apps/server/src/orchestration-v2/IdleCompactionService.ts index 30c81ba703323..2b16cb8c1d73d 100644 --- a/apps/server/src/orchestration-v2/IdleCompactionService.ts +++ b/apps/server/src/orchestration-v2/IdleCompactionService.ts @@ -1,5 +1,6 @@ import { CommandId, + IDLE_COMPACTION_TEXT, MessageId, resolveIdleCompaction, resolveProjectProviderInstanceEnabled, @@ -70,10 +71,11 @@ export const make = Effect.gen(function* () { commandId: CommandId.make(`server:idle-compact:${threadId}:${runId}`), messageId: MessageId.make(`idle-compact:${threadId}:${runId}`), threadId, - text: "/compact", + text: IDLE_COMPACTION_TEXT, attachments: [], createdBy: "agent", creationSource: "server", + idleCompaction: "beforeCacheExpires", dispatchMode: { type: "start_immediately" }, }); return; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 77533f593e9a7..48972ce4cd445 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1876,6 +1876,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.senderThreadId === undefined ? {} : { senderThreadId: queuedMessage.senderThreadId }), + ...(queuedMessage.idleCompaction === undefined + ? {} + : { idleCompaction: queuedMessage.idleCompaction }), }), providerThreadId: queuedProviderThread.id, inputIntent: "queued_turn", @@ -4145,6 +4148,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly creationSource: OrchestrationV2ConversationMessage["creationSource"]; readonly scheduledTaskId?: OrchestrationV2ConversationMessage["scheduledTaskId"]; readonly senderThreadId?: OrchestrationV2ConversationMessage["senderThreadId"]; + readonly idleCompaction?: OrchestrationV2ConversationMessage["idleCompaction"]; readonly delegatedCompletion?: OrchestrationV2ConversationMessage["delegatedCompletion"]; readonly forceRestart: boolean; }) { @@ -4302,6 +4306,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: input.scheduledTaskId }), ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + ...(input.idleCompaction === undefined ? {} : { idleCompaction: input.idleCompaction }), id: input.messageId, threadId: input.command.threadId, runId: messageInput.runId, @@ -4321,6 +4326,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: input.scheduledTaskId }), ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + ...(input.idleCompaction === undefined ? {} : { idleCompaction: input.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: input.messageId }), threadId: input.command.threadId, runId: messageInput.runId, @@ -5256,6 +5262,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), forceRestart: dispatchMode.type === "restart_active", }); return; @@ -5491,6 +5498,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -5831,6 +5839,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -5852,6 +5861,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -6518,6 +6528,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -6539,6 +6550,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -8165,6 +8177,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.senderThreadId === undefined ? {} : { senderThreadId: queuedMessage.senderThreadId }), + ...(queuedMessage.idleCompaction === undefined + ? {} + : { idleCompaction: queuedMessage.idleCompaction }), forceRestart: false, }); }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index fcadd8036b924..14fed37c06183 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2879,6 +2879,150 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + it.effect.each([ + ["T3 Code's idle compaction", "agent", "server", undefined, true, "/compact"], + ["compacting on return", "user", "web", "onReturn", true, "/compact"], + ["a typed /compact", "user", "mobile", undefined, false, "/compact"], + ["a normalized idle command", "agent", "server", undefined, true, " \t\u00a0/COMPACT\u3000\n"], + ["an ordinary prompt with idle metadata", "user", "web", "onReturn", false, "Continue"], + ] as const)( + "marks the shell's latest run silent only for %s", + ([name, createdBy, creationSource, idleCompaction, silent, text]) => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const key = name.replaceAll(/\W+/g, "-"); + const threadId = ThreadId.make(`thread:projection-shell-silent:${key}`); + const runId = RunId.make(`run:projection-shell-silent:${key}`); + const messageId = MessageId.make(`message:projection-shell-silent:${key}`); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:thread`), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:projection-shell-silent"), + title: "Idle compaction", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:message`), + type: "message.updated", + threadId, + occurredAt: now, + payload: { + createdBy, + creationSource, + ...(idleCompaction === undefined ? {} : { idleCompaction }), + id: messageId, + threadId, + runId, + nodeId: null, + role: "user", + text, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:run`), + type: "run.created", + threadId, + runId, + nodeId: NodeId.make(`node:projection-shell-silent:${key}`), + driver, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }); + + const sqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ); + const memoryShell = ProjectionStore.threadShellFromProjection( + yield* projectionStore.getThreadProjection(threadId), + ); + assert.equal(sqlShell?.latestRunSilent, silent); + assert.equal(memoryShell.latestRunSilent, silent); + + // Claude can finish the run successfully even when its /compact failed. + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:failure`), + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:projection-shell-silent:${key}:compaction`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "compaction", + driver, + status: "failed", + title: "Context compaction failed", + summary: "Not enough messages to compact.", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }); + assert.equal( + (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )?.latestRunSilent, + false, + ); + assert.equal( + ProjectionStore.threadShellFromProjection( + yield* projectionStore.getThreadProjection(threadId), + ).latestRunSilent, + false, + ); + }), + ); + it.effect("only exposes interruptible runs through the shell activeRunId", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a50ce6ff35244..438cf79138dd8 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -42,6 +42,8 @@ import type { RunAttemptId, } from "@t3tools/contracts"; import { + IDLE_COMPACTION_TEXT, + idleCompactionOf, OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema, OrchestrationV2CheckpointJson as OrchestrationV2CheckpointJsonSchema, OrchestrationV2CheckpointScopeJson as OrchestrationV2CheckpointScopeJsonSchema, @@ -1084,6 +1086,7 @@ type ShellThreadRow = { readonly latest_run_started_at: string | null; readonly latest_run_completed_at: string | null; readonly latest_run_stop_requested: number; + readonly latest_run_silent: number; readonly active_run_id: string | null; readonly active_run_status: string | null; readonly activity_run_status: string | null; @@ -1634,6 +1637,16 @@ export function threadShellFromProjection( projection.turnItems.some( (item) => item.runId === latestRun.id && item.type === "run_interrupt_request", ), + latestRunSilent: + latestRun != null && + projection.messages.some( + (message) => + message.id === latestRun.userMessageId && idleCompactionOf(message) !== undefined, + ) && + !projection.turnItems.some( + (item) => + item.runId === latestRun.id && item.type === "compaction" && item.status === "failed", + ), activeRunId: activeRun?.id ?? null, blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun, turnItems: projection.turnItems }), activityRunStatus: activityRun?.status ?? null, @@ -1735,6 +1748,7 @@ type ShellThreadState = { readonly latestRunStartedAt: DateTime.Utc | null; readonly latestRunCompletedAt: DateTime.Utc | null; readonly latestRunStopRequested: boolean; + readonly latestRunSilent: boolean; readonly activeRunId: RunId | null; readonly blockedOnThreadWait: boolean; readonly activityRunStatus: ShellActivityRunStatus | null; @@ -1890,6 +1904,7 @@ function shellFromState(input: { latestRunStartedAt: input.state.latestRunStartedAt, latestRunCompletedAt: input.state.latestRunCompletedAt, latestRunStopRequested: input.state.latestRunStopRequested, + latestRunSilent: input.state.latestRunSilent, activeRunId: input.state.activeRunId, blockedOnThreadWait: input.state.blockedOnThreadWait, activityRunStatus: input.state.activityRunStatus, @@ -5687,6 +5702,29 @@ export const layer: Layer.Layer = WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = presented.run_id AND stop_request.type = 'run_interrupt_request' ) AS latest_run_stop_requested, + -- Mirrors idleCompactionOf, including JavaScript trim whitespace. + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_messages prompt + WHERE prompt.message_id = json_extract(presented.payload_json, '$.userMessageId') + AND prompt.role = 'user' + AND lower(trim(json_extract(prompt.payload_json, '$.text'), + char(9, 10, 11, 12, 13, 32, 160, 5760, 8192, 8193, 8194, 8195, + 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288, 65279) + )) = ${IDLE_COMPACTION_TEXT} + AND coalesce(json_array_length(prompt.payload_json, '$.attachments'), 0) = 0 + AND ( + json_extract(prompt.payload_json, '$.idleCompaction') IS NOT NULL + OR ( + json_extract(prompt.payload_json, '$.createdBy') = 'agent' + AND json_extract(prompt.payload_json, '$.creationSource') = 'server' + ) + ) + ) AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items compaction + WHERE compaction.thread_id = t.thread_id AND compaction.run_id = presented.run_id + AND compaction.type = 'compaction' + AND compaction.status = 'failed' + ) AS latest_run_silent, active.run_id AS active_run_id, active.status AS active_run_status, ( @@ -6221,6 +6259,7 @@ export const layer: Layer.Layer = ? null : DateTime.makeUnsafe(row.latest_run_completed_at); let latestRunStopRequested = row.latest_run_stop_requested === 1; + let latestRunSilent = row.latest_run_silent === 1; if (row.blocking_run_id !== null && row.blocking_failure_payload_json !== null) { const blockingFailure = yield* decodeTurnItemPayload( row.blocking_failure_payload_json, @@ -6234,6 +6273,7 @@ export const layer: Layer.Layer = terminalFailureItem = blockingFailure; latestRunId = RunId.make(row.blocking_run_id); latestRunStopRequested = row.blocking_run_stop_requested === 1; + latestRunSilent = false; latestRunStatus = "failed"; latestRunRequestedAt = row.blocking_run_requested_at === null @@ -6274,6 +6314,7 @@ export const layer: Layer.Layer = latestRunStartedAt, latestRunCompletedAt, latestRunStopRequested, + latestRunSilent, activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun: diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index dd537f2a8d0b4..145d6f740f02c 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -80,6 +80,7 @@ const thread = { id: "thread", title: "Test thread", archivedAt: null as string | null, + latestRunSilent: false, hasPendingApprovals: false, hasPendingUserInput: false, session: null as { status: string } | null, @@ -112,6 +113,7 @@ function toV2ThreadShell(input: typeof thread) { createdBy: "user", creationSource: "web", latestRunId: latestTurn.turnId, + latestRunSilent: input.latestRunSilent, activeRunId: null, status: latestTurn.state === "completed" @@ -186,6 +188,7 @@ function setup() { ); let screen: Screen | undefined; return { + ui, async render() { if (screen) await screen.rerender(ui()); else screen = stack.use(await render(ui())); @@ -215,3 +218,23 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( expect(notification.close).toHaveBeenCalled(); }, ); + +it.each([ + ["pre-expiry compaction", true, "completed", []], + ["manual compaction", false, "completed", ["Thread completed"]], + ["failed pre-expiry compaction", true, "error", ["Thread failed"]], +] as const)("notifies for %s only when attention is needed", async (_, silent, status, titles) => { + await using app = setup(); + state.environmentIds = ["one"]; + state.shells.set("one", shell({ latestRunSilent: silent })); + await using screen = await render(app.ui()); + state.shells.set( + "one", + shell({ + latestRunSilent: silent, + latestTurn: { turnId: "turn", state: status, completedAt: "2026-09-13T07:01:00.000Z" }, + }), + ); + await screen.rerender(app.ui()); + expect(TestNotification.sent.map((notification) => notification.title)).toEqual(titles); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index c9355fc7f8206..edc1b8e2f3c67 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -155,6 +155,7 @@ function EnvironmentNotifications({ attention && attention !== prior.attention ? "input" : !rawThread.latestRunStopRequested && + !rawThread.latestRunSilent && completion !== null && (prior.completion === null || completion > prior.completion) ? "completion" diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 616650f5461e4..af22e5020a71f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -7,6 +7,8 @@ import "~/index.css"; import { act, render } from "../../../test/render"; import { MessagesTimeline } from "./MessagesTimeline"; +import { makeStreamingTimelineFixture } from "../../test-fixtures"; +import { deriveTimelineEntriesFromVisibleTurnItems } from "../../session-logic"; import { remarkThoughtPreview } from "./TimelineWorkEntryRow.logic"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; import { collectThreadFindRanges } from "./threadFindHighlights"; @@ -386,3 +388,78 @@ describe("compact reasoning math", () => { ]); }); }); + +it("renders automatic compaction as one live divider that finishes with its reason and token delta", async () => { + setup(); + const fixture = makeStreamingTimelineFixture(); + const prompt = fixture.visibleTurnItems[3]!; + const assistant = fixture.visibleTurnItems[5]!; + if (prompt.item.type !== "user_message" || assistant.item.type !== "assistant_message") { + throw new Error("Expected streaming fixture messages"); + } + const promptItem = prompt.item; + const compaction = { + ...assistant, + item: { + ...assistant.item, + type: "compaction" as const, + driver: null, + beforeTokenCount: 899_000, + }, + }; + const entries = (completed: boolean) => + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + ...prompt, + item: { ...promptItem, text: "/compact", idleCompaction: "beforeCacheExpires" }, + }, + { + ...compaction, + item: { + ...compaction.item, + status: completed ? "completed" : "running", + afterTokenCount: completed ? 19_000 : undefined, + }, + }, + ], + optimisticMessages: [], + }); + const props = baseProps(); + const ui = (completed: boolean) => ( + + ); + const container = document.createElement("div"); + container.style.cssText = "height: 1200px; width: 900px; display: flex; flex-direction: column;"; + await using screen = await render(ui(false), { container }); + await expect + .element(screen.getByRole("separator", { name: "Compacting before the cache expires…" })) + .toBeVisible(); + expect(container.textContent).not.toContain("/compact"); + expect(container.textContent).not.toContain("Thinking"); + expect(container.textContent).not.toContain("Working"); + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(1); + await screen.rerender(ui(true)); + await expect + .element( + screen.getByRole("separator", { + name: "Context automatically compacted before cache expiry 899K → 19K tokens", + }), + ) + .toBeVisible(); + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(1); + expect(container.textContent).not.toContain("Compacting before"); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 84521d67af0cd..2fae89baadcf8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -457,68 +457,347 @@ describe("deriveMessagesTimelineRows", () => { }, ); - it("gives live compaction the activity slot and restores Thinking when it completes", () => { + it.each([false, true])( + "gives live compaction the activity slot and restores Thinking (runless: %s)", + (runless) => { + const fixture = makeStreamingTimelineFixture(); + const last = fixture.visibleTurnItems.at(-1)!; + if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); + const compact = { + ...last, + item: { + ...last.item, + type: "compaction" as const, + status: "running" as const, + driver: null, + beforeTokenCount: 899_000, + }, + }; + const input = { + isWorking: true, + runningRunId: fixture.runId, + activeTurnStartedAt: fixture.time(5), + turnDiffSummaries: [], + supportsConversationRollback: false, + }; + const runningEntries = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [...fixture.visibleTurnItems.slice(0, -1), compact], + optimisticMessages: [], + }); + const runningRows = deriveMessagesTimelineRows({ + ...input, + runningRunId: runless ? null : fixture.runId, + runlessWorkActive: runless, + timelineEntries: runless + ? runningEntries.map((entry) => + entry.kind === "work" ? { ...entry, entry: { ...entry.entry, runId: null } } : entry, + ) + : runningEntries, + }); + expect(runningRows.find((row) => row.kind === "context-compaction")).toMatchObject({ + active: true, + label: "Compacting context", + }); + expect(runningRows.some((row) => row.kind === "thinking")).toBe(false); + + const completedRows = deriveMessagesTimelineRows({ + ...input, + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + ...fixture.visibleTurnItems.slice(0, -1), + { + ...compact, + item: { ...compact.item, status: "completed", afterTokenCount: 19_000 }, + }, + ], + optimisticMessages: [], + }), + }); + expect(completedRows.find((row) => row.kind === "context-compaction")).toMatchObject({ + active: false, + label: "Context compacted 899K → 19K tokens", + tokenCounts: { before: "899K", after: "19K" }, + }); + expect(completedRows.at(-1)?.kind).toBe("thinking"); + + const settledRows = deriveMessagesTimelineRows({ + ...input, + isWorking: false, + timelineEntries: runningEntries, + }); + expect(settledRows.find((row) => row.kind === "context-compaction")).toMatchObject({ + active: false, + }); + const stable = computeStableMessagesTimelineRows(runningRows, { + result: [], + byId: new Map(), + }); + expect(computeStableMessagesTimelineRows(settledRows, stable).result).toEqual(settledRows); + }, + ); + + it("shows each compaction trigger as one live status, then a reasoned token divider", () => { const fixture = makeStreamingTimelineFixture(); - const last = fixture.visibleTurnItems.at(-1)!; - if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); - const compact = { - ...last, + const [history0, history1, history2, liveUser, , liveAssistant] = fixture.visibleTurnItems; + if (liveUser?.item.type !== "user_message" || liveAssistant?.item.type !== "assistant_message") + throw new Error("Expected live fixture items"); + const compactPrompt = (idleCompaction: "beforeCacheExpires" | "onReturn" | undefined) => ({ + ...liveUser, + item: { ...liveUser.item, text: "/compact", idleCompaction }, + }); + const compaction = { + ...liveAssistant, item: { - ...last.item, + ...liveAssistant.item, type: "compaction" as const, - status: "running" as const, driver: null, beforeTokenCount: 899_000, }, }; - const input = { - isWorking: true, - runningRunId: fixture.runId, - activeTurnStartedAt: fixture.time(5), - turnDiffSummaries: [], - supportsConversationRollback: false, - }; - const runningEntries = deriveTimelineEntriesFromVisibleTurnItems({ - visibleTurnItems: [...fixture.visibleTurnItems.slice(0, -1), compact], - optimisticMessages: [], - }); - const runningRows = deriveMessagesTimelineRows({ ...input, timelineEntries: runningEntries }); - expect(runningRows.find((row) => row.kind === "context-compaction")).toMatchObject({ - active: true, - label: "Compacting context", - }); - expect(runningRows.some((row) => row.kind === "thinking")).toBe(false); + const rowsFor = (visibleTurnItems: OrchestrationV2ProjectedTurnItem[], isWorking = true) => + deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [history0!, history1!, history2!, ...visibleTurnItems], + optimisticMessages: [], + }), + isWorking, + runningRunId: fixture.runId, + latestRun: { + runId: fixture.runId, + status: isWorking ? "running" : "completed", + startedAt: fixture.time(5), + completedAt: isWorking ? null : fixture.time(9), + }, + activeTurnStartedAt: fixture.time(5), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); - const completedRows = deriveMessagesTimelineRows({ - ...input, - timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ - visibleTurnItems: [ - ...fixture.visibleTurnItems.slice(0, -1), + // Only compacting on return holds a sent message back, so only it speaks through the working + // row; the others keep their live divider. + for (const [reason, live, title] of [ + [ + "beforeCacheExpires", + { + kind: "context-compaction", + active: true, + label: "Compacting before the cache expires…", + }, + "Context automatically compacted before cache expiry", + ], + [ + "onReturn", + { kind: "working", compacting: "Compacting after being idle…" }, + "Context compacted after being idle", + ], + [undefined, { kind: "context-compaction", active: true }, undefined], + ] as const) { + const prompt = compactPrompt(reason); + for (const rows of [rowsFor([prompt]), rowsFor([prompt, compaction])]) { + const liveRows = rows.slice( + rows.findIndex((row) => row.id.includes("history-assistant")) + 1, + ); + expect(liveRows).toMatchObject([live]); + } + const completed = rowsFor( + [ + prompt, { - ...compact, - item: { ...compact.item, status: "completed", afterTokenCount: 19_000 }, + ...compaction, + item: { ...compaction.item, status: "completed", afterTokenCount: 19_000 }, }, ], - optimisticMessages: [], - }), - }); - expect(completedRows.find((row) => row.kind === "context-compaction")).toMatchObject({ - active: false, - label: "Context compacted 899K → 19K tokens", - tokenCounts: { before: "899K", after: "19K" }, - }); - expect(completedRows.at(-1)?.kind).toBe("thinking"); + false, + ); + const liveRows = completed.slice( + completed.findIndex((row) => row.id.includes("history-assistant")) + 1, + ); + expect(liveRows).toMatchObject([ + { + kind: "context-compaction", + active: false, + tokenCounts: { before: "899K", after: "19K" }, + }, + ]); + expect(liveRows[0]?.kind === "context-compaction" ? liveRows[0].title : null).toBe(title); + const terminalCompaction = { + ...compaction, + item: { ...compaction.item, status: "completed" as const, afterTokenCount: 19_000 }, + }; + const finishingRows = rowsFor([prompt, terminalCompaction]); + const finishingLiveRows = finishingRows.slice( + finishingRows.findIndex((row) => row.id.includes("history-assistant")) + 1, + ); + expect(finishingLiveRows).toEqual(liveRows); + const continuingRows = rowsFor([ + prompt, + terminalCompaction, + { ...liveAssistant, item: { ...liveAssistant.item, id: TurnItemId.make("continued") } }, + ]); + expect(continuingRows.filter((row) => row.kind === "context-compaction")).toHaveLength(1); + expect(continuingRows.filter((row) => row.kind === "working")).toMatchObject([ + { kind: "working" }, + ]); + expect(continuingRows.some((row) => row.kind === "working" && row.compacting)).toBe(false); + const retries = rowsFor( + [ + prompt, + { + ...compaction, + item: { ...compaction.item, status: "failed", summary: "retry needed" }, + }, + { + ...compaction, + item: { + ...compaction.item, + id: TurnItemId.make("retry-compaction"), + status: "completed", + }, + }, + ], + false, + ).filter((row) => row.kind === "context-compaction"); + expect(retries).toHaveLength(2); + expect(retries[0]).toMatchObject({ failed: true, failure: "retry needed" }); + expect(new Set(retries.map((row) => row.id)).size).toBe(2); + const stable = computeStableMessagesTimelineRows(retries, { result: [], byId: new Map() }); + expect(computeStableMessagesTimelineRows(retries, stable).result).toEqual(retries); + } + }); - const settledRows = deriveMessagesTimelineRows({ - ...input, - isWorking: false, - timelineEntries: runningEntries, + it.each(["/compact", "Continue"])( + "keeps a completed compaction divider visible while other settled work folds for %s", + (promptText) => { + const fixture = makeStreamingTimelineFixture(); + const [, , , liveUser, liveWork, liveAssistant] = fixture.visibleTurnItems; + if ( + liveUser?.item.type !== "user_message" || + liveAssistant?.item.type !== "assistant_message" || + !liveWork + ) + throw new Error("Expected live fixture items"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { ...liveUser, item: { ...liveUser.item, text: promptText } }, + liveWork, + { + ...liveAssistant, + item: { + ...liveAssistant.item, + type: "compaction", + driver: null, + status: "completed", + beforeTokenCount: 100_000, + afterTokenCount: 20_000, + }, + }, + ], + optimisticMessages: [], + }), + isWorking: false, + latestRun: { + runId: fixture.runId, + status: "completed", + startedAt: fixture.time(5), + completedAt: fixture.time(8), + }, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.filter((row) => row.kind === "turn-fold")).toHaveLength(1); + expect(rows.some((row) => row.kind === "work")).toBe(false); + expect(rows.filter((row) => row.kind === "context-compaction")).toMatchObject([ + { active: false, tokenCounts: { before: "100K", after: "20K" } }, + ]); + if (promptText === "/compact") { + expect(rows.some((row) => row.kind === "message")).toBe(false); + } + }, + ); + + it("folds an answered compact-on-return question into its compaction row", () => { + const fixture = makeStreamingTimelineFixture(); + const [history0, history1, history2, liveUser, liveWork, liveAssistant] = + fixture.visibleTurnItems; + if (liveWork?.item.type !== "dynamic_tool" || liveAssistant?.item.type !== "assistant_message") + throw new Error("Expected live fixture items"); + const { toolName: _toolName, input: _input, output: _output, ...workBase } = liveWork.item; + const question = + "This session is 1h 5m old and uses 412,000 tokens. Compact it before continuing?"; + const requestId = RuntimeRequestId.make("resume-compaction"); + const answered = { + ...liveWork, + item: { + ...workBase, + type: "user_input_request" as const, + requestId, + questions: [], + questionAnswer: { + requestId, + questionTextById: { resume: question }, + answers: { resume: "Compact and continue" }, + attachmentsByQuestionId: {}, + }, + }, + }; + const assistantItem = liveAssistant.item; + const compactionAt = (status: "running" | "completed") => ({ + ...liveAssistant, + item: { ...assistantItem, type: "compaction" as const, status, driver: null }, }); - expect(settledRows.find((row) => row.kind === "context-compaction")).toMatchObject({ + const rowsFor = (status: "running" | "completed", laterCompaction = false) => + deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + history0!, + history1!, + history2!, + liveUser!, + answered, + compactionAt(laterCompaction ? "completed" : status), + ...(laterCompaction + ? [ + { + ...compactionAt(status), + item: { + ...compactionAt(status).item, + id: TurnItemId.make("later-compaction"), + }, + }, + ] + : []), + ], + optimisticMessages: [], + }), + isWorking: status === "running", + runningRunId: fixture.runId, + activeTurnStartedAt: fixture.time(5), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + const liveRows = (status: "running" | "completed") => { + const rows = rowsFor(status); + return rows.slice(rows.findIndex((row) => row.id.includes("live-user")) + 1); + }; + expect(liveRows("running")).toMatchObject([ + { kind: "working", compacting: "Compacting after 1h 5m idle…" }, + ]); + expect(liveRows("completed")).toMatchObject([ + { kind: "context-compaction", title: "Context compacted after 1h 5m idle" }, + ]); + const laterRows = rowsFor("running", true); + const dividers = laterRows.filter((row) => row.kind === "context-compaction"); + expect(dividers).toHaveLength(2); + expect(dividers[0]).toMatchObject({ + title: "Context compacted after 1h 5m idle", active: false, }); - const stable = computeStableMessagesTimelineRows(runningRows, { result: [], byId: new Map() }); - expect(computeStableMessagesTimelineRows(settledRows, stable).result).toEqual(settledRows); + expect(dividers[1]).toMatchObject({ active: true }); + expect(dividers[1]).not.toHaveProperty("title"); + expect(laterRows.some((row) => row.kind === "working" && row.compacting !== undefined)).toBe( + false, + ); }); it("only enables assistant copy for the terminal assistant message in a turn", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5e9dd6b539598..abd091bcb6601 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -8,6 +8,11 @@ import { collectAssistantCitations, } from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; +import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; +import { + IDLE_COMPACTING_LABELS, + IDLE_COMPACTION_TITLES, +} from "@t3tools/client-runtime/user-message"; import type { HandoffTimelineRun } from "@t3tools/client-runtime/handoff"; import { renderCodexDirectivesForCopy } from "@t3tools/shared/codexMarkdownDirectives"; import { @@ -45,7 +50,10 @@ import { type TurnDiffSummary, } from "../../types"; import { + idleCompactionOf, + isCompactionCommandMessage, type MessageId, + type OrchestrationV2IdleCompaction, type WorktreeSetupSnapshot, type OrchestrationV2ProjectedTurnItem, type RunAttemptId, @@ -540,6 +548,8 @@ type MessagesTimelineRowContent = kind: "working"; id: string; createdAt: string | null; + /** A running compaction owns the working row, so its divider appears only once it ends. */ + compacting?: string; } | { kind: "thinking"; @@ -587,6 +597,11 @@ type MessagesTimelineRowContent = label: string; tokenCounts: ReturnType; active: boolean; + /** Replaces "Context compacted" when T3 Code compacted without being asked. */ + title?: string; + failed?: true; + /** The provider's reason, when a failed compaction gave one. */ + failure?: string; } | { kind: "message"; @@ -1141,6 +1156,7 @@ function deriveTurnFolds(input: { continue; } if ( + isCompaction || timelineEntryIsPersistentResourceCard(entry) || input.liveSubagentEntryIds.has(entry.id) || input.completeSupersededReplyEntryIds.has(entry.id) @@ -1572,15 +1588,94 @@ export function deriveMessagesTimelineRows(input: { const activeWorkEntryIds = new Set( activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [], ); + // A bare `/compact` turn is the compaction itself: one compaction row stands in for its prompt + // and working indicator, from the moment the turn starts. Likewise an answered "compact before + // continuing?" question folds into the compaction it asked for. + const compactionRuns = new Map(); + const compactedRunIds = new Set(); + const pendingResumeCompactionAgeByRunId = new Map(); + const resumeCompactionAgeByEntryId = new Map(); + for (const entry of timelineEntries) { + if ( + entry.kind === "message" && + entry.message.runId !== null && + isCompactionCommandMessage(entry.message) + ) { + compactionRuns.set(entry.message.runId, idleCompactionOf(entry.message)); + } else if (entry.kind === "work" && entry.entry.runId) { + if (timelineEntryIsCompaction(entry)) { + compactedRunIds.add(entry.entry.runId); + const age = pendingResumeCompactionAgeByRunId.get(entry.entry.runId); + if (age !== undefined) { + resumeCompactionAgeByEntryId.set(entry.id, age); + pendingResumeCompactionAgeByRunId.delete(entry.entry.runId); + } + } + const age = entry.entry.questionAnswer + ? claudeResumeCompactionAge(entry.entry.questionAnswer) + : null; + if (age !== null) pendingResumeCompactionAgeByRunId.set(entry.entry.runId, age); + } + } + const compactionTitle = (runId: RunId | null | undefined, entryId: string) => { + if (runId == null) return undefined; + const reason = compactionRuns.get(runId); + if (reason !== undefined) return IDLE_COMPACTION_TITLES[reason]; + const age = resumeCompactionAgeByEntryId.get(entryId); + return age ? `Context compacted after ${age} idle` : undefined; + }; + const activeCompactionEntry = timelineEntries.find( + (entry): entry is Extract => + timelineEntryIsCompaction(entry) && + entry.kind === "work" && + workEntryIsInActiveRun(entry.entry), + ); + const activeRunIsCompaction = + input.isWorking && unsettledRunId !== null && compactionRuns.has(unsettledRunId); + // The item can finish before its run settles; only other work should keep activity visible. + const compactionRunFinished = + activeRunIsCompaction && + compactedRunIds.has(unsettledRunId) && + activeCompactionEntry === undefined; + const compactionOnlyRunFinished = + compactionRunFinished && + !timelineEntries.some( + (entry) => + timelineEntryRunId(entry) === unsettledRunId && + !timelineEntryIsCompaction(entry) && + !(entry.kind === "message" && isCompactionCommandMessage(entry.message)), + ); + const runningCompactionRunId = + activeRunIsCompaction && !compactionRunFinished ? unsettledRunId : null; + // Compacting on return holds back the message the user just sent, so the working row says why + // it is waiting. Any other compaction shows only its live divider, never both. + const activeCompactionRunId = + runningCompactionRunId !== null && compactionRuns.get(runningCompactionRunId) === "onReturn" + ? runningCompactionRunId + : activeCompactionEntry?.entry.runId != null && + resumeCompactionAgeByEntryId.has(activeCompactionEntry.id) + ? activeCompactionEntry.entry.runId + : null; + const compactingLabel = (() => { + if (activeCompactionRunId === null) return undefined; + const age = activeCompactionEntry + ? resumeCompactionAgeByEntryId.get(activeCompactionEntry.id) + : undefined; + return age ? `Compacting after ${age} idle…` : IDLE_COMPACTING_LABELS.onReturn; + })(); const appendWorkingRow = () => { + if (compactionOnlyRunFinished) return; + if (runningCompactionRunId !== null && activeCompactionRunId === null) return; nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt ?? null, + ...(compactingLabel ? { compacting: compactingLabel } : {}), }); }; let hasActivityRow = false; - let hasActiveCompaction = false; + const hasActiveCompaction = + activeCompactionEntry !== undefined || (activeRunIsCompaction && !compactionRunFinished); const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); @@ -1658,13 +1753,26 @@ export function deriveMessagesTimelineRows(input: { timelineEntry.entry.sourceActivityKind === "context-compaction" ) { const active = workEntryIsInActiveRun(timelineEntry.entry); - hasActiveCompaction ||= active; + if ( + active && + activeCompactionRunId !== null && + timelineEntry.entry.runId === activeCompactionRunId + ) + continue; const compaction = timelineEntry.entry.structuredPayload; + const compactionRunId = timelineEntry.entry.runId; + const title = compactionTitle(compactionRunId, timelineEntry.id); + const reason = compactionRunId == null ? undefined : compactionRuns.get(compactionRunId); + const failed = compaction?.type === "compaction" && compaction.status === "failed"; + const failure = failed ? compaction.summary?.trim() || undefined : undefined; nextRows.push({ kind: "context-compaction", id: timelineEntry.id, + ...(title ? { title } : {}), + ...(failed ? { failed: true } : {}), + ...(failure ? { failure } : {}), createdAt: timelineEntry.createdAt, - label: timelineEntry.entry.label, + label: active && reason ? IDLE_COMPACTING_LABELS[reason] : timelineEntry.entry.label, tokenCounts: compaction?.type === "compaction" ? contextCompactionTokenCounts(compaction) : null, active, @@ -1672,6 +1780,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + if ( + timelineEntry.kind === "work" && + timelineEntry.entry.runId != null && + compactedRunIds.has(timelineEntry.entry.runId) && + timelineEntry.entry.questionAnswer !== undefined && + claudeResumeCompactionAge(timelineEntry.entry.questionAnswer) !== null + ) { + continue; + } + if (timelineEntry.kind === "work") { if ( (timelineEntry.entry.runId != null && failedRunIds.has(timelineEntry.entry.runId)) || @@ -1882,6 +2000,29 @@ export function deriveMessagesTimelineRows(input: { continue; } + const compactionRunId = timelineEntry.message.runId; + const compactionRun = + compactionRunId !== null && isCompactionCommandMessage(timelineEntry.message); + if (compactionRun) { + if (!compactedRunIds.has(compactionRunId) && compactionRunId !== activeCompactionRunId) { + const pending = compactionRunId === runningCompactionRunId; + const reason = compactionRuns.get(compactionRunId); + nextRows.push({ + kind: "context-compaction", + id: `compaction:${compactionRunId}`, + createdAt: timelineEntry.createdAt, + label: pending + ? reason + ? IDLE_COMPACTING_LABELS[reason] + : "Compacting context" + : "Context compaction", + tokenCounts: null, + active: pending, + }); + } + continue; + } + const assistantResponseStillInProgress = timelineEntry.message.role === "assistant" && runIdIsActiveResponse(timelineEntry.message.runId); @@ -1969,6 +2110,7 @@ export function deriveMessagesTimelineRows(input: { input.latestRun?.turnEnded !== true && !setupOwnsWorkingSlot && !hasActiveCompaction && + !compactionOnlyRunFinished && (!hasActivityRow || latestToolFailed) ) { const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined; @@ -2237,7 +2379,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return a.createdAt === (b as typeof a).createdAt; + return ( + a.createdAt === (b as typeof a).createdAt && a.compacting === (b as typeof a).compacting + ); case "thinking": { const bt = b as typeof a; return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded; @@ -2268,7 +2412,14 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "context-compaction": { const bc = b as typeof a; - return a.createdAt === bc.createdAt && a.label === bc.label && a.active === bc.active; + return ( + a.createdAt === bc.createdAt && + a.label === bc.label && + a.active === bc.active && + a.title === bc.title && + a.failed === bc.failed && + a.failure === bc.failure + ); } case "proposed-plan": diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 19b1aeb55a5d5..ee61f27e20c91 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -43,7 +43,7 @@ import { import { type TimelineEntry } from "../../session-logic"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { type ChatFileAttachment, type TurnDiffSummary } from "../../types"; -import { ArrowRightIcon, BotIcon, Minimize2Icon } from "lucide-react"; +import { ArrowRightIcon, BotIcon, CircleAlertIcon, Minimize2Icon } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -946,7 +946,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ workGroupViewState, }; const compactionAwaitingRow = - isCompacting && !rows.some((row) => row.kind === "context-compaction" && row.active); + isCompacting && + !rows.some( + (row) => + (row.kind === "context-compaction" && row.active) || + (row.kind === "working" && row.compacting), + ); const backgroundWorktreeSetup = worktreeSetup !== null && worktreeSetup.phase === "running" && @@ -1336,35 +1341,49 @@ function ContextCompactionTimelineRow({ }: { row: Extract; }) { + const label = + row.title && row.label.startsWith("Context compacted") + ? row.title + row.label.slice("Context compacted".length) + : row.label; const content = ( - -
diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 457e960e9fb62..76daf03c10671 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -534,6 +534,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { onOpenTurnDiff={ctx.onOpenTurnDiff} onRollbackCheckpoint={ctx.onRollbackCheckpoint} onImageExpand={ctx.onImageExpand} + source={ctx.turnItemSource} /> ) : ( <> @@ -545,6 +546,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { projectedItem={workEntry.projectedItem} environmentId={ctx.activeThreadEnvironmentId} onImageExpand={onImageExpand} + source={ctx.turnItemSource} /> ) : null} diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index a93e8a5b611b5..b0727a1599f5b 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -31,6 +31,7 @@ import { CommandOutputPanel, useCommandOutputStreaming } from "./CommandOutputPa import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; import { ShellCommandBlock } from "./ShellCommandBlock"; +import type { TurnItemSource } from "./timelineRowContext"; interface V2ItemInspectorProps { readonly projectedItem: OrchestrationV2ProjectedTurnItem; @@ -38,12 +39,13 @@ interface V2ItemInspectorProps { readonly cwd?: string | undefined; readonly workspaceRoot?: string | undefined; readonly onOpenThread: (threadId: ThreadId) => void; - readonly onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + readonly onOpenTurnDiff?: ((runId: RunId, filePath?: string) => void) | undefined; readonly onRollbackCheckpoint?: (input: { readonly checkpointId: string; readonly scopeId: string; }) => void; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + readonly source?: TurnItemSource | undefined; } function SyntaxTokens({ @@ -112,6 +114,7 @@ function StructuredValue({ function useFetchedTurnItem( projectedItem: OrchestrationV2ProjectedTurnItem, environmentId: EnvironmentId, + source: TurnItemSource | undefined, skip = false, ) { const wireItem = projectedItem.item; @@ -125,6 +128,7 @@ function useFetchedTurnItem( revision: turnItemDetailRevision(wireItem), } : null, + source?.detail, ); const fetchedItem = detail.data?.item; const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem; @@ -188,8 +192,9 @@ export function FetchedToolOutput(props: { readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly environmentId: EnvironmentId; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + readonly source?: TurnItemSource | undefined; }) { - const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId); + const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId, props.source); return (
@@ -236,10 +241,16 @@ function ToolCallBody( export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { // Command rows stream their output, including the final output, from the server. + const environmentStreamsOutput = useCommandOutputStreaming(props.environmentId); const streamsOutput = - useCommandOutputStreaming(props.environmentId) && + (props.source !== undefined || environmentStreamsOutput) && props.projectedItem.item.type === "command_execution"; - const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId, streamsOutput); + const fetched = useFetchedTurnItem( + props.projectedItem, + props.environmentId, + props.source, + streamsOutput, + ); const item = fetched.item; const outputState = fetched.output; const support = useV2ItemSupport({ @@ -275,6 +286,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec environmentId={props.environmentId} threadId={props.projectedItem.sourceThreadId} itemId={props.projectedItem.sourceItemId} + source={props.source?.commandOutput} /> ) : undefined } @@ -293,11 +305,11 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec -{formatDiffCount(item.deletions ?? 0)} ) : null} - {item.runId !== null ? ( + {item.runId !== null && props.onOpenTurnDiff ? ( @@ -386,7 +398,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec {item.type === "approval_request" ? : null} {item.type === "user_input_request" ? ( - question.question).join("\n\n")} /> + + [question.question, ...question.options.map((option) => `• ${option.label}`)].join( + "\n", + ), + ) + .join("\n\n")} + /> ) : null} {item.type === "notification" ? : null} {item.type === "system_notice" ? : null} diff --git a/apps/web/src/components/chat/guestMessageStyle.tsx b/apps/web/src/components/chat/guestMessageStyle.tsx new file mode 100644 index 0000000000000..90e49994f8e88 --- /dev/null +++ b/apps/web/src/components/chat/guestMessageStyle.tsx @@ -0,0 +1,14 @@ +/** Bubble classes for a guest's message. A pending one has a dashed edge and no fill. */ +export function guestBubbleClassName(pending: boolean): string { + return pending ? "border border-dashed border-guest/50 bg-transparent" : "bg-guest-surface"; +} + +/** The guest's name above their message, where "Sent by T3 Code" sits for server messages. */ +export function GuestMessageName({ name }: { readonly name: string }) { + return ( +

+ {name} + (guest) +

+ ); +} diff --git a/apps/web/src/components/chat/guestShareAvailability.ts b/apps/web/src/components/chat/guestShareAvailability.ts new file mode 100644 index 0000000000000..618b8f042e460 --- /dev/null +++ b/apps/web/src/components/chat/guestShareAvailability.ts @@ -0,0 +1,18 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { environmentPresentations } from "~/state/presentation"; +import { serverEnvironment } from "~/state/server"; + +/** + * Whether this environment offers "Share with guest": the user may create links there, and its + * Guest links beta is on (Settings > Beta). The server refuses links while the beta is off. + */ +export const canShareWithGuestAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get) => + get(serverEnvironment.createGuestShare.permissionAtom(environmentId)) && + get(environmentPresentations.serverConfigsAtom).get(environmentId)?.settings + .guestSharingEnabled === true, + ).pipe(Atom.withLabel(`guest-share:available:${environmentId}`)), +); diff --git a/apps/web/src/components/chat/guestShareDialogState.ts b/apps/web/src/components/chat/guestShareDialogState.ts new file mode 100644 index 0000000000000..f8d0e5d095f79 --- /dev/null +++ b/apps/web/src/components/chat/guestShareDialogState.ts @@ -0,0 +1,17 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +/** + * Which thread has the guest share dialog open. The thread menu and the command palette both + * close as their command runs, so the chat view renders the dialog once. + */ +export const guestShareDialogThreadAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("guest-share:dialog-thread"), +); + +export function openGuestShareDialog(threadRef: ScopedThreadRef): void { + appAtomRegistry.set(guestShareDialogThreadAtom, threadRef); +} diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index fdfb833ecc6aa..aa0c919ab10c5 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -8,9 +8,12 @@ import { type ServerProviderSkill, type RunId, ThreadId, + type TurnItemId, } from "@t3tools/contracts"; +import type { AsyncResult, Atom } from "effect/reactivity"; +import type { CommandOutputView } from "@t3tools/client-runtime/state/command-output"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; -import { createContext, type MouseEvent } from "react"; +import { createContext, type MouseEvent, type ReactNode } from "react"; import { type LegendListRef } from "@legendapp/list/react"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { type ChatFileAttachment } from "../../types"; @@ -18,8 +21,21 @@ import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { type AssistantCitationTarget } from "./AssistantCitationSource"; import { type MessagesTimelineRow, type WorkGroupScrollAnchor } from "./MessagesTimeline.logic"; import { type TimestampFormat } from "@t3tools/contracts/settings"; +import type { TurnItemDetailSource } from "../../state/queries"; import { type HandoffTimelineRun } from "./V2LifecycleRow"; +/** + * Where an expanded row reads an item's detail and a command's output. The owner's rows read them + * from the environment; the guest page reads them through its own routes. + */ +export interface TurnItemSource { + readonly detail: TurnItemDetailSource; + readonly commandOutput: (target: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Atom.Atom>; +} + export interface TimelineRowSharedState { citationRequest: AssistantCitationTarget | null; listRef: React.RefObject; @@ -40,13 +56,21 @@ export interface TimelineRowSharedState { findActive: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; displayThreadKey?: string; - onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + /** Unset where there is no diff to open, so the timeline shows no "Open diff" button. */ + onOpenTurnDiff: ((runId: RunId, filePath?: string) => void) | undefined; + /** + * Draws a published HTML visual. Unset, the row loads it from the environment; the guest page + * loads it through its own route. + */ + renderHtmlRender: ((row: Extract) => ReactNode) | undefined; + /** Unset, expanded rows read detail and command output from the environment. */ + turnItemSource: TurnItemSource | undefined; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; workflowOpening: WorkflowOpening | null; - onForkFromRun: (input: { - readonly sourceThreadId: ThreadId; - readonly runId: RunId; - }) => Promise; + /** Absent hides "Fork from this response", as in the guest view. */ + onForkFromRun: + | ((input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => Promise) + | undefined; onRollbackCheckpoint: (input: { readonly checkpointId: string; readonly scopeId: string; diff --git a/apps/web/src/components/guest/GuestHtmlRender.tsx b/apps/web/src/components/guest/GuestHtmlRender.tsx new file mode 100644 index 0000000000000..75b7445ac6e8b --- /dev/null +++ b/apps/web/src/components/guest/GuestHtmlRender.tsx @@ -0,0 +1,93 @@ +import { GUEST_RENDER_ROUTE, GUEST_SHARE_PARAM } from "@t3tools/contracts"; +import { + type HtmlRenderReference, + HTML_RENDER_COLUMN_WIDTH, + htmlRenderFrameHeight, + htmlRenderResult, + htmlRenderThemeFragment, + htmlRenderThemeMessage, + readHtmlRenderContentHeight, + readHtmlRenderLinkRequest, +} from "@t3tools/shared/htmlRender"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; +import { observeResize } from "~/lib/observeResize"; + +/** + * A visual the agent published, as the guest sees it. The server sends it with a sandbox CSP and + * the frame is sandboxed too, so the page runs scripts in an opaque origin. It can size itself + * and ask to open a web link; it cannot reach the guest's session. + */ +export function GuestHtmlRender({ + itemId, + reference, + shareId, +}: { + readonly itemId: string; + readonly reference: HtmlRenderReference; + readonly shareId: string; +}) { + const theme = useHtmlRenderTheme(); + const boxRef = useRef(null); + const frameRef = useRef(null); + const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH); + const [contentHeight, setContentHeight] = useState(); + // The theme rides in the fragment for the first paint; later changes are posted. + const [src] = useState( + () => + `${GUEST_RENDER_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&item=${encodeURIComponent(itemId)}${htmlRenderThemeFragment(theme)}`, + ); + useLayoutEffect(() => { + const box = boxRef.current; + if (!box) return; + setWidth(box.clientWidth); + return observeResize(box, ([entry]) => { + if (entry) setWidth(entry.contentRect.width); + }); + }, []); + useEffect(() => { + frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); + }, [theme]); + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const frame = frameRef.current; + if (frame === null || event.source !== frame.contentWindow) return; + const height = readHtmlRenderContentHeight(event.data); + if (height !== undefined) { + setContentHeight(height); + return; + } + // Only while the reader is using this frame, so a page cannot open links on its own. + const link = readHtmlRenderLinkRequest(event.data); + if ( + link === undefined || + document.activeElement !== frame || + navigator.userActivation?.isActive === false + ) { + return; + } + window.open(link.url, "_blank", "noopener,noreferrer"); + frame.contentWindow?.postMessage(htmlRenderResult(link.id), "*"); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + return ( +
+