| author | |
| committer | |
| log | db514607f4026cd2bbb7b96674168bb227ac043a |
| tree | 1b4b3b8fe6f364c8ed6f3a64124d6d07bc908bbc |
| parent | 5c0429b1578cff3307c35c51a305e2749a6dcb6e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 162 insertions(+), 22 deletions(-)
apps/mobile/src/lib/threadActivity.test.ts+60-5| ... | @@ -1085,6 +1085,62 @@ describe("buildThreadFeed", () => { | ... | @@ -1085,6 +1085,62 @@ describe("buildThreadFeed", () => { |
| 1085 | ]); | 1085 | ]); |
| 1086 | }); | 1086 | }); |
| 1087 | 1087 | ||
| 1088 | it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { | ||
| 1089 | const imported = <T extends OrchestrationV2TurnItem>(item: T, id: string) => ({ | ||
| 1090 | ...item, | ||
| 1091 | id: TurnItemId.make(id), | ||
| 1092 | runId: null, | ||
| 1093 | }); | ||
| 1094 | const presented = (start: OrchestrationV2TurnItem) => | ||
| 1095 | deriveThreadFeedPresentation( | ||
| 1096 | buildThreadFeed( | ||
| 1097 | [ | ||
| 1098 | imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"), | ||
| 1099 | imported( | ||
| 1100 | { | ||
| 1101 | ...assistantMessage("2026-06-20T00:00:02.000Z"), | ||
| 1102 | messageId: MessageId.make("update"), | ||
| 1103 | }, | ||
| 1104 | "imported-update", | ||
| 1105 | ), | ||
| 1106 | imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"), | ||
| 1107 | imported( | ||
| 1108 | { | ||
| 1109 | ...assistantMessage("2026-06-20T00:00:08.000Z"), | ||
| 1110 | messageId: MessageId.make("answer"), | ||
| 1111 | }, | ||
| 1112 | "imported-answer", | ||
| 1113 | ), | ||
| 1114 | start, | ||
| 1115 | ].map((item, position) => projected(item, position)), | ||
| 1116 | ), | ||
| 1117 | { runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null }, | ||
| 1118 | new Set(), | ||
| 1119 | new Set(), | ||
| 1120 | "2026-06-20T00:01:00.000Z", | ||
| 1121 | ) | ||
| 1122 | .slice(0, 4) | ||
| 1123 | .map((entry) => (entry.type === "message" ? entry.message.role : entry.type)); | ||
| 1124 | |||
| 1125 | // A sent prompt and an automatic wake both start V2 work below the import. | ||
| 1126 | expect( | ||
| 1127 | presented({ | ||
| 1128 | ...userMessage("2026-06-20T00:01:00.000Z"), | ||
| 1129 | id: TurnItemId.make("new-prompt"), | ||
| 1130 | messageId: MessageId.make("new-prompt"), | ||
| 1131 | }), | ||
| 1132 | ).toEqual(["user", "assistant", "run-fold", "assistant"]); | ||
| 1133 | expect( | ||
| 1134 | presented({ | ||
| 1135 | ...base("wake", "2026-06-20T00:01:00.000Z", 4), | ||
| 1136 | type: "notification", | ||
| 1137 | source: { kind: "background_task" }, | ||
| 1138 | outcome: "completed", | ||
| 1139 | summary: "Background task finished", | ||
| 1140 | }), | ||
| 1141 | ).toEqual(["user", "assistant", "run-fold", "assistant"]); | ||
| 1142 | }); | ||
| 1143 | |||
| 1088 | it("keeps a provider-native subagent's runless tool call live while it works", () => { | 1144 | it("keeps a provider-native subagent's runless tool call live while it works", () => { |
| 1089 | const startedAt = "2026-06-20T00:00:01.000Z"; | 1145 | const startedAt = "2026-06-20T00:00:01.000Z"; |
| 1090 | const { exitCode: _exitCode, ...completedCommand } = command(); | 1146 | const { exitCode: _exitCode, ...completedCommand } = command(); |
| ... | @@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => { | ... | @@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => { |
| 1116 | expect(presented.some((entry) => entry.type === "thinking")).toBe(false); | 1172 | expect(presented.some((entry) => entry.type === "thinking")).toBe(false); |
| 1117 | }); | 1173 | }); |
| 1118 | 1174 | ||
| 1119 | it("keeps a runless tail settled while a normal thread waits for its sent run", () => { | 1175 | it("keeps a runless tail folded while a normal thread waits for its sent run", () => { |
| 1120 | // Right after a send the local clock runs before the server creates the | 1176 | // Right after a send the local clock runs before the server creates the |
| 1121 | // run, and the latest run may still be queued: neither is runless work. | 1177 | // run, and the latest run may still be queued: neither is runless work, |
| 1178 | // so the settled tail must not reopen and shift the feed. | ||
| 1122 | const startedAt = "2026-06-20T00:00:05.000Z"; | 1179 | const startedAt = "2026-06-20T00:00:05.000Z"; |
| 1123 | const feed = buildThreadFeed([ | 1180 | const feed = buildThreadFeed([ |
| 1124 | projected({ ...userMessage(), runId: null }, 0), | 1181 | projected({ ...userMessage(), runId: null }, 0), |
| ... | @@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => { | ... | @@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => { |
| 1135 | new Set(), | 1192 | new Set(), |
| 1136 | startedAt, | 1193 | startedAt, |
| 1137 | ); | 1194 | ); |
| 1138 | const toggle = presented.find((entry) => entry.type === "work-toggle"); | 1195 | expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]); |
| 1139 | expect(toggle).toMatchObject({ live: false, shimmer: false }); | ||
| 1140 | expect(presented.at(-1)?.type).toBe("thinking"); | ||
| 1141 | } | 1196 | } |
| 1142 | }); | 1197 | }); |
| 1143 | 1198 |
apps/mobile/src/lib/threadActivity.ts+14-8| ... | @@ -972,13 +972,14 @@ export function failedFeedRunIds( | ... | @@ -972,13 +972,14 @@ export function failedFeedRunIds( |
| 972 | } | 972 | } |
| 973 | 973 | ||
| 974 | /** | 974 | /** |
| 975 | * A thread without runs (a provider-native subagent) folds each prompt's | 975 | * A prompt without a run (a provider-native subagent, or a turn imported from |
| 976 | * response like a run; `isWorking` keeps its latest response open. | 976 | * V1) folds its response like a run. `runlessWorkActive` keeps the latest |
| 977 | * runless response open; V2 work must not reopen imported turns. | ||
| 977 | */ | 978 | */ |
| 978 | function deriveThreadFeedRunFolds( | 979 | function deriveThreadFeedRunFolds( |
| 979 | feed: ReadonlyArray<ThreadFeedEntry>, | 980 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 980 | latestRun: ThreadFeedLatestRun | null, | 981 | latestRun: ThreadFeedLatestRun | null, |
| 981 | isWorking: boolean, | 982 | runlessWorkActive: boolean, |
| 982 | ): ReadonlyMap<string, ThreadFeedRunFold> { | 983 | ): ReadonlyMap<string, ThreadFeedRunFold> { |
| 983 | const firstAssistantMessageIdByRun = new Map<RunId, string>(); | 984 | const firstAssistantMessageIdByRun = new Map<RunId, string>(); |
| 984 | const terminalAssistantMessageIdByRun = new Map<RunId, string>(); | 985 | const terminalAssistantMessageIdByRun = new Map<RunId, string>(); |
| ... | @@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds( | ... | @@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds( |
| 988 | RunId, | 989 | RunId, |
| 989 | { entries: ThreadFeedEntry[]; startBoundary: string | null } | 990 | { entries: ThreadFeedEntry[]; startBoundary: string | null } |
| 990 | >(); | 991 | >(); |
| 991 | // Fold state is keyed by run, so each prompt of a runless thread lends its | 992 | // Fold state is keyed by run, so each runless prompt lends its response a |
| 992 | // response a stable key of its own. | 993 | // stable key of its own. Decide per prompt, not per thread: a V1 thread's |
| 994 | // first V2 run must not unfold every imported turn above it. | ||
| 993 | let runlessKey: RunId | null = null; | 995 | let runlessKey: RunId | null = null; |
| 994 | let pendingUserBoundary: string | null = null; | 996 | let pendingUserBoundary: string | null = null; |
| 995 | for (const entry of feed) { | 997 | for (const entry of feed) { |
| 996 | if (entry.type === "message" && entry.message.role === "user") { | 998 | if (entry.type === "message" && entry.message.role === "user") { |
| 997 | pendingUserBoundary = entry.message.createdAt; | 999 | pendingUserBoundary = entry.message.createdAt; |
| 998 | runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null; | 1000 | runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; |
| 999 | continue; | 1001 | continue; |
| 1000 | } | 1002 | } |
| 1001 | const runId = | 1003 | const runId = |
| ... | @@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds( | ... | @@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds( |
| 1038 | for (const [runId, group] of groupsByRunId) { | 1040 | for (const [runId, group] of groupsByRunId) { |
| 1039 | if ( | 1041 | if ( |
| 1040 | runId === activeRunId || | 1042 | runId === activeRunId || |
| 1041 | (isWorking && runId === runlessKey) || | 1043 | (runlessWorkActive && runId === runlessKey) || |
| 1042 | interruptedRunIds.has(runId) || | 1044 | interruptedRunIds.has(runId) || |
| 1043 | failedRunIds.has(runId) || | 1045 | failedRunIds.has(runId) || |
| 1044 | group.entries.some((entry) => entry.type === "message" && entry.message.streaming) | 1046 | group.entries.some((entry) => entry.type === "message" && entry.message.streaming) |
| ... | @@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation( | ... | @@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation( |
| 1161 | const activeTailGroup = sourceFeed.at(-1); | 1163 | const activeTailGroup = sourceFeed.at(-1); |
| 1162 | const activeRunId = unsettledRunId(latestRun); | 1164 | const activeRunId = unsettledRunId(latestRun); |
| 1163 | const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; | 1165 | const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; |
| 1164 | const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking); | 1166 | const foldsByAnchorId = deriveThreadFeedRunFolds( |
| 1167 | sourceFeed, | ||
| 1168 | latestRun, | ||
| 1169 | isWorking && runlessWorkActive, | ||
| 1170 | ); | ||
| 1165 | const collapsedEntryIds = new Set<string>(); | 1171 | const collapsedEntryIds = new Set<string>(); |
| 1166 | for (const fold of foldsByAnchorId.values()) { | 1172 | for (const fold of foldsByAnchorId.values()) { |
| 1167 | if (!expandedRunIds.has(fold.runId)) { | 1173 | if (!expandedRunIds.has(fold.runId)) { |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+70| ... | @@ -2230,6 +2230,76 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -2230,6 +2230,76 @@ describe("deriveMessagesTimelineRows", () => { |
| 2230 | ]); | 2230 | ]); |
| 2231 | }); | 2231 | }); |
| 2232 | 2232 | ||
| 2233 | it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { | ||
| 2234 | const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`; | ||
| 2235 | const message = ( | ||
| 2236 | id: string, | ||
| 2237 | role: "user" | "assistant", | ||
| 2238 | second: number, | ||
| 2239 | runId: string | null = null, | ||
| 2240 | ) => ({ | ||
| 2241 | id, | ||
| 2242 | kind: "message" as const, | ||
| 2243 | createdAt: at(second), | ||
| 2244 | message: { | ||
| 2245 | id: id as never, | ||
| 2246 | role, | ||
| 2247 | text: id, | ||
| 2248 | runId: runId as never, | ||
| 2249 | createdAt: at(second), | ||
| 2250 | updatedAt: at(second), | ||
| 2251 | streaming: false, | ||
| 2252 | }, | ||
| 2253 | }); | ||
| 2254 | const rows = (tail: ReadonlyArray<ReturnType<typeof message>>) => | ||
| 2255 | deriveMessagesTimelineRows({ | ||
| 2256 | timelineEntries: [ | ||
| 2257 | message("imported-prompt", "user", 0), | ||
| 2258 | message("imported-update", "assistant", 4), | ||
| 2259 | { | ||
| 2260 | id: "imported-command", | ||
| 2261 | kind: "work", | ||
| 2262 | createdAt: at(5), | ||
| 2263 | entry: { | ||
| 2264 | id: "imported-command", | ||
| 2265 | createdAt: at(5), | ||
| 2266 | runId: null, | ||
| 2267 | label: "Ran git", | ||
| 2268 | command: "git status", | ||
| 2269 | requestKind: "command", | ||
| 2270 | tone: "tool" as const, | ||
| 2271 | toolLifecycleStatus: "completed" as const, | ||
| 2272 | }, | ||
| 2273 | }, | ||
| 2274 | message("imported-answer", "assistant", 8), | ||
| 2275 | ...tail, | ||
| 2276 | ], | ||
| 2277 | latestRun: { | ||
| 2278 | runId: "run-1" as never, | ||
| 2279 | status: "running", | ||
| 2280 | startedAt: at(20), | ||
| 2281 | completedAt: null, | ||
| 2282 | }, | ||
| 2283 | isWorking: true, | ||
| 2284 | activeTurnStartedAt: at(20), | ||
| 2285 | turnDiffSummaries: [], | ||
| 2286 | supportsConversationRollback: false, | ||
| 2287 | }).map((row) => | ||
| 2288 | row.kind === "message" ? `${row.message.role}:${row.message.id}` : row.kind, | ||
| 2289 | ); | ||
| 2290 | |||
| 2291 | // V2 work starts from a sent prompt, or with no new prompt (a wake or a resume). | ||
| 2292 | expect(rows([message("new-prompt", "user", 20, "run-1")]).slice(0, 4)).toEqual([ | ||
| 2293 | "user:imported-prompt", | ||
| 2294 | "turn-fold", | ||
| 2295 | "assistant:imported-answer", | ||
| 2296 | "user:new-prompt", | ||
| 2297 | ]); | ||
| 2298 | const withoutPrompt = rows([]); | ||
| 2299 | expect(withoutPrompt).toContain("turn-fold"); | ||
| 2300 | expect(withoutPrompt).not.toContain("assistant:imported-update"); | ||
| 2301 | }); | ||
| 2302 | |||
| 2233 | it("shows a provider-native subagent's runless tools as live work while it works", () => { | 2303 | it("shows a provider-native subagent's runless tools as live work while it works", () => { |
| 2234 | const entries = (commandStatus: "inProgress" | "completed") => [ | 2304 | const entries = (commandStatus: "inProgress" | "completed") => [ |
| 2235 | { | 2305 | { |
apps/web/src/components/chat/MessagesTimeline.logic.ts+18-9| ... | @@ -850,15 +850,17 @@ function failedTimelineRunIds( | ... | @@ -850,15 +850,17 @@ function failedTimelineRunIds( |
| 850 | /** | 850 | /** |
| 851 | * Settled turns fold activity before their terminal assistant message behind | 851 | * Settled turns fold activity before their terminal assistant message behind |
| 852 | * a "Worked for ..." row. Ordinary trailing work joins the fold, while failures | 852 | * a "Worked for ..." row. Ordinary trailing work joins the fold, while failures |
| 853 | * and work still in progress stay visible. A thread without runs (a | 853 | * and work still in progress stay visible. A prompt without a run (a |
| 854 | * provider-native subagent) folds each prompt's response the same way. | 854 | * provider-native subagent, or a turn imported from V1) folds its response |
| 855 | * the same way. | ||
| 855 | */ | 856 | */ |
| 856 | function deriveTurnFolds(input: { | 857 | function deriveTurnFolds(input: { |
| 857 | timelineEntries: ReadonlyArray<TimelineEntry>; | 858 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 858 | terminalAssistantMessageIds: ReadonlySet<string>; | 859 | terminalAssistantMessageIds: ReadonlySet<string>; |
| 859 | latestRun: TimelineLatestRun | null; | 860 | latestRun: TimelineLatestRun | null; |
| 860 | unfoldedRunIds: ReadonlySet<RunId>; | 861 | unfoldedRunIds: ReadonlySet<RunId>; |
| 861 | isWorking: boolean; | 862 | /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ |
| 863 | runlessWorkActive: boolean; | ||
| 862 | }): ReadonlyMap<string, TurnFold> { | 864 | }): ReadonlyMap<string, TurnFold> { |
| 863 | const interruptedRunIds = new Set<RunId>(); | 865 | const interruptedRunIds = new Set<RunId>(); |
| 864 | for (const entry of input.timelineEntries) { | 866 | for (const entry of input.timelineEntries) { |
| ... | @@ -888,8 +890,9 @@ function deriveTurnFolds(input: { | ... | @@ -888,8 +890,9 @@ function deriveTurnFolds(input: { |
| 888 | const groupsByRunId = new Map<RunId, TurnGroup>(); | 890 | const groupsByRunId = new Map<RunId, TurnGroup>(); |
| 889 | const runlessFailedKeys = new Set<RunId>(); | 891 | const runlessFailedKeys = new Set<RunId>(); |
| 890 | 892 | ||
| 891 | // Fold state is keyed by run, so each prompt of a runless thread lends its | 893 | // Fold state is keyed by run, so each runless prompt lends its response a |
| 892 | // response a stable key of its own. | 894 | // stable key of its own. Decide per prompt, not per thread: a V1 thread's |
| 895 | // first V2 run must not unfold every imported turn above it. | ||
| 893 | let runlessKey: RunId | null = null; | 896 | let runlessKey: RunId | null = null; |
| 894 | let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; | 897 | let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; |
| 895 | for (const [index, entry] of input.timelineEntries.entries()) { | 898 | for (const [index, entry] of input.timelineEntries.entries()) { |
| ... | @@ -898,7 +901,13 @@ function deriveTurnFolds(input: { | ... | @@ -898,7 +901,13 @@ function deriveTurnFolds(input: { |
| 898 | pendingBoundary = nextEntry | 901 | pendingBoundary = nextEntry |
| 899 | ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } | 902 | ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } |
| 900 | : null; | 903 | : null; |
| 901 | runlessKey = input.latestRun === null ? RunId.make(`runless:${entry.id}`) : null; | 904 | const boundaryRunId = |
| 905 | entry.kind === "message" | ||
| 906 | ? entry.message.runId | ||
| 907 | : entry.kind === "work" | ||
| 908 | ? entry.entry.runId | ||
| 909 | : null; | ||
| 910 | runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null; | ||
| 902 | continue; | 911 | continue; |
| 903 | } | 912 | } |
| 904 | const runId = timelineEntryFoldRunId(entry, runlessKey); | 913 | const runId = timelineEntryFoldRunId(entry, runlessKey); |
| ... | @@ -940,7 +949,7 @@ function deriveTurnFolds(input: { | ... | @@ -940,7 +949,7 @@ function deriveTurnFolds(input: { |
| 940 | input.unfoldedRunIds.has(runId) || | 949 | input.unfoldedRunIds.has(runId) || |
| 941 | interruptedRunIds.has(runId) || | 950 | interruptedRunIds.has(runId) || |
| 942 | runlessFailedKeys.has(runId) || | 951 | runlessFailedKeys.has(runId) || |
| 943 | (input.isWorking && runId === runlessKey) | 952 | (input.runlessWorkActive && runId === runlessKey) |
| 944 | ) { | 953 | ) { |
| 945 | continue; | 954 | continue; |
| 946 | } | 955 | } |
| ... | @@ -1229,12 +1238,13 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1229,12 +1238,13 @@ export function deriveMessagesTimelineRows(input: { |
| 1229 | unsettledRunId, | 1238 | unsettledRunId, |
| 1230 | isWorking: input.isWorking, | 1239 | isWorking: input.isWorking, |
| 1231 | }); | 1240 | }); |
| 1241 | const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; | ||
| 1232 | const foldsByAnchorEntryId = deriveTurnFolds({ | 1242 | const foldsByAnchorEntryId = deriveTurnFolds({ |
| 1233 | timelineEntries: timelineEntries, | 1243 | timelineEntries: timelineEntries, |
| 1234 | terminalAssistantMessageIds, | 1244 | terminalAssistantMessageIds, |
| 1235 | latestRun: input.latestRun ?? null, | 1245 | latestRun: input.latestRun ?? null, |
| 1236 | unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), | 1246 | unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), |
| 1237 | isWorking: input.isWorking, | 1247 | runlessWorkActive, |
| 1238 | }); | 1248 | }); |
| 1239 | const collapsedEntryIds = new Set<string>(); | 1249 | const collapsedEntryIds = new Set<string>(); |
| 1240 | for (const fold of foldsByAnchorEntryId.values()) { | 1250 | for (const fold of foldsByAnchorEntryId.values()) { |
| ... | @@ -1252,7 +1262,6 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1252,7 +1262,6 @@ export function deriveMessagesTimelineRows(input: { |
| 1252 | } | 1262 | } |
| 1253 | } | 1263 | } |
| 1254 | } | 1264 | } |
| 1255 | const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; | ||
| 1256 | const runIdIsActiveResponse = (runId: RunId | null | undefined) => | 1265 | const runIdIsActiveResponse = (runId: RunId | null | undefined) => |
| 1257 | runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId); | 1266 | runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId); |
| 1258 | const workEntryIsInActiveRun = (entry: WorkLogEntry) => | 1267 | const workEntryIsInActiveRun = (entry: WorkLogEntry) => |