authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 23:00:04-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 23:00:04-04:00
log65f1839ae82af4e67f389f23e4ccc50f51a4a83f
tree94c4c554101e79f599b99fd916c057774784d099
parentfee2e0ff8168b82ab19f496fcc874c1f3871f522
signature Signed by PGP key B5690EEEBB952194

fix(web): keep codex restart responses continuous (#9560)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 159 insertions(+), 18 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+105
......@@ -1326,6 +1326,111 @@ describe("deriveMessagesTimelineRows", () => {
13261326 ]);
13271327 });
13281328
1329 it("keeps a promptless restart in one active visual response", () => {
1330 const rows = deriveMessagesTimelineRows({
1331 timelineEntries: [
1332 {
1333 id: "user-entry",
1334 kind: "message",
1335 createdAt: "2026-01-01T00:00:00Z",
1336 message: {
1337 id: "user-1" as never,
1338 role: "user",
1339 text: "keep going",
1340 turnId: null,
1341 createdAt: "2026-01-01T00:00:00Z",
1342 updatedAt: "2026-01-01T00:00:00Z",
1343 streaming: false,
1344 },
1345 },
1346 {
1347 id: "old-work-entry",
1348 kind: "work",
1349 createdAt: "2026-01-01T00:00:05Z",
1350 entry: {
1351 id: "old-work",
1352 createdAt: "2026-01-01T00:00:05Z",
1353 turnId: "turn-before-restart" as never,
1354 label: "Searched files",
1355 command: "rg restart",
1356 tone: "tool" as const,
1357 toolLifecycleStatus: "completed" as const,
1358 },
1359 },
1360 {
1361 id: "old-stale-work-entry",
1362 kind: "work",
1363 createdAt: "2026-01-01T00:00:06Z",
1364 entry: {
1365 id: "old-stale-work",
1366 createdAt: "2026-01-01T00:00:06Z",
1367 turnId: "turn-before-restart" as never,
1368 label: "Running stale command",
1369 command: "rg stale",
1370 tone: "tool" as const,
1371 toolLifecycleStatus: "inProgress" as const,
1372 },
1373 },
1374 {
1375 id: "old-commentary-entry",
1376 kind: "message",
1377 createdAt: "2026-01-01T00:00:08Z",
1378 message: {
1379 id: "old-commentary" as never,
1380 role: "assistant",
1381 text: "the server restarted, continuing here.",
1382 turnId: "turn-before-restart" as never,
1383 createdAt: "2026-01-01T00:00:08Z",
1384 updatedAt: "2026-01-01T00:00:08Z",
1385 streaming: false,
1386 },
1387 },
1388 {
1389 id: "new-work-entry",
1390 kind: "work",
1391 createdAt: "2026-01-01T00:01:05Z",
1392 entry: {
1393 id: "new-work",
1394 createdAt: "2026-01-01T00:01:05Z",
1395 turnId: "turn-after-restart" as never,
1396 label: "Running tests",
1397 command: "vp test run",
1398 tone: "tool" as const,
1399 toolLifecycleStatus: "inProgress" as const,
1400 },
1401 },
1402 ],
1403 latestTurn: {
1404 turnId: "turn-after-restart" as never,
1405 state: "running",
1406 startedAt: "2026-01-01T00:01:00Z",
1407 completedAt: null,
1408 },
1409 isWorking: true,
1410 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1411 turnDiffSummaryByAssistantMessageId: new Map(),
1412 revertTurnCountByUserMessageId: new Map(),
1413 });
1414
1415 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
1416 expect(rows.filter((row) => row.id === "working-indicator-row")).toHaveLength(1);
1417 expect(rows.findIndex((row) => row.id === "working-indicator-row")).toBeLessThan(
1418 rows.findIndex((row) => row.id === "old-work-entry"),
1419 );
1420 expect(rows.find((row) => row.id === "working-indicator-row")).toMatchObject({
1421 createdAt: "2026-01-01T00:00:00Z",
1422 });
1423 expect(rows.find((row) => row.id === "old-commentary-entry")).toMatchObject({
1424 showAssistantMeta: false,
1425 showAssistantCopyButton: false,
1426 assistantCopyStreaming: true,
1427 });
1428 expect(rows.filter((row) => row.kind === "work-live" && row.active)).toEqual([
1429 expect.objectContaining({ entry: expect.objectContaining({ id: "new-work" }) }),
1430 ]);
1431 expect(rows.some((row) => row.kind === "thinking")).toBe(false);
1432 });
1433
13291434 it("keeps an actually running tool in the shared activity row", () => {
13301435 const rows = deriveMessagesTimelineRows({
13311436 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+54-18
......@@ -512,6 +512,37 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
512512 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;
513513}
514514
515/**
516 * A promptless provider restart replaces the native turn without adding a
517 * user message. Keep every provider turn since the latest user message in one
518 * visual response until the replacement turn settles. A steer has its own
519 * user message, so it naturally starts a new visual response.
520 */
521function deriveActiveVisualResponseTurnIds(input: {
522 timelineEntries: ReadonlyArray<TimelineEntry>;
523 unsettledTurnId: TurnId | null;
524 isWorking: boolean;
525}): ReadonlySet<TurnId> {
526 const turnIds = new Set<TurnId>();
527 if (input.unsettledTurnId === null) {
528 return turnIds;
529 }
530
531 turnIds.add(input.unsettledTurnId);
532 if (!input.isWorking) {
533 return turnIds;
534 }
535
536 const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
537 for (let index = latestUserMessageIndex + 1; index < input.timelineEntries.length; index += 1) {
538 const turnId = timelineEntryTurnId(input.timelineEntries[index]!);
539 if (turnId !== null) {
540 turnIds.add(turnId);
541 }
542 }
543 return turnIds;
544}
545
515546function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
516547 return (
517548 entry.toolLifecycleStatus === "inProgress" ||
......@@ -529,7 +560,7 @@ function deriveTurnFolds(input: {
529560 timelineEntries: ReadonlyArray<TimelineEntry>;
530561 terminalAssistantMessageIds: ReadonlySet<string>;
531562 latestTurn: TimelineLatestTurn | null;
532 unsettledTurnId: TurnId | null;
563 unfoldedTurnIds: ReadonlySet<TurnId>;
533564}): ReadonlyMap<string, TurnFold> {
534565 interface TurnGroup {
535566 entries: Array<TimelineEntry>;
......@@ -587,7 +618,7 @@ function deriveTurnFolds(input: {
587618
588619 const foldsByAnchorEntryId = new Map<string, TurnFold>();
589620 for (const [turnId, group] of groupsByTurnId) {
590 if (turnId === input.unsettledTurnId) {
621 if (input.unfoldedTurnIds.has(turnId)) {
591622 continue;
592623 }
593624 if (group.hasStreamingMessage) {
......@@ -769,11 +800,16 @@ export function deriveMessagesTimelineRows(input: {
769800 input.latestTurn ?? null,
770801 input.runningTurnId ?? null,
771802 );
803 const activeVisualResponseTurnIds = deriveActiveVisualResponseTurnIds({
804 timelineEntries: input.timelineEntries,
805 unsettledTurnId,
806 isWorking: input.isWorking,
807 });
772808 const foldsByAnchorEntryId = deriveTurnFolds({
773809 timelineEntries: input.timelineEntries,
774810 terminalAssistantMessageIds,
775811 latestTurn: input.latestTurn ?? null,
776 unsettledTurnId,
812 unfoldedTurnIds: activeVisualResponseTurnIds,
777813 });
778814 const collapsedEntryIds = new Set<string>();
779815 for (const fold of foldsByAnchorEntryId.values()) {
......@@ -787,15 +823,7 @@ export function deriveMessagesTimelineRows(input: {
787823 let activeTurnHeaderIndex = input.timelineEntries.length;
788824 if (input.isWorking) {
789825 const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
790 const firstOwnedAfterUser =
791 unsettledTurnId === null
792 ? -1
793 : input.timelineEntries.findIndex(
794 (entry, index) =>
795 index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId,
796 );
797 activeTurnHeaderIndex =
798 firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1;
826 activeTurnHeaderIndex = latestUserMessageIndex + 1;
799827 }
800828 const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>
801829 input.isWorking &&
......@@ -862,10 +890,17 @@ export function deriveMessagesTimelineRows(input: {
862890 activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [],
863891 );
864892 const appendWorkingRow = () => {
893 const latestUserMessage = input.timelineEntries[lastUserMessageIndex(input.timelineEntries)];
894 const visualResponseStartedAt =
895 activeVisualResponseTurnIds.size > 1 &&
896 latestUserMessage?.kind === "message" &&
897 latestUserMessage.message.role === "user"
898 ? latestUserMessage.message.createdAt
899 : input.activeTurnStartedAt;
865900 nextRows.push({
866901 kind: "working",
867902 id: "working-indicator-row",
868 createdAt: input.activeTurnStartedAt,
903 createdAt: visualResponseStartedAt,
869904 });
870905 };
871906 let hasActivityRow = false;
......@@ -1081,10 +1116,11 @@ export function deriveMessagesTimelineRows(input: {
10811116 continue;
10821117 }
10831118
1084 const assistantTurnStillInProgress =
1119 const assistantResponseStillInProgress =
10851120 timelineEntry.message.role === "assistant" &&
1086 unsettledTurnId !== null &&
1087 timelineEntry.message.turnId === unsettledTurnId;
1121 timelineEntry.message.turnId !== null &&
1122 timelineEntry.message.turnId !== undefined &&
1123 activeVisualResponseTurnIds.has(timelineEntry.message.turnId);
10881124
10891125 const durationStart =
10901126 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;
......@@ -1095,7 +1131,7 @@ export function deriveMessagesTimelineRows(input: {
10951131 const showAssistantMeta =
10961132 timelineEntry.message.role === "assistant" &&
10971133 terminalAssistantMessageIds.has(timelineEntry.message.id) &&
1098 !assistantTurnStillInProgress;
1134 !assistantResponseStillInProgress;
10991135
11001136 nextRows.push({
11011137 kind: "message",
......@@ -1105,7 +1141,7 @@ export function deriveMessagesTimelineRows(input: {
11051141 durationStart,
11061142 showAssistantMeta,
11071143 showAssistantCopyButton: showAssistantMeta,
1108 assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress,
1144 assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress,
11091145 assistantTurnDiffSummary:
11101146 timelineEntry.message.role === "assistant"
11111147 ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)