Skip to content

Commit 9b882d6

Browse files
authored
Dock terminal below the right panel on desktop (#466)
- Add a pure helper for counted desktop shells and terminal dock placement - Portal the existing terminal drawer into a right-panel dock host with inline fallback - Cover the layout rules with Vitest and document the desktop shell design
1 parent b4c3c6c commit 9b882d6

6 files changed

Lines changed: 364 additions & 37 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
useRef,
4040
useState,
4141
} from "react";
42+
import { createPortal } from "react-dom";
4243
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4344
import { useDebouncedValue } from "@tanstack/react-pacer";
4445
import { useNavigate } from "@tanstack/react-router";
@@ -236,6 +237,7 @@ import { hasCustomThreadTitle, normalizeThreadTitle } from "~/threadTitle";
236237
import { resolveLiveModelSelection } from "~/modelSelection";
237238
import { getProviderModelOptionsByProvider } from "~/providerModels";
238239
import { enhancePrompt, type PromptEnhancementId } from "../promptEnhancement";
240+
import { resolveTerminalDockPlacement } from "~/desktopShellLayout";
239241

240242
function preloadThreadTerminalDrawer() {
241243
return import("./ThreadTerminalDrawer");
@@ -398,6 +400,8 @@ function normalizeVisibleInteractionMode(
398400
interface ChatViewProps {
399401
threadId: ThreadId;
400402
onMinimize?: (() => void) | undefined;
403+
rightPanelOpen: boolean;
404+
rightPanelTerminalDock: HTMLDivElement | null;
401405
}
402406

403407
interface RunProjectScriptOptions {
@@ -408,7 +412,12 @@ interface RunProjectScriptOptions {
408412
rememberAsLastInvoked?: boolean;
409413
}
410414

411-
export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
415+
export default function ChatView({
416+
threadId,
417+
onMinimize,
418+
rightPanelOpen,
419+
rightPanelTerminalDock,
420+
}: ChatViewProps) {
412421
const clientMode = useClientMode();
413422
const transportState = useTransportState();
414423
const threads = useStore((store) => store.threads);
@@ -4842,6 +4851,45 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
48424851
return <ChatHomeEmptyState />;
48434852
}
48444853

4854+
const terminalDockPlacement = resolveTerminalDockPlacement({
4855+
clientMode,
4856+
rightPanelOpen,
4857+
hasRightPanelTerminalDock: rightPanelTerminalDock !== null,
4858+
});
4859+
const terminalDrawer =
4860+
activeProject && shouldMountTerminalDrawer ? (
4861+
<div style={{ display: terminalState.terminalOpen ? undefined : "none" }}>
4862+
<Suspense
4863+
fallback={<TerminalDrawerLoadingFallback height={terminalState.terminalHeight} />}
4864+
>
4865+
<ThreadTerminalDrawer
4866+
key={activeThread.id}
4867+
threadId={activeThread.id}
4868+
cwd={gitCwd ?? activeProject.cwd}
4869+
runtimeEnv={threadTerminalRuntimeEnv}
4870+
height={terminalState.terminalHeight}
4871+
terminalIds={terminalState.terminalIds}
4872+
activeTerminalId={terminalState.activeTerminalId}
4873+
terminalGroups={terminalState.terminalGroups}
4874+
activeTerminalGroupId={terminalState.activeTerminalGroupId}
4875+
focusRequestId={terminalFocusRequestId}
4876+
onSplitTerminal={splitTerminal}
4877+
onNewTerminal={createNewTerminal}
4878+
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4879+
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4880+
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4881+
onActiveTerminalChange={activateTerminal}
4882+
onCloseTerminal={closeTerminal}
4883+
onCollapseTerminal={toggleTerminalVisibility}
4884+
onHeightChange={setTerminalHeight}
4885+
onAddTerminalContext={addTerminalContextToDraft}
4886+
onSendTerminalContext={sendSelectedTerminalContext}
4887+
onPreviewUrl={onPreviewUrl}
4888+
/>
4889+
</Suspense>
4890+
</div>
4891+
) : null;
4892+
48454893
return (
48464894
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
48474895
{/* Top bar */}
@@ -5873,38 +5921,9 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
58735921
{/* Terminal drawer – once mounted, stay mounted to avoid the
58745922
unmount/remount flicker when toggling visibility or switching threads.
58755923
We hide it with display:none when collapsed so the DOM is retained. */}
5876-
{activeProject && shouldMountTerminalDrawer ? (
5877-
<div style={{ display: terminalState.terminalOpen ? undefined : "none" }}>
5878-
<Suspense
5879-
fallback={<TerminalDrawerLoadingFallback height={terminalState.terminalHeight} />}
5880-
>
5881-
<ThreadTerminalDrawer
5882-
key={activeThread.id}
5883-
threadId={activeThread.id}
5884-
cwd={gitCwd ?? activeProject.cwd}
5885-
runtimeEnv={threadTerminalRuntimeEnv}
5886-
height={terminalState.terminalHeight}
5887-
terminalIds={terminalState.terminalIds}
5888-
activeTerminalId={terminalState.activeTerminalId}
5889-
terminalGroups={terminalState.terminalGroups}
5890-
activeTerminalGroupId={terminalState.activeTerminalGroupId}
5891-
focusRequestId={terminalFocusRequestId}
5892-
onSplitTerminal={splitTerminal}
5893-
onNewTerminal={createNewTerminal}
5894-
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
5895-
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
5896-
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
5897-
onActiveTerminalChange={activateTerminal}
5898-
onCloseTerminal={closeTerminal}
5899-
onCollapseTerminal={toggleTerminalVisibility}
5900-
onHeightChange={setTerminalHeight}
5901-
onAddTerminalContext={addTerminalContextToDraft}
5902-
onSendTerminalContext={sendSelectedTerminalContext}
5903-
onPreviewUrl={onPreviewUrl}
5904-
/>
5905-
</Suspense>
5906-
</div>
5907-
) : null}
5924+
{terminalDockPlacement === "right-panel" && rightPanelTerminalDock && terminalDrawer
5925+
? createPortal(terminalDrawer, rightPanelTerminalDock)
5926+
: terminalDrawer}
59085927

59095928
<Dialog
59105929
open={pendingProjectScriptRun !== null}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
countCountedDesktopShells,
5+
getCountedDesktopShells,
6+
resolveTerminalDockPlacement,
7+
} from "./desktopShellLayout";
8+
9+
describe("desktopShellLayout", () => {
10+
it("counts only the approved desktop shells and excludes terminal", () => {
11+
expect(
12+
getCountedDesktopShells({
13+
sidebarOpen: true,
14+
previewOpen: true,
15+
rightPanelOpen: true,
16+
planSidebarOpen: true,
17+
terminalOpen: true,
18+
}),
19+
).toEqual(["sidebar", "preview", "right-panel", "plan-sidebar"]);
20+
21+
expect(
22+
countCountedDesktopShells({
23+
sidebarOpen: true,
24+
previewOpen: true,
25+
rightPanelOpen: true,
26+
planSidebarOpen: true,
27+
terminalOpen: true,
28+
}),
29+
).toBe(4);
30+
});
31+
32+
it("returns zero when none of the counted desktop shells are open", () => {
33+
expect(
34+
countCountedDesktopShells({
35+
sidebarOpen: false,
36+
previewOpen: false,
37+
rightPanelOpen: false,
38+
planSidebarOpen: false,
39+
terminalOpen: true,
40+
}),
41+
).toBe(0);
42+
});
43+
44+
it("docks the terminal under the right panel only when the desktop dock host exists", () => {
45+
expect(
46+
resolveTerminalDockPlacement({
47+
clientMode: "desktop",
48+
rightPanelOpen: true,
49+
hasRightPanelTerminalDock: true,
50+
}),
51+
).toBe("right-panel");
52+
});
53+
54+
it("keeps the terminal inline when the right panel is closed", () => {
55+
expect(
56+
resolveTerminalDockPlacement({
57+
clientMode: "desktop",
58+
rightPanelOpen: false,
59+
hasRightPanelTerminalDock: true,
60+
}),
61+
).toBe("inline");
62+
});
63+
64+
it("keeps the terminal inline for mobile even if a dock host exists", () => {
65+
expect(
66+
resolveTerminalDockPlacement({
67+
clientMode: "mobile",
68+
rightPanelOpen: true,
69+
hasRightPanelTerminalDock: true,
70+
}),
71+
).toBe("inline");
72+
});
73+
});

apps/web/src/desktopShellLayout.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export type CountedDesktopShell = "sidebar" | "preview" | "right-panel" | "plan-sidebar";
2+
3+
interface DesktopShellState {
4+
sidebarOpen: boolean;
5+
previewOpen: boolean;
6+
rightPanelOpen: boolean;
7+
planSidebarOpen: boolean;
8+
terminalOpen: boolean;
9+
}
10+
11+
interface TerminalDockPlacementInput {
12+
clientMode: "desktop" | "mobile";
13+
rightPanelOpen: boolean;
14+
hasRightPanelTerminalDock: boolean;
15+
}
16+
17+
export type TerminalDockPlacement = "inline" | "right-panel";
18+
19+
export function getCountedDesktopShells(input: DesktopShellState): CountedDesktopShell[] {
20+
const shells: CountedDesktopShell[] = [];
21+
if (input.sidebarOpen) shells.push("sidebar");
22+
if (input.previewOpen) shells.push("preview");
23+
if (input.rightPanelOpen) shells.push("right-panel");
24+
if (input.planSidebarOpen) shells.push("plan-sidebar");
25+
return shells;
26+
}
27+
28+
export function countCountedDesktopShells(input: DesktopShellState): number {
29+
return getCountedDesktopShells(input).length;
30+
}
31+
32+
export function resolveTerminalDockPlacement(
33+
input: TerminalDockPlacementInput,
34+
): TerminalDockPlacement {
35+
return input.clientMode === "desktop" && input.rightPanelOpen && input.hasRightPanelTerminalDock
36+
? "right-panel"
37+
: "inline";
38+
}

apps/web/src/routes/_chat.$threadId.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ function ChatThreadRouteView() {
226226

227227
// ── Keep-alive flags so lazy content doesn't unmount on tab switch ─
228228
const [hasOpenedSimulation, setHasOpenedSimulation] = useState(simulationOpen);
229+
const [rightPanelTerminalDock, setRightPanelTerminalDock] = useState<HTMLDivElement | null>(null);
229230

230231
const closeCodeViewer = useCallback(() => {
231232
closeCodeViewerStore();
@@ -236,6 +237,9 @@ function ChatThreadRouteView() {
236237
const closeSimulation = useCallback(() => {
237238
closeSimulationStore();
238239
}, [closeSimulationStore]);
240+
const setRightPanelTerminalDockRef = useCallback((node: HTMLDivElement | null) => {
241+
setRightPanelTerminalDock(node);
242+
}, []);
239243

240244
useEffect(() => {
241245
const onWindowKeyDown = (event: KeyboardEvent) => {
@@ -330,7 +334,7 @@ function ChatThreadRouteView() {
330334

331335
// ── Right panel content (shared between desktop sidebar & mobile sheet) ──
332336
const rightPanelContent = (
333-
<div className="flex h-full flex-col bg-background">
337+
<div className="flex min-h-0 flex-1 flex-col bg-background">
334338
<RightPanelHeader />
335339
<div className="relative flex-1 overflow-hidden">
336340
{rightPanelTab === "workspace" ? (
@@ -404,7 +408,13 @@ function ChatThreadRouteView() {
404408
return (
405409
<>
406410
<SidebarInset className="relative h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
407-
<ChatView key={threadId} threadId={threadId} onMinimize={onMinimize} />
411+
<ChatView
412+
key={threadId}
413+
threadId={threadId}
414+
onMinimize={onMinimize}
415+
rightPanelOpen={rightPanelOpen}
416+
rightPanelTerminalDock={null}
417+
/>
408418
</SidebarInset>
409419
<RightPanelSheet open={rightPanelOpen} onClose={closeRightPanel}>
410420
{rightPanelContent}
@@ -417,7 +427,13 @@ function ChatThreadRouteView() {
417427
return (
418428
<>
419429
<SidebarInset className="relative h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
420-
<ChatView key={threadId} threadId={threadId} onMinimize={onMinimize} />
430+
<ChatView
431+
key={threadId}
432+
threadId={threadId}
433+
onMinimize={onMinimize}
434+
rightPanelOpen={rightPanelOpen}
435+
rightPanelTerminalDock={rightPanelTerminalDock}
436+
/>
421437
</SidebarInset>
422438
<SidebarProvider
423439
defaultOpen={false}
@@ -438,7 +454,10 @@ function ChatThreadRouteView() {
438454
storageKey: RIGHT_PANEL_SIDEBAR_WIDTH_STORAGE_KEY,
439455
}}
440456
>
441-
{rightPanelContent}
457+
<div className="flex min-h-0 flex-1 flex-col">
458+
{rightPanelContent}
459+
<div ref={setRightPanelTerminalDockRef} data-right-panel-terminal-dock="" />
460+
</div>
442461
<SidebarRail />
443462
</Sidebar>
444463
</SidebarProvider>

0 commit comments

Comments
 (0)