Skip to content

Commit 3a5ec94

Browse files
Render the plan surface in the inline right panel (pingdotgg#3118)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent e56bb20 commit 3a5ec94

6 files changed

Lines changed: 357 additions & 202 deletions

File tree

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

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,6 +2110,48 @@ describe("ChatView timeline estimator parity (full app)", () => {
21102110
}
21112111
});
21122112

2113+
it("keeps the compact chat header on one row", async () => {
2114+
const mounted = await mountChatView({
2115+
viewport: COMPACT_FOOTER_VIEWPORT,
2116+
snapshot: createSnapshotForTargetUser({
2117+
targetMessageId: "msg-user-compact-header" as MessageId,
2118+
targetText: "keep the compact header aligned",
2119+
}),
2120+
});
2121+
2122+
try {
2123+
const chatHeader = await waitForElement(
2124+
() => document.querySelector<HTMLElement>("[data-chat-header]"),
2125+
"Unable to find chat header.",
2126+
);
2127+
const threadTitle = await waitForElement(
2128+
() => chatHeader.querySelector<HTMLElement>("h2"),
2129+
"Unable to find thread title.",
2130+
);
2131+
const headerActions = await waitForElement(
2132+
() => document.querySelector<HTMLElement>("[data-chat-header-actions]"),
2133+
"Unable to find chat header actions.",
2134+
);
2135+
2136+
const headerRect = chatHeader.getBoundingClientRect();
2137+
const titleRect = threadTitle.getBoundingClientRect();
2138+
const actionsRect = headerActions.getBoundingClientRect();
2139+
const headerCenter = headerRect.top + headerRect.height / 2;
2140+
2141+
expect(headerRect.height).toBe(52);
2142+
expect(titleRect.top).toBeGreaterThanOrEqual(headerRect.top);
2143+
expect(titleRect.bottom).toBeLessThanOrEqual(headerRect.bottom);
2144+
expect(actionsRect.top).toBeGreaterThanOrEqual(headerRect.top);
2145+
expect(actionsRect.bottom).toBeLessThanOrEqual(headerRect.bottom);
2146+
expect(Math.abs(titleRect.top + titleRect.height / 2 - headerCenter)).toBeLessThanOrEqual(1);
2147+
expect(Math.abs(actionsRect.top + actionsRect.height / 2 - headerCenter)).toBeLessThanOrEqual(
2148+
1,
2149+
);
2150+
} finally {
2151+
await mounted.cleanup();
2152+
}
2153+
});
2154+
21132155
it("keeps panel toggles fixed and can maximize the right panel", async () => {
21142156
const mounted = await mountChatView({
21152157
viewport: WIDE_FOOTER_VIEWPORT,
@@ -2276,6 +2318,151 @@ describe("ChatView timeline estimator parity (full app)", () => {
22762318
}
22772319
});
22782320

2321+
it("renders the plan surface in the inline right panel", async () => {
2322+
useRightPanelStore.getState().open(THREAD_REF, "plan");
2323+
2324+
const mounted = await mountChatView({
2325+
viewport: WIDE_FOOTER_VIEWPORT,
2326+
snapshot: createSnapshotForTargetUser({
2327+
targetMessageId: "msg-user-inline-plan-panel" as MessageId,
2328+
targetText: "show the inline plan panel",
2329+
}),
2330+
});
2331+
2332+
try {
2333+
await waitForElement(
2334+
() =>
2335+
Array.from(document.querySelectorAll<HTMLElement>("p")).find(
2336+
(element) => element.textContent?.trim() === "No active plan yet.",
2337+
) ?? null,
2338+
"Unable to find inline plan panel content.",
2339+
);
2340+
2341+
expect(
2342+
document.querySelector<HTMLElement>("[data-right-panel-tabbar]")?.textContent,
2343+
).toContain("Plan");
2344+
expect(document.body.textContent).toContain("Plans will appear here when generated.");
2345+
} finally {
2346+
await mounted.cleanup();
2347+
}
2348+
});
2349+
2350+
it("renders the shared panel toggles in the responsive right-panel sheet", async () => {
2351+
useRightPanelStore.getState().open(THREAD_REF, "plan");
2352+
useRightPanelStore.getState().openTerminal(THREAD_REF, DEFAULT_TERMINAL_ID);
2353+
useRightPanelStore.getState().activateSurface(THREAD_REF, "plan");
2354+
const baseSnapshot = createSnapshotForTargetUser({
2355+
targetMessageId: "msg-user-responsive-plan-panel-controls" as MessageId,
2356+
targetText: "show responsive plan panel controls",
2357+
});
2358+
const snapshot: OrchestrationReadModel = {
2359+
...baseSnapshot,
2360+
threads: baseSnapshot.threads.map((thread) =>
2361+
thread.id === THREAD_ID
2362+
? {
2363+
...thread,
2364+
activities: [
2365+
{
2366+
id: EventId.make("activity-responsive-panel-plan"),
2367+
tone: "info",
2368+
kind: "turn.plan.updated",
2369+
summary: "Plan updated",
2370+
payload: {
2371+
explanation: "Claude Tasks",
2372+
plan: [{ step: "Keep terminal navigation available", status: "inProgress" }],
2373+
},
2374+
turnId: null,
2375+
sequence: 1,
2376+
createdAt: isoAt(1_000),
2377+
},
2378+
],
2379+
}
2380+
: thread,
2381+
),
2382+
};
2383+
2384+
const mounted = await mountChatView({
2385+
viewport: COMPACT_FOOTER_VIEWPORT,
2386+
snapshot,
2387+
});
2388+
2389+
try {
2390+
const sheet = await waitForElement(
2391+
() => document.querySelector<HTMLElement>('[data-slot="sheet-popup"]'),
2392+
"Unable to find responsive right-panel sheet.",
2393+
);
2394+
const controls = await waitForElement(
2395+
() => sheet.querySelector<HTMLElement>("[data-panel-layout-controls]"),
2396+
"Unable to find shared controls in the responsive right-panel sheet.",
2397+
);
2398+
const tabbar = await waitForElement(
2399+
() => sheet.querySelector<HTMLElement>("[data-right-panel-tabbar]"),
2400+
"Unable to find responsive right-panel tabbar.",
2401+
);
2402+
const controlButtons = Array.from(controls.querySelectorAll<HTMLButtonElement>("button"));
2403+
const tabbarRect = tabbar.getBoundingClientRect();
2404+
const controlsRect = controls.getBoundingClientRect();
2405+
2406+
expect(controlButtons.map((button) => button.getAttribute("aria-label"))).toEqual([
2407+
"Toggle terminal drawer",
2408+
"Toggle right panel",
2409+
]);
2410+
expect(tabbarRect.height).toBe(52);
2411+
expect(controlsRect.height).toBe(52);
2412+
expect(controlsRect.top).toBe(tabbarRect.top);
2413+
expect(window.innerWidth - controlsRect.right).toBe(12);
2414+
for (const button of controlButtons) {
2415+
const rect = button.getBoundingClientRect();
2416+
const buttonCenter = rect.top + rect.height / 2;
2417+
const tabbarCenter = tabbarRect.top + tabbarRect.height / 2;
2418+
expect(rect.width).toBe(32);
2419+
expect(rect.height).toBe(32);
2420+
expect(Math.abs(buttonCenter - tabbarCenter)).toBeLessThanOrEqual(1);
2421+
}
2422+
expect(
2423+
controlButtons[1]!.getBoundingClientRect().left -
2424+
controlButtons[0]!.getBoundingClientRect().right,
2425+
).toBe(4);
2426+
expect(sheet.querySelector('button[aria-label="Maximize panel"]')).toBeNull();
2427+
expect(sheet.querySelector('button[aria-label="Close tasks sidebar"]')).toBeNull();
2428+
2429+
const terminalTab = Array.from(
2430+
sheet.querySelectorAll<HTMLButtonElement>("[data-right-panel-tab-list] button"),
2431+
).find((button) => button.textContent?.includes("Terminal"));
2432+
terminalTab?.click();
2433+
2434+
await vi.waitFor(() => {
2435+
expect(
2436+
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF)
2437+
.activeSurfaceId,
2438+
).toBe(`terminal:${DEFAULT_TERMINAL_ID}`);
2439+
expect(sheet.querySelector('[data-terminal-owner="right-panel"]')).not.toBeNull();
2440+
expect(sheet.textContent).not.toContain("Claude Tasks");
2441+
});
2442+
2443+
sheet.querySelector<HTMLButtonElement>('button[aria-label="Close Plan"]')?.click();
2444+
2445+
await vi.waitFor(() => {
2446+
const panelState = selectThreadRightPanelState(
2447+
useRightPanelStore.getState().byThreadKey,
2448+
THREAD_REF,
2449+
);
2450+
expect(panelState.surfaces.some((surface) => surface.kind === "plan")).toBe(false);
2451+
expect(panelState.activeSurfaceId).toBe(`terminal:${DEFAULT_TERMINAL_ID}`);
2452+
});
2453+
2454+
controls.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]')?.click();
2455+
2456+
await vi.waitFor(() => {
2457+
expect(
2458+
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF).isOpen,
2459+
).toBe(false);
2460+
});
2461+
} finally {
2462+
await mounted.cleanup();
2463+
}
2464+
});
2465+
22792466
it("loads file previews from the active thread worktree", async () => {
22802467
const worktreePath = "/repo/worktrees/file-preview-thread";
22812468
const snapshot = createSnapshotForTargetUser({

0 commit comments

Comments
 (0)