| author | |
| committer | |
| log | 3a5ec9464b4ba22be623abb3ceeaa28ab7c807e7 |
| tree | d08af666ea97eb1b3ef4f3c0de48bd81c5e19fc8 |
| parent | e56bb200fa977d016c751af1a65946f0273614e8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2110 | } |
| 2111 | 2111 | }); |
| 2112 | 2112 | |
| 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 | 2155 | it("keeps panel toggles fixed and can maximize the right panel", async () => { |
| 2114 | 2156 | const mounted = await mountChatView({ |
| 2115 | 2157 | viewport: WIDE_FOOTER_VIEWPORT, |
| ... | ... | @@ -2276,6 +2318,151 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2276 | 2318 | } |
| 2277 | 2319 | }); |
| 2278 | 2320 | |
| 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 | 2466 | it("loads file previews from the active thread worktree", async () => { |
| 2280 | 2467 | const worktreePath = "/repo/worktrees/file-preview-thread"; |
| 2281 | 2468 | const snapshot = createSnapshotForTargetUser({ |
apps/web/src/components/ChatView.tsx+128-145| ... | ... | @@ -170,7 +170,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 170 | 170 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 171 | 171 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 172 | 172 | import { ChatHeader } from "./chat/ChatHeader"; |
| 173 | import { PanelLayoutControls } from "./chat/PanelLayoutControls"; | |
| 173 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; | |
| 174 | 174 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 175 | 175 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 176 | 176 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| ... | ... | @@ -1333,9 +1333,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1333 | 1333 | const planSidebarOpen = activeRightPanelKind === "plan"; |
| 1334 | 1334 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); |
| 1335 | 1335 | const rightPanelOpen = rightPanelState.isOpen; |
| 1336 | const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; | |
| 1337 | 1336 | const rightPanelMaximized = |
| 1338 | canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; | |
| 1337 | rightPanelOpen && !shouldUsePlanSidebarSheet && maximizedRightPanelThreadKey === routeThreadKey; | |
| 1339 | 1338 | const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; |
| 1340 | 1339 | |
| 1341 | 1340 | useEffect(() => { |
| ... | ... | @@ -3036,23 +3035,25 @@ function ChatViewContent(props: ChatViewProps) { |
| 3036 | 3035 | const toggleInteractionMode = useCallback(() => { |
| 3037 | 3036 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); |
| 3038 | 3037 | }, [handleInteractionModeChange, interactionMode]); |
| 3038 | const dismissPlanSidebarForCurrentTurn = useCallback(() => { | |
| 3039 | planSidebarDismissedForTurnRef.current = | |
| 3040 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | |
| 3041 | }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | |
| 3039 | 3042 | const togglePlanSidebar = useCallback(() => { |
| 3040 | 3043 | if (!activeThreadRef) return; |
| 3041 | 3044 | if (planSidebarOpen) { |
| 3042 | planSidebarDismissedForTurnRef.current = | |
| 3043 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | |
| 3045 | dismissPlanSidebarForCurrentTurn(); | |
| 3044 | 3046 | } else { |
| 3045 | 3047 | planSidebarDismissedForTurnRef.current = null; |
| 3046 | 3048 | } |
| 3047 | 3049 | useRightPanelStore.getState().toggle(activeThreadRef, "plan"); |
| 3048 | }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]); | |
| 3050 | }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); | |
| 3049 | 3051 | const closePlanSidebar = useCallback(() => { |
| 3050 | 3052 | if (!activeThreadRef) return; |
| 3051 | 3053 | setMaximizedRightPanelThreadKey(null); |
| 3052 | 3054 | useRightPanelStore.getState().close(activeThreadRef); |
| 3053 | planSidebarDismissedForTurnRef.current = | |
| 3054 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | |
| 3055 | }, [activePlan?.turnId, activeThreadRef, sidebarProposedPlan?.turnId]); | |
| 3055 | dismissPlanSidebarForCurrentTurn(); | |
| 3056 | }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); | |
| 3056 | 3057 | const closePreviewPanel = useCallback(() => { |
| 3057 | 3058 | if (!activeThreadRef) return; |
| 3058 | 3059 | setMaximizedRightPanelThreadKey(null); |
| ... | ... | @@ -3061,6 +3062,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 3061 | 3062 | const activateRightPanelSurface = useCallback( |
| 3062 | 3063 | (surface: RightPanelSurface) => { |
| 3063 | 3064 | if (!activeThreadRef) return; |
| 3065 | if (surface.kind === "plan") { | |
| 3066 | planSidebarDismissedForTurnRef.current = null; | |
| 3067 | } else if (planSidebarOpen) { | |
| 3068 | dismissPlanSidebarForCurrentTurn(); | |
| 3069 | } | |
| 3064 | 3070 | useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); |
| 3065 | 3071 | if (surface.kind === "preview" && surface.resourceId) { |
| 3066 | 3072 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); |
| ... | ... | @@ -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 | 3105 | const toggleRightPanel = useCallback(() => { |
| 3091 | 3106 | if (!activeThreadRef) return; |
| 3092 | 3107 | if (rightPanelOpen) { |
| 3093 | setMaximizedRightPanelThreadKey(null); | |
| 3108 | if (planSidebarOpen) { | |
| 3109 | closePlanSidebar(); | |
| 3110 | } else { | |
| 3111 | closePreviewPanel(); | |
| 3112 | } | |
| 3113 | return; | |
| 3094 | 3114 | } |
| 3095 | 3115 | useRightPanelStore.getState().toggleVisibility(activeThreadRef); |
| 3096 | }, [activeThreadRef, rightPanelOpen]); | |
| 3116 | }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); | |
| 3097 | 3117 | const toggleRightPanelMaximized = () => { |
| 3098 | if (!canMaximizeRightPanel) return; | |
| 3118 | if (!rightPanelOpen || shouldUsePlanSidebarSheet) return; | |
| 3099 | 3119 | setMaximizedRightPanelThreadKey((threadKey) => |
| 3100 | 3120 | threadKey === routeThreadKey ? null : routeThreadKey, |
| 3101 | 3121 | ); |
| ... | ... | @@ -3103,6 +3123,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 3103 | 3123 | const cleanupRightPanelSurfaces = useCallback( |
| 3104 | 3124 | (surfaces: readonly RightPanelSurface[]) => { |
| 3105 | 3125 | if (!activeThreadRef) return; |
| 3126 | if (surfaces.some((surface) => surface.kind === "plan")) { | |
| 3127 | dismissPlanSidebarForCurrentTurn(); | |
| 3128 | } | |
| 3106 | 3129 | |
| 3107 | 3130 | const api = readEnvironmentApi(activeThreadRef.environmentId); |
| 3108 | 3131 | for (const surface of surfaces) { |
| ... | ... | @@ -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 | 3168 | const syncActivePreviewSurface = useCallback(() => { |
| 3139 | 3169 | if (!activeThreadRef) return; |
| ... | ... | @@ -4654,7 +4684,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4654 | 4684 | return <NoActiveThreadState />; |
| 4655 | 4685 | } |
| 4656 | 4686 | |
| 4657 | const panelLayoutControls = ( | |
| 4687 | const panelToggleControls = ( | |
| 4658 | 4688 | <PanelLayoutControls |
| 4659 | 4689 | terminalAvailable={Boolean(activeProject)} |
| 4660 | 4690 | terminalOpen={Boolean(terminalUiState.terminalOpen)} |
| ... | ... | @@ -4662,20 +4692,95 @@ function ChatViewContent(props: ChatViewProps) { |
| 4662 | 4692 | rightPanelAvailable={Boolean(activeProject)} |
| 4663 | 4693 | rightPanelOpen={rightPanelOpen} |
| 4664 | 4694 | rightPanelShortcutLabel={rightPanelToggleShortcutLabel} |
| 4665 | rightPanelMaximized={rightPanelMaximized} | |
| 4666 | canMaximizeRightPanel={canMaximizeRightPanel} | |
| 4667 | 4695 | onToggleTerminal={toggleTerminalVisibility} |
| 4668 | 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; | |
| 4672 | 4777 | |
| 4673 | 4778 | return ( |
| 4674 | 4779 | <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"> |
| 4675 | 4780 | {isElectron && activeThreadRef ? ( |
| 4676 | 4781 | <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> |
| 4677 | 4782 | ) : null} |
| 4678 | {rightPanelOpen ? panelLayoutControls : null} | |
| 4783 | {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} | |
| 4679 | 4784 | <div |
| 4680 | 4785 | className={cn( |
| 4681 | 4786 | "flex min-h-0 min-w-0 flex-col overflow-hidden", |
| ... | ... | @@ -4944,69 +5049,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 4944 | 5049 | diffAvailable={isServerThread && isGitRepo} |
| 4945 | 5050 | filesAvailable={Boolean(activeProject)} |
| 4946 | 5051 | > |
| 4947 | {activeRightPanelSurface?.kind === "preview" ? ( | |
| 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} | |
| 5052 | {rightPanelContent} | |
| 5003 | 5053 | </RightPanelTabs> |
| 5004 | 5054 | ) : null} |
| 5005 | 5055 | |
| 5006 | 5056 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( |
| 5007 | <RightPanelSheet open onClose={closePreviewPanel}> | |
| 5057 | <RightPanelSheet open onClose={planSidebarOpen ? closePlanSidebar : closePreviewPanel}> | |
| 5008 | 5058 | <RightPanelTabs |
| 5009 | 5059 | mode="sheet" |
| 5060 | layoutControls={panelToggleControls} | |
| 5010 | 5061 | surfaces={rightPanelState.surfaces} |
| 5011 | 5062 | activeSurfaceId={activeRightPanelSurface?.id ?? null} |
| 5012 | 5063 | pendingSurfaceIds={pendingFileSurfaceIds} |
| ... | ... | @@ -5026,75 +5077,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5026 | 5077 | diffAvailable={isServerThread && isGitRepo} |
| 5027 | 5078 | filesAvailable={Boolean(activeProject)} |
| 5028 | 5079 | > |
| 5029 | {activeRightPanelSurface?.kind === "preview" ? ( | |
| 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} | |
| 5080 | {rightPanelContent} | |
| 5098 | 5081 | </RightPanelTabs> |
| 5099 | 5082 | </RightPanelSheet> |
| 5100 | 5083 | ) : null} |
apps/web/src/components/PlanSidebar.tsx-12| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | ChevronRightIcon, |
| 12 | 12 | EllipsisIcon, |
| 13 | 13 | LoaderIcon, |
| 14 | PanelRightCloseIcon, | |
| 15 | 14 | } from "lucide-react"; |
| 16 | 15 | import { cn } from "~/lib/utils"; |
| 17 | 16 | import type { ActivePlanState } from "../session-logic"; |
| ... | ... | @@ -61,7 +60,6 @@ interface PlanSidebarProps { |
| 61 | 60 | workspaceRoot: string | undefined; |
| 62 | 61 | timestampFormat: TimestampFormat; |
| 63 | 62 | mode?: "sheet" | "sidebar" | "embedded"; |
| 64 | onClose: () => void; | |
| 65 | 63 | } |
| 66 | 64 | |
| 67 | 65 | const PlanSidebar = memo(function PlanSidebar({ |
| ... | ... | @@ -74,7 +72,6 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 74 | 72 | workspaceRoot, |
| 75 | 73 | timestampFormat, |
| 76 | 74 | mode = "sidebar", |
| 77 | onClose, | |
| 78 | 75 | }: PlanSidebarProps) { |
| 79 | 76 | const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); |
| 80 | 77 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); |
| ... | ... | @@ -182,15 +179,6 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 182 | 179 | </MenuPopup> |
| 183 | 180 | </Menu> |
| 184 | 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 | 182 | </div> |
| 195 | 183 | </div> |
| 196 | 184 |
apps/web/src/components/RightPanelTabs.tsx+4-2| ... | ... | @@ -27,6 +27,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 27 | 27 | interface RightPanelTabsProps { |
| 28 | 28 | mode: PreviewPanelMode; |
| 29 | 29 | maximized?: boolean; |
| 30 | layoutControls?: ReactNode; | |
| 30 | 31 | surfaces: readonly RightPanelSurface[]; |
| 31 | 32 | activeSurfaceId: string | null; |
| 32 | 33 | pendingSurfaceIds: ReadonlySet<string>; |
| ... | ... | @@ -342,8 +343,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 342 | 343 | > |
| 343 | 344 | <div |
| 344 | 345 | className={cn( |
| 345 | "px-2 pr-28", | |
| 346 | props.mode === "inline" ? "workspace-topbar" : "flex h-10 shrink-0 items-center", | |
| 346 | "workspace-topbar gap-1 pl-2", | |
| 347 | props.mode === "inline" ? "pr-28" : "pr-3", | |
| 347 | 348 | ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", |
| 348 | 349 | )} |
| 349 | 350 | data-right-panel-tabbar |
| ... | ... | @@ -457,6 +458,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 457 | 458 | ) : null} |
| 458 | 459 | </div> |
| 459 | 460 | </ScrollArea> |
| 461 | {props.layoutControls} | |
| 460 | 462 | </div> |
| 461 | 463 | <div className="flex min-h-0 flex-1 flex-col"> |
| 462 | 464 | {props.activeSurfaceId === null ? ( |
apps/web/src/components/chat/ChatHeader.tsx+4-4| ... | ... | @@ -72,15 +72,15 @@ export const ChatHeader = memo(function ChatHeader({ |
| 72 | 72 | primaryEnvironmentId, |
| 73 | 73 | }); |
| 74 | 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"> | |
| 76 | <div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3"> | |
| 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-1 items-center gap-2 overflow-hidden sm:gap-3"> | |
| 77 | 77 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> |
| 78 | 78 | <Tooltip> |
| 79 | 79 | <TooltipTrigger |
| 80 | 80 | render={ |
| 81 | 81 | <h2 |
| 82 | 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 | 85 | {activeThreadTitle} |
| 86 | 86 | </h2> |
| ... | ... | @@ -92,7 +92,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 92 | 92 | <div |
| 93 | 93 | data-chat-header-actions |
| 94 | 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 | 96 | rightPanelOpen ? "pr-0" : "pr-16", |
| 97 | 97 | )} |
| 98 | 98 | > |
apps/web/src/components/chat/PanelLayoutControls.tsx+34-39| ... | ... | @@ -1,8 +1,6 @@ |
| 1 | 1 | import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; |
| 2 | 2 | import { memo } from "react"; |
| 3 | 3 | |
| 4 | import { cn } from "~/lib/utils"; | |
| 5 | ||
| 6 | 4 | import { Toggle } from "../ui/toggle"; |
| 7 | 5 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 8 | 6 | |
| ... | ... | @@ -13,11 +11,8 @@ interface PanelLayoutControlsProps { |
| 13 | 11 | rightPanelAvailable: boolean; |
| 14 | 12 | rightPanelOpen: boolean; |
| 15 | 13 | rightPanelShortcutLabel: string | null; |
| 16 | rightPanelMaximized: boolean; | |
| 17 | canMaximizeRightPanel: boolean; | |
| 18 | 14 | onToggleTerminal: () => void; |
| 19 | 15 | onToggleRightPanel: () => void; |
| 20 | onToggleRightPanelMaximized: () => void; | |
| 21 | 16 | } |
| 22 | 17 | |
| 23 | 18 | export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| ... | ... | @@ -27,47 +22,14 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 27 | 22 | rightPanelAvailable, |
| 28 | 23 | rightPanelOpen, |
| 29 | 24 | rightPanelShortcutLabel, |
| 30 | rightPanelMaximized, | |
| 31 | canMaximizeRightPanel, | |
| 32 | 25 | onToggleTerminal, |
| 33 | 26 | onToggleRightPanel, |
| 34 | onToggleRightPanelMaximized, | |
| 35 | 27 | }: PanelLayoutControlsProps) { |
| 36 | 28 | return ( |
| 37 | 29 | <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 | 31 | 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 | 33 | <Tooltip> |
| 72 | 34 | <TooltipTrigger |
| 73 | 35 | render={ |
| ... | ... | @@ -115,3 +77,36 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ |
| 115 | 77 | </div> |
| 116 | 78 | ); |
| 117 | 79 | }); |
| 80 | ||
| 81 | export 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 | }); |