diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b86f81c3..72dbcf66 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -39,6 +39,7 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate } from "@tanstack/react-router"; @@ -236,6 +237,7 @@ import { hasCustomThreadTitle, normalizeThreadTitle } from "~/threadTitle"; import { resolveLiveModelSelection } from "~/modelSelection"; import { getProviderModelOptionsByProvider } from "~/providerModels"; import { enhancePrompt, type PromptEnhancementId } from "../promptEnhancement"; +import { resolveTerminalDockPlacement } from "~/desktopShellLayout"; function preloadThreadTerminalDrawer() { return import("./ThreadTerminalDrawer"); @@ -398,6 +400,8 @@ function normalizeVisibleInteractionMode( interface ChatViewProps { threadId: ThreadId; onMinimize?: (() => void) | undefined; + rightPanelOpen: boolean; + rightPanelTerminalDock: HTMLDivElement | null; } interface RunProjectScriptOptions { @@ -408,7 +412,12 @@ interface RunProjectScriptOptions { rememberAsLastInvoked?: boolean; } -export default function ChatView({ threadId, onMinimize }: ChatViewProps) { +export default function ChatView({ + threadId, + onMinimize, + rightPanelOpen, + rightPanelTerminalDock, +}: ChatViewProps) { const clientMode = useClientMode(); const transportState = useTransportState(); const threads = useStore((store) => store.threads); @@ -4842,6 +4851,45 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { return ; } + const terminalDockPlacement = resolveTerminalDockPlacement({ + clientMode, + rightPanelOpen, + hasRightPanelTerminalDock: rightPanelTerminalDock !== null, + }); + const terminalDrawer = + activeProject && shouldMountTerminalDrawer ? ( +
+ } + > + + +
+ ) : null; + return (
{/* Top bar */} @@ -5873,38 +5921,9 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { {/* Terminal drawer – once mounted, stay mounted to avoid the unmount/remount flicker when toggling visibility or switching threads. We hide it with display:none when collapsed so the DOM is retained. */} - {activeProject && shouldMountTerminalDrawer ? ( -
- } - > - - -
- ) : null} + {terminalDockPlacement === "right-panel" && rightPanelTerminalDock && terminalDrawer + ? createPortal(terminalDrawer, rightPanelTerminalDock) + : terminalDrawer} { + it("counts only the approved desktop shells and excludes terminal", () => { + expect( + getCountedDesktopShells({ + sidebarOpen: true, + previewOpen: true, + rightPanelOpen: true, + planSidebarOpen: true, + terminalOpen: true, + }), + ).toEqual(["sidebar", "preview", "right-panel", "plan-sidebar"]); + + expect( + countCountedDesktopShells({ + sidebarOpen: true, + previewOpen: true, + rightPanelOpen: true, + planSidebarOpen: true, + terminalOpen: true, + }), + ).toBe(4); + }); + + it("returns zero when none of the counted desktop shells are open", () => { + expect( + countCountedDesktopShells({ + sidebarOpen: false, + previewOpen: false, + rightPanelOpen: false, + planSidebarOpen: false, + terminalOpen: true, + }), + ).toBe(0); + }); + + it("docks the terminal under the right panel only when the desktop dock host exists", () => { + expect( + resolveTerminalDockPlacement({ + clientMode: "desktop", + rightPanelOpen: true, + hasRightPanelTerminalDock: true, + }), + ).toBe("right-panel"); + }); + + it("keeps the terminal inline when the right panel is closed", () => { + expect( + resolveTerminalDockPlacement({ + clientMode: "desktop", + rightPanelOpen: false, + hasRightPanelTerminalDock: true, + }), + ).toBe("inline"); + }); + + it("keeps the terminal inline for mobile even if a dock host exists", () => { + expect( + resolveTerminalDockPlacement({ + clientMode: "mobile", + rightPanelOpen: true, + hasRightPanelTerminalDock: true, + }), + ).toBe("inline"); + }); +}); diff --git a/apps/web/src/desktopShellLayout.ts b/apps/web/src/desktopShellLayout.ts new file mode 100644 index 00000000..b67bfff8 --- /dev/null +++ b/apps/web/src/desktopShellLayout.ts @@ -0,0 +1,38 @@ +export type CountedDesktopShell = "sidebar" | "preview" | "right-panel" | "plan-sidebar"; + +interface DesktopShellState { + sidebarOpen: boolean; + previewOpen: boolean; + rightPanelOpen: boolean; + planSidebarOpen: boolean; + terminalOpen: boolean; +} + +interface TerminalDockPlacementInput { + clientMode: "desktop" | "mobile"; + rightPanelOpen: boolean; + hasRightPanelTerminalDock: boolean; +} + +export type TerminalDockPlacement = "inline" | "right-panel"; + +export function getCountedDesktopShells(input: DesktopShellState): CountedDesktopShell[] { + const shells: CountedDesktopShell[] = []; + if (input.sidebarOpen) shells.push("sidebar"); + if (input.previewOpen) shells.push("preview"); + if (input.rightPanelOpen) shells.push("right-panel"); + if (input.planSidebarOpen) shells.push("plan-sidebar"); + return shells; +} + +export function countCountedDesktopShells(input: DesktopShellState): number { + return getCountedDesktopShells(input).length; +} + +export function resolveTerminalDockPlacement( + input: TerminalDockPlacementInput, +): TerminalDockPlacement { + return input.clientMode === "desktop" && input.rightPanelOpen && input.hasRightPanelTerminalDock + ? "right-panel" + : "inline"; +} diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index be34afc1..0f5e1051 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -226,6 +226,7 @@ function ChatThreadRouteView() { // ── Keep-alive flags so lazy content doesn't unmount on tab switch ─ const [hasOpenedSimulation, setHasOpenedSimulation] = useState(simulationOpen); + const [rightPanelTerminalDock, setRightPanelTerminalDock] = useState(null); const closeCodeViewer = useCallback(() => { closeCodeViewerStore(); @@ -236,6 +237,9 @@ function ChatThreadRouteView() { const closeSimulation = useCallback(() => { closeSimulationStore(); }, [closeSimulationStore]); + const setRightPanelTerminalDockRef = useCallback((node: HTMLDivElement | null) => { + setRightPanelTerminalDock(node); + }, []); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -330,7 +334,7 @@ function ChatThreadRouteView() { // ── Right panel content (shared between desktop sidebar & mobile sheet) ── const rightPanelContent = ( -
+
{rightPanelTab === "workspace" ? ( @@ -404,7 +408,13 @@ function ChatThreadRouteView() { return ( <> - + {rightPanelContent} @@ -417,7 +427,13 @@ function ChatThreadRouteView() { return ( <> - + - {rightPanelContent} +
+ {rightPanelContent} +
+
diff --git a/docs/superpowers/plans/2026-04-18-terminal-right-column.md b/docs/superpowers/plans/2026-04-18-terminal-right-column.md new file mode 100644 index 00000000..2020a999 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-terminal-right-column.md @@ -0,0 +1,139 @@ +# Desktop Terminal Dock Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the approved desktop shell cap interpretation explicit and dock the terminal below the right panel on desktop without rewriting terminal behavior. + +**Architecture:** Add a small pure helper for shell counting and terminal dock placement, then portal the existing `ThreadTerminalDrawer` into a route-owned dock host under the right sidebar. Preserve the current inline terminal fallback whenever the dock host is unavailable. + +**Tech Stack:** React, TanStack Router, Zustand, Vitest, TypeScript + +--- + +### Task 1: Define The Layout Rules In A Pure Helper + +**Files:** + +- Create: `apps/web/src/desktopShellLayout.ts` +- Test: `apps/web/src/desktopShellLayout.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it } from "vitest"; + +import { countCountedDesktopShells, resolveTerminalDockPlacement } from "./desktopShellLayout"; + +describe("desktopShellLayout", () => { + it("counts only the approved desktop shells and excludes terminal", () => { + expect( + countCountedDesktopShells({ + sidebarOpen: true, + previewOpen: true, + rightPanelOpen: true, + planSidebarOpen: true, + terminalOpen: true, + }), + ).toBe(4); + }); + + it("docks the terminal under the right panel only when the desktop dock host exists", () => { + expect( + resolveTerminalDockPlacement({ + clientMode: "desktop", + rightPanelOpen: true, + hasRightPanelTerminalDock: true, + }), + ).toBe("right-panel"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test apps/web/src/desktopShellLayout.test.ts` +Expected: FAIL because `./desktopShellLayout` does not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Create a helper that exports: + +```ts +export function countCountedDesktopShells(input: { + sidebarOpen: boolean; + previewOpen: boolean; + rightPanelOpen: boolean; + planSidebarOpen: boolean; + terminalOpen: boolean; +}): number; + +export function resolveTerminalDockPlacement(input: { + clientMode: "desktop" | "mobile"; + rightPanelOpen: boolean; + hasRightPanelTerminalDock: boolean; +}): "inline" | "right-panel"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test apps/web/src/desktopShellLayout.test.ts` +Expected: PASS + +### Task 2: Add The Right-Panel Terminal Dock Host + +**Files:** + +- Modify: `apps/web/src/routes/_chat.$threadId.tsx` + +- [ ] **Step 1: Add a route-owned dock host below the right panel** + +Render a stable container below `rightPanelContent` inside the desktop right sidebar and expose its DOM node to descendants. + +- [ ] **Step 2: Keep mobile behavior unchanged** + +Do not render or use the dock host in the mobile sheet path. + +### Task 3: Portal The Existing Terminal Drawer Into The Dock Host + +**Files:** + +- Modify: `apps/web/src/components/ChatView.tsx` +- Modify: `apps/web/src/routes/_chat.$threadId.tsx` + +- [ ] **Step 1: Resolve terminal placement through the helper** + +Use the pure helper to decide whether the terminal drawer should remain inline or render into the right-panel dock host. + +- [ ] **Step 2: Preserve the inline fallback** + +If the right panel is closed or the dock host is unavailable, keep the existing inline terminal drawer behavior. + +- [ ] **Step 3: Keep drawer props and behavior unchanged** + +Do not rework terminal state, shortcuts, split/new terminal behavior, or context actions. + +### Task 4: Verify And Clean Up + +**Files:** + +- Modify: `apps/web/src/desktopShellLayout.test.ts` if needed + +- [ ] **Step 1: Run the focused test file** + +Run: `bun run test apps/web/src/desktopShellLayout.test.ts` +Expected: PASS + +- [ ] **Step 2: Run formatting** + +Run: `bun fmt` +Expected: PASS + +- [ ] **Step 3: Run lint** + +Run: `bun lint` +Expected: PASS + +- [ ] **Step 4: Run typecheck** + +Run: `bun typecheck` +Expected: PASS diff --git a/docs/superpowers/specs/2026-04-18-terminal-right-column-design.md b/docs/superpowers/specs/2026-04-18-terminal-right-column-design.md new file mode 100644 index 00000000..ec5b867b --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-terminal-right-column-design.md @@ -0,0 +1,39 @@ +# Desktop Terminal Dock Design + +**Goal:** Keep the approved desktop shell interpretation explicit and render the thread terminal below the right panel when that panel is open. + +## Approved Shell Interpretation + +The counted desktop shells are: + +- `sidebar` +- `preview` +- `right panel` +- `plan sidebar` + +The terminal is explicitly excluded from the shell cap. + +## Current Constraint + +`ThreadTerminalDrawer` is owned by `ChatView` and depends on substantial composer and thread-local behavior. Moving all terminal state and callbacks into the route would be a larger refactor than this layout change needs. + +## Design + +Use a two-part approach: + +1. Add a small pure helper that defines: + - which desktop shells are counted + - when the terminal should dock into the right column versus remain inline +2. Keep terminal behavior inside `ChatView`, but portal the rendered drawer into a host element supplied by the desktop thread route when the right panel is open. + +## Route Layout Change + +The desktop thread route will render an empty terminal dock host below the existing right panel content inside the right sidebar. That preserves the current right panel width and places the terminal directly underneath it when both are open. + +If the right panel is closed, the terminal keeps its existing inline fallback in `ChatView`. + +## Testing + +- Add a pure helper test that asserts the counted shells exclude terminal and top out at the four approved shells. +- Add helper tests that assert terminal docking only happens on desktop when the right-panel dock host is available. +- Run web formatting, lint, and typecheck gates after implementation.