authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 23:16:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 23:16:07-07:00
logdb514607f4026cd2bbb7b96674168bb227ac043a
tree1b4b3b8fe6f364c8ed6f3a64124d6d07bc908bbc
parent5c0429b1578cff3307c35c51a305e2749a6dcb6e
signature Signed by PGP key B5690EEEBB952194

fix(chat): sending on an older thread no longer jumps to the top (#15059)

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 });
10871087
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 });
11181174
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 the1176 // 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 });
11431198
apps/mobile/src/lib/threadActivity.ts+14-8
...@@ -972,13 +972,14 @@ export function failedFeedRunIds(...@@ -972,13 +972,14 @@ export function failedFeedRunIds(
972}972}
973973
974/**974/**
975 * A thread without runs (a provider-native subagent) folds each prompt's975 * 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 */
978function deriveThreadFeedRunFolds(979function 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 its992 // 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 });
22322232
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 behind851 * Settled turns fold activity before their terminal assistant message behind
852 * a "Worked for ..." row. Ordinary trailing work joins the fold, while failures852 * a "Worked for ..." row. Ordinary trailing work joins the fold, while failures
853 * and work still in progress stay visible. A thread without runs (a853 * 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 */
856function deriveTurnFolds(input: {857function 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>();
890892
891 // Fold state is keyed by run, so each prompt of a runless thread lends its893 // 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 = nextEntry901 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) =>