From 340ef72e9d11b38d66c27de7c3b28bba73d4db71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 12:01:50 -0700 Subject: [PATCH 1/3] fix(chat): smooth worktree setup status (#8922) (cherry picked from commit ef84bc9873a6c4565fbeb64dce3f552570e95a2d) --- .../features/threads/NewTaskDraftScreen.tsx | 69 +++++++++------ .../src/features/threads/thread-work-log.tsx | 2 +- apps/mobile/src/lib/threadActivity.test.ts | 77 +++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 2 + .../web/src/components/ChatView.logic.test.ts | 62 +++++++++++++- apps/web/src/components/ChatView.logic.ts | 10 ++- apps/web/src/components/ChatView.tsx | 15 ++-- apps/web/src/components/chat/ChatComposer.tsx | 3 - .../chat/MessagesTimeline.logic.test.ts | 52 ++++++++++++ .../components/chat/MessagesTimeline.test.tsx | 8 +- .../src/components/chat/MessagesTimeline.tsx | 80 ++++++++++++------ .../chat/timelineScrollAnchoring.ts | 3 + apps/web/src/routes/_chat.draft.$draftId.tsx | 8 +- apps/web/src/session-logic.test.ts | 83 +++++++++++++++++++ apps/web/src/session-logic.ts | 2 + .../src/work-log/presentation.ts | 4 + 16 files changed, 408 insertions(+), 72 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e9b621c6f..3bef44dbd 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -37,6 +37,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; +import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { @@ -368,7 +369,8 @@ export function NewTaskDraftScreen(props: { }; }, [props.pendingTaskId, cancelEditingPendingTask]); - const foregroundColor = useUniwindTheme()["--color-foreground"]; + const theme = useUniwindTheme(); + const foregroundColor = theme["--color-foreground"]; const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); @@ -1098,31 +1100,50 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( - + + + ) : ( + <> + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => + flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local") + } + showChevron={false} /> - } - label={workspaceLabel} - maxWidth={flow.workspaceMode === "local" ? 220 : 148} - onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} - showChevron={false} - /> - openContextPicker("NewTaskBranch")} - /> + openContextPicker("NewTaskBranch")} + /> + + )} ); diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 04eaf8e41..410661845 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -82,7 +82,7 @@ function ShimmerWorkContent(props: { ); } -function ShimmeringWorkContent(props: { +export function ShimmeringWorkContent(props: { readonly icon: AppSymbolName; readonly iconSubtleColor: ColorValue; readonly label: string; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4687cd46e..53a6e37bf 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -234,6 +234,83 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps setup failures visible without routine setup notices before or after a turn", () => { + const thread = makeThread({ + id: ThreadId.make("thread-worktree-setup"), + projectId: ProjectId.make("project-1"), + title: "Worktree setup", + activities: [ + makeActivity({ + id: EventId.make("setup-requested"), + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: "2026-08-30T00:00:00.000Z", + }), + makeActivity({ + id: EventId.make("setup-started"), + kind: "setup-script.started", + summary: "Setup script started", + createdAt: "2026-08-30T00:00:01.000Z", + }), + makeActivity({ + id: EventId.make("setup-failed"), + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: "2026-08-30T00:00:02.000Z", + tone: "error", + payload: { detail: "Setup command was not found" }, + }), + ], + }); + const latestTurn = { + turnId: TurnId.make("turn-after-setup"), + state: "running" as const, + requestedAt: "2026-08-30T00:00:03.000Z", + startedAt: "2026-08-30T00:00:04.000Z", + completedAt: null, + assistantMessageId: null, + }; + + for (const currentTurn of [null, latestTurn]) { + const feed = buildThreadFeed({ ...thread, latestTurn: currentTurn }); + expect(feed).toMatchObject([ + { + type: "activity-group", + activities: [{ id: "setup-failed", status: "failure" }], + }, + ]); + const group = feed[0]; + if (group?.type !== "activity-group") throw new Error("Expected the setup failure group"); + expect(group.activities[0]?.getCopyText()).toContain("Setup command was not found"); + } + }); + + it.each(["setup-script.requested", "setup-script.started"])( + "keeps error-toned %s notices visible", + (kind) => { + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-setup-error"), + projectId: ProjectId.make("project-1"), + title: "Setup error", + activities: [ + makeActivity({ + id: EventId.make("setup-error"), + kind, + summary: "Setup failed", + createdAt: "2026-08-30T00:00:00.000Z", + tone: "error", + }), + ], + }), + ); + + expect(feed).toMatchObject([ + { type: "activity-group", activities: [{ id: "setup-error", status: "failure" }] }, + ]); + }, + ); + it("keeps older local feedback before newer messages returned by the server", () => { const submission = { id: MessageId.make("feedback-command-ordering"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cb0a5a979..8fbf43fa7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/client-runtime/state/turn-costs"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { + isWorktreeSetupActivity, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, summarizeToolGroup, @@ -350,6 +351,7 @@ function deriveWorkLogEntries( const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; // Terminal bypassed updates pass: Codex children's only terminal signal. diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 163dc8833..95dd87e06 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -116,7 +116,7 @@ describe("draft hero submission transition", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, - serverThreadStarted: true, + serverThread: makeThread({ latestTurn: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); @@ -317,6 +317,66 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("draft promotion during worktree setup", () => { + const serverThreadRef = { environmentId, threadId }; + + it.each([null, "idle", "starting", "ready"] as const)( + "keeps the draft mounted while the first turn waits with session %s", + (status) => { + const serverThread = makeThread({ + messages: [ + { + id: MessageId.make("submitted-message"), + role: "user", + text: "Start in a new worktree", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + session: status ? { ...readySession, status } : null, + }); + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread, + backgroundSubmissionPending: false, + }), + ).toBeNull(); + }, + ); + + it("promotes when the provider starts the first turn", () => { + const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null }; + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread: makeThread({ + latestTurn, + session: { ...readySession, status: "running", activeTurnId: latestTurn.turnId }, + }), + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }); + + it.each(["error", "stopped", "interrupted"] as const)( + "promotes a startup that ends as %s before a turn starts", + (status) => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread: makeThread({ session: { ...readySession, status } }), + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }, + ); +}); + describe("buildLoadingThreadFromShell", () => { it("preserves shell metadata and supplies empty detail collections", () => { const shell = { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index feda7e46a..6dcdcf869 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -111,13 +111,19 @@ export function resolveDraftHeroState(input: { export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; - serverThreadStarted: boolean; + serverThread: Pick | null | undefined; backgroundSubmissionPending: boolean; }): ScopedThreadRef | null { if (input.backgroundSubmissionPending) { return null; } - return input.serverThreadStarted ? input.serverThreadRef : null; + const sessionStatus = input.serverThread?.session?.status; + const turnStarted = input.serverThread?.latestTurn?.startedAt != null; + const startupStopped = + sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted"; + // Keep local preparation feedback mounted until the server can render the + // running turn or its startup error on the canonical thread route. + return turnStarted || startupStopped ? input.serverThreadRef : null; } export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a358df41e..aff65ef12 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -62,7 +62,6 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { @@ -120,7 +119,11 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + getAnchoredTurnMetrics, + type TimelineScrollMode, +} from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -4384,7 +4387,7 @@ function ChatViewContent(props: ChatViewProps) { state, anchorIndex, composerOverlayHeight, - anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }); }, [composerOverlayHeight], @@ -4412,7 +4415,7 @@ function ChatViewContent(props: ChatViewProps) { const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); const visibleScrollLength = Math.max( 0, - (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, + (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, ); return realContentBottom > visibleScrollLength; }, @@ -4600,7 +4603,7 @@ function ChatViewContent(props: ChatViewProps) { index: anchorIndex, animated: true, viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, + viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }) .then(() => { if (positionedTimelineAnchorRef.current !== messageId) { @@ -6573,7 +6576,6 @@ function ChatViewContent(props: ChatViewProps) { : {}), } : undefined; - beginLocalDispatch({ preparingWorktree: false }); const backgroundThreadRef = resolvedSubmissionIntent === "background" ? scopeThreadRef(activeThread.environmentId, threadIdForSend) @@ -8311,6 +8313,7 @@ function ChatViewContent(props: ChatViewProps) { isWorking={isWorking} workingStepLabel={workingStepLabel} activeTurnInProgress={isWorking || !latestTurnSettled} + isPreparingWorktree={isPreparingWorktree} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7d5416f2f..1c7e46818 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -653,9 +653,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( harnessRefinement={props.harnessRefinement} /> ) : null} - {props.isPreparingWorktree ? ( - Preparing worktree... - ) : null} { ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true }); }); it("does not fold the active in-progress turn", () => { @@ -1041,6 +1043,7 @@ describe("deriveMessagesTimelineRows", () => { }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, groupedEntries: [ @@ -1481,6 +1484,7 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRow?.showAssistantMeta).toBe(false); expect(assistantRow?.showAssistantCopyButton).toBe(false); expect(assistantRow?.reportedCostLabel).toBeUndefined(); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); }); it.each([ @@ -1649,6 +1653,54 @@ describe("deriveMessagesTimelineRows", () => { }); describe("computeStableMessagesTimelineRows", () => { + it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => { + const startedAt = "2026-01-01T00:00:00Z"; + const turnId = TurnId.make("turn-1"); + const input = { + runningTurnId: turnId, + isWorking: true, + activeTurnStartedAt: startedAt, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + const assistantEntry = { + id: "assistant-entry", + kind: "message" as const, + createdAt: startedAt, + message: { + id: MessageId.make("assistant-1"), + role: "assistant" as const, + text, + turnId, + createdAt: startedAt, + updatedAt: startedAt, + streaming: true, + }, + }; + const initial = computeStableMessagesTimelineRows( + deriveMessagesTimelineRows({ ...input, timelineEntries: [assistantEntry] }), + { byId: new Map(), result: [] }, + ); + const updated = computeStableMessagesTimelineRows( + deriveMessagesTimelineRows({ + ...input, + timelineEntries: [ + { + ...assistantEntry, + message: { ...assistantEntry.message, text: "I will inspect the repository." }, + }, + ], + }), + initial, + ); + + const initialWorking = initial.byId.get("working-indicator-row"); + const updatedWorking = updated.byId.get("working-indicator-row"); + expect(initialWorking).toMatchObject({ showThinking: true }); + expect(updatedWorking).toMatchObject({ showThinking: false }); + expect(updatedWorking).not.toBe(initialWorking); + }); + it("returns the previous result when row order and content are unchanged", () => { const firstUserMessage = { id: "user-1" as never, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index fc4d86ff9..83fd396e2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -563,7 +563,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain('data-anchor-index="0"'); - expect(markup).toContain('data-anchor-offset="16"'); + expect(markup).toContain('data-anchor-offset="24"'); expect(markup).toContain('data-anchor-on-ready="true"'); expect(markup).not.toContain("data-anchor-max-size="); expect(markup).toContain('data-content-inset-end="144"'); @@ -1522,9 +1522,11 @@ describe("MessagesTimeline", () => { expect(workingIndex).toBeGreaterThan(userIndex); expect(assistantIndex).toBeGreaterThan(workingIndex); expect(markup).toContain('class="border-b border-border/60 pb-2 pt-1"'); - // #8734 lays the row out with flex so a long plan step truncates. + // #8734 lays the row out with flex so a long plan step truncates; #8922 + // pins it to h-6 so swapping between "Working for" and the worktree-setup + // label cannot change the row's height. expect(markup).toContain( - 'class="flex min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"', + 'class="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"', ); expect(markup).not.toContain('class="pt-0.5 pb-5 pl-1.5"'); expect(markup).not.toContain('data-slot="dot-matrix"'); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f4da563f3..1ac6a4566 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -89,7 +89,10 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { SessionNotificationRow } from "./ComposerSessionInteractionPanel"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; -import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + keepTimelineEndVisibleAfterOverlayGrowth, +} from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, @@ -176,6 +179,7 @@ interface TimelineRowSharedState { interface TimelineRowActivityState { isWorking: boolean; + isPreparingWorktree: boolean; isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; latestTurnId: TurnId | null; @@ -237,6 +241,7 @@ interface MessagesTimelineProps { isWorking: boolean; workingStepLabel?: string | null; activeTurnInProgress: boolean; + isPreparingWorktree?: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; @@ -287,6 +292,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, workingStepLabel = null, activeTurnInProgress, + isPreparingWorktree = false, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, @@ -489,8 +495,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [anchorMessageId, onAnchorReady], ); const anchoredEndSpace = useMemo(() => { - const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => - row.kind === "message" && row.message.role === "user" ? row.message.id : null, + const config = resolveChatListAnchoredEndSpace( + rows, + anchorMessageId, + (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null), + { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, ); return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); @@ -603,12 +612,20 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const activityState = useMemo( () => ({ isWorking, + isPreparingWorktree, isRevertingCheckpoint, activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, workingStepLabel, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], + [ + activeTurnInProgress, + isRevertingCheckpoint, + isWorking, + isPreparingWorktree, + latestTurn?.turnId, + workingStepLabel, + ], ); // Stable renderItem — no closure deps. Row components read shared state @@ -1359,13 +1376,21 @@ function ProposedPlanTimelineRow({ } function WorkingTimelineRow({ row }: { row: Extract }) { - const { workingStepLabel } = use(TimelineRowActivityCtx); + const { workingStepLabel, isPreparingWorktree } = use(TimelineRowActivityCtx); return (
-
- - {row.createdAt ? ( +
+ + {isPreparingWorktree ? ( + <> + Setting up worktree… + Setting up worktree… + + ) : row.createdAt ? ( <> Working for @@ -1381,8 +1406,9 @@ function WorkingTimelineRow({ row }: { row: Extract
{row.showThinking ? ( -
- + // Reserve the activity row during setup so the handoff keeps the same height. +
+ {isPreparingWorktree ? null : }
) : null}
@@ -1470,6 +1496,19 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); +function ActivityShimmerOverlay({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + function LiveActivityRow({ label, iconName, @@ -1487,24 +1526,13 @@ function LiveActivityRow({ failed={failed} announceFailure={failed} /> -
-
-
- -
-
-
+ + +
); } -function ThinkingActivityRow() { - return ; -} - function LiveActivityContent({ label, iconName, @@ -1521,7 +1549,7 @@ function LiveActivityContent({ const resolvedIconName = failed ? "x" : iconName; return ( -
) : null} {label} -
+ ); } diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index f38d0920b..505efef29 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,3 +1,6 @@ +// Match the titlebar fade inset so draft promotion preserves the first row's position. +export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; + export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; export interface TimelineListMeasurementState { diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index d067c6a8d..04cdf3ce8 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,10 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import ChatView from "../components/ChatView"; -import { - resolveDraftPromotionNavigationTarget, - threadHasStarted, -} from "../components/ChatView.logic"; +import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic"; import { DraftId, markPromotedDraftThreadByRef, @@ -31,11 +28,10 @@ function DraftChatThreadRouteView() { : null; const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; const serverThread = useThread(serverThreadRef); - const serverThreadStarted = threadHasStarted(serverThread); const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ serverThreadRef, - serverThreadStarted, + serverThread, backgroundSubmissionPending, }); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 31498f7b5..4bc45a78b 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1065,6 +1065,89 @@ describe("deriveWorkLogEntries", () => { expect(entry && workLogEntryIsToolLike(entry)).toBe(false); }); + it("omits routine setup updates before work starts and after later turn activity", () => { + const setupActivities = [ + makeActivity({ + id: "setup-requested", + kind: "setup-script.requested", + summary: "Preparing setup script", + tone: "info", + sequence: 1, + }), + makeActivity({ + id: "setup-started", + kind: "setup-script.started", + summary: "Setup script started", + tone: "info", + sequence: 2, + }), + ]; + + expect(deriveWorkLogEntries(setupActivities)).toEqual([]); + expect( + deriveWorkLogEntries([ + ...setupActivities, + makeActivity({ + id: "first-turn-tool", + kind: "tool.completed", + summary: "Read project files", + turnId: "turn-1", + sequence: 3, + }), + makeActivity({ + id: "later-turn-tool", + kind: "tool.completed", + summary: "Ran tests", + turnId: "turn-2", + sequence: 4, + }), + ]).map((entry) => entry.id), + ).toEqual(["first-turn-tool", "later-turn-tool"]); + }); + + it("preserves setup failures and unrelated info without a turn id", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + id: "setup-requested", + kind: "setup-script.requested", + summary: "Preparing setup script", + tone: "info", + sequence: 1, + }), + makeActivity({ + id: "setup-failed", + kind: "setup-script.failed", + summary: "Setup script failed to start", + tone: "error", + payload: { detail: "Could not start the setup terminal" }, + sequence: 2, + }), + makeActivity({ + id: "runtime-notice", + kind: "runtime.warning", + summary: "Reconnecting to provider", + tone: "info", + sequence: 3, + }), + ]); + + expect(entries).toMatchObject([ + { + id: "setup-failed", + label: "Setup script failed to start", + tone: "error", + detail: "Could not start the setup terminal", + turnId: null, + }, + { + id: "runtime-notice", + label: "Reconnecting to provider", + tone: "info", + turnId: null, + }, + ]); + }); + it("drops runtime warnings with no displayable content, keeps ones with a preview", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index edf61d53b..132034e8c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -6,6 +6,7 @@ import { foldSubagentActivities, isBackgroundTaskActivity, } from "@t3tools/client-runtime/state/subagentRuntime"; +import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation"; import { ApprovalRequestId, isToolLifecycleItemType, @@ -876,6 +877,7 @@ export function deriveWorkLogEntries( ) { continue; } + if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index a53155e1e..1a51b01bb 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -1,5 +1,9 @@ import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts"; +export function isWorktreeSetupActivity(kind: string): boolean { + return kind === "setup-script.requested" || kind === "setup-script.started"; +} + export interface WorkLogPresentationEntry { readonly label: string; readonly toolTitle?: string; From e5308cb2cd6a7229e31e75ede907656a45d141ee Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 31 Aug 2026 22:46:21 -0400 Subject: [PATCH 2/3] fix(chat): keep agent activity visible between actions (#8984) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 9ecfc07a8b3acadd1612665b6e24425f625480de) --- .../chat/MessagesTimeline.logic.test.ts | 51 +++++----- .../components/chat/MessagesTimeline.logic.ts | 68 ++++++++------ .../components/chat/MessagesTimeline.test.tsx | 13 +-- .../src/components/chat/MessagesTimeline.tsx | 94 +++++++++++-------- 4 files changed, 129 insertions(+), 97 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index cef07261e..5250cca3f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -925,10 +925,11 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", + "thinking-indicator-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the active in-progress turn", () => { @@ -985,18 +986,18 @@ describe("deriveMessagesTimelineRows", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { - id: "completed-command-entry", + id: "running-command-entry", kind: "work", createdAt: "2026-01-01T00:00:05Z", entry: { - id: "completed-command", + id: "running-command", createdAt: "2026-01-01T00:00:05Z", turnId: "turn-1" as never, - label: "Ran rg", + label: "Running rg", command: "rg toolCall", requestKind: "command", tone: "tool" as const, - toolLifecycleStatus: "completed" as const, + toolLifecycleStatus: "inProgress" as const, }, }, { @@ -1015,18 +1016,18 @@ describe("deriveMessagesTimelineRows", () => { }, }, { - id: "running-command-entry", + id: "completed-command-entry", kind: "work", createdAt: "2026-01-01T00:00:07Z", entry: { - id: "running-command", + id: "completed-command", createdAt: "2026-01-01T00:00:07Z", turnId: "turn-1" as never, - label: "Running tests", + label: "Ran tests", command: "vp test run", requestKind: "command", tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, + toolLifecycleStatus: "completed" as const, }, }, ], @@ -1043,13 +1044,13 @@ describe("deriveMessagesTimelineRows", () => { }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, groupedEntries: [ - { id: "completed-command" }, - { id: "completed-edit" }, { id: "running-command" }, + { id: "completed-edit" }, + { id: "completed-command" }, ], }); }); @@ -1294,7 +1295,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("keeps the latest completed tool call live while the turn is running", () => { + it("shows thinking after the latest tool call completes while the turn is running", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -1325,11 +1326,9 @@ describe("deriveMessagesTimelineRows", () => { revertTurnCountByUserMessageId: new Map(), }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ - entry: { id: "latest-command" }, - groupedEntries: [{ id: "latest-command" }], - }); + expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1484,7 +1483,9 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRow?.showAssistantMeta).toBe(false); expect(assistantRow?.showAssistantCopyButton).toBe(false); expect(assistantRow?.reportedCostLabel).toBeUndefined(); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); + // #8984 replaced the working row's showThinking flag with a separate + // thinking row, so assert the row exists rather than the dropped property. + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it.each([ @@ -1653,7 +1654,7 @@ describe("deriveMessagesTimelineRows", () => { }); describe("computeStableMessagesTimelineRows", () => { - it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => { + it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => { const startedAt = "2026-01-01T00:00:00Z"; const turnId = TurnId.make("turn-1"); const input = { @@ -1694,11 +1695,11 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialWorking = initial.byId.get("working-indicator-row"); - const updatedWorking = updated.byId.get("working-indicator-row"); - expect(initialWorking).toMatchObject({ showThinking: true }); - expect(updatedWorking).toMatchObject({ showThinking: false }); - expect(updatedWorking).not.toBe(initialWorking); + const initialThinking = initial.byId.get("thinking-indicator-row"); + const updatedThinking = updated.byId.get("thinking-indicator-row"); + expect(initialThinking).toMatchObject({ kind: "thinking" }); + expect(updatedThinking).toBe(initialThinking); + expect(updated.result.at(-1)).toBe(updatedThinking); }); it("returns the previous result when row order and content are unchanged", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index dcf3d1e96..4031abf51 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -195,6 +195,7 @@ export type MessagesTimelineRow = groupedEntries: WorkLogEntry[]; groupId: string; expanded: boolean; + active: boolean; } | { kind: "work-toggle"; @@ -238,7 +239,11 @@ export type MessagesTimelineRow = kind: "working"; id: string; createdAt: string | null; - showThinking: boolean; + } + | { + kind: "thinking"; + id: string; + createdAt: string | null; }; export interface StableMessagesTimelineRowsState { @@ -515,6 +520,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; } +function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { + return ( + entry.toolLifecycleStatus === "inProgress" || + entry.sourceActivityKind === "task.progress" || + (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry)) + ); +} + /** * Settled turns keep only their terminal assistant message visible. * Everything before it folds behind a "Worked for ..." row anchored at the @@ -724,24 +737,6 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId !== null && entry.toolLifecycleStatus === "inProgress" && entry.turnId === unsettledTurnId; - const activeEntries = input.isWorking - ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) - : []; - const activeTurnHasVisibleContent = activeEntries.some((entry) => { - if (entry.kind === "message") { - return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; - } - if (entry.kind === "work") { - return ( - entry.entry.agentSpawn === undefined && - workLogEntryIsToolLike(entry.entry) && - entry.entry.toolLifecycleStatus === "inProgress" - ); - } - if (entry.kind === "proposed-plan") return true; - return false; - }); - const activeToolEntries: Array> = []; for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { const entry = input.timelineEntries[index]!; @@ -755,40 +750,48 @@ export function deriveMessagesTimelineRows(input: { } activeToolEntries.unshift(entry); } - const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id)); const visibleActiveToolEntries = omitSupersededLifecycleMarkers( activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), (entry) => entry.entry, ); const activeWorkAnchor = activeToolEntries[0]; - const latestActiveToolEntry = visibleActiveToolEntries.at(-1); - const activeWorkPlacementEntryId = latestActiveToolEntry?.id; + const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); + const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => + workEntryIsActiveTurnActivity(entry.entry), + ); + const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; + const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && latestActiveToolEntry + activeWorkAnchor && displayedToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: latestActiveToolEntry.entry, + entry: displayedToolEntry.entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + active: latestRunningToolEntry !== undefined, }; })() : null; + const activeWorkEntryIds = new Set( + activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id), + ); const appendWorkingRow = () => { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, - showThinking: activeWorkRow === null && !activeTurnHasVisibleContent, }); }; + let hasLiveWorkRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); + hasLiveWorkRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -888,7 +891,9 @@ export function deriveMessagesTimelineRows(input: { groupedEntries: visibleGroupedEntries, groupId, expanded, + active: true, }); + hasLiveWorkRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -998,6 +1003,13 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } + if (input.isWorking && !hasLiveWorkRow) { + nextRows.push({ + kind: "thinking", + id: "thinking-indicator-row", + createdAt: input.activeTurnStartedAt, + }); + } return nextRows; } @@ -1028,9 +1040,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return ( - a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking - ); + case "thinking": + return a.createdAt === (b as typeof a).createdAt; case "turn-fold": { const bf = b as typeof a; @@ -1055,6 +1066,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.createdAt === bw.createdAt && a.groupId === bw.groupId && a.expanded === bw.expanded && + a.active === bw.active && Equal.equals(a.entry, bw.entry) && Equal.equals(a.groupedEntries, bw.groupedEntries) ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83fd396e2..b4fae07fe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1433,7 +1433,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps terminal command copy live while the parent turn is active", () => { + it("keeps declined command copy visible while thinking continues", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-failed", + id: "entry-declined", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-failed", + id: "work-declined", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-failed", + toolCallId: "call-declined", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "failed", + toolLifecycleStatus: "declined", }, }, ]} />, ); - expect(markup).toContain("Running pnpm"); + expect(markup).toContain("Declined pnpm"); + expect(markup).toContain("Thinking"); expect(markup).toContain("tool call failed"); expect(markup).toContain("lucide-x"); expect(markup).not.toContain('data-slot="dot-matrix"'); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1ac6a4566..48abd5476 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1016,7 +1016,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" + : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && @@ -1048,6 +1048,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} + {row.kind === "thinking" ? : null}
); }); @@ -1378,39 +1379,41 @@ function ProposedPlanTimelineRow({ function WorkingTimelineRow({ row }: { row: Extract }) { const { workingStepLabel, isPreparingWorktree } = use(TimelineRowActivityCtx); return ( -
-
-
- - {isPreparingWorktree ? ( - <> - Setting up worktree… - Setting up worktree… - - ) : row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} +
+
+ + {isPreparingWorktree ? ( + <> + Setting up worktree… + Setting up worktree… + + ) : row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + )} + + {workingStepLabel ? ( + + · {workingStepLabel} - {workingStepLabel ? ( - - · {workingStepLabel} - - ) : null} -
+ ) : null}
- {row.showThinking ? ( - // Reserve the activity row during setup so the handoff keeps the same height. -
- {isPreparingWorktree ? null : } -
- ) : null} +
+ ); +} + +function ThinkingTimelineRow() { + const { isPreparingWorktree } = use(TimelineRowActivityCtx); + // Reserve the activity row during setup so the handoff keeps the same height. + return ( +
+ {isPreparingWorktree ? null : }
); } @@ -1578,7 +1581,7 @@ function LiveActivityContent({ function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot); + const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( @@ -1589,7 +1592,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > - + {row.active ? ( + + ) : ( +
+ +
+ )} ); } @@ -2269,14 +2283,18 @@ function workEntryRawCommand( function liveWorkEntryLabel( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, + active: boolean, ): string { const command = workEntry.command?.trim(); if (command) { - // This row describes the active parent turn, not the command lifecycle. - // Keep its live "Running" copy until the turn or contiguous tool run settles. const program = commandProgramName(command); - if (program) return `Running ${program}`; - return "Running command"; + const verb = active + ? "Running" + : workEntry.toolLifecycleStatus === "declined" + ? "Declined" + : "Ran"; + if (program) return `${verb} ${program}`; + return `${verb} command`; } return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); From f41b6677082a0f9863859cc330b51fdbbb0274e7 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 18:06:21 -0400 Subject: [PATCH 3/3] fix(chat): keep latest command live between messages (#9098) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 590a579f2e9292ce314c69e459e19620004578fe) --- .../chat/MessagesTimeline.logic.test.ts | 95 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 28 ++++-- .../components/chat/MessagesTimeline.test.tsx | 52 +++++++--- .../src/components/chat/MessagesTimeline.tsx | 13 ++- 4 files changed, 125 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5250cca3f..b1ecdc067 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -925,7 +925,7 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", - "thinking-indicator-row", + "live-activity-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); @@ -978,11 +978,11 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.map((row) => row.id)).toEqual([ "working-indicator-row", "assistant-thought-entry", - "work-live:work-entry-1", + "live-activity-row", ]); }); - it("keeps adjacent active tool calls in one replacing row", () => { + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -1047,6 +1047,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, + active: true, groupedEntries: [ { id: "running-command" }, { id: "completed-edit" }, @@ -1295,40 +1296,58 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("shows thinking after the latest tool call completes while the turn is running", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "latest-command-entry", - kind: "work", - createdAt: "2026-01-01T00:00:05Z", - entry: { - id: "latest-command", - createdAt: "2026-01-01T00:00:05Z", - turnId: "turn-1" as never, - label: "Ran rg", - command: "rg toolCall", - requestKind: "command", - tone: "tool" as const, - toolLifecycleStatus: "completed" as const, - }, + it("reuses one activity row for initial thinking and the latest tool", () => { + const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) => + deriveMessagesTimelineRows({ + timelineEntries: + toolLifecycleStatus === null + ? [] + : [ + { + id: "latest-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "latest-command", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg", + command: "rg toolCall", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, }, - ], - latestTurn: { - turnId: "turn-1" as never, - state: "running", - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); - expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); + const initialRows = deriveRows(null); + const runningRows = deriveRows("inProgress"); + const completedRows = deriveRows("completed"); + const declinedRows = deriveRows("declined"); + const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); + const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); + const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row"); + + expect(initialActivityRow).toMatchObject({ kind: "thinking" }); + expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); + expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1389,7 +1408,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); + expect(rows.map((row) => row.id)).toContain("live-activity-row"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1695,8 +1714,8 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialThinking = initial.byId.get("thinking-indicator-row"); - const updatedThinking = updated.byId.get("thinking-indicator-row"); + const initialThinking = initial.byId.get("live-activity-row"); + const updatedThinking = updated.byId.get("live-activity-row"); expect(initialThinking).toMatchObject({ kind: "thinking" }); expect(updatedThinking).toBe(initialThinking); expect(updated.result.at(-1)).toBe(updatedThinking); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4031abf51..13ea4b025 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -3,6 +3,7 @@ import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-mark import { formatDuration, workEntryDisplayIndicatesToolFailure, + workEntryIndicatesToolSuccess, workEntryIndicatesToolNeutralStatus, workLogEntryIsMissingResponse, workLogEntryIsToolLike, @@ -178,6 +179,8 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; +const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + export type MessagesTimelineRow = | { kind: "work"; @@ -759,21 +762,26 @@ export function deriveMessagesTimelineRows(input: { const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => workEntryIsActiveTurnActivity(entry.entry), ); - const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; + const latestToolKeepsActivityLive = + latestRunningToolEntry !== undefined || + (latestVisibleToolEntry !== undefined && + workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && displayedToolEntry + activeWorkAnchor && latestVisibleToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, - id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, + id: latestToolKeepsActivityLive + ? LIVE_ACTIVITY_ROW_ID + : `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: displayedToolEntry.entry, + entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, - active: latestRunningToolEntry !== undefined, + active: latestToolKeepsActivityLive, }; })() : null; @@ -787,11 +795,11 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); }; - let hasLiveWorkRow = false; + let hasActivityRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); - hasLiveWorkRow ||= activeWorkRow.active; + hasActivityRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -893,7 +901,7 @@ export function deriveMessagesTimelineRows(input: { expanded, active: true, }); - hasLiveWorkRow = true; + hasActivityRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -1003,10 +1011,10 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && !hasLiveWorkRow) { + if (input.isWorking && !hasActivityRow) { nextRows.push({ kind: "thinking", - id: "thinking-indicator-row", + id: LIVE_ACTIVITY_ROW_ID, createdAt: input.activeTurnStartedAt, }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b4fae07fe..bcfe6cb23 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1433,7 +1433,30 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps declined command copy visible while thinking continues", () => { + it("renders initial thinking as the shared live activity row", () => { + const turnId = TurnId.make("turn-live"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thinking"); + expect(markup).toContain("lucide-brain"); + expect(markup).toContain('data-timeline-row-id="live-activity-row"'); + }); + + it("keeps the completed command in the shared activity row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-declined", + id: "entry-completed", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-declined", + id: "work-completed", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-declined", + toolCallId: "call-completed", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "declined", + toolLifecycleStatus: "completed", }, }, ]} />, ); - expect(markup).toContain("Declined pnpm"); - expect(markup).toContain("Thinking"); - expect(markup).toContain("tool call failed"); - expect(markup).toContain("lucide-x"); + expect(markup).toContain("Running pnpm"); + expect(markup).toContain("lucide-terminal"); + expect(markup).toContain("live-activity-focus"); + expect(markup).not.toContain("Ran pnpm"); + expect(markup).not.toContain("Thinking"); + expect(markup).not.toContain('data-timeline-row-kind="thinking"'); expect(markup).not.toContain('data-slot="dot-matrix"'); }); @@ -1533,7 +1558,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain('data-slot="dot-matrix"'); }); - it("aligns the iconless Thinking row with the working timer", () => { + it("aligns the Thinking row with the working timer", () => { const markup = renderToStaticMarkup( { expect(markup).toContain("Working for"); expect(markup).toContain("Thinking"); expect(markup).not.toContain('data-slot="dot-matrix"'); - expect(markup).toContain("gap-1.5 py-0.5 px-1"); + // #9098 gave the row a brain glyph, so it is no longer iconless and its own + // padding tightened to px-0.5. Alignment now comes from the size-6 icon box + // matching the working row's h-6, which is what this asserts. + expect(markup).toContain("gap-1.5 py-0.5 px-0.5"); + expect(markup).toContain("flex size-6 shrink-0 items-center justify-center"); + expect(markup).toContain("flex h-6 min-w-0 items-baseline px-1"); }); it("renders review comment contexts as structured cards instead of raw tags", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 48abd5476..cf00525fc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -61,6 +61,7 @@ import { import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, + BrainIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, @@ -1016,14 +1017,15 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" + : row.kind === "turn-fold" || row.kind === "working" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || row.kind === "work-live" || - row.kind === "work-toggle" + row.kind === "work-toggle" || + row.kind === "thinking" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -1413,7 +1415,7 @@ function ThinkingTimelineRow() { // Reserve the activity row during setup so the handoff keeps the same height. return (
- {isPreparingWorktree ? null : } + {isPreparingWorktree ? null : }
); } @@ -2183,6 +2185,7 @@ function formatWorkingTimerNow(startIso: string): string { type WorkEntryIconName = | "bot" + | "brain" | "check" | "circle-alert" | "eye" @@ -2200,6 +2203,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN switch (name) { case "bot": return ; + case "brain": + return ; case "check": return ; case "circle-alert": @@ -2239,7 +2244,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { } if (tone === "thinking") { return { - iconName: "bot", + iconName: "brain", className: "text-foreground", }; }