Skip to content

Commit e02cf89

Browse files
BunsDevVal Alexander
andauthored
Stabilize plan sidebar and file navigation (#505)
- Sort thread activities once before deriving plan and work log state - Keep plan sidebar auto-open keyed to the active turn without rerender loops - Read latest threads lazily when navigating file views and add regression coverage Co-authored-by: Val Alexander <val@viewdue.ai>
1 parent 6bb9359 commit e02cf89

5 files changed

Lines changed: 566 additions & 32 deletions

File tree

apps/web/src/components/ChatView.browser.tsx

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import "../index.css";
33

44
import {
5+
EventId,
56
type GitBranch,
67
ORCHESTRATION_WS_METHODS,
78
type MessageId,
@@ -40,6 +41,12 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";
4041
const BASE_TIME_MS = Date.parse(NOW_ISO);
4142
const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";
4243
const ONBOARDING_STORAGE_KEY = "okcode:onboarding-completed:v1";
44+
const PLAN_FOLLOW_UP_TURN_ID = "turn-plan-follow-up" as TurnId;
45+
const PLAN_IMPLEMENTATION_TURN_ID = "turn-plan-implementation" as TurnId;
46+
const PLAN_FOLLOW_UP_ID = "plan-follow-up-browser";
47+
const PLAN_STEP_INSPECT_FILES = "Inspect files";
48+
const PLAN_STEP_APPLY_PATCH = "Apply patch";
49+
const PLAN_STEP_VERIFY_SIDEBAR = "Verify sidebar stability";
4350

4451
interface WsRequestEnvelope {
4552
id: string;
@@ -462,6 +469,147 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
462469
};
463470
}
464471

472+
function replaceThreadInSnapshot(
473+
snapshot: OrchestrationReadModel,
474+
updater: (
475+
thread: OrchestrationReadModel["threads"][number],
476+
) => OrchestrationReadModel["threads"][number],
477+
): OrchestrationReadModel {
478+
const nextThreadIndex = snapshot.threads.findIndex((thread) => thread.id === THREAD_ID);
479+
if (nextThreadIndex < 0) {
480+
return snapshot;
481+
}
482+
const nextThreads = [...snapshot.threads];
483+
nextThreads[nextThreadIndex] = updater(nextThreads[nextThreadIndex]!);
484+
return {
485+
...snapshot,
486+
threads: nextThreads,
487+
};
488+
}
489+
490+
function createPlanFollowUpSnapshot(): OrchestrationReadModel {
491+
const snapshot = createSnapshotForTargetUser({
492+
targetMessageId: "msg-user-plan-follow-up-target" as MessageId,
493+
targetText: "plan follow-up thread",
494+
});
495+
const planMarkdown = [
496+
"# Stabilize plan implementation flow",
497+
"",
498+
`- ${PLAN_STEP_INSPECT_FILES}`,
499+
`- ${PLAN_STEP_APPLY_PATCH}`,
500+
`- ${PLAN_STEP_VERIFY_SIDEBAR}`,
501+
].join("\n");
502+
503+
return replaceThreadInSnapshot(snapshot, (thread) => {
504+
if (!thread.session) {
505+
return thread;
506+
}
507+
return {
508+
...thread,
509+
interactionMode: "plan",
510+
latestTurn: {
511+
turnId: PLAN_FOLLOW_UP_TURN_ID,
512+
state: "completed",
513+
requestedAt: isoAt(1_100),
514+
startedAt: isoAt(1_101),
515+
completedAt: isoAt(1_102),
516+
assistantMessageId: null,
517+
},
518+
proposedPlans: [
519+
{
520+
id: PLAN_FOLLOW_UP_ID,
521+
turnId: PLAN_FOLLOW_UP_TURN_ID,
522+
planMarkdown,
523+
implementedAt: null,
524+
implementationThreadId: null,
525+
createdAt: isoAt(1_100),
526+
updatedAt: isoAt(1_101),
527+
},
528+
],
529+
session: {
530+
...thread.session,
531+
status: "ready",
532+
activeTurnId: null,
533+
updatedAt: isoAt(1_102),
534+
},
535+
updatedAt: isoAt(1_102),
536+
};
537+
});
538+
}
539+
540+
function createRunningPlanImplementationSnapshot(snapshotSequence: number): OrchestrationReadModel {
541+
const snapshot = createPlanFollowUpSnapshot();
542+
const snapshotWithRunningThread = replaceThreadInSnapshot(snapshot, (thread) => {
543+
if (!thread.session) {
544+
return thread;
545+
}
546+
const activities: OrchestrationReadModel["threads"][number]["activities"] = [
547+
{
548+
id: EventId.makeUnsafe("evt-plan-implementation-1"),
549+
tone: "info",
550+
kind: "turn.plan.updated",
551+
summary: "Plan updated",
552+
payload: {
553+
explanation: "Executing the queued plan steps.",
554+
plan: [
555+
{ step: PLAN_STEP_INSPECT_FILES, status: "completed" },
556+
{ step: PLAN_STEP_APPLY_PATCH, status: "in_progress" },
557+
{ step: PLAN_STEP_VERIFY_SIDEBAR, status: "pending" },
558+
],
559+
},
560+
turnId: PLAN_IMPLEMENTATION_TURN_ID,
561+
sequence: 40,
562+
createdAt: isoAt(1_202),
563+
},
564+
{
565+
id: EventId.makeUnsafe("evt-plan-implementation-2"),
566+
tone: "tool",
567+
kind: "tool.updated",
568+
summary: "Editing files",
569+
payload: {
570+
itemType: "command_execution",
571+
status: "in_progress",
572+
title: "Apply patch",
573+
detail: "Editing files",
574+
},
575+
turnId: PLAN_IMPLEMENTATION_TURN_ID,
576+
sequence: 41,
577+
createdAt: isoAt(1_203),
578+
},
579+
];
580+
581+
return {
582+
...thread,
583+
interactionMode: "code",
584+
latestTurn: {
585+
turnId: PLAN_IMPLEMENTATION_TURN_ID,
586+
state: "running",
587+
requestedAt: isoAt(1_200),
588+
startedAt: isoAt(1_201),
589+
completedAt: null,
590+
assistantMessageId: null,
591+
sourceProposedPlan: {
592+
threadId: THREAD_ID,
593+
planId: PLAN_FOLLOW_UP_ID,
594+
},
595+
},
596+
activities,
597+
session: {
598+
...thread.session,
599+
status: "running",
600+
activeTurnId: PLAN_IMPLEMENTATION_TURN_ID,
601+
updatedAt: isoAt(1_204),
602+
},
603+
updatedAt: isoAt(1_204),
604+
};
605+
});
606+
607+
return {
608+
...snapshotWithRunningThread,
609+
snapshotSequence,
610+
};
611+
}
612+
465613
function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
466614
const tag = body._tag;
467615
const fixtureThread = fixture.snapshot.threads.find((thread) => thread.id === THREAD_ID) ?? null;
@@ -679,6 +827,24 @@ async function waitForSendButton(): Promise<HTMLButtonElement> {
679827
);
680828
}
681829

