| author | |
| committer | |
| log | ef84bc9873a6c4565fbeb64dce3f552570e95a2d |
| tree | bf21c3fe3cb6f79195a2df3f694d686938c21244 |
| parent | 4a9d2d0ced2a2b899dbee9e4a5162fd83f81edb8 |
| signature | Signed by PGP key B5690EEEBB952194 |
16 files changed, 397 insertions(+), 69 deletions(-)
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+45-24| ... | ... | @@ -39,6 +39,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 39 | 39 | import { SymbolView } from "../../components/AppSymbol"; |
| 40 | 40 | import { AppText as Text } from "../../components/AppText"; |
| 41 | 41 | import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; |
| 42 | import { ShimmeringWorkContent } from "./thread-work-log"; | |
| 42 | 43 | import { ComposerCommandPopover } from "./ComposerCommandPopover"; |
| 43 | 44 | import { useComposerCommandMenu } from "./use-composer-command-menu"; |
| 44 | 45 | import { |
| ... | ... | @@ -380,7 +381,8 @@ export function NewTaskDraftScreen(props: { |
| 380 | 381 | }; |
| 381 | 382 | }, [props.pendingTaskId, cancelEditingPendingTask]); |
| 382 | 383 | |
| 383 | const foregroundColor = useUniwindTheme()["--color-foreground"]; | |
| 384 | const theme = useUniwindTheme(); | |
| 385 | const foregroundColor = theme["--color-foreground"]; | |
| 384 | 386 | const regularFontFamily = useFontFamily("regular"); |
| 385 | 387 | const bodyText = useScaledTextRole("body"); |
| 386 | 388 | |
| ... | ... | @@ -1110,31 +1112,50 @@ export function NewTaskDraftScreen(props: { |
| 1110 | 1112 | |
| 1111 | 1113 | const workspaceControls = ( |
| 1112 | 1114 | <View className="flex-row items-center gap-1 px-2"> |
| 1113 | <ComposerInlineControl | |
| 1114 | accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} | |
| 1115 | accessibilityLabel={workspaceLabel} | |
| 1116 | disabled={isComposerInteractionLocked || voiceInput.isBusy} | |
| 1117 | iconNode={ | |
| 1118 | <NewTaskWorkspaceIcon | |
| 1119 | workspaceMode={flow.workspaceMode} | |
| 1120 | worktreePath={flow.selectedWorktreePath} | |
| 1115 | {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? ( | |
| 1116 | <View | |
| 1117 | accessible | |
| 1118 | accessibilityLabel="Setting up worktree…" | |
| 1119 | className="h-11 w-full max-w-[260px] flex-row items-center px-2" | |
| 1120 | > | |
| 1121 | <ShimmeringWorkContent | |
| 1122 | icon="arrow.triangle.branch" | |
| 1123 | iconSubtleColor={theme["--color-icon-subtle"]} | |
| 1124 | label="Setting up worktree…" | |
| 1125 | showIcon | |
| 1126 | /> | |
| 1127 | </View> | |
| 1128 | ) : ( | |
| 1129 | <> | |
| 1130 | <ComposerInlineControl | |
| 1131 | accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} | |
| 1132 | accessibilityLabel={workspaceLabel} | |
| 1133 | disabled={isComposerInteractionLocked || voiceInput.isBusy} | |
| 1134 | iconNode={ | |
| 1135 | <NewTaskWorkspaceIcon | |
| 1136 | workspaceMode={flow.workspaceMode} | |
| 1137 | worktreePath={flow.selectedWorktreePath} | |
| 1138 | /> | |
| 1139 | } | |
| 1140 | label={workspaceLabel} | |
| 1141 | maxWidth={flow.workspaceMode === "local" ? 220 : 148} | |
| 1142 | onPress={() => | |
| 1143 | flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local") | |
| 1144 | } | |
| 1145 | showChevron={false} | |
| 1121 | 1146 | /> |
| 1122 | } | |
| 1123 | label={workspaceLabel} | |
| 1124 | maxWidth={flow.workspaceMode === "local" ? 220 : 148} | |
| 1125 | onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} | |
| 1126 | showChevron={false} | |
| 1127 | /> | |
| 1128 | 1147 | |
| 1129 | <ComposerInlineControl | |
| 1130 | accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`} | |
| 1131 | chevronDirection="right" | |
| 1132 | disabled={isComposerInteractionLocked} | |
| 1133 | icon="arrow.triangle.branch" | |
| 1134 | label={showBranchLoading ? "Loading branches…" : selectedBranchLabel} | |
| 1135 | maxWidth={190} | |
| 1136 | onPress={() => openContextPicker("NewTaskBranch")} | |
| 1137 | /> | |
| 1148 | <ComposerInlineControl | |
| 1149 | accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`} | |
| 1150 | chevronDirection="right" | |
| 1151 | disabled={isComposerInteractionLocked} | |
| 1152 | icon="arrow.triangle.branch" | |
| 1153 | label={showBranchLoading ? "Loading branches…" : selectedBranchLabel} | |
| 1154 | maxWidth={190} | |
| 1155 | onPress={() => openContextPicker("NewTaskBranch")} | |
| 1156 | /> | |
| 1157 | </> | |
| 1158 | )} | |
| 1138 | 1159 | </View> |
| 1139 | 1160 | ); |
| 1140 | 1161 |
apps/mobile/src/features/threads/thread-work-log.tsx+1-1| ... | ... | @@ -79,7 +79,7 @@ function ShimmerWorkContent(props: { |
| 79 | 79 | ); |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | function ShimmeringWorkContent(props: { | |
| 82 | export function ShimmeringWorkContent(props: { | |
| 83 | 83 | readonly icon: AppSymbolName; |
| 84 | 84 | readonly iconSubtleColor: ColorValue; |
| 85 | 85 | readonly label: string; |
apps/mobile/src/lib/threadActivity.test.ts+77| ... | ... | @@ -234,6 +234,83 @@ function makeThread( |
| 234 | 234 | } |
| 235 | 235 | |
| 236 | 236 | describe("buildThreadFeed", () => { |
| 237 | it("keeps setup failures visible without routine setup notices before or after a turn", () => { | |
| 238 | const thread = makeThread({ | |
| 239 | id: ThreadId.make("thread-worktree-setup"), | |
| 240 | projectId: ProjectId.make("project-1"), | |
| 241 | title: "Worktree setup", | |
| 242 | activities: [ | |
| 243 | makeActivity({ | |
| 244 | id: EventId.make("setup-requested"), | |
| 245 | kind: "setup-script.requested", | |
| 246 | summary: "Starting setup script", | |
| 247 | createdAt: "2026-08-30T00:00:00.000Z", | |
| 248 | }), | |
| 249 | makeActivity({ | |
| 250 | id: EventId.make("setup-started"), | |
| 251 | kind: "setup-script.started", | |
| 252 | summary: "Setup script started", | |
| 253 | createdAt: "2026-08-30T00:00:01.000Z", | |
| 254 | }), | |
| 255 | makeActivity({ | |
| 256 | id: EventId.make("setup-failed"), | |
| 257 | kind: "setup-script.failed", | |
| 258 | summary: "Setup script failed to start", | |
| 259 | createdAt: "2026-08-30T00:00:02.000Z", | |
| 260 | tone: "error", | |
| 261 | payload: { detail: "Setup command was not found" }, | |
| 262 | }), | |
| 263 | ], | |
| 264 | }); | |
| 265 | const latestTurn = { | |
| 266 | turnId: TurnId.make("turn-after-setup"), | |
| 267 | state: "running" as const, | |
| 268 | requestedAt: "2026-08-30T00:00:03.000Z", | |
| 269 | startedAt: "2026-08-30T00:00:04.000Z", | |
| 270 | completedAt: null, | |
| 271 | assistantMessageId: null, | |
| 272 | }; | |
| 273 | ||
| 274 | for (const currentTurn of [null, latestTurn]) { | |
| 275 | const feed = buildThreadFeed({ ...thread, latestTurn: currentTurn }); | |
| 276 | expect(feed).toMatchObject([ | |
| 277 | { | |
| 278 | type: "activity-group", | |
| 279 | activities: [{ id: "setup-failed", status: "failure" }], | |
| 280 | }, | |
| 281 | ]); | |
| 282 | const group = feed[0]; | |
| 283 | if (group?.type !== "activity-group") throw new Error("Expected the setup failure group"); | |
| 284 | expect(group.activities[0]?.getCopyText()).toContain("Setup command was not found"); | |
| 285 | } | |
| 286 | }); | |
| 287 | ||
| 288 | it.each(["setup-script.requested", "setup-script.started"])( | |
| 289 | "keeps error-toned %s notices visible", | |
| 290 | (kind) => { | |
| 291 | const feed = buildThreadFeed( | |
| 292 | makeThread({ | |
| 293 | id: ThreadId.make("thread-setup-error"), | |
| 294 | projectId: ProjectId.make("project-1"), | |
| 295 | title: "Setup error", | |
| 296 | activities: [ | |
| 297 | makeActivity({ | |
| 298 | id: EventId.make("setup-error"), | |
| 299 | kind, | |
| 300 | summary: "Setup failed", | |
| 301 | createdAt: "2026-08-30T00:00:00.000Z", | |
| 302 | tone: "error", | |
| 303 | }), | |
| 304 | ], | |
| 305 | }), | |
| 306 | ); | |
| 307 | ||
| 308 | expect(feed).toMatchObject([ | |
| 309 | { type: "activity-group", activities: [{ id: "setup-error", status: "failure" }] }, | |
| 310 | ]); | |
| 311 | }, | |
| 312 | ); | |
| 313 | ||
| 237 | 314 | it("keeps older local feedback before newer messages returned by the server", () => { |
| 238 | 315 | const submission = { |
| 239 | 316 | id: MessageId.make("feedback-command-ordering"), |
apps/mobile/src/lib/threadActivity.ts+2| ... | ... | @@ -14,6 +14,7 @@ import type { |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 16 | 16 | import { |
| 17 | isWorktreeSetupActivity, | |
| 17 | 18 | normalizeCompactToolLabel, |
| 18 | 19 | omitSupersededLifecycleMarkers, |
| 19 | 20 | summarizeToolGroup, |
| ... | ... | @@ -343,6 +344,7 @@ function deriveWorkLogEntries( |
| 343 | 344 | const ordered = Arr.sort(activities, activityOrder); |
| 344 | 345 | const entries: DerivedWorkLogEntry[] = []; |
| 345 | 346 | for (const activity of ordered) { |
| 347 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | |
| 346 | 348 | if (activity.kind === "tool.started") continue; |
| 347 | 349 | if (activity.kind === "task.started") continue; |
| 348 | 350 | // Terminal bypassed updates pass: Codex children's only terminal signal. |
apps/web/src/components/ChatView.logic.test.ts+61-1| ... | ... | @@ -115,7 +115,7 @@ describe("draft hero submission transition", () => { |
| 115 | 115 | expect( |
| 116 | 116 | resolveDraftPromotionNavigationTarget({ |
| 117 | 117 | serverThreadRef: { environmentId, threadId }, |
| 118 | serverThreadStarted: true, | |
| 118 | serverThread: makeThread({ latestTurn: completedTurn }), | |
| 119 | 119 | backgroundSubmissionPending: true, |
| 120 | 120 | }), |
| 121 | 121 | ).toBeNull(); |
| ... | ... | @@ -316,6 +316,66 @@ const readySession = { |
| 316 | 316 | updatedAt: "2026-03-29T00:00:10.000Z", |
| 317 | 317 | }; |
| 318 | 318 | |
| 319 | describe("draft promotion during worktree setup", () => { | |
| 320 | const serverThreadRef = { environmentId, threadId }; | |
| 321 | ||
| 322 | it.each([null, "idle", "starting", "ready"] as const)( | |
| 323 | "keeps the draft mounted while the first turn waits with session %s", | |
| 324 | (status) => { | |
| 325 | const serverThread = makeThread({ | |
| 326 | messages: [ | |
| 327 | { | |
| 328 | id: MessageId.make("submitted-message"), | |
| 329 | role: "user", | |
| 330 | text: "Start in a new worktree", | |
| 331 | turnId: null, | |
| 332 | createdAt: now, | |
| 333 | updatedAt: now, | |
| 334 | streaming: false, | |
| 335 | }, | |
| 336 | ], | |
| 337 | session: status ? { ...readySession, status } : null, | |
| 338 | }); | |
| 339 | ||
| 340 | expect( | |
| 341 | resolveDraftPromotionNavigationTarget({ | |
| 342 | serverThreadRef, | |
| 343 | serverThread, | |
| 344 | backgroundSubmissionPending: false, | |
| 345 | }), | |
| 346 | ).toBeNull(); | |
| 347 | }, | |
| 348 | ); | |
| 349 | ||
| 350 | it("promotes when the provider starts the first turn", () => { | |
| 351 | const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null }; | |
| 352 | ||
| 353 | expect( | |
| 354 | resolveDraftPromotionNavigationTarget({ | |
| 355 | serverThreadRef, | |
| 356 | serverThread: makeThread({ | |
| 357 | latestTurn, | |
| 358 | session: { ...readySession, status: "running", activeTurnId: latestTurn.turnId }, | |
| 359 | }), | |
| 360 | backgroundSubmissionPending: false, | |
| 361 | }), | |
| 362 | ).toEqual(serverThreadRef); | |
| 363 | }); | |
| 364 | ||
| 365 | it.each(["error", "stopped", "interrupted"] as const)( | |
| 366 | "promotes a startup that ends as %s before a turn starts", | |
| 367 | (status) => { | |
| 368 | expect( | |
| 369 | resolveDraftPromotionNavigationTarget({ | |
| 370 | serverThreadRef, | |
| 371 | serverThread: makeThread({ session: { ...readySession, status } }), | |
| 372 | backgroundSubmissionPending: false, | |
| 373 | }), | |
| 374 | ).toEqual(serverThreadRef); | |
| 375 | }, | |
| 376 | ); | |
| 377 | }); | |
| 378 | ||
| 319 | 379 | describe("buildLoadingThreadFromShell", () => { |
| 320 | 380 | it("preserves shell metadata and supplies empty detail collections", () => { |
| 321 | 381 | const shell = { |
apps/web/src/components/ChatView.logic.ts+8-2| ... | ... | @@ -110,13 +110,19 @@ export function resolveDraftHeroState(input: { |
| 110 | 110 | |
| 111 | 111 | export function resolveDraftPromotionNavigationTarget(input: { |
| 112 | 112 | serverThreadRef: ScopedThreadRef | null; |
| 113 | serverThreadStarted: boolean; | |
| 113 | serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined; | |
| 114 | 114 | backgroundSubmissionPending: boolean; |
| 115 | 115 | }): ScopedThreadRef | null { |
| 116 | 116 | if (input.backgroundSubmissionPending) { |
| 117 | 117 | return null; |
| 118 | 118 | } |
| 119 | return input.serverThreadStarted ? input.serverThreadRef : null; | |
| 119 | const sessionStatus = input.serverThread?.session?.status; | |
| 120 | const turnStarted = input.serverThread?.latestTurn?.startedAt != null; | |
| 121 | const startupStopped = | |
| 122 | sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted"; | |
| 123 | // Keep local preparation feedback mounted until the server can render the | |
| 124 | // running turn or its startup error on the canonical thread route. | |
| 125 | return turnStarted || startupStopped ? input.serverThreadRef : null; | |
| 120 | 126 | } |
| 121 | 127 | |
| 122 | 128 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { |
apps/web/src/components/ChatView.tsx+9-6| ... | ... | @@ -53,7 +53,6 @@ import { |
| 53 | 53 | createModelSelection, |
| 54 | 54 | resolvePromptInjectedEffort, |
| 55 | 55 | } from "@t3tools/shared/model"; |
| 56 | import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; | |
| 57 | 56 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 58 | 57 | import { truncate } from "@t3tools/shared/String"; |
| 59 | 58 | import { |
| ... | ... | @@ -108,7 +107,11 @@ import { |
| 108 | 107 | isLatestTurnSettled, |
| 109 | 108 | } from "../session-logic"; |
| 110 | 109 | import { type LegendListRef } from "@legendapp/list/react"; |
| 111 | import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; | |
| 110 | import { | |
| 111 | CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 112 | getAnchoredTurnMetrics, | |
| 113 | type TimelineScrollMode, | |
| 114 | } from "./chat/timelineScrollAnchoring"; | |
| 112 | 115 | import { |
| 113 | 116 | buildPendingUserInputAnswers, |
| 114 | 117 | derivePendingUserInputProgress, |
| ... | ... | @@ -4110,7 +4113,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4110 | 4113 | state, |
| 4111 | 4114 | anchorIndex, |
| 4112 | 4115 | composerOverlayHeight, |
| 4113 | anchorOffset: CHAT_LIST_ANCHOR_OFFSET, | |
| 4116 | anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 4114 | 4117 | }); |
| 4115 | 4118 | }, |
| 4116 | 4119 | [composerOverlayHeight], |
| ... | ... | @@ -4138,7 +4141,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4138 | 4141 | const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); |
| 4139 | 4142 | const visibleScrollLength = Math.max( |
| 4140 | 4143 | 0, |
| 4141 | (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, | |
| 4144 | (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 4142 | 4145 | ); |
| 4143 | 4146 | return realContentBottom > visibleScrollLength; |
| 4144 | 4147 | }, |
| ... | ... | @@ -4326,7 +4329,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4326 | 4329 | index: anchorIndex, |
| 4327 | 4330 | animated: true, |
| 4328 | 4331 | viewPosition: 0, |
| 4329 | viewOffset: CHAT_LIST_ANCHOR_OFFSET, | |
| 4332 | viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 4330 | 4333 | }) |
| 4331 | 4334 | .then(() => { |
| 4332 | 4335 | if (positionedTimelineAnchorRef.current !== messageId) { |
| ... | ... | @@ -6146,7 +6149,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 6146 | 6149 | : {}), |
| 6147 | 6150 | } |
| 6148 | 6151 | : undefined; |
| 6149 | beginLocalDispatch({ preparingWorktree: false }); | |
| 6150 | 6152 | const backgroundThreadRef = |
| 6151 | 6153 | resolvedSubmissionIntent === "background" |
| 6152 | 6154 | ? scopeThreadRef(activeThread.environmentId, threadIdForSend) |
| ... | ... | @@ -7225,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7225 | 7227 | onOpenAgents={addAgentsSurface} |
| 7226 | 7228 | key={activeThread.id} |
| 7227 | 7229 | isWorking={isWorking} |
| 7230 | isPreparingWorktree={isPreparingWorktree} | |
| 7228 | 7231 | activeTurnStartedAt={activeWorkStartedAt} |
| 7229 | 7232 | listRef={legendListRef} |
| 7230 | 7233 | timelineEntries={timelineEntries} |
apps/web/src/components/chat/ChatComposer.tsx-3| ... | ... | @@ -537,9 +537,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 537 | 537 | compactDisabledReason={props.compactDisabledReason} |
| 538 | 538 | /> |
| 539 | 539 | ) : null} |
| 540 | {props.isPreparingWorktree ? ( | |
| 541 | <span className="text-secondary-label text-xs">Preparing worktree...</span> | |
| 542 | ) : null} | |
| 543 | 540 | <ComposerPrimaryActions |
| 544 | 541 | compact={props.compact} |
| 545 | 542 | pendingAction={props.pendingAction} |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+52| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { MessageId, TurnId } from "@t3tools/contracts"; | |
| 2 | 3 | import { |
| 3 | 4 | computeStableMessagesTimelineRows, |
| 4 | 5 | computeMessageDurationStart, |
| ... | ... | @@ -828,6 +829,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 828 | 829 | ]); |
| 829 | 830 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 830 | 831 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); |
| 832 | expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true }); | |
| 831 | 833 | }); |
| 832 | 834 | |
| 833 | 835 | it("does not fold the active in-progress turn", () => { |
| ... | ... | @@ -942,6 +944,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 942 | 944 | }); |
| 943 | 945 | |
| 944 | 946 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); |
| 947 | expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); | |
| 945 | 948 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ |
| 946 | 949 | entry: { id: "running-command" }, |
| 947 | 950 | groupedEntries: [ |
| ... | ... | @@ -1375,6 +1378,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1375 | 1378 | |
| 1376 | 1379 | expect(assistantRow?.showAssistantMeta).toBe(false); |
| 1377 | 1380 | expect(assistantRow?.showAssistantCopyButton).toBe(false); |
| 1381 | expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); | |
| 1378 | 1382 | }); |
| 1379 | 1383 | |
| 1380 | 1384 | it.each([ |
| ... | ... | @@ -1543,6 +1547,54 @@ describe("deriveMessagesTimelineRows", () => { |
| 1543 | 1547 | }); |
| 1544 | 1548 | |
| 1545 | 1549 | describe("computeStableMessagesTimelineRows", () => { |
| 1550 | it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => { | |
| 1551 | const startedAt = "2026-01-01T00:00:00Z"; | |
| 1552 | const turnId = TurnId.make("turn-1"); | |
| 1553 | const input = { | |
| 1554 | runningTurnId: turnId, | |
| 1555 | isWorking: true, | |
| 1556 | activeTurnStartedAt: startedAt, | |
| 1557 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1558 | revertTurnCountByUserMessageId: new Map(), | |
| 1559 | }; | |
| 1560 | const assistantEntry = { | |
| 1561 | id: "assistant-entry", | |
| 1562 | kind: "message" as const, | |
| 1563 | createdAt: startedAt, | |
| 1564 | message: { | |
| 1565 | id: MessageId.make("assistant-1"), | |
| 1566 | role: "assistant" as const, | |
| 1567 | text, | |
| 1568 | turnId, | |
| 1569 | createdAt: startedAt, | |
| 1570 | updatedAt: startedAt, | |
| 1571 | streaming: true, | |
| 1572 | }, | |
| 1573 | }; | |
| 1574 | const initial = computeStableMessagesTimelineRows( | |
| 1575 | deriveMessagesTimelineRows({ ...input, timelineEntries: [assistantEntry] }), | |
| 1576 | { byId: new Map(), result: [] }, | |
| 1577 | ); | |
| 1578 | const updated = computeStableMessagesTimelineRows( | |
| 1579 | deriveMessagesTimelineRows({ | |
| 1580 | ...input, | |
| 1581 | timelineEntries: [ | |
| 1582 | { | |
| 1583 | ...assistantEntry, | |
| 1584 | message: { ...assistantEntry.message, text: "I will inspect the repository." }, | |
| 1585 | }, | |
| 1586 | ], | |
| 1587 | }), | |
| 1588 | initial, | |
| 1589 | ); | |
| 1590 | ||
| 1591 | const initialWorking = initial.byId.get("working-indicator-row"); | |
| 1592 | const updatedWorking = updated.byId.get("working-indicator-row"); | |
| 1593 | expect(initialWorking).toMatchObject({ showThinking: true }); | |
| 1594 | expect(updatedWorking).toMatchObject({ showThinking: false }); | |
| 1595 | expect(updatedWorking).not.toBe(initialWorking); | |
| 1596 | }); | |
| 1597 | ||
| 1546 | 1598 | it("returns the previous result when row order and content are unchanged", () => { |
| 1547 | 1599 | const firstUserMessage = { |
| 1548 | 1600 | id: "user-1" as never, |
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1| ... | ... | @@ -520,7 +520,7 @@ describe("MessagesTimeline", () => { |
| 520 | 520 | ); |
| 521 | 521 | |
| 522 | 522 | expect(markup).toContain('data-anchor-index="0"'); |
| 523 | expect(markup).toContain('data-anchor-offset="16"'); | |
| 523 | expect(markup).toContain('data-anchor-offset="24"'); | |
| 524 | 524 | expect(markup).toContain('data-anchor-on-ready="true"'); |
| 525 | 525 | expect(markup).not.toContain("data-anchor-max-size="); |
| 526 | 526 | expect(markup).toContain('data-content-inset-end="144"'); |
apps/web/src/components/chat/MessagesTimeline.tsx+47-25| ... | ... | @@ -84,7 +84,10 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage |
| 84 | 84 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 85 | 85 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 86 | 86 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; |
| 87 | import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring"; | |
| 87 | import { | |
| 88 | CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 89 | keepTimelineEndVisibleAfterOverlayGrowth, | |
| 90 | } from "./timelineScrollAnchoring"; | |
| 88 | 91 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 89 | 92 | import { |
| 90 | 93 | computeStableMessagesTimelineRows, |
| ... | ... | @@ -169,6 +172,7 @@ interface TimelineRowSharedState { |
| 169 | 172 | |
| 170 | 173 | interface TimelineRowActivityState { |
| 171 | 174 | isWorking: boolean; |
| 175 | isPreparingWorktree: boolean; | |
| 172 | 176 | isRevertingCheckpoint: boolean; |
| 173 | 177 | latestTurnId: TurnId | null; |
| 174 | 178 | } |
| ... | ... | @@ -225,6 +229,7 @@ interface MessagesTimelineProps { |
| 225 | 229 | agentPanelModel?: AgentPanelModel; |
| 226 | 230 | onOpenAgents?: () => void; |
| 227 | 231 | isWorking: boolean; |
| 232 | isPreparingWorktree?: boolean; | |
| 228 | 233 | activeTurnStartedAt: string | null; |
| 229 | 234 | listRef: React.RefObject<LegendListRef | null>; |
| 230 | 235 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| ... | ... | @@ -270,6 +275,7 @@ interface MessagesTimelineProps { |
| 270 | 275 | |
| 271 | 276 | export const MessagesTimeline = memo(function MessagesTimeline({ |
| 272 | 277 | isWorking, |
| 278 | isPreparingWorktree = false, | |
| 273 | 279 | activeTurnStartedAt, |
| 274 | 280 | agentPanelModel = EMPTY_AGENT_PANEL_MODEL, |
| 275 | 281 | onOpenAgents = NOOP_OPEN_AGENTS, |
| ... | ... | @@ -467,8 +473,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 467 | 473 | [anchorMessageId, onAnchorReady], |
| 468 | 474 | ); |
| 469 | 475 | const anchoredEndSpace = useMemo(() => { |
| 470 | const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => | |
| 471 | row.kind === "message" && row.message.role === "user" ? row.message.id : null, | |
| 476 | const config = resolveChatListAnchoredEndSpace( | |
| 477 | rows, | |
| 478 | anchorMessageId, | |
| 479 | (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null), | |
| 480 | { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, | |
| 472 | 481 | ); |
| 473 | 482 | return config ? { ...config, onReady: handleAnchorReady } : undefined; |
| 474 | 483 | }, [anchorMessageId, handleAnchorReady, rows]); |
| ... | ... | @@ -577,10 +586,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 577 | 586 | const activityState = useMemo<TimelineRowActivityState>( |
| 578 | 587 | () => ({ |
| 579 | 588 | isWorking, |
| 589 | isPreparingWorktree, | |
| 580 | 590 | isRevertingCheckpoint, |
| 581 | 591 | latestTurnId: latestTurn?.turnId ?? null, |
| 582 | 592 | }), |
| 583 | [isRevertingCheckpoint, isWorking, latestTurn?.turnId], | |
| 593 | [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], | |
| 584 | 594 | ); |
| 585 | 595 | |
| 586 | 596 | // Stable renderItem — no closure deps. Row components read shared state |
| ... | ... | @@ -1318,12 +1328,21 @@ function ProposedPlanTimelineRow({ |
| 1318 | 1328 | } |
| 1319 | 1329 | |
| 1320 | 1330 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| 1331 | const { isPreparingWorktree } = use(TimelineRowActivityCtx); | |
| 1321 | 1332 | return ( |
| 1322 | 1333 | <div> |
| 1323 | 1334 | <div className="border-b border-border/60 pb-2 pt-1"> |
| 1324 | <div className="flex min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> | |
| 1325 | <span className="shrink-0 whitespace-nowrap"> | |
| 1326 | {row.createdAt ? ( | |
| 1335 | <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> | |
| 1336 | <span | |
| 1337 | key={isPreparingWorktree ? "setup" : "working"} | |
| 1338 | className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none" | |
| 1339 | > | |
| 1340 | {isPreparingWorktree ? ( | |
| 1341 | <> | |
| 1342 | Setting up worktree… | |
| 1343 | <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay> | |
| 1344 | </> | |
| 1345 | ) : row.createdAt ? ( | |
| 1327 | 1346 | <> |
| 1328 | 1347 | Working for <WorkingTimer createdAt={row.createdAt} /> |
| 1329 | 1348 | </> |
| ... | ... | @@ -1334,8 +1353,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1334 | 1353 | </div> |
| 1335 | 1354 | </div> |
| 1336 | 1355 | {row.showThinking ? ( |
| 1337 | <div className="mt-1"> | |
| 1338 | <ThinkingActivityRow /> | |
| 1356 | // Reserve the activity row during setup so the handoff keeps the same height. | |
| 1357 | <div className="mt-1 min-h-7"> | |
| 1358 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | |
| 1339 | 1359 | </div> |
| 1340 | 1360 | ) : null} |
| 1341 | 1361 | </div> |
| ... | ... | @@ -1412,6 +1432,19 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1412 | 1432 | ); |
| 1413 | 1433 | }); |
| 1414 | 1434 | |
| 1435 | function ActivityShimmerOverlay({ children }: { children: ReactNode }) { | |
| 1436 | return ( | |
| 1437 | <span | |
| 1438 | aria-hidden | |
| 1439 | className="live-activity-focus pointer-events-none absolute inset-y-0 select-none" | |
| 1440 | > | |
| 1441 | <span className="live-activity-focus-counter block"> | |
| 1442 | <span className="live-activity-focus-aligned block text-foreground">{children}</span> | |
| 1443 | </span> | |
| 1444 | </span> | |
| 1445 | ); | |
| 1446 | } | |
| 1447 | ||
| 1415 | 1448 | function LiveActivityRow({ |
| 1416 | 1449 | label, |
| 1417 | 1450 | iconName, |
| ... | ... | @@ -1429,24 +1462,13 @@ function LiveActivityRow({ |
| 1429 | 1462 | failed={failed} |
| 1430 | 1463 | announceFailure={failed} |
| 1431 | 1464 | /> |
| 1432 | <div | |
| 1433 | aria-hidden | |
| 1434 | className="live-activity-focus pointer-events-none absolute inset-y-0 select-none" | |
| 1435 | > | |
| 1436 | <div className="live-activity-focus-counter"> | |
| 1437 | <div className="live-activity-focus-aligned"> | |
| 1438 | <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted /> | |
| 1439 | </div> | |
| 1440 | </div> | |
| 1441 | </div> | |
| 1465 | <ActivityShimmerOverlay> | |
| 1466 | <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted /> | |
| 1467 | </ActivityShimmerOverlay> | |
| 1442 | 1468 | </div> |
| 1443 | 1469 | ); |
| 1444 | 1470 | } |
| 1445 | 1471 | |
| 1446 | function ThinkingActivityRow() { | |
| 1447 | return <LiveActivityRow label="Thinking" />; | |
| 1448 | } | |
| 1449 | ||
| 1450 | 1472 | function LiveActivityContent({ |
| 1451 | 1473 | label, |
| 1452 | 1474 | iconName, |
| ... | ... | @@ -1463,7 +1485,7 @@ function LiveActivityContent({ |
| 1463 | 1485 | const resolvedIconName = failed ? "circle-alert" : iconName; |
| 1464 | 1486 | |
| 1465 | 1487 | return ( |
| 1466 | <div | |
| 1488 | <span | |
| 1467 | 1489 | className={cn( |
| 1468 | 1490 | "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5", |
| 1469 | 1491 | resolvedIconName ? "px-0.5" : "px-1", |
| ... | ... | @@ -1486,7 +1508,7 @@ function LiveActivityContent({ |
| 1486 | 1508 | </span> |
| 1487 | 1509 | ) : null} |
| 1488 | 1510 | <span className="min-w-0 flex-1 truncate">{label}</span> |
| 1489 | </div> | |
| 1511 | </span> | |
| 1490 | 1512 | ); |
| 1491 | 1513 | } |
| 1492 | 1514 |
apps/web/src/components/chat/timelineScrollAnchoring.ts+3| ... | ... | @@ -1,3 +1,6 @@ |
| 1 | // Match the titlebar fade inset so draft promotion preserves the first row's position. | |
| 2 | export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; | |
| 3 | ||
| 1 | 4 | export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; |
| 2 | 5 | |
| 3 | 6 | export interface TimelineListMeasurementState { |
apps/web/src/routes/_chat.draft.$draftId.tsx+2-6| ... | ... | @@ -1,10 +1,7 @@ |
| 1 | 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | 2 | import { useEffect } from "react"; |
| 3 | 3 | import ChatView from "../components/ChatView"; |
| 4 | import { | |
| 5 | resolveDraftPromotionNavigationTarget, | |
| 6 | threadHasStarted, | |
| 7 | } from "../components/ChatView.logic"; | |
| 4 | import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic"; | |
| 8 | 5 | import { |
| 9 | 6 | DraftId, |
| 10 | 7 | markPromotedDraftThreadByRef, |
| ... | ... | @@ -31,11 +28,10 @@ function DraftChatThreadRouteView() { |
| 31 | 28 | : null; |
| 32 | 29 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; |
| 33 | 30 | const serverThread = useThread(serverThreadRef); |
| 34 | const serverThreadStarted = threadHasStarted(serverThread); | |
| 35 | 31 | const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); |
| 36 | 32 | const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ |
| 37 | 33 | serverThreadRef, |
| 38 | serverThreadStarted, | |
| 34 | serverThread, | |
| 39 | 35 | backgroundSubmissionPending, |
| 40 | 36 | }); |
| 41 | 37 |
apps/web/src/session-logic.test.ts+83| ... | ... | @@ -894,6 +894,89 @@ describe("deriveWorkLogEntries", () => { |
| 894 | 894 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 895 | 895 | }); |
| 896 | 896 | |
| 897 | it("omits routine setup updates before work starts and after later turn activity", () => { | |
| 898 | const setupActivities = [ | |
| 899 | makeActivity({ | |
| 900 | id: "setup-requested", | |
| 901 | kind: "setup-script.requested", | |
| 902 | summary: "Preparing setup script", | |
| 903 | tone: "info", | |
| 904 | sequence: 1, | |
| 905 | }), | |
| 906 | makeActivity({ | |
| 907 | id: "setup-started", | |
| 908 | kind: "setup-script.started", | |
| 909 | summary: "Setup script started", | |
| 910 | tone: "info", | |
| 911 | sequence: 2, | |
| 912 | }), | |
| 913 | ]; | |
| 914 | ||
| 915 | expect(deriveWorkLogEntries(setupActivities)).toEqual([]); | |
| 916 | expect( | |
| 917 | deriveWorkLogEntries([ | |
| 918 | ...setupActivities, | |
| 919 | makeActivity({ | |
| 920 | id: "first-turn-tool", | |
| 921 | kind: "tool.completed", | |
| 922 | summary: "Read project files", | |
| 923 | turnId: "turn-1", | |
| 924 | sequence: 3, | |
| 925 | }), | |
| 926 | makeActivity({ | |
| 927 | id: "later-turn-tool", | |
| 928 | kind: "tool.completed", | |
| 929 | summary: "Ran tests", | |
| 930 | turnId: "turn-2", | |
| 931 | sequence: 4, | |
| 932 | }), | |
| 933 | ]).map((entry) => entry.id), | |
| 934 | ).toEqual(["first-turn-tool", "later-turn-tool"]); | |
| 935 | }); | |
| 936 | ||
| 937 | it("preserves setup failures and unrelated info without a turn id", () => { | |
| 938 | const entries = deriveWorkLogEntries([ | |
| 939 | makeActivity({ | |
| 940 | id: "setup-requested", | |
| 941 | kind: "setup-script.requested", | |
| 942 | summary: "Preparing setup script", | |
| 943 | tone: "info", | |
| 944 | sequence: 1, | |
| 945 | }), | |
| 946 | makeActivity({ | |
| 947 | id: "setup-failed", | |
| 948 | kind: "setup-script.failed", | |
| 949 | summary: "Setup script failed to start", | |
| 950 | tone: "error", | |
| 951 | payload: { detail: "Could not start the setup terminal" }, | |
| 952 | sequence: 2, | |
| 953 | }), | |
| 954 | makeActivity({ | |
| 955 | id: "runtime-notice", | |
| 956 | kind: "runtime.warning", | |
| 957 | summary: "Reconnecting to provider", | |
| 958 | tone: "info", | |
| 959 | sequence: 3, | |
| 960 | }), | |
| 961 | ]); | |
| 962 | ||
| 963 | expect(entries).toMatchObject([ | |
| 964 | { | |
| 965 | id: "setup-failed", | |
| 966 | label: "Setup script failed to start", | |
| 967 | tone: "error", | |
| 968 | detail: "Could not start the setup terminal", | |
| 969 | turnId: null, | |
| 970 | }, | |
| 971 | { | |
| 972 | id: "runtime-notice", | |
| 973 | label: "Reconnecting to provider", | |
| 974 | tone: "info", | |
| 975 | turnId: null, | |
| 976 | }, | |
| 977 | ]); | |
| 978 | }); | |
| 979 | ||
| 897 | 980 | it("drops runtime warnings with no displayable content, keeps ones with a preview", () => { |
| 898 | 981 | const activities: OrchestrationThreadActivity[] = [ |
| 899 | 982 | makeActivity({ |
apps/web/src/session-logic.ts+2| ... | ... | @@ -2,6 +2,7 @@ import * as Option from "effect/Option"; |
| 2 | 2 | import * as Arr from "effect/Array"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 5 | import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation"; | |
| 5 | 6 | import { |
| 6 | 7 | ApprovalRequestId, |
| 7 | 8 | isToolLifecycleItemType, |
| ... | ... | @@ -809,6 +810,7 @@ export function deriveWorkLogEntries( |
| 809 | 810 | const ordered = [...activities].toSorted(compareActivitiesByOrder); |
| 810 | 811 | const entries: DerivedWorkLogEntry[] = []; |
| 811 | 812 | for (const activity of ordered) { |
| 813 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | |
| 812 | 814 | if (activity.kind === "tool.started") continue; |
| 813 | 815 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, |
| 814 | 816 | // which is the batch key (completions of background subagents arrive |
packages/client-runtime/src/work-log/presentation.ts+4| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | export function isWorktreeSetupActivity(kind: string): boolean { | |
| 4 | return kind === "setup-script.requested" || kind === "setup-script.started"; | |
| 5 | } | |
| 6 | ||
| 3 | 7 | export interface WorkLogPresentationEntry { |
| 4 | 8 | readonly label: string; |
| 5 | 9 | readonly toolTitle?: string; |