authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-16 17:30:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-16 17:30:22-07:00
log3a5ec9464b4ba22be623abb3ceeaa28ab7c807e7
treed08af666ea97eb1b3ef4f3c0de48bd81c5e19fc8
parente56bb200fa977d016c751af1a65946f0273614e8
signature Signed by PGP key B5690EEEBB952194

Render the plan surface in the inline right panel (#3118)

Co-authored-by: codex <codex@users.noreply.github.com>

6 files changed, 357 insertions(+), 202 deletions(-)

apps/web/src/components/ChatView.browser.tsx+187
...@@ -2110,6 +2110,48 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2110,6 +2110,48 @@ describe("ChatView timeline estimator parity (full app)", () => {
2110 }2110 }
2111 });2111 });
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
2113 it("keeps panel toggles fixed and can maximize the right panel", async () => {2155 it("keeps panel toggles fixed and can maximize the right panel", async () => {
2114 const mounted = await mountChatView({2156 const mounted = await mountChatView({
2115 viewport: WIDE_FOOTER_VIEWPORT,2157 viewport: WIDE_FOOTER_VIEWPORT,
...@@ -2276,6 +2318,151 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2276,6 +2318,151 @@ describe("ChatView timeline estimator parity (full app)", () => {
2276 }2318 }
2277 });2319 });
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
2279 it("loads file previews from the active thread worktree", async () => {2466 it("loads file previews from the active thread worktree", async () => {
2280 const worktreePath = "/repo/worktrees/file-preview-thread";2467 const worktreePath = "/repo/worktrees/file-preview-thread";
2281 const snapshot = createSnapshotForTargetUser({2468 const snapshot = createSnapshotForTargetUser({
apps/web/src/components/ChatView.tsx+128-145
...@@ -170,7 +170,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";...@@ -170,7 +170,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
170import { PullRequestThreadDialog } from "./PullRequestThreadDialog";170import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
171import { MessagesTimeline } from "./chat/MessagesTimeline";171import { MessagesTimeline } from "./chat/MessagesTimeline";
172import { ChatHeader } from "./chat/ChatHeader";172import { ChatHeader } from "./chat/ChatHeader";
173import { PanelLayoutControls } from "./chat/PanelLayoutControls";173import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
174import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";174import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
175import { NoActiveThreadState } from "./NoActiveThreadState";175import { NoActiveThreadState } from "./NoActiveThreadState";
176import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";176import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
...@@ -1333,9 +1333,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1333,9 +1333,8 @@ function ChatViewContent(props: ChatViewProps) {
1333 const planSidebarOpen = activeRightPanelKind === "plan";1333 const planSidebarOpen = activeRightPanelKind === "plan";
1334 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();1334 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
1335 const rightPanelOpen = rightPanelState.isOpen;1335 const rightPanelOpen = rightPanelState.isOpen;
1336 const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
1337 const rightPanelMaximized =1336 const rightPanelMaximized =
1338 canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey;1337 rightPanelOpen && !shouldUsePlanSidebarSheet && maximizedRightPanelThreadKey === routeThreadKey;
1339 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;1338 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
13401339
1341 useEffect(() => {1340 useEffect(() => {
...@@ -3036,23 +3035,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3036,23 +3035,25 @@ function ChatViewContent(props: ChatViewProps) {
3036 const toggleInteractionMode = useCallback(() => {3035 const toggleInteractionMode = useCallback(() => {
3037 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");3036 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
3038 }, [handleInteractionModeChange, interactionMode]);3037 }, [handleInteractionModeChange, interactionMode]);
3038 const dismissPlanSidebarForCurrentTurn = useCallback(() => {
3039 planSidebarDismissedForTurnRef.current =
3040 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
3041 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
3039 const togglePlanSidebar = useCallback(() => {3042 const togglePlanSidebar = useCallback(() => {
3040 if (!activeThreadRef) return;3043 if (!activeThreadRef) return;
3041 if (planSidebarOpen) {3044 if (planSidebarOpen) {
3042 planSidebarDismissedForTurnRef.current =3045 dismissPlanSidebarForCurrentTurn();
3043 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
3044 } else {3046 } else {
3045 planSidebarDismissedForTurnRef.current = null;3047 planSidebarDismissedForTurnRef.current = null;
3046 }3048 }
3047 useRightPanelStore.getState().toggle(activeThreadRef, "plan");3049 useRightPanelStore.getState().toggle(activeThreadRef, "plan");
3048 }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]);3050 }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]);
3049 const closePlanSidebar = useCallback(() => {3051 const closePlanSidebar = useCallback(() => {
3050 if (!activeThreadRef) return;3052 if (!activeThreadRef) return;
3051 setMaximizedRightPanelThreadKey(null);3053 setMaximizedRightPanelThreadKey(null);
3052 useRightPanelStore.getState().close(activeThreadRef);3054 useRightPanelStore.getState().close(activeThreadRef);
3053 planSidebarDismissedForTurnRef.current =3055 dismissPlanSidebarForCurrentTurn();
3054 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";3056 }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]);
3055 }, [activePlan?.turnId, activeThreadRef, sidebarProposedPlan?.turnId]);
3056 const closePreviewPanel = useCallback(() => {3057 const closePreviewPanel = useCallback(() => {
3057 if (!activeThreadRef) return;3058 if (!activeThreadRef) return;
3058 setMaximizedRightPanelThreadKey(null);3059 setMaximizedRightPanelThreadKey(null);
...@@ -3061,6 +3062,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3061,6 +3062,11 @@ function ChatViewContent(props: ChatViewProps) {
3061 const activateRightPanelSurface = useCallback(3062 const activateRightPanelSurface = useCallback(
3062 (surface: RightPanelSurface) => {3063 (surface: RightPanelSurface) => {
3063 if (!activeThreadRef) return;3064 if (!activeThreadRef) return;
3065 if (surface.kind === "plan") {
3066 planSidebarDismissedForTurnRef.current = null;
3067 } else if (planSidebarOpen) {
3068 dismissPlanSidebarForCurrentTurn();
3069 }
3064 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);3070 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
3065 if (surface.kind === "preview" && surface.resourceId) {3071 if (surface.kind === "preview" && surface.resourceId) {
3066 usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId);3072 usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId);
...@@ -3085,17 +3091,31 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3085,17 +3091,31 @@ function ChatViewContent(props: ChatViewProps) {
3085 });3091 });
3086 }3092 }
3087 },3093 },
3088 [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId],3094 [
3095 activeThreadRef,
3096 diffOpen,
3097 dismissPlanSidebarForCurrentTurn,
3098 environmentId,
3099 navigate,
3100 onDiffPanelOpen,
3101 planSidebarOpen,
3102 threadId,
3103 ],
3089 );3104 );
3090 const toggleRightPanel = useCallback(() => {3105 const toggleRightPanel = useCallback(() => {
3091 if (!activeThreadRef) return;3106 if (!activeThreadRef) return;
3092 if (rightPanelOpen) {3107 if (rightPanelOpen) {
3093 setMaximizedRightPanelThreadKey(null);3108 if (planSidebarOpen) {
3109 closePlanSidebar();
3110 } else {
3111 closePreviewPanel();
3112 }
3113 return;
3094 }3114 }
3095 useRightPanelStore.getState().toggleVisibility(activeThreadRef);3115 useRightPanelStore.getState().toggleVisibility(activeThreadRef);
3096 }, [activeThreadRef, rightPanelOpen]);3116 }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]);
3097 const toggleRightPanelMaximized = () => {3117 const toggleRightPanelMaximized = () => {
3098 if (!canMaximizeRightPanel) return;3118 if (!rightPanelOpen || shouldUsePlanSidebarSheet) return;
3099 setMaximizedRightPanelThreadKey((threadKey) =>3119 setMaximizedRightPanelThreadKey((threadKey) =>
3100 threadKey === routeThreadKey ? null : routeThreadKey,3120 threadKey === routeThreadKey ? null : routeThreadKey,
3101 );3121 );
...@@ -3103,6 +3123,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3103,6 +3123,9 @@ function ChatViewContent(props: ChatViewProps) {
3103 const cleanupRightPanelSurfaces = useCallback(3123 const cleanupRightPanelSurfaces = useCallback(
3104 (surfaces: readonly RightPanelSurface[]) => {3124 (surfaces: readonly RightPanelSurface[]) => {
3105 if (!activeThreadRef) return;3125 if (!activeThreadRef) return;
3126 if (surfaces.some((surface) => surface.kind === "plan")) {
3127 dismissPlanSidebarForCurrentTurn();
3128 }
31063129
3107 const api = readEnvironmentApi(activeThreadRef.environmentId);3130 const api = readEnvironmentApi(activeThreadRef.environmentId);
3108 for (const surface of surfaces) {3131 for (const surface of surfaces) {
...@@ -3133,7 +3156,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3133,7 +3156,14 @@ function ChatViewContent(props: ChatViewProps) {
3133 });3156 });
3134 }3157 }
3135 },3158 },
3136 [activeThreadRef, diffOpen, environmentId, navigate, threadId],3159 [
3160 activeThreadRef,
3161 diffOpen,
3162 dismissPlanSidebarForCurrentTurn,
3163 environmentId,
3164 navigate,
3165 threadId,
3166 ],
3137 );3167 );
3138 const syncActivePreviewSurface = useCallback(() => {3168 const syncActivePreviewSurface = useCallback(() => {
3139 if (!activeThreadRef) return;3169 if (!activeThreadRef) return;
...@@ -4654,7 +4684,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4654,7 +4684,7 @@ function ChatViewContent(props: ChatViewProps) {
4654 return <NoActiveThreadState />;4684 return <NoActiveThreadState />;
4655 }4685 }
46564686
4657 const panelLayoutControls = (4687 const panelToggleControls = (
4658 <PanelLayoutControls4688 <PanelLayoutControls
4659 terminalAvailable={Boolean(activeProject)}4689 terminalAvailable={Boolean(activeProject)}
4660 terminalOpen={Boolean(terminalUiState.terminalOpen)}4690 terminalOpen={Boolean(terminalUiState.terminalOpen)}
...@@ -4662,20 +4692,95 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4662,20 +4692,95 @@ function ChatViewContent(props: ChatViewProps) {
4662 rightPanelAvailable={Boolean(activeProject)}4692 rightPanelAvailable={Boolean(activeProject)}
4663 rightPanelOpen={rightPanelOpen}4693 rightPanelOpen={rightPanelOpen}
4664 rightPanelShortcutLabel={rightPanelToggleShortcutLabel}4694 rightPanelShortcutLabel={rightPanelToggleShortcutLabel}
4665 rightPanelMaximized={rightPanelMaximized}
4666 canMaximizeRightPanel={canMaximizeRightPanel}
4667 onToggleTerminal={toggleTerminalVisibility}4695 onToggleTerminal={toggleTerminalVisibility}
4668 onToggleRightPanel={toggleRightPanel}4696 onToggleRightPanel={toggleRightPanel}
4669 onToggleRightPanelMaximized={toggleRightPanelMaximized}
4670 />4697 />
4671 );4698 );
4699 const panelLayoutControls = (
4700 <div className="workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]">
4701 {rightPanelOpen && !shouldUsePlanSidebarSheet ? (
4702 <RightPanelMaximizeControl
4703 maximized={rightPanelMaximized}
4704 onToggle={toggleRightPanelMaximized}
4705 />
4706 ) : null}
4707 {panelToggleControls}
4708 </div>
4709 );
4710 const rightPanelContent = activeThreadRef ? (
4711 activeRightPanelSurface?.kind === "preview" ? (
4712 <Suspense fallback={null}>
4713 <PreviewPanel
4714 mode="embedded"
4715 threadRef={activeThreadRef}
4716 tabId={activeRightPanelSurface.resourceId}
4717 configuredUrls={configuredPreviewUrls}
4718 visible
4719 />
4720 </Suspense>
4721 ) : activeRightPanelSurface?.kind === "terminal" ? (
4722 <PersistentThreadTerminalPanel
4723 threadRef={activeThreadRef}
4724 surface={activeRightPanelSurface}
4725 launchContext={activeTerminalLaunchContext ?? null}
4726 focusRequestId={terminalFocusRequestId}
4727 keybindings={keybindings}
4728 onAddTerminalContext={addTerminalContextToDraft}
4729 onSplitTerminal={splitPanelTerminal}
4730 onSplitTerminalVertical={splitPanelTerminalVertical}
4731 onNewTerminal={addTerminalSurface}
4732 onActiveTerminalChange={activatePanelTerminal}
4733 onCloseTerminal={closePanelTerminal}
4734 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4735 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
4736 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4737 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4738 />
4739 ) : activeRightPanelSurface?.kind === "diff" ? (
4740 <Suspense fallback={null}>
4741 <DiffPanel mode="embedded" composerDraftTarget={composerDraftTarget} />
4742 </Suspense>
4743 ) : activeRightPanelSurface?.kind === "plan" ? (
4744 <PlanSidebar
4745 activePlan={activePlan}
4746 activeProposedPlan={sidebarProposedPlan}
4747 label={planSidebarLabel}
4748 environmentId={environmentId}
4749 threadRef={activeThreadRef}
4750 markdownCwd={gitCwd ?? undefined}
4751 workspaceRoot={activeWorkspaceRoot}
4752 timestampFormat={timestampFormat}
4753 mode="embedded"
4754 />
4755 ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") &&
4756 activeProject &&
4757 activeWorkspaceRoot ? (
4758 <Suspense fallback={null}>
4759 <FilePreviewPanel
4760 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
4761 environmentId={activeProject.environmentId}
4762 cwd={activeWorkspaceRoot}
4763 projectName={activeProject.name}
4764 threadRef={activeThreadRef}
4765 composerDraftTarget={composerDraftTarget}
4766 keybindings={keybindings}
4767 availableEditors={availableEditors}
4768 relativePath={
4769 activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null
4770 }
4771 onOpenFile={openFileSurface}
4772 onPendingChange={handleFilePendingChange}
4773 />
4774 </Suspense>
4775 ) : null
4776 ) : null;
46724777
4673 return (4778 return (
4674 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">4779 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
4675 {isElectron && activeThreadRef ? (4780 {isElectron && activeThreadRef ? (
4676 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />4781 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />
4677 ) : null}4782 ) : null}
4678 {rightPanelOpen ? panelLayoutControls : null}4783 {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
4679 <div4784 <div
4680 className={cn(4785 className={cn(
4681 "flex min-h-0 min-w-0 flex-col overflow-hidden",4786 "flex min-h-0 min-w-0 flex-col overflow-hidden",
...@@ -4944,69 +5049,15 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4944,69 +5049,15 @@ function ChatViewContent(props: ChatViewProps) {
4944 diffAvailable={isServerThread && isGitRepo}5049 diffAvailable={isServerThread && isGitRepo}
4945 filesAvailable={Boolean(activeProject)}5050 filesAvailable={Boolean(activeProject)}
4946 >5051 >
4947 {activeRightPanelSurface?.kind === "preview" ? (5052 {rightPanelContent}
4948 <Suspense fallback={null}>
4949 <PreviewPanel
4950 mode="embedded"
4951 threadRef={activeThreadRef}
4952 tabId={activeRightPanelSurface.resourceId}
4953 configuredUrls={configuredPreviewUrls}
4954 visible
4955 />
4956 </Suspense>
4957 ) : activeRightPanelSurface?.kind === "terminal" ? (
4958 <PersistentThreadTerminalPanel
4959 threadRef={activeThreadRef}
4960 surface={activeRightPanelSurface}
4961 launchContext={activeTerminalLaunchContext ?? null}
4962 focusRequestId={terminalFocusRequestId}
4963 keybindings={keybindings}
4964 onAddTerminalContext={addTerminalContextToDraft}
4965 onSplitTerminal={splitPanelTerminal}
4966 onSplitTerminalVertical={splitPanelTerminalVertical}
4967 onNewTerminal={addTerminalSurface}
4968 onActiveTerminalChange={activatePanelTerminal}
4969 onCloseTerminal={closePanelTerminal}
4970 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4971 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
4972 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4973 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4974 />
4975 ) : activeRightPanelSurface?.kind === "diff" ? (
4976 <Suspense fallback={null}>
4977 <DiffPanel mode="embedded" composerDraftTarget={composerDraftTarget} />
4978 </Suspense>
4979 ) : (activeRightPanelSurface?.kind === "files" ||
4980 activeRightPanelSurface?.kind === "file") &&
4981 activeProject &&
4982 activeWorkspaceRoot ? (
4983 <Suspense fallback={null}>
4984 <FilePreviewPanel
4985 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
4986 environmentId={activeProject.environmentId}
4987 cwd={activeWorkspaceRoot}
4988 projectName={activeProject.name}
4989 threadRef={activeThreadRef}
4990 composerDraftTarget={composerDraftTarget}
4991 keybindings={keybindings}
4992 availableEditors={availableEditors}
4993 relativePath={
4994 activeRightPanelSurface.kind === "file"
4995 ? activeRightPanelSurface.relativePath
4996 : null
4997 }
4998 onOpenFile={openFileSurface}
4999 onPendingChange={handleFilePendingChange}
5000 />
5001 </Suspense>
5002 ) : null}
5003 </RightPanelTabs>5053 </RightPanelTabs>
5004 ) : null}5054 ) : null}
50055055
5006 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (5056 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (
5007 <RightPanelSheet open onClose={closePreviewPanel}>5057 <RightPanelSheet open onClose={planSidebarOpen ? closePlanSidebar : closePreviewPanel}>
5008 <RightPanelTabs5058 <RightPanelTabs
5009 mode="sheet"5059 mode="sheet"
5060 layoutControls={panelToggleControls}
5010 surfaces={rightPanelState.surfaces}5061 surfaces={rightPanelState.surfaces}
5011 activeSurfaceId={activeRightPanelSurface?.id ?? null}5062 activeSurfaceId={activeRightPanelSurface?.id ?? null}
5012 pendingSurfaceIds={pendingFileSurfaceIds}5063 pendingSurfaceIds={pendingFileSurfaceIds}
...@@ -5026,75 +5077,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5026,75 +5077,7 @@ function ChatViewContent(props: ChatViewProps) {
5026 diffAvailable={isServerThread && isGitRepo}5077 diffAvailable={isServerThread && isGitRepo}
5027 filesAvailable={Boolean(activeProject)}5078 filesAvailable={Boolean(activeProject)}
5028 >5079 >
5029 {activeRightPanelSurface?.kind === "preview" ? (5080 {rightPanelContent}
5030 <Suspense fallback={null}>
5031 <PreviewPanel
5032 mode="embedded"
5033 threadRef={activeThreadRef}
5034 tabId={activeRightPanelSurface.resourceId}
5035 configuredUrls={configuredPreviewUrls}
5036 visible
5037 />
5038 </Suspense>
5039 ) : activeRightPanelSurface?.kind === "terminal" ? (
5040 <PersistentThreadTerminalPanel
5041 threadRef={activeThreadRef}
5042 surface={activeRightPanelSurface}
5043 launchContext={activeTerminalLaunchContext ?? null}
5044 focusRequestId={terminalFocusRequestId}
5045 keybindings={keybindings}
5046 onAddTerminalContext={addTerminalContextToDraft}
5047 onSplitTerminal={splitPanelTerminal}
5048 onSplitTerminalVertical={splitPanelTerminalVertical}
5049 onNewTerminal={addTerminalSurface}
5050 onActiveTerminalChange={activatePanelTerminal}
5051 onCloseTerminal={closePanelTerminal}
5052 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
5053 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
5054 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
5055 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
5056 />
5057 ) : activeRightPanelSurface?.kind === "diff" ? (
5058 <Suspense fallback={null}>
5059 <DiffPanel mode="embedded" composerDraftTarget={composerDraftTarget} />
5060 </Suspense>
5061 ) : activeRightPanelSurface?.kind === "plan" ? (
5062 <PlanSidebar
5063 activePlan={activePlan}
5064 activeProposedPlan={sidebarProposedPlan}
5065 label={planSidebarLabel}
5066 environmentId={environmentId}
5067 threadRef={activeThreadRef}
5068 markdownCwd={gitCwd ?? undefined}
5069 workspaceRoot={activeWorkspaceRoot}
5070 timestampFormat={timestampFormat}
5071 mode="embedded"
5072 onClose={closePlanSidebar}
5073 />
5074 ) : (activeRightPanelSurface?.kind === "files" ||
5075 activeRightPanelSurface?.kind === "file") &&
5076 activeProject &&
5077 activeWorkspaceRoot ? (
5078 <Suspense fallback={null}>
5079 <FilePreviewPanel
5080 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
5081 environmentId={activeProject.environmentId}
5082 cwd={activeWorkspaceRoot}
5083 projectName={activeProject.name}
5084 threadRef={activeThreadRef}
5085 composerDraftTarget={composerDraftTarget}
5086 keybindings={keybindings}
5087 availableEditors={availableEditors}
5088 relativePath={
5089 activeRightPanelSurface.kind === "file"
5090 ? activeRightPanelSurface.relativePath
5091 : null
5092 }
5093 onOpenFile={openFileSurface}
5094 onPendingChange={handleFilePendingChange}
5095 />
5096 </Suspense>
5097 ) : null}
5098 </RightPanelTabs>5081 </RightPanelTabs>
5099 </RightPanelSheet>5082 </RightPanelSheet>
5100 ) : null}5083 ) : null}
apps/web/src/components/PlanSidebar.tsx-12
...@@ -11,7 +11,6 @@ import {...@@ -11,7 +11,6 @@ import {
11 ChevronRightIcon,11 ChevronRightIcon,
12 EllipsisIcon,12 EllipsisIcon,
13 LoaderIcon,13 LoaderIcon,
14 PanelRightCloseIcon,
15} from "lucide-react";14} from "lucide-react";
16import { cn } from "~/lib/utils";15import { cn } from "~/lib/utils";
17import type { ActivePlanState } from "../session-logic";16import type { ActivePlanState } from "../session-logic";
...@@ -61,7 +60,6 @@ interface PlanSidebarProps {...@@ -61,7 +60,6 @@ interface PlanSidebarProps {
61 workspaceRoot: string | undefined;60 workspaceRoot: string | undefined;
62 timestampFormat: TimestampFormat;61 timestampFormat: TimestampFormat;
63 mode?: "sheet" | "sidebar" | "embedded";62 mode?: "sheet" | "sidebar" | "embedded";
64 onClose: () => void;
65}63}
6664
67const PlanSidebar = memo(function PlanSidebar({65const PlanSidebar = memo(function PlanSidebar({
...@@ -74,7 +72,6 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -74,7 +72,6 @@ const PlanSidebar = memo(function PlanSidebar({
74 workspaceRoot,72 workspaceRoot,
75 timestampFormat,73 timestampFormat,
76 mode = "sidebar",74 mode = "sidebar",
77 onClose,
78}: PlanSidebarProps) {75}: PlanSidebarProps) {
79 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);76 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
80 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);77 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
...@@ -182,15 +179,6 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -182,15 +179,6 @@ const PlanSidebar = memo(function PlanSidebar({
182 </MenuPopup>179 </MenuPopup>
183 </Menu>180 </Menu>
184 ) : null}181 ) : null}
185 <Button
186 size="icon-xs"
187 variant="ghost"
188 onClick={onClose}
189 aria-label={`Close ${label.toLowerCase()} sidebar`}
190 className="text-muted-foreground/50 hover:text-foreground/70"
191 >
192 <PanelRightCloseIcon className="size-3.5" />
193 </Button>
194 </div>182 </div>
195 </div>183 </div>
196184
apps/web/src/components/RightPanelTabs.tsx+4-2
...@@ -27,6 +27,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon";...@@ -27,6 +27,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon";
27interface RightPanelTabsProps {27interface RightPanelTabsProps {
28 mode: PreviewPanelMode;28 mode: PreviewPanelMode;
29 maximized?: boolean;29 maximized?: boolean;
30 layoutControls?: ReactNode;
30 surfaces: readonly RightPanelSurface[];31 surfaces: readonly RightPanelSurface[];
31 activeSurfaceId: string | null;32 activeSurfaceId: string | null;
32 pendingSurfaceIds: ReadonlySet<string>;33 pendingSurfaceIds: ReadonlySet<string>;
...@@ -342,8 +343,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -342,8 +343,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
342 >343 >
343 <div344 <div
344 className={cn(345 className={cn(
345 "px-2 pr-28",346 "workspace-topbar gap-1 pl-2",
346 props.mode === "inline" ? "workspace-topbar" : "flex h-10 shrink-0 items-center",347 props.mode === "inline" ? "pr-28" : "pr-3",
347 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",348 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
348 )}349 )}
349 data-right-panel-tabbar350 data-right-panel-tabbar
...@@ -457,6 +458,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -457,6 +458,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
457 ) : null}458 ) : null}
458 </div>459 </div>
459 </ScrollArea>460 </ScrollArea>
461 {props.layoutControls}
460 </div>462 </div>
461 <div className="flex min-h-0 flex-1 flex-col">463 <div className="flex min-h-0 flex-1 flex-col">
462 {props.activeSurfaceId === null ? (464 {props.activeSurfaceId === null ? (
apps/web/src/components/chat/ChatHeader.tsx+4-4
...@@ -72,15 +72,15 @@ export const ChatHeader = memo(function ChatHeader({...@@ -72,15 +72,15 @@ export const ChatHeader = memo(function ChatHeader({
72 primaryEnvironmentId,72 primaryEnvironmentId,
73 });73 });
74 return (74 return (
75 <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">75 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
76 <div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3">76 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
77 <SidebarTrigger className="size-7 shrink-0 md:hidden" />77 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
78 <Tooltip>78 <Tooltip>
79 <TooltipTrigger79 <TooltipTrigger
80 render={80 render={
81 <h281 <h2
82 aria-label={activeThreadTitle}82 aria-label={activeThreadTitle}
83 className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground"83 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
84 >84 >
85 {activeThreadTitle}85 {activeThreadTitle}
86 </h2>86 </h2>
...@@ -92,7 +92,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -92,7 +92,7 @@ export const ChatHeader = memo(function ChatHeader({
92 <div92 <div
93 data-chat-header-actions93 data-chat-header-actions
94 className={cn(94 className={cn(
95 "flex min-w-0 flex-wrap items-center justify-start gap-2 sm:shrink-0 sm:justify-end @3xl/header-actions:gap-3",95 "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",
96 rightPanelOpen ? "pr-0" : "pr-16",96 rightPanelOpen ? "pr-0" : "pr-16",
97 )}97 )}
98 >98 >
apps/web/src/components/chat/PanelLayoutControls.tsx+34-39
...@@ -1,8 +1,6 @@...@@ -1,8 +1,6 @@
1import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react";1import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react";
2import { memo } from "react";2import { memo } from "react";
33
4import { cn } from "~/lib/utils";
5
6import { Toggle } from "../ui/toggle";4import { Toggle } from "../ui/toggle";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
86
...@@ -13,11 +11,8 @@ interface PanelLayoutControlsProps {...@@ -13,11 +11,8 @@ interface PanelLayoutControlsProps {
13 rightPanelAvailable: boolean;11 rightPanelAvailable: boolean;
14 rightPanelOpen: boolean;12 rightPanelOpen: boolean;
15 rightPanelShortcutLabel: string | null;13 rightPanelShortcutLabel: string | null;
16 rightPanelMaximized: boolean;
17 canMaximizeRightPanel: boolean;
18 onToggleTerminal: () => void;14 onToggleTerminal: () => void;
19 onToggleRightPanel: () => void;15 onToggleRightPanel: () => void;
20 onToggleRightPanelMaximized: () => void;
21}16}
2217
23export const PanelLayoutControls = memo(function PanelLayoutControls({18export const PanelLayoutControls = memo(function PanelLayoutControls({
...@@ -27,47 +22,14 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -27,47 +22,14 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
27 rightPanelAvailable,22 rightPanelAvailable,
28 rightPanelOpen,23 rightPanelOpen,
29 rightPanelShortcutLabel,24 rightPanelShortcutLabel,
30 rightPanelMaximized,
31 canMaximizeRightPanel,
32 onToggleTerminal,25 onToggleTerminal,
33 onToggleRightPanel,26 onToggleRightPanel,
34 onToggleRightPanelMaximized,
35}: PanelLayoutControlsProps) {27}: PanelLayoutControlsProps) {
36 return (28 return (
37 <div29 <div
38 className={cn("workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]")}30 className="flex h-full shrink-0 items-center gap-1 [-webkit-app-region:no-drag]"
39 data-panel-layout-controls31 data-panel-layout-controls
40 >32 >
41 {rightPanelOpen ? (
42 <Tooltip>
43 <TooltipTrigger
44 render={
45 <Toggle
46 className="shrink-0 [-webkit-app-region:no-drag]"
47 pressed={rightPanelMaximized}
48 onPressedChange={onToggleRightPanelMaximized}
49 aria-label={rightPanelMaximized ? "Restore panel size" : "Maximize panel"}
50 variant="ghost"
51 size="sm"
52 disabled={!canMaximizeRightPanel}
53 >
54 {rightPanelMaximized ? (
55 <Minimize2Icon className="size-3.5" />
56 ) : (
57 <Maximize2Icon className="size-3.5" />
58 )}
59 </Toggle>
60 }
61 />
62 <TooltipPopup side="bottom">
63 {canMaximizeRightPanel
64 ? rightPanelMaximized
65 ? "Restore panel size"
66 : "Maximize panel"
67 : "Panel maximization is unavailable at this width"}
68 </TooltipPopup>
69 </Tooltip>
70 ) : null}
71 <Tooltip>33 <Tooltip>
72 <TooltipTrigger34 <TooltipTrigger
73 render={35 render={
...@@ -115,3 +77,36 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -115,3 +77,36 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
115 </div>77 </div>
116 );78 );
117});79});
80
81export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl({
82 maximized,
83 onToggle,
84}: {
85 maximized: boolean;
86 onToggle: () => void;
87}) {
88 const label = maximized ? "Restore panel size" : "Maximize panel";
89 return (
90 <Tooltip>
91 <TooltipTrigger
92 render={
93 <Toggle
94 className="shrink-0 [-webkit-app-region:no-drag]"
95 pressed={maximized}
96 onPressedChange={onToggle}
97 aria-label={label}
98 variant="ghost"
99 size="sm"
100 >
101 {maximized ? (
102 <Minimize2Icon className="size-3.5" />
103 ) : (
104 <Maximize2Icon className="size-3.5" />
105 )}
106 </Toggle>
107 }
108 />
109 <TooltipPopup side="bottom">{label}</TooltipPopup>
110 </Tooltip>
111 );
112});