830+
async function waitForPlanFollowUpImplementButton(): Promise<HTMLButtonElement> {
831+
return waitForElement(
832+
() =>
833+
[...document.querySelectorAll<HTMLButtonElement>('button[type="submit"]')].find(
834+
(button) => button.textContent?.trim() === "Implement",
835+
) ?? null,
836+
"Unable to find plan implement button.",
837+
);
838+
}
839+
840+
function submitWithButton(button: HTMLButtonElement): void {
841+
const form = button.closest("form");
842+
if (!(form instanceof HTMLFormElement)) {
843+
throw new Error("Unable to locate composer form for submit button.");
844+
}
845+
form.requestSubmit(button);
846+
}
847+
682848
function isVisibleElement(element: Element | null): element is HTMLElement {
683849
return (
684850
element instanceof HTMLElement &&
@@ -910,6 +1076,15 @@ async function mountChatView(options: {
9101076
};
9111077
}
9121078

1079+
async function syncFixtureSnapshot(snapshot: OrchestrationReadModel): Promise<void> {
1080+
fixture = {
1081+
...fixture,
1082+
snapshot,
1083+
};
1084+
useStore.getState().syncServerReadModel(snapshot);
1085+
await waitForLayout();
1086+
}
1087+
9131088
async function measureUserRowAtViewport(options: {
9141089
snapshot: OrchestrationReadModel;
9151090
targetMessageId: MessageId;
@@ -1006,6 +1181,141 @@ describe("ChatView timeline estimator parity (full app)", () => {
10061181
}
10071182
});
10081183

1184+
it("implements a proposed plan on the same thread without tripping a render loop", async () => {
1185+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1186+
const mounted = await mountChatView({
1187+
viewport: DEFAULT_VIEWPORT,
1188+
snapshot: createPlanFollowUpSnapshot(),
1189+
});
1190+
1191+
try {
1192+
await waitForServerConfigToApply();
1193+
await vi.waitFor(async () => {
1194+
expect(await readCurrentInteractionModeLabel()).toBe("Plan");
1195+
});
1196+
1197+
const sendButton = await waitForPlanFollowUpImplementButton();
1198+
await vi.waitFor(() => {
1199+
expect(sendButton.disabled).toBe(false);
1200+
});
1201+
submitWithButton(sendButton);
1202+
1203+
await vi.waitFor(() => {
1204+
const request = wsRequests.find(
1205+
(entry) =>
1206+
entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
1207+
typeof entry.command === "object" &&
1208+
entry.command !== null &&
1209+
"type" in entry.command &&
1210+
entry.command.type === "thread.turn.start" &&
1211+
"threadId" in entry.command &&
1212+
entry.command.threadId === THREAD_ID,
1213+
);
1214+
expect(request).toMatchObject({
1215+
_tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
1216+
command: {
1217+
type: "thread.turn.start",
1218+
threadId: THREAD_ID,
1219+
interactionMode: "code",
1220+
sourceProposedPlan: {
1221+
threadId: THREAD_ID,
1222+
planId: PLAN_FOLLOW_UP_ID,
1223+
},
1224+
},
1225+
});
1226+
});
1227+
1228+
await vi.waitFor(async () => {
1229+
expect(await readCurrentInteractionModeLabel()).toBe("Code");
1230+
});
1231+
1232+
await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(2));
1233+
1234+
await vi.waitFor(
1235+
() => {
1236+
expect(
1237+
document.querySelector<HTMLButtonElement>('button[aria-label="Close plan sidebar"]'),
1238+
).toBeTruthy();
1239+
expect(document.body.textContent).toContain(PLAN_STEP_INSPECT_FILES);
1240+
expect(document.body.textContent).toContain(PLAN_STEP_APPLY_PATCH);
1241+
},
1242+
{ timeout: 8_000, interval: 16 },
1243+
);
1244+
1245+
expect(
1246+
consoleErrorSpy.mock.calls.some((call) =>
1247+
call.some(
1248+
(value) =>
1249+
typeof value === "string" &&
1250+
(value.includes("Too many re-renders") ||
1251+
value.includes("Minified React error #301")),
1252+
),
1253+
),
1254+
).toBe(false);
1255+
} finally {
1256+
consoleErrorSpy.mockRestore();
1257+
await mounted.cleanup();
1258+
}
1259+
});
1260+
1261+
it("keeps the same-thread implementation surface stable across repeated identical snapshots", async () => {
1262+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1263+
const mounted = await mountChatView({
1264+
viewport: DEFAULT_VIEWPORT,
1265+
snapshot: createPlanFollowUpSnapshot(),
1266+
});
1267+
1268+
try {
1269+
await waitForServerConfigToApply();
1270+
const sendButton = await waitForPlanFollowUpImplementButton();
1271+
await vi.waitFor(() => {
1272+
expect(sendButton.disabled).toBe(false);
1273+
});
1274+
submitWithButton(sendButton);
1275+
1276+
await vi.waitFor(() => {
1277+
const request = wsRequests.find(
1278+
(entry) =>
1279+
entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
1280+
typeof entry.command === "object" &&
1281+
entry.command !== null &&
1282+
"type" in entry.command &&
1283+
entry.command.type === "thread.turn.start" &&
1284+
"threadId" in entry.command &&
1285+
entry.command.threadId === THREAD_ID,
1286+
);
1287+
expect(request).toBeTruthy();
1288+
});
1289+
1290+
await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(2));
1291+
await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(3));
1292+
await syncFixtureSnapshot(createRunningPlanImplementationSnapshot(4));
1293+
1294+
await vi.waitFor(
1295+
() => {
1296+
expect(document.body.textContent).toContain(PLAN_STEP_VERIFY_SIDEBAR);
1297+
expect(document.body.textContent).not.toContain("Plan ready");
1298+
expect(document.querySelectorAll('[aria-label="Close plan sidebar"]')).toHaveLength(1);
1299+
},
1300+
{ timeout: 8_000, interval: 16 },
1301+
);
1302+
1303+
expect(
1304+
consoleErrorSpy.mock.calls.some((call) =>
1305+
call.some(
1306+
(value) =>
1307+
typeof value === "string" &&
1308+
(value.includes("Too many re-renders") ||
1309+
value.includes("Minified React error #301")),
1310+
),
1311+
),
1312+
).toBe(false);
1313+
} finally {
1314+
consoleErrorSpy.mockRestore();
1315+
await mounted.cleanup();
1316+
}
1317+
});
1318+
10091319
it.each(TEXT_VIEWPORT_MATRIX)(
10101320
"keeps long user message estimate close at the $name viewport",
10111321
async (viewport) => {

0 commit comments

Comments
 (0)