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", () => {...@@ -1326,6 +1326,111 @@ describe("deriveMessagesTimelineRows", () => {
1326 ]);1326 ]);
1327 });1327 });
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
1329 it("keeps an actually running tool in the shared activity row", () => {1434 it("keeps an actually running tool in the shared activity row", () => {
1330 const rows = deriveMessagesTimelineRows({1435 const rows = deriveMessagesTimelineRows({
1331 timelineEntries: [1436 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+54-18
...@@ -512,6 +512,37 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {...@@ -512,6 +512,37 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
512 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;512 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;
513}513}
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
515function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {546function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
516 return (547 return (
517 entry.toolLifecycleStatus === "inProgress" ||548 entry.toolLifecycleStatus === "inProgress" ||
...@@ -529,7 +560,7 @@ function deriveTurnFolds(input: {...@@ -529,7 +560,7 @@ function deriveTurnFolds(input: {
529 timelineEntries: ReadonlyArray<TimelineEntry>;560 timelineEntries: ReadonlyArray<TimelineEntry>;
530 terminalAssistantMessageIds: ReadonlySet<string>;561 terminalAssistantMessageIds: ReadonlySet<string>;
531 latestTurn: TimelineLatestTurn | null;562 latestTurn: TimelineLatestTurn | null;
532 unsettledTurnId: TurnId | null;563 unfoldedTurnIds: ReadonlySet<TurnId>;
533}): ReadonlyMap<string, TurnFold> {564}): ReadonlyMap<string, TurnFold> {
534 interface TurnGroup {565 interface TurnGroup {
535 entries: Array<TimelineEntry>;566 entries: Array<TimelineEntry>;
...@@ -587,7 +618,7 @@ function deriveTurnFolds(input: {...@@ -587,7 +618,7 @@ function deriveTurnFolds(input: {
587618
588 const foldsByAnchorEntryId = new Map<string, TurnFold>();619 const foldsByAnchorEntryId = new Map<string, TurnFold>();
589 for (const [turnId, group] of groupsByTurnId) {620 for (const [turnId, group] of groupsByTurnId) {
590 if (turnId === input.unsettledTurnId) {621 if (input.unfoldedTurnIds.has(turnId)) {
591 continue;622 continue;
592 }623 }
593 if (group.hasStreamingMessage) {624 if (group.hasStreamingMessage) {
...@@ -769,11 +800,16 @@ export function deriveMessagesTimelineRows(input: {...@@ -769,11 +800,16 @@ export function deriveMessagesTimelineRows(input: {
769 input.latestTurn ?? null,800 input.latestTurn ?? null,
770 input.runningTurnId ?? null,801 input.runningTurnId ?? null,
771 );802 );
803 const activeVisualResponseTurnIds = deriveActiveVisualResponseTurnIds({
804 timelineEntries: input.timelineEntries,
805 unsettledTurnId,
806 isWorking: input.isWorking,
807 });
772 const foldsByAnchorEntryId = deriveTurnFolds({808 const foldsByAnchorEntryId = deriveTurnFolds({
773 timelineEntries: input.timelineEntries,809 timelineEntries: input.timelineEntries,
774 terminalAssistantMessageIds,810 terminalAssistantMessageIds,
775 latestTurn: input.latestTurn ?? null,811 latestTurn: input.latestTurn ?? null,
776 unsettledTurnId,812 unfoldedTurnIds: activeVisualResponseTurnIds,
777 });813 });
778 const collapsedEntryIds = new Set<string>();814 const collapsedEntryIds = new Set<string>();
779 for (const fold of foldsByAnchorEntryId.values()) {815 for (const fold of foldsByAnchorEntryId.values()) {
...@@ -787,15 +823,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -787,15 +823,7 @@ export function deriveMessagesTimelineRows(input: {
787 let activeTurnHeaderIndex = input.timelineEntries.length;823 let activeTurnHeaderIndex = input.timelineEntries.length;
788 if (input.isWorking) {824 if (input.isWorking) {
789 const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);825 const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
790 const firstOwnedAfterUser =826 activeTurnHeaderIndex = latestUserMessageIndex + 1;
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;
799 }827 }
800 const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>828 const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>
801 input.isWorking &&829 input.isWorking &&
...@@ -862,10 +890,17 @@ export function deriveMessagesTimelineRows(input: {...@@ -862,10 +890,17 @@ export function deriveMessagesTimelineRows(input: {
862 activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [],890 activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [],
863 );891 );
864 const appendWorkingRow = () => {892 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;
865 nextRows.push({900 nextRows.push({
866 kind: "working",901 kind: "working",
867 id: "working-indicator-row",902 id: "working-indicator-row",
868 createdAt: input.activeTurnStartedAt,903 createdAt: visualResponseStartedAt,
869 });904 });
870 };905 };
871 let hasActivityRow = false;906 let hasActivityRow = false;
...@@ -1081,10 +1116,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -1081,10 +1116,11 @@ export function deriveMessagesTimelineRows(input: {
1081 continue;1116 continue;
1082 }1117 }
10831118
1084 const assistantTurnStillInProgress =1119 const assistantResponseStillInProgress =
1085 timelineEntry.message.role === "assistant" &&1120 timelineEntry.message.role === "assistant" &&
1086 unsettledTurnId !== null &&1121 timelineEntry.message.turnId !== null &&
1087 timelineEntry.message.turnId === unsettledTurnId;1122 timelineEntry.message.turnId !== undefined &&
1123 activeVisualResponseTurnIds.has(timelineEntry.message.turnId);
10881124
1089 const durationStart =1125 const durationStart =
1090 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;1126 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;
...@@ -1095,7 +1131,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -1095,7 +1131,7 @@ export function deriveMessagesTimelineRows(input: {
1095 const showAssistantMeta =1131 const showAssistantMeta =
1096 timelineEntry.message.role === "assistant" &&1132 timelineEntry.message.role === "assistant" &&
1097 terminalAssistantMessageIds.has(timelineEntry.message.id) &&1133 terminalAssistantMessageIds.has(timelineEntry.message.id) &&
1098 !assistantTurnStillInProgress;1134 !assistantResponseStillInProgress;
10991135
1100 nextRows.push({1136 nextRows.push({
1101 kind: "message",1137 kind: "message",
...@@ -1105,7 +1141,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -1105,7 +1141,7 @@ export function deriveMessagesTimelineRows(input: {
1105 durationStart,1141 durationStart,
1106 showAssistantMeta,1142 showAssistantMeta,
1107 showAssistantCopyButton: showAssistantMeta,1143 showAssistantCopyButton: showAssistantMeta,
1108 assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress,1144 assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress,
1109 assistantTurnDiffSummary:1145 assistantTurnDiffSummary:
1110 timelineEntry.message.role === "assistant"1146 timelineEntry.message.role === "assistant"
1111 ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)1147 ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)