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", () => {
10851085 ]);
10861086 });
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
10881144 it("keeps a provider-native subagent's runless tool call live while it works", () => {
10891145 const startedAt = "2026-06-20T00:00:01.000Z";
10901146 const { exitCode: _exitCode, ...completedCommand } = command();
......@@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => {
11161172 expect(presented.some((entry) => entry.type === "thinking")).toBe(false);
11171173 });
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", () => {
11201176 // 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.
11221179 const startedAt = "2026-06-20T00:00:05.000Z";
11231180 const feed = buildThreadFeed([
11241181 projected({ ...userMessage(), runId: null }, 0),
......@@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => {
11351192 new Set(),
11361193 startedAt,
11371194 );
1138 const toggle = presented.find((entry) => entry.type === "work-toggle");
1139 expect(toggle).toMatchObject({ live: false, shimmer: false });
1140 expect(presented.at(-1)?.type).toBe("thinking");
1195 expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]);
11411196 }
11421197 });
11431198
apps/mobile/src/lib/threadActivity.ts+14-8
......@@ -972,13 +972,14 @@ export function failedFeedRunIds(
972972}
973973
974974/**
975 * A thread without runs (a provider-native subagent) folds each prompt's
976 * response like a run; `isWorking` keeps its latest response open.
975 * A prompt without a run (a provider-native subagent, or a turn imported from
976 * V1) folds its response like a run. `runlessWorkActive` keeps the latest
977 * runless response open; V2 work must not reopen imported turns.
977978 */
978979function deriveThreadFeedRunFolds(
979980 feed: ReadonlyArray<ThreadFeedEntry>,
980981 latestRun: ThreadFeedLatestRun | null,
981 isWorking: boolean,
982 runlessWorkActive: boolean,
982983): ReadonlyMap<string, ThreadFeedRunFold> {
983984 const firstAssistantMessageIdByRun = new Map<RunId, string>();
984985 const terminalAssistantMessageIdByRun = new Map<RunId, string>();
......@@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds(
988989 RunId,
989990 { entries: ThreadFeedEntry[]; startBoundary: string | null }
990991 >();
991 // Fold state is keyed by run, so each prompt of a runless thread lends its
992 // response a stable key of its own.
992 // Fold state is keyed by run, so each runless prompt lends its response a
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.
993995 let runlessKey: RunId | null = null;
994996 let pendingUserBoundary: string | null = null;
995997 for (const entry of feed) {
996998 if (entry.type === "message" && entry.message.role === "user") {
997999 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;
9991001 continue;
10001002 }
10011003 const runId =
......@@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds(
10381040 for (const [runId, group] of groupsByRunId) {
10391041 if (
10401042 runId === activeRunId ||
1041 (isWorking && runId === runlessKey) ||
1043 (runlessWorkActive && runId === runlessKey) ||
10421044 interruptedRunIds.has(runId) ||
10431045 failedRunIds.has(runId) ||
10441046 group.entries.some((entry) => entry.type === "message" && entry.message.streaming)
......@@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation(
11611163 const activeTailGroup = sourceFeed.at(-1);
11621164 const activeRunId = unsettledRunId(latestRun);
11631165 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 );
11651171 const collapsedEntryIds = new Set<string>();
11661172 for (const fold of foldsByAnchorId.values()) {
11671173 if (!expandedRunIds.has(fold.runId)) {
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+70
......@@ -2230,6 +2230,76 @@ describe("deriveMessagesTimelineRows", () => {
22302230 ]);
22312231 });
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
22332303 it("shows a provider-native subagent's runless tools as live work while it works", () => {
22342304 const entries = (commandStatus: "inProgress" | "completed") => [
22352305 {
apps/web/src/components/chat/MessagesTimeline.logic.ts+18-9
......@@ -850,15 +850,17 @@ function failedTimelineRunIds(
850850/**
851851 * Settled turns fold activity before their terminal assistant message behind
852852 * 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
854 * provider-native subagent) folds each prompt's response the same way.
853 * and work still in progress stay visible. A prompt without a run (a
854 * provider-native subagent, or a turn imported from V1) folds its response
855 * the same way.
855856 */
856857function deriveTurnFolds(input: {
857858 timelineEntries: ReadonlyArray<TimelineEntry>;
858859 terminalAssistantMessageIds: ReadonlySet<string>;
859860 latestRun: TimelineLatestRun | null;
860861 unfoldedRunIds: ReadonlySet<RunId>;
861 isWorking: boolean;
862 /** Keeps the latest runless response open; V2 work must not reopen imported turns. */
863 runlessWorkActive: boolean;
862864}): ReadonlyMap<string, TurnFold> {
863865 const interruptedRunIds = new Set<RunId>();
864866 for (const entry of input.timelineEntries) {
......@@ -888,8 +890,9 @@ function deriveTurnFolds(input: {
888890 const groupsByRunId = new Map<RunId, TurnGroup>();
889891 const runlessFailedKeys = new Set<RunId>();
890892
891 // Fold state is keyed by run, so each prompt of a runless thread lends its
892 // response a stable key of its own.
893 // Fold state is keyed by run, so each runless prompt lends its response a
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.
893896 let runlessKey: RunId | null = null;
894897 let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null;
895898 for (const [index, entry] of input.timelineEntries.entries()) {
......@@ -898,7 +901,13 @@ function deriveTurnFolds(input: {
898901 pendingBoundary = nextEntry
899902 ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id }
900903 : 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;
902911 continue;
903912 }
904913 const runId = timelineEntryFoldRunId(entry, runlessKey);
......@@ -940,7 +949,7 @@ function deriveTurnFolds(input: {
940949 input.unfoldedRunIds.has(runId) ||
941950 interruptedRunIds.has(runId) ||
942951 runlessFailedKeys.has(runId) ||
943 (input.isWorking && runId === runlessKey)
952 (input.runlessWorkActive && runId === runlessKey)
944953 ) {
945954 continue;
946955 }
......@@ -1229,12 +1238,13 @@ export function deriveMessagesTimelineRows(input: {
12291238 unsettledRunId,
12301239 isWorking: input.isWorking,
12311240 });
1241 const runlessWorkActive = input.isWorking && input.runlessWorkActive === true;
12321242 const foldsByAnchorEntryId = deriveTurnFolds({
12331243 timelineEntries: timelineEntries,
12341244 terminalAssistantMessageIds,
12351245 latestRun: input.latestRun ?? null,
12361246 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
1237 isWorking: input.isWorking,
1247 runlessWorkActive,
12381248 });
12391249 const collapsedEntryIds = new Set<string>();
12401250 for (const fold of foldsByAnchorEntryId.values()) {
......@@ -1252,7 +1262,6 @@ export function deriveMessagesTimelineRows(input: {
12521262 }
12531263 }
12541264 }
1255 const runlessWorkActive = input.isWorking && input.runlessWorkActive === true;
12561265 const runIdIsActiveResponse = (runId: RunId | null | undefined) =>
12571266 runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId);
12581267 const workEntryIsInActiveRun = (entry: WorkLogEntry) =>