| 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,6 +39,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 39 | import { SymbolView } from "../../components/AppSymbol"; | 39 | import { SymbolView } from "../../components/AppSymbol"; |
| 40 | import { AppText as Text } from "../../components/AppText"; | 40 | import { AppText as Text } from "../../components/AppText"; |
| 41 | import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; | 41 | import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; |
| 42 | import { ShimmeringWorkContent } from "./thread-work-log"; | ||
| 42 | import { ComposerCommandPopover } from "./ComposerCommandPopover"; | 43 | import { ComposerCommandPopover } from "./ComposerCommandPopover"; |
| 43 | import { useComposerCommandMenu } from "./use-composer-command-menu"; | 44 | import { useComposerCommandMenu } from "./use-composer-command-menu"; |
| 44 | import { | 45 | import { |
| ... | @@ -380,7 +381,8 @@ export function NewTaskDraftScreen(props: { | ... | @@ -380,7 +381,8 @@ export function NewTaskDraftScreen(props: { |
| 380 | }; | 381 | }; |
| 381 | }, [props.pendingTaskId, cancelEditingPendingTask]); | 382 | }, [props.pendingTaskId, cancelEditingPendingTask]); |
| 382 | 383 | ||
| 383 | const foregroundColor = useUniwindTheme()["--color-foreground"]; | 384 | const theme = useUniwindTheme(); |
| 385 | const foregroundColor = theme["--color-foreground"]; | ||
| 384 | const regularFontFamily = useFontFamily("regular"); | 386 | const regularFontFamily = useFontFamily("regular"); |
| 385 | const bodyText = useScaledTextRole("body"); | 387 | const bodyText = useScaledTextRole("body"); |
| 386 | 388 | ||
| ... | @@ -1110,31 +1112,50 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1110,31 +1112,50 @@ export function NewTaskDraftScreen(props: { |
| 1110 | 1112 | ||
| 1111 | const workspaceControls = ( | 1113 | const workspaceControls = ( |
| 1112 | <View className="flex-row items-center gap-1 px-2"> | 1114 | <View className="flex-row items-center gap-1 px-2"> |
| 1113 | <ComposerInlineControl | 1115 | {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? ( |
| 1114 | accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} | 1116 | <View |
| 1115 | accessibilityLabel={workspaceLabel} | 1117 | accessible |
| 1116 | disabled={isComposerInteractionLocked || voiceInput.isBusy} | 1118 | accessibilityLabel="Setting up worktree…" |
| 1117 | iconNode={ | 1119 | className="h-11 w-full max-w-[260px] flex-row items-center px-2" |
| 1118 | <NewTaskWorkspaceIcon | 1120 | > |
| 1119 | workspaceMode={flow.workspaceMode} | 1121 | <ShimmeringWorkContent |
| 1120 | worktreePath={flow.selectedWorktreePath} | 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 | 1148 | <ComposerInlineControl |
| 1130 | accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`} | 1149 | accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`} |
| 1131 | chevronDirection="right" | 1150 | chevronDirection="right" |
| 1132 | disabled={isComposerInteractionLocked} | 1151 | disabled={isComposerInteractionLocked} |
| 1133 | icon="arrow.triangle.branch" | 1152 | icon="arrow.triangle.branch" |
| 1134 | label={showBranchLoading ? "Loading branches…" : selectedBranchLabel} | 1153 | label={showBranchLoading ? "Loading branches…" : selectedBranchLabel} |
| 1135 | maxWidth={190} | 1154 | maxWidth={190} |
| 1136 | onPress={() => openContextPicker("NewTaskBranch")} | 1155 | onPress={() => openContextPicker("NewTaskBranch")} |
| 1137 | /> | 1156 | /> |
| 1157 | </> | ||
| 1158 | )} | ||
| 1138 | </View> | 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,7 +79,7 @@ function ShimmerWorkContent(props: { |
| 79 | ); | 79 | ); |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | function ShimmeringWorkContent(props: { | 82 | export function ShimmeringWorkContent(props: { |
| 83 | readonly icon: AppSymbolName; | 83 | readonly icon: AppSymbolName; |
| 84 | readonly iconSubtleColor: ColorValue; | 84 | readonly iconSubtleColor: ColorValue; |
| 85 | readonly label: string; | 85 | readonly label: string; |
apps/mobile/src/lib/threadActivity.test.ts+77| ... | @@ -234,6 +234,83 @@ function makeThread( | ... | @@ -234,6 +234,83 @@ function makeThread( |
| 234 | } | 234 | } |
| 235 | 235 | ||
| 236 | describe("buildThreadFeed", () => { | 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 | it("keeps older local feedback before newer messages returned by the server", () => { | 314 | it("keeps older local feedback before newer messages returned by the server", () => { |
| 238 | const submission = { | 315 | const submission = { |
| 239 | id: MessageId.make("feedback-command-ordering"), | 316 | id: MessageId.make("feedback-command-ordering"), |
apps/mobile/src/lib/threadActivity.ts+2| ... | @@ -14,6 +14,7 @@ import type { | ... | @@ -14,6 +14,7 @@ import type { |
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | 15 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 16 | import { | 16 | import { |
| 17 | isWorktreeSetupActivity, | ||
| 17 | normalizeCompactToolLabel, | 18 | normalizeCompactToolLabel, |
| 18 | omitSupersededLifecycleMarkers, | 19 | omitSupersededLifecycleMarkers, |
| 19 | summarizeToolGroup, | 20 | summarizeToolGroup, |
| ... | @@ -343,6 +344,7 @@ function deriveWorkLogEntries( | ... | @@ -343,6 +344,7 @@ function deriveWorkLogEntries( |
| 343 | const ordered = Arr.sort(activities, activityOrder); | 344 | const ordered = Arr.sort(activities, activityOrder); |
| 344 | const entries: DerivedWorkLogEntry[] = []; | 345 | const entries: DerivedWorkLogEntry[] = []; |
| 345 | for (const activity of ordered) { | 346 | for (const activity of ordered) { |
| 347 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | ||
| 346 | if (activity.kind === "tool.started") continue; | 348 | if (activity.kind === "tool.started") continue; |
| 347 | if (activity.kind === "task.started") continue; | 349 | if (activity.kind === "task.started") continue; |
| 348 | // Terminal bypassed updates pass: Codex children's only terminal signal. | 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,7 +115,7 @@ describe("draft hero submission transition", () => { |
| 115 | expect( | 115 | expect( |
| 116 | resolveDraftPromotionNavigationTarget({ | 116 | resolveDraftPromotionNavigationTarget({ |
| 117 | serverThreadRef: { environmentId, threadId }, | 117 | serverThreadRef: { environmentId, threadId }, |
| 118 | serverThreadStarted: true, | 118 | serverThread: makeThread({ latestTurn: completedTurn }), |
| 119 | backgroundSubmissionPending: true, | 119 | backgroundSubmissionPending: true, |
| 120 | }), | 120 | }), |
| 121 | ).toBeNull(); | 121 | ).toBeNull(); |
| ... | @@ -316,6 +316,66 @@ const readySession = { | ... | @@ -316,6 +316,66 @@ const readySession = { |
| 316 | updatedAt: "2026-03-29T00:00:10.000Z", | 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 | describe("buildLoadingThreadFromShell", () => { | 379 | describe("buildLoadingThreadFromShell", () => { |
| 320 | it("preserves shell metadata and supplies empty detail collections", () => { | 380 | it("preserves shell metadata and supplies empty detail collections", () => { |
| 321 | const shell = { | 381 | const shell = { |
apps/web/src/components/ChatView.logic.ts+8-2| ... | @@ -110,13 +110,19 @@ export function resolveDraftHeroState(input: { | ... | @@ -110,13 +110,19 @@ export function resolveDraftHeroState(input: { |
| 110 | 110 | ||
| 111 | export function resolveDraftPromotionNavigationTarget(input: { | 111 | export function resolveDraftPromotionNavigationTarget(input: { |
| 112 | serverThreadRef: ScopedThreadRef | null; | 112 | serverThreadRef: ScopedThreadRef | null; |
| 113 | serverThreadStarted: boolean; | 113 | serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined; |
| 114 | backgroundSubmissionPending: boolean; | 114 | backgroundSubmissionPending: boolean; |
| 115 | }): ScopedThreadRef | null { | 115 | }): ScopedThreadRef | null { |
| 116 | if (input.backgroundSubmissionPending) { | 116 | if (input.backgroundSubmissionPending) { |
| 117 | return null; | 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 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { | 128 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { |
apps/web/src/components/ChatView.tsx+9-6| ... | @@ -53,7 +53,6 @@ import { | ... | @@ -53,7 +53,6 @@ import { |
| 53 | createModelSelection, | 53 | createModelSelection, |
| 54 | resolvePromptInjectedEffort, | 54 | resolvePromptInjectedEffort, |
| 55 | } from "@t3tools/shared/model"; | 55 | } from "@t3tools/shared/model"; |
| 56 | import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; | ||
| 57 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 56 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 58 | import { truncate } from "@t3tools/shared/String"; | 57 | import { truncate } from "@t3tools/shared/String"; |
| 59 | import { | 58 | import { |
| ... | @@ -108,7 +107,11 @@ import { | ... | @@ -108,7 +107,11 @@ import { |
| 108 | isLatestTurnSettled, | 107 | isLatestTurnSettled, |
| 109 | } from "../session-logic"; | 108 | } from "../session-logic"; |
| 110 | import { type LegendListRef } from "@legendapp/list/react"; | 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 | import { | 115 | import { |
| 113 | buildPendingUserInputAnswers, | 116 | buildPendingUserInputAnswers, |
| 114 | derivePendingUserInputProgress, | 117 | derivePendingUserInputProgress, |
| ... | @@ -4110,7 +4113,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4110,7 +4113,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4110 | state, | 4113 | state, |
| 4111 | anchorIndex, | 4114 | anchorIndex, |
| 4112 | composerOverlayHeight, | 4115 | composerOverlayHeight, |
| 4113 | anchorOffset: CHAT_LIST_ANCHOR_OFFSET, | 4116 | anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, |
| 4114 | }); | 4117 | }); |
| 4115 | }, | 4118 | }, |
| 4116 | [composerOverlayHeight], | 4119 | [composerOverlayHeight], |
| ... | @@ -4138,7 +4141,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4138,7 +4141,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4138 | const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); | 4141 | const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); |
| 4139 | const visibleScrollLength = Math.max( | 4142 | const visibleScrollLength = Math.max( |
| 4140 | 0, | 4143 | 0, |
| 4141 | (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, | 4144 | (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, |
| 4142 | ); | 4145 | ); |
| 4143 | return realContentBottom > visibleScrollLength; | 4146 | return realContentBottom > visibleScrollLength; |
| 4144 | }, | 4147 | }, |
| ... | @@ -4326,7 +4329,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4326,7 +4329,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4326 | index: anchorIndex, | 4329 | index: anchorIndex, |
| 4327 | animated: true, | 4330 | animated: true, |
| 4328 | viewPosition: 0, | 4331 | viewPosition: 0, |
| 4329 | viewOffset: CHAT_LIST_ANCHOR_OFFSET, | 4332 | viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET, |
| 4330 | }) | 4333 | }) |
| 4331 | .then(() => { | 4334 | .then(() => { |
| 4332 | if (positionedTimelineAnchorRef.current !== messageId) { | 4335 | if (positionedTimelineAnchorRef.current !== messageId) { |
| ... | @@ -6146,7 +6149,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6146,7 +6149,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 6146 | : {}), | 6149 | : {}), |
| 6147 | } | 6150 | } |
| 6148 | : undefined; | 6151 | : undefined; |
| 6149 | beginLocalDispatch({ preparingWorktree: false }); | ||
| 6150 | const backgroundThreadRef = | 6152 | const backgroundThreadRef = |
| 6151 | resolvedSubmissionIntent === "background" | 6153 | resolvedSubmissionIntent === "background" |
| 6152 | ? scopeThreadRef(activeThread.environmentId, threadIdForSend) | 6154 | ? scopeThreadRef(activeThread.environmentId, threadIdForSend) |
| ... | @@ -7225,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7225,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7225 | onOpenAgents={addAgentsSurface} | 7227 | onOpenAgents={addAgentsSurface} |
| 7226 | key={activeThread.id} | 7228 | key={activeThread.id} |
| 7227 | isWorking={isWorking} | 7229 | isWorking={isWorking} |
| 7230 | isPreparingWorktree={isPreparingWorktree} | ||
| 7228 | activeTurnStartedAt={activeWorkStartedAt} | 7231 | activeTurnStartedAt={activeWorkStartedAt} |
| 7229 | listRef={legendListRef} | 7232 | listRef={legendListRef} |
| 7230 | timelineEntries={timelineEntries} | 7233 | timelineEntries={timelineEntries} |
apps/web/src/components/chat/ChatComposer.tsx-3| ... | @@ -537,9 +537,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -537,9 +537,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 537 | compactDisabledReason={props.compactDisabledReason} | 537 | compactDisabledReason={props.compactDisabledReason} |
| 538 | /> | 538 | /> |
| 539 | ) : null} | 539 | ) : null} |
| 540 | {props.isPreparingWorktree ? ( | ||
| 541 | <span className="text-secondary-label text-xs">Preparing worktree...</span> | ||
| 542 | ) : null} | ||
| 543 | <ComposerPrimaryActions | 540 | <ComposerPrimaryActions |
| 544 | compact={props.compact} | 541 | compact={props.compact} |
| 545 | pendingAction={props.pendingAction} | 542 | pendingAction={props.pendingAction} |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+52| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { MessageId, TurnId } from "@t3tools/contracts"; | ||
| 2 | import { | 3 | import { |
| 3 | computeStableMessagesTimelineRows, | 4 | computeStableMessagesTimelineRows, |
| 4 | computeMessageDurationStart, | 5 | computeMessageDurationStart, |
| ... | @@ -828,6 +829,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -828,6 +829,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 828 | ]); | 829 | ]); |
| 829 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); | 830 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 830 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); | 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 | it("does not fold the active in-progress turn", () => { | 835 | it("does not fold the active in-progress turn", () => { |
| ... | @@ -942,6 +944,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -942,6 +944,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 942 | }); | 944 | }); |
| 943 | 945 | ||
| 944 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); | 946 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); |
| 947 | expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); | ||
| 945 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ | 948 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ |
| 946 | entry: { id: "running-command" }, | 949 | entry: { id: "running-command" }, |
| 947 | groupedEntries: [ | 950 | groupedEntries: [ |
| ... | @@ -1375,6 +1378,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1375,6 +1378,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1375 | 1378 | ||
| 1376 | expect(assistantRow?.showAssistantMeta).toBe(false); | 1379 | expect(assistantRow?.showAssistantMeta).toBe(false); |
| 1377 | expect(assistantRow?.showAssistantCopyButton).toBe(false); | 1380 | expect(assistantRow?.showAssistantCopyButton).toBe(false); |
| 1381 | expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); | ||
| 1378 | }); | 1382 | }); |
| 1379 | 1383 | ||
| 1380 | it.each([ | 1384 | it.each([ |
| ... | @@ -1543,6 +1547,54 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1543,6 +1547,54 @@ describe("deriveMessagesTimelineRows", () => { |
| 1543 | }); | 1547 | }); |
| 1544 | 1548 | ||
| 1545 | describe("computeStableMessagesTimelineRows", () => { | 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 | it("returns the previous result when row order and content are unchanged", () => { | 1598 | it("returns the previous result when row order and content are unchanged", () => { |
| 1547 | const firstUserMessage = { | 1599 | const firstUserMessage = { |
| 1548 | id: "user-1" as never, | 1600 | id: "user-1" as never, |
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1| ... | @@ -520,7 +520,7 @@ describe("MessagesTimeline", () => { | ... | @@ -520,7 +520,7 @@ describe("MessagesTimeline", () => { |
| 520 | ); | 520 | ); |
| 521 | 521 | ||
| 522 | expect(markup).toContain('data-anchor-index="0"'); | 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 | expect(markup).toContain('data-anchor-on-ready="true"'); | 524 | expect(markup).toContain('data-anchor-on-ready="true"'); |
| 525 | expect(markup).not.toContain("data-anchor-max-size="); | 525 | expect(markup).not.toContain("data-anchor-max-size="); |
| 526 | expect(markup).toContain('data-content-inset-end="144"'); | 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,7 +84,10 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage |
| 84 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 84 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 85 | import { ChangedFilesCard } from "./ChangedFilesTree"; | 85 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 86 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; | 86 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; |
| 87 | import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring"; | 87 | import { |
| 88 | CHAT_TIMELINE_ANCHOR_OFFSET, | ||
| 89 | keepTimelineEndVisibleAfterOverlayGrowth, | ||
| 90 | } from "./timelineScrollAnchoring"; | ||
| 88 | import { MessageCopyButton } from "./MessageCopyButton"; | 91 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 89 | import { | 92 | import { |
| 90 | computeStableMessagesTimelineRows, | 93 | computeStableMessagesTimelineRows, |
| ... | @@ -169,6 +172,7 @@ interface TimelineRowSharedState { | ... | @@ -169,6 +172,7 @@ interface TimelineRowSharedState { |
| 169 | 172 | ||
| 170 | interface TimelineRowActivityState { | 173 | interface TimelineRowActivityState { |
| 171 | isWorking: boolean; | 174 | isWorking: boolean; |
| 175 | isPreparingWorktree: boolean; | ||
| 172 | isRevertingCheckpoint: boolean; | 176 | isRevertingCheckpoint: boolean; |
| 173 | latestTurnId: TurnId | null; | 177 | latestTurnId: TurnId | null; |
| 174 | } | 178 | } |
| ... | @@ -225,6 +229,7 @@ interface MessagesTimelineProps { | ... | @@ -225,6 +229,7 @@ interface MessagesTimelineProps { |
| 225 | agentPanelModel?: AgentPanelModel; | 229 | agentPanelModel?: AgentPanelModel; |
| 226 | onOpenAgents?: () => void; | 230 | onOpenAgents?: () => void; |
| 227 | isWorking: boolean; | 231 | isWorking: boolean; |
| 232 | isPreparingWorktree?: boolean; | ||
| 228 | activeTurnStartedAt: string | null; | 233 | activeTurnStartedAt: string | null; |
| 229 | listRef: React.RefObject<LegendListRef | null>; | 234 | listRef: React.RefObject<LegendListRef | null>; |
| 230 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | 235 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| ... | @@ -270,6 +275,7 @@ interface MessagesTimelineProps { | ... | @@ -270,6 +275,7 @@ interface MessagesTimelineProps { |
| 270 | 275 | ||
| 271 | export const MessagesTimeline = memo(function MessagesTimeline({ | 276 | export const MessagesTimeline = memo(function MessagesTimeline({ |
| 272 | isWorking, | 277 | isWorking, |
| 278 | isPreparingWorktree = false, | ||
| 273 | activeTurnStartedAt, | 279 | activeTurnStartedAt, |
| 274 | agentPanelModel = EMPTY_AGENT_PANEL_MODEL, | 280 | agentPanelModel = EMPTY_AGENT_PANEL_MODEL, |
| 275 | onOpenAgents = NOOP_OPEN_AGENTS, | 281 | onOpenAgents = NOOP_OPEN_AGENTS, |
| ... | @@ -467,8 +473,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -467,8 +473,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 467 | [anchorMessageId, onAnchorReady], | 473 | [anchorMessageId, onAnchorReady], |
| 468 | ); | 474 | ); |
| 469 | const anchoredEndSpace = useMemo(() => { | 475 | const anchoredEndSpace = useMemo(() => { |
| 470 | const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => | 476 | const config = resolveChatListAnchoredEndSpace( |
| 471 | row.kind === "message" && row.message.role === "user" ? row.message.id : null, | 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 | return config ? { ...config, onReady: handleAnchorReady } : undefined; | 482 | return config ? { ...config, onReady: handleAnchorReady } : undefined; |
| 474 | }, [anchorMessageId, handleAnchorReady, rows]); | 483 | }, [anchorMessageId, handleAnchorReady, rows]); |
| ... | @@ -577,10 +586,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -577,10 +586,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 577 | const activityState = useMemo<TimelineRowActivityState>( | 586 | const activityState = useMemo<TimelineRowActivityState>( |
| 578 | () => ({ | 587 | () => ({ |
| 579 | isWorking, | 588 | isWorking, |
| 589 | isPreparingWorktree, | ||
| 580 | isRevertingCheckpoint, | 590 | isRevertingCheckpoint, |
| 581 | latestTurnId: latestTurn?.turnId ?? null, | 591 | latestTurnId: latestTurn?.turnId ?? null, |
| 582 | }), | 592 | }), |
| 583 | [isRevertingCheckpoint, isWorking, latestTurn?.turnId], | 593 | [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], |
| 584 | ); | 594 | ); |
| 585 | 595 | ||
| 586 | // Stable renderItem — no closure deps. Row components read shared state | 596 | // Stable renderItem — no closure deps. Row components read shared state |
| ... | @@ -1318,12 +1328,21 @@ function ProposedPlanTimelineRow({ | ... | @@ -1318,12 +1328,21 @@ function ProposedPlanTimelineRow({ |
| 1318 | } | 1328 | } |
| 1319 | 1329 | ||
| 1320 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { | 1330 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| 1331 | const { isPreparingWorktree } = use(TimelineRowActivityCtx); | ||
| 1321 | return ( | 1332 | return ( |
| 1322 | <div> | 1333 | <div> |
| 1323 | <div className="border-b border-border/60 pb-2 pt-1"> | 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"> | 1335 | <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> |
| 1325 | <span className="shrink-0 whitespace-nowrap"> | 1336 | <span |
| 1326 | {row.createdAt ? ( | 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 | Working for <WorkingTimer createdAt={row.createdAt} /> | 1347 | Working for <WorkingTimer createdAt={row.createdAt} /> |
| 1329 | </> | 1348 | </> |
| ... | @@ -1334,8 +1353,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin | ... | @@ -1334,8 +1353,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1334 | </div> | 1353 | </div> |
| 1335 | </div> | 1354 | </div> |
| 1336 | {row.showThinking ? ( | 1355 | {row.showThinking ? ( |
| 1337 | <div className="mt-1"> | 1356 | // Reserve the activity row during setup so the handoff keeps the same height. |
| 1338 | <ThinkingActivityRow /> | 1357 | <div className="mt-1 min-h-7"> |
| 1358 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | ||
| 1339 | </div> | 1359 | </div> |
| 1340 | ) : null} | 1360 | ) : null} |
| 1341 | </div> | 1361 | </div> |
| ... | @@ -1412,6 +1432,19 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -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 | function LiveActivityRow({ | 1448 | function LiveActivityRow({ |
| 1416 | label, | 1449 | label, |
| 1417 | iconName, | 1450 | iconName, |
| ... | @@ -1429,24 +1462,13 @@ function LiveActivityRow({ | ... | @@ -1429,24 +1462,13 @@ function LiveActivityRow({ |
| 1429 | failed={failed} | 1462 | failed={failed} |
| 1430 | announceFailure={failed} | 1463 | announceFailure={failed} |
| 1431 | /> | 1464 | /> |
| 1432 | <div | 1465 | <ActivityShimmerOverlay> |
| 1433 | aria-hidden | 1466 | <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted /> |
| 1434 | className="live-activity-focus pointer-events-none absolute inset-y-0 select-none" | 1467 | </ActivityShimmerOverlay> |
| 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> | ||
| 1442 | </div> | 1468 | </div> |
| 1443 | ); | 1469 | ); |
| 1444 | } | 1470 | } |
| 1445 | 1471 | ||
| 1446 | function ThinkingActivityRow() { | ||
| 1447 | return <LiveActivityRow label="Thinking" />; | ||
| 1448 | } | ||
| 1449 | |||
| 1450 | function LiveActivityContent({ | 1472 | function LiveActivityContent({ |
| 1451 | label, | 1473 | label, |
| 1452 | iconName, | 1474 | iconName, |
| ... | @@ -1463,7 +1485,7 @@ function LiveActivityContent({ | ... | @@ -1463,7 +1485,7 @@ function LiveActivityContent({ |
| 1463 | const resolvedIconName = failed ? "circle-alert" : iconName; | 1485 | const resolvedIconName = failed ? "circle-alert" : iconName; |
| 1464 | 1486 | ||
| 1465 | return ( | 1487 | return ( |
| 1466 | <div | 1488 | <span |
| 1467 | className={cn( | 1489 | className={cn( |
| 1468 | "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5", | 1490 | "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5", |
| 1469 | resolvedIconName ? "px-0.5" : "px-1", | 1491 | resolvedIconName ? "px-0.5" : "px-1", |
| ... | @@ -1486,7 +1508,7 @@ function LiveActivityContent({ | ... | @@ -1486,7 +1508,7 @@ function LiveActivityContent({ |
| 1486 | </span> | 1508 | </span> |
| 1487 | ) : null} | 1509 | ) : null} |
| 1488 | <span className="min-w-0 flex-1 truncate">{label}</span> | 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,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 | export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; | 4 | export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; |
| 2 | 5 | ||
| 3 | export interface TimelineListMeasurementState { | 6 | export interface TimelineListMeasurementState { |
apps/web/src/routes/_chat.draft.$draftId.tsx+2-6| ... | @@ -1,10 +1,7 @@ | ... | @@ -1,10 +1,7 @@ |
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | 2 | import { useEffect } from "react"; |
| 3 | import ChatView from "../components/ChatView"; | 3 | import ChatView from "../components/ChatView"; |
| 4 | import { | 4 | import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic"; |
| 5 | resolveDraftPromotionNavigationTarget, | ||
| 6 | threadHasStarted, | ||
| 7 | } from "../components/ChatView.logic"; | ||
| 8 | import { | 5 | import { |
| 9 | DraftId, | 6 | DraftId, |
| 10 | markPromotedDraftThreadByRef, | 7 | markPromotedDraftThreadByRef, |
| ... | @@ -31,11 +28,10 @@ function DraftChatThreadRouteView() { | ... | @@ -31,11 +28,10 @@ function DraftChatThreadRouteView() { |
| 31 | : null; | 28 | : null; |
| 32 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; | 29 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; |
| 33 | const serverThread = useThread(serverThreadRef); | 30 | const serverThread = useThread(serverThreadRef); |
| 34 | const serverThreadStarted = threadHasStarted(serverThread); | ||
| 35 | const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); | 31 | const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); |
| 36 | const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ | 32 | const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ |
| 37 | serverThreadRef, | 33 | serverThreadRef, |
| 38 | serverThreadStarted, | 34 | serverThread, |
| 39 | backgroundSubmissionPending, | 35 | backgroundSubmissionPending, |
| 40 | }); | 36 | }); |
| 41 | 37 |
apps/web/src/session-logic.test.ts+83| ... | @@ -894,6 +894,89 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -894,6 +894,89 @@ describe("deriveWorkLogEntries", () => { |
| 894 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | 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 | it("drops runtime warnings with no displayable content, keeps ones with a preview", () => { | 980 | it("drops runtime warnings with no displayable content, keeps ones with a preview", () => { |
| 898 | const activities: OrchestrationThreadActivity[] = [ | 981 | const activities: OrchestrationThreadActivity[] = [ |
| 899 | makeActivity({ | 982 | makeActivity({ |
apps/web/src/session-logic.ts+2| ... | @@ -2,6 +2,7 @@ import * as Option from "effect/Option"; | ... | @@ -2,6 +2,7 @@ import * as Option from "effect/Option"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; | 4 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 5 | import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation"; | ||
| 5 | import { | 6 | import { |
| 6 | ApprovalRequestId, | 7 | ApprovalRequestId, |
| 7 | isToolLifecycleItemType, | 8 | isToolLifecycleItemType, |
| ... | @@ -809,6 +810,7 @@ export function deriveWorkLogEntries( | ... | @@ -809,6 +810,7 @@ export function deriveWorkLogEntries( |
| 809 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | 810 | const ordered = [...activities].toSorted(compareActivitiesByOrder); |
| 810 | const entries: DerivedWorkLogEntry[] = []; | 811 | const entries: DerivedWorkLogEntry[] = []; |
| 811 | for (const activity of ordered) { | 812 | for (const activity of ordered) { |
| 813 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | ||
| 812 | if (activity.kind === "tool.started") continue; | 814 | if (activity.kind === "tool.started") continue; |
| 813 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, | 815 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, |
| 814 | // which is the batch key (completions of background subagents arrive | 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,5 +1,9 @@ |
| 1 | import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts"; | 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 | export interface WorkLogPresentationEntry { | 7 | export interface WorkLogPresentationEntry { |
| 4 | readonly label: string; | 8 | readonly label: string; |
| 5 | readonly toolTitle?: string; | 9 | readonly toolTitle?: string; |