From c9ea5948f614f9a512c9bccd00fd00ed8bd449f6 Mon Sep 17 00:00:00 2001
From: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Date: Thu, 2 Apr 2026 15:47:21 +0100
Subject: [PATCH 1/3] Add workspace-aware terminal panel layout
---
apps/web/src/components/ChatView.tsx | 165 +++++---
apps/web/src/components/DiffPanelShell.tsx | 4 +-
...er.test.ts => ThreadTerminalPanel.test.ts} | 41 +-
...inalDrawer.tsx => ThreadTerminalPanel.tsx} | 296 +++++++++++---
.../src/components/WorkspaceRightSidebar.tsx | 104 +++++
apps/web/src/components/chat/ChatHeader.tsx | 6 +-
.../components/settings/SettingsPanels.tsx | 162 +++++++-
apps/web/src/components/ui/sidebar.tsx | 18 +-
apps/web/src/index.css | 6 +-
apps/web/src/lib/terminalFocus.test.ts | 2 +-
apps/web/src/lib/terminalFocus.ts | 2 +-
apps/web/src/routes/_chat.$threadId.tsx | 362 ++++++++++++------
apps/web/src/terminal-links.test.ts | 14 +-
apps/web/src/workspacePanels.test.ts | 153 ++++++++
apps/web/src/workspacePanels.ts | 77 ++++
apps/web/src/workspaceTerminalPortal.ts | 19 +
packages/contracts/src/settings.test.ts | 58 +++
packages/contracts/src/settings.ts | 32 ++
18 files changed, 1278 insertions(+), 243 deletions(-)
rename apps/web/src/components/{ThreadTerminalDrawer.test.ts => ThreadTerminalPanel.test.ts} (68%)
rename apps/web/src/components/{ThreadTerminalDrawer.tsx => ThreadTerminalPanel.tsx} (82%)
create mode 100644 apps/web/src/components/WorkspaceRightSidebar.tsx
create mode 100644 apps/web/src/workspacePanels.test.ts
create mode 100644 apps/web/src/workspacePanels.ts
create mode 100644 apps/web/src/workspaceTerminalPortal.ts
create mode 100644 packages/contracts/src/settings.test.ts
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 76133712d4..5296aac1c6 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -26,6 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate, useSearch } from "@tanstack/react-router";
+import { createPortal } from "react-dom";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { isElectron } from "../env";
@@ -89,7 +90,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import BranchToolbar from "./BranchToolbar";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import PlanSidebar from "./PlanSidebar";
-import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
+import ThreadTerminalPanel from "./ThreadTerminalPanel";
import {
BotIcon,
ChevronDownIcon,
@@ -126,6 +127,7 @@ import {
} from "../providerModels";
import { useSettings } from "../hooks/useSettings";
import { resolveAppModelSelection } from "../modelSelection";
+import type { TerminalDockTarget } from "../workspacePanels";
import { isTerminalFocused } from "../lib/terminalFocus";
import {
type ComposerImageAttachment,
@@ -197,6 +199,7 @@ import {
useServerConfig,
useServerKeybindings,
} from "~/rpc/serverState";
+import { useWorkspaceTerminalPortalTargets } from "~/workspaceTerminalPortal";
const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
@@ -328,6 +331,11 @@ const terminalContextIdListsEqual = (
contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
interface ChatViewProps {
+ layoutState: {
+ diffToggleActive: boolean;
+ terminalDockTarget: TerminalDockTarget;
+ terminalToggleActive: boolean;
+ };
threadId: ThreadId;
}
@@ -404,7 +412,7 @@ function useLocalDispatchState(input: {
};
}
-export default function ChatView({ threadId }: ChatViewProps) {
+export default function ChatView({ layoutState, threadId }: ChatViewProps) {
const serverThread = useThreadById(threadId);
const setStoreThreadError = useStore((store) => store.setError);
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
@@ -412,10 +420,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[threadId],
);
+ const workspaceTerminalPortalTargets = useWorkspaceTerminalPortalTargets();
const settings = useSettings();
const setStickyComposerModelSelection = useComposerDraftStore(
(store) => store.setStickyModelSelection,
);
+ const terminalPosition = settings.terminalPosition;
const timestampFormat = settings.timestampFormat;
const navigate = useNavigate();
const rawSearch = useSearch({
@@ -1377,17 +1387,60 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions),
[keybindings, nonTerminalShortcutLabelOptions],
);
- const onToggleDiff = useCallback(() => {
+ const terminalToggleActive = layoutState.terminalToggleActive;
+ const diffToggleActive = layoutState.diffToggleActive;
+ const closeDiffPanel = useCallback(() => {
+ void navigate({
+ to: "/$threadId",
+ params: { threadId },
+ replace: true,
+ search: (previous) => ({
+ ...stripDiffSearchParams(previous),
+ diff: undefined,
+ }),
+ });
+ }, [navigate, threadId]);
+ const openDiffPanel = useCallback(() => {
void navigate({
to: "/$threadId",
params: { threadId },
replace: true,
search: (previous) => {
const rest = stripDiffSearchParams(previous);
- return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
+ return { ...rest, diff: "1" };
},
});
- }, [diffOpen, navigate, threadId]);
+ }, [navigate, threadId]);
+ const setTerminalOpen = useCallback(
+ (open: boolean) => {
+ if (!activeThreadId) return;
+ storeSetTerminalOpen(activeThreadId, open);
+ },
+ [activeThreadId, storeSetTerminalOpen],
+ );
+ const onToggleDiff = useCallback(() => {
+ if (terminalPosition === "right") {
+ if (diffToggleActive) {
+ closeDiffPanel();
+ return;
+ }
+ setTerminalOpen(false);
+ openDiffPanel();
+ return;
+ }
+ if (diffOpen) {
+ closeDiffPanel();
+ return;
+ }
+ openDiffPanel();
+ }, [
+ closeDiffPanel,
+ diffOpen,
+ diffToggleActive,
+ openDiffPanel,
+ setTerminalOpen,
+ terminalPosition,
+ ]);
const envLocked = Boolean(
activeThread &&
@@ -1474,13 +1527,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
},
[activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext],
);
- const setTerminalOpen = useCallback(
- (open: boolean) => {
- if (!activeThreadId) return;
- storeSetTerminalOpen(activeThreadId, open);
- },
- [activeThreadId, storeSetTerminalOpen],
- );
const setTerminalHeight = useCallback(
(height: number) => {
if (!activeThreadId) return;
@@ -1490,8 +1536,31 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
const toggleTerminalVisibility = useCallback(() => {
if (!activeThreadId) return;
+ if (terminalPosition === "right") {
+ if (terminalToggleActive) {
+ setTerminalOpen(false);
+ return;
+ }
+ if (diffOpen) {
+ setTerminalOpen(true);
+ closeDiffPanel();
+ return;
+ }
+ if (!terminalState.terminalOpen) {
+ setTerminalOpen(true);
+ }
+ return;
+ }
setTerminalOpen(!terminalState.terminalOpen);
- }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
+ }, [
+ activeThreadId,
+ closeDiffPanel,
+ diffOpen,
+ setTerminalOpen,
+ terminalToggleActive,
+ terminalPosition,
+ terminalState.terminalOpen,
+ ]);
const splitTerminal = useCallback(() => {
if (!activeThreadId || hasReachedSplitLimit) return;
const terminalId = `terminal-${randomUUID()}`;
@@ -3749,6 +3818,40 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
}
+ const terminalPortalTarget =
+ layoutState.terminalDockTarget === "bottom-workspace"
+ ? workspaceTerminalPortalTargets.bottom
+ : layoutState.terminalDockTarget === "right"
+ ? workspaceTerminalPortalTargets.right
+ : null;
+ const terminalPanel =
+ terminalState.terminalOpen && activeProject ? (
+
+ ) : null;
+ const shouldRenderInlineBottomTerminal = layoutState.terminalDockTarget === "bottom-inline";
+
return (
{/* Top bar */}
@@ -3771,11 +3874,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
keybindings={keybindings}
availableEditors={availableEditors}
terminalAvailable={activeProject !== undefined}
- terminalOpen={terminalState.terminalOpen}
+ terminalOpen={terminalToggleActive}
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
diffToggleShortcutLabel={diffPanelShortcutLabel}
gitCwd={gitCwd}
- diffOpen={diffOpen}
+ diffOpen={diffToggleActive}
onRunProjectScript={(script) => {
void runProjectScript(script);
}}
@@ -4284,34 +4387,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* end horizontal flex container */}
- {(() => {
- if (!terminalState.terminalOpen || !activeProject) {
- return null;
- }
- return (
-
- );
- })()}
+ {terminalPortalTarget && terminalPanel
+ ? createPortal(terminalPanel, terminalPortalTarget)
+ : null}
+ {shouldRenderInlineBottomTerminal ? terminalPanel : null}
{expandedImage && expandedImageItem && (
{
+ it("reserves room for the workspace row when the bottom terminal spans the workspace", () => {
+ expect(
+ resolveTerminalPanelMaxHeight({
+ layout: "bottom",
+ bottomScope: "workspace",
+ parentHeight: 480,
+ viewportHeight: 1200,
+ }),
+ ).toBe(260);
+ });
+
+ it("uses the viewport ratio for chat-scoped bottom terminals", () => {
+ expect(
+ resolveTerminalPanelMaxHeight({
+ layout: "bottom",
+ bottomScope: "chat",
+ parentHeight: 480,
+ viewportHeight: 1200,
+ }),
+ ).toBe(900);
+ });
+});
+
+describe("clampTerminalPanelHeight", () => {
+ it("clamps workspace-spanning bottom terminals to the available workspace height", () => {
+ expect(
+ clampTerminalPanelHeight(420, {
+ layout: "bottom",
+ bottomScope: "workspace",
+ parentHeight: 480,
+ viewportHeight: 1200,
+ }),
+ ).toBe(260);
+ });
+});
describe("resolveTerminalSelectionActionPosition", () => {
it("prefers the selection rect over the last pointer position", () => {
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx
similarity index 82%
rename from apps/web/src/components/ThreadTerminalDrawer.tsx
rename to apps/web/src/components/ThreadTerminalPanel.tsx
index 1bdbfb6ad6..9bab6d47c9 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.tsx
+++ b/apps/web/src/components/ThreadTerminalPanel.tsx
@@ -1,6 +1,7 @@
import { FitAddon } from "@xterm/addon-fit";
import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
import { type ThreadId } from "@t3tools/contracts";
+import type { TerminalBottomScope } from "@t3tools/contracts/settings";
import { Terminal, type ITheme } from "@xterm/xterm";
import {
type PointerEvent as ReactPointerEvent,
@@ -30,16 +31,45 @@ import { readNativeApi } from "~/nativeApi";
const MIN_DRAWER_HEIGHT = 180;
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
+const MIN_BOTTOM_WORKSPACE_CONTENT_HEIGHT = 220;
const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;
+const MIN_TERMINAL_COLS = 20;
+const MIN_TERMINAL_ROWS = 5;
+
+interface TerminalPanelHeightBoundsInput {
+ bottomScope: TerminalBottomScope;
+ layout: "bottom" | "side";
+ parentHeight?: number | null;
+ viewportHeight?: number;
+}
+
+export function resolveTerminalPanelMaxHeight(input: TerminalPanelHeightBoundsInput): number {
+ const viewportHeight =
+ input.viewportHeight ??
+ (typeof window === "undefined" ? DEFAULT_THREAD_TERMINAL_HEIGHT : window.innerHeight);
+ let maxHeight = Math.floor(viewportHeight * MAX_DRAWER_HEIGHT_RATIO);
+
+ if (
+ input.layout === "bottom" &&
+ input.bottomScope === "workspace" &&
+ typeof input.parentHeight === "number" &&
+ Number.isFinite(input.parentHeight)
+ ) {
+ maxHeight = Math.min(
+ maxHeight,
+ Math.floor(input.parentHeight - MIN_BOTTOM_WORKSPACE_CONTENT_HEIGHT),
+ );
+ }
-function maxDrawerHeight(): number {
- if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
- return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO));
+ return Math.max(MIN_DRAWER_HEIGHT, maxHeight);
}
-function clampDrawerHeight(height: number): number {
+export function clampTerminalPanelHeight(
+ height: number,
+ input: TerminalPanelHeightBoundsInput,
+): number {
const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT;
- const maxHeight = maxDrawerHeight();
+ const maxHeight = resolveTerminalPanelMaxHeight(input);
return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);
}
@@ -47,6 +77,18 @@ function writeSystemMessage(terminal: Terminal, message: string): void {
terminal.write(`\r\n[terminal] ${message}\r\n`);
}
+function resolveTerminalViewportSize(terminal: Terminal): { cols: number; rows: number } | null {
+ const cols = Math.round(terminal.cols);
+ const rows = Math.round(terminal.rows);
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
+ return null;
+ }
+ if (cols < MIN_TERMINAL_COLS || rows < MIN_TERMINAL_ROWS) {
+ return null;
+ }
+ return { cols, rows };
+}
+
function terminalThemeFromApp(): ITheme {
const isDark = document.documentElement.classList.contains("dark");
const bodyStyles = getComputedStyle(document.body);
@@ -192,6 +234,7 @@ interface TerminalViewportProps {
autoFocus: boolean;
resizeEpoch: number;
drawerHeight: number;
+ layout: "bottom" | "side";
}
function TerminalViewport({
@@ -206,6 +249,7 @@ function TerminalViewport({
autoFocus,
resizeEpoch,
drawerHeight,
+ layout,
}: TerminalViewportProps) {
const containerRef = useRef
(null);
const terminalRef = useRef(null);
@@ -219,6 +263,8 @@ function TerminalViewport({
const selectionActionRequestIdRef = useRef(0);
const selectionActionOpenRef = useRef(false);
const selectionActionTimerRef = useRef(null);
+ const sessionOpenRef = useRef(false);
+ const openingSessionRef = useRef(false);
useEffect(() => {
onSessionExitedRef.current = onSessionExited;
@@ -232,6 +278,11 @@ function TerminalViewport({
terminalLabelRef.current = terminalLabel;
}, [terminalLabel]);
+ useEffect(() => {
+ sessionOpenRef.current = false;
+ openingSessionRef.current = false;
+ }, [cwd, runtimeEnv, terminalId, threadId]);
+
useEffect(() => {
const mount = containerRef.current;
if (!mount) return;
@@ -470,20 +521,29 @@ function TerminalViewport({
});
const openTerminal = async () => {
+ if (openingSessionRef.current || sessionOpenRef.current) {
+ return;
+ }
try {
const activeTerminal = terminalRef.current;
const activeFitAddon = fitAddonRef.current;
if (!activeTerminal || !activeFitAddon) return;
activeFitAddon.fit();
+ const viewportSize = resolveTerminalViewportSize(activeTerminal);
+ if (!viewportSize) {
+ return;
+ }
+ openingSessionRef.current = true;
const snapshot = await api.terminal.open({
threadId,
terminalId,
cwd,
- cols: activeTerminal.cols,
- rows: activeTerminal.rows,
+ cols: viewportSize.cols,
+ rows: viewportSize.rows,
...(runtimeEnv ? { env: runtimeEnv } : {}),
});
if (disposed) return;
+ sessionOpenRef.current = true;
activeTerminal.write("\u001bc");
if (snapshot.history.length > 0) {
activeTerminal.write(snapshot.history);
@@ -499,6 +559,8 @@ function TerminalViewport({
terminal,
err instanceof Error ? err.message : "Failed to open terminal",
);
+ } finally {
+ openingSessionRef.current = false;
}
};
@@ -514,6 +576,7 @@ function TerminalViewport({
}
if (event.type === "started" || event.type === "restarted") {
+ sessionOpenRef.current = true;
hasHandledExitRef.current = false;
clearSelectionAction();
activeTerminal.write("\u001bc");
@@ -536,6 +599,7 @@ function TerminalViewport({
}
if (event.type === "exited") {
+ sessionOpenRef.current = false;
const details = [
typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,
typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,
@@ -566,15 +630,23 @@ function TerminalViewport({
const wasAtBottom =
activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
activeFitAddon.fit();
+ const viewportSize = resolveTerminalViewportSize(activeTerminal);
if (wasAtBottom) {
activeTerminal.scrollToBottom();
}
+ if (!viewportSize) {
+ return;
+ }
+ if (!sessionOpenRef.current) {
+ void openTerminal();
+ return;
+ }
void api.terminal
.resize({
threadId,
terminalId,
- cols: activeTerminal.cols,
- rows: activeTerminal.rows,
+ cols: viewportSize.cols,
+ rows: viewportSize.rows,
})
.catch(() => undefined);
}, 30);
@@ -593,6 +665,8 @@ function TerminalViewport({
window.removeEventListener("mouseup", handleMouseUp);
mount.removeEventListener("pointerdown", handlePointerDown);
themeObserver.disconnect();
+ sessionOpenRef.current = false;
+ openingSessionRef.current = false;
terminalRef.current = null;
fitAddonRef.current = null;
terminal.dispose();
@@ -614,6 +688,86 @@ function TerminalViewport({
};
}, [autoFocus, focusRequestId]);
+ useEffect(() => {
+ const container = containerRef.current;
+ const api = readNativeApi();
+ if (!container || !api || typeof ResizeObserver === "undefined") return;
+
+ let frame: number | null = null;
+
+ const fitToContainer = () => {
+ const terminal = terminalRef.current;
+ const fitAddon = fitAddonRef.current;
+ if (!terminal || !fitAddon) return;
+ const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
+ fitAddon.fit();
+ const viewportSize = resolveTerminalViewportSize(terminal);
+ if (wasAtBottom) {
+ terminal.scrollToBottom();
+ }
+ if (!viewportSize) {
+ return;
+ }
+ if (!sessionOpenRef.current) {
+ void (async () => {
+ const activeTerminal = terminalRef.current;
+ const activeFitAddon = fitAddonRef.current;
+ if (!activeTerminal || !activeFitAddon) return;
+ activeFitAddon.fit();
+ const nextViewportSize = resolveTerminalViewportSize(activeTerminal);
+ if (!nextViewportSize || openingSessionRef.current || sessionOpenRef.current) {
+ return;
+ }
+ openingSessionRef.current = true;
+ try {
+ const snapshot = await api.terminal.open({
+ threadId,
+ terminalId,
+ cwd,
+ cols: nextViewportSize.cols,
+ rows: nextViewportSize.rows,
+ ...(runtimeEnv ? { env: runtimeEnv } : {}),
+ });
+ sessionOpenRef.current = true;
+ activeTerminal.write("\u001bc");
+ if (snapshot.history.length > 0) {
+ activeTerminal.write(snapshot.history);
+ }
+ } catch {
+ // Let the next valid resize retry opening the session.
+ } finally {
+ openingSessionRef.current = false;
+ }
+ })();
+ return;
+ }
+ void api.terminal
+ .resize({
+ threadId,
+ terminalId,
+ cols: viewportSize.cols,
+ rows: viewportSize.rows,
+ })
+ .catch(() => undefined);
+ };
+
+ const observer = new ResizeObserver(() => {
+ if (frame !== null) return;
+ frame = window.requestAnimationFrame(() => {
+ frame = null;
+ fitToContainer();
+ });
+ });
+ observer.observe(container);
+
+ return () => {
+ observer.disconnect();
+ if (frame !== null) {
+ window.cancelAnimationFrame(frame);
+ }
+ };
+ }, [cwd, runtimeEnv, terminalId, threadId]);
+
useEffect(() => {
const api = readNativeApi();
const terminal = terminalRef.current;
@@ -622,32 +776,37 @@ function TerminalViewport({
const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
const frame = window.requestAnimationFrame(() => {
fitAddon.fit();
+ const viewportSize = resolveTerminalViewportSize(terminal);
if (wasAtBottom) {
terminal.scrollToBottom();
}
+ if (!viewportSize || !sessionOpenRef.current) {
+ return;
+ }
void api.terminal
.resize({
threadId,
terminalId,
- cols: terminal.cols,
- rows: terminal.rows,
+ cols: viewportSize.cols,
+ rows: viewportSize.rows,
})
.catch(() => undefined);
});
return () => {
window.cancelAnimationFrame(frame);
};
- }, [drawerHeight, resizeEpoch, terminalId, threadId]);
+ }, [drawerHeight, layout, resizeEpoch, terminalId, threadId]);
return (
);
}
-interface ThreadTerminalDrawerProps {
+interface ThreadTerminalPanelProps {
threadId: ThreadId;
cwd: string;
runtimeEnv?: Record;
height: number;
+ bottomScope?: TerminalBottomScope;
terminalIds: string[];
activeTerminalId: string;
terminalGroups: ThreadTerminalGroup[];
@@ -662,6 +821,7 @@ interface ThreadTerminalDrawerProps {
onCloseTerminal: (terminalId: string) => void;
onHeightChange: (height: number) => void;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
+ layout?: "bottom" | "side";
}
interface TerminalActionButtonProps {
@@ -693,11 +853,12 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA
);
}
-export default function ThreadTerminalDrawer({
+export default function ThreadTerminalPanel({
threadId,
cwd,
runtimeEnv,
height,
+ bottomScope = "chat",
terminalIds,
activeTerminalId,
terminalGroups,
@@ -712,11 +873,30 @@ export default function ThreadTerminalDrawer({
onCloseTerminal,
onHeightChange,
onAddTerminalContext,
-}: ThreadTerminalDrawerProps) {
- const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
+ layout = "bottom",
+}: ThreadTerminalPanelProps) {
+ const isSideLayout = layout === "side";
+ const panelRef = useRef(null);
+ const resolveParentHeight = useCallback(
+ () =>
+ panelRef.current?.closest("[data-workspace-shell='true']")?.clientHeight ?? null,
+ [],
+ );
+ const clampPanelHeight = useCallback(
+ (nextHeight: number) =>
+ clampTerminalPanelHeight(nextHeight, {
+ bottomScope,
+ layout,
+ parentHeight: resolveParentHeight(),
+ }),
+ [bottomScope, layout, resolveParentHeight],
+ );
+ const [drawerHeight, setDrawerHeight] = useState(() =>
+ clampTerminalPanelHeight(height, { bottomScope, layout }),
+ );
const [resizeEpoch, setResizeEpoch] = useState(0);
const drawerHeightRef = useRef(drawerHeight);
- const lastSyncedHeightRef = useRef(clampDrawerHeight(height));
+ const lastSyncedHeightRef = useRef(clampTerminalPanelHeight(height, { bottomScope, layout }));
const onHeightChangeRef = useRef(onHeightChange);
const resizeStateRef = useRef<{
pointerId: number;
@@ -852,19 +1032,24 @@ export default function ThreadTerminalDrawer({
drawerHeightRef.current = drawerHeight;
}, [drawerHeight]);
- const syncHeight = useCallback((nextHeight: number) => {
- const clampedHeight = clampDrawerHeight(nextHeight);
- if (lastSyncedHeightRef.current === clampedHeight) return;
- lastSyncedHeightRef.current = clampedHeight;
- onHeightChangeRef.current(clampedHeight);
- }, []);
+ const syncHeight = useCallback(
+ (nextHeight: number) => {
+ if (isSideLayout) return;
+ const clampedHeight = clampPanelHeight(nextHeight);
+ if (lastSyncedHeightRef.current === clampedHeight) return;
+ lastSyncedHeightRef.current = clampedHeight;
+ onHeightChangeRef.current(clampedHeight);
+ },
+ [clampPanelHeight, isSideLayout],
+ );
useEffect(() => {
- const clampedHeight = clampDrawerHeight(height);
+ if (isSideLayout) return;
+ const clampedHeight = clampPanelHeight(height);
setDrawerHeight(clampedHeight);
drawerHeightRef.current = clampedHeight;
lastSyncedHeightRef.current = clampedHeight;
- }, [height, threadId]);
+ }, [clampPanelHeight, height, isSideLayout, threadId]);
const handleResizePointerDown = useCallback((event: ReactPointerEvent) => {
if (event.button !== 0) return;
@@ -878,20 +1063,23 @@ export default function ThreadTerminalDrawer({
};
}, []);
- const handleResizePointerMove = useCallback((event: ReactPointerEvent) => {
- const resizeState = resizeStateRef.current;
- if (!resizeState || resizeState.pointerId !== event.pointerId) return;
- event.preventDefault();
- const clampedHeight = clampDrawerHeight(
- resizeState.startHeight + (resizeState.startY - event.clientY),
- );
- if (clampedHeight === drawerHeightRef.current) {
- return;
- }
- didResizeDuringDragRef.current = true;
- drawerHeightRef.current = clampedHeight;
- setDrawerHeight(clampedHeight);
- }, []);
+ const handleResizePointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ const resizeState = resizeStateRef.current;
+ if (!resizeState || resizeState.pointerId !== event.pointerId) return;
+ event.preventDefault();
+ const clampedHeight = clampPanelHeight(
+ resizeState.startHeight + (resizeState.startY - event.clientY),
+ );
+ if (clampedHeight === drawerHeightRef.current) {
+ return;
+ }
+ didResizeDuringDragRef.current = true;
+ drawerHeightRef.current = clampedHeight;
+ setDrawerHeight(clampedHeight);
+ },
+ [clampPanelHeight],
+ );
const handleResizePointerEnd = useCallback(
(event: ReactPointerEvent) => {
@@ -911,8 +1099,9 @@ export default function ThreadTerminalDrawer({
);
useEffect(() => {
+ if (isSideLayout) return;
const onWindowResize = () => {
- const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
+ const clampedHeight = clampPanelHeight(drawerHeightRef.current);
const changed = clampedHeight !== drawerHeightRef.current;
if (changed) {
setDrawerHeight(clampedHeight);
@@ -927,7 +1116,7 @@ export default function ThreadTerminalDrawer({
return () => {
window.removeEventListener("resize", onWindowResize);
};
- }, [syncHeight]);
+ }, [clampPanelHeight, isSideLayout, syncHeight]);
useEffect(() => {
return () => {
@@ -937,16 +1126,21 @@ export default function ThreadTerminalDrawer({
return (
@@ -1037,6 +1232,7 @@ export default function ThreadTerminalDrawer({
autoFocus
resizeEpoch={resizeEpoch}
drawerHeight={drawerHeight}
+ layout={layout}
/>
)}
diff --git a/apps/web/src/components/WorkspaceRightSidebar.tsx b/apps/web/src/components/WorkspaceRightSidebar.tsx
new file mode 100644
index 0000000000..a015748b01
--- /dev/null
+++ b/apps/web/src/components/WorkspaceRightSidebar.tsx
@@ -0,0 +1,104 @@
+import type { CSSProperties, ReactNode } from "react";
+import { useCallback } from "react";
+import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
+
+type WorkspaceRightSidebarProps = {
+ children: ReactNode;
+ defaultWidth: string;
+ minWidth: number;
+ onOpenChange?: (open: boolean) => void;
+ open: boolean;
+ storageKey: string;
+};
+
+const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
+
+function shouldAcceptWorkspaceSidebarWidth({
+ nextWidth,
+ wrapper,
+}: {
+ nextWidth: number;
+ wrapper: HTMLElement;
+}) {
+ const composerForm = document.querySelector("[data-chat-composer-form='true']");
+ if (!composerForm) return true;
+ const composerViewport = composerForm.parentElement;
+ if (!composerViewport) return true;
+ const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width");
+ wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
+
+ const viewportStyle = window.getComputedStyle(composerViewport);
+ const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0;
+ const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0;
+ const viewportContentWidth = Math.max(
+ 0,
+ composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
+ );
+ const formRect = composerForm.getBoundingClientRect();
+ const composerFooter = composerForm.querySelector(
+ "[data-chat-composer-footer='true']",
+ );
+ const composerRightActions = composerForm.querySelector(
+ "[data-chat-composer-actions='right']",
+ );
+ const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0;
+ const composerFooterGap = composerFooter
+ ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) ||
+ Number.parseFloat(window.getComputedStyle(composerFooter).gap) ||
+ 0
+ : 0;
+ const minimumComposerWidth =
+ COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
+ const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
+ const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
+ const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;
+
+ if (previousSidebarWidth.length > 0) {
+ wrapper.style.setProperty("--sidebar-width", previousSidebarWidth);
+ } else {
+ wrapper.style.removeProperty("--sidebar-width");
+ }
+
+ return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth;
+}
+
+export function WorkspaceRightSidebar({
+ children,
+ defaultWidth,
+ minWidth,
+ onOpenChange,
+ open,
+ storageKey,
+}: WorkspaceRightSidebarProps) {
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ onOpenChange?.(nextOpen);
+ },
+ [onOpenChange],
+ );
+
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index f04c9879fa..260651bdac 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -109,7 +109,7 @@ export const ChatHeader = memo(function ChatHeader({
className="shrink-0"
pressed={terminalOpen}
onPressedChange={onToggleTerminal}
- aria-label="Toggle terminal drawer"
+ aria-label="Toggle terminal panel"
variant="outline"
size="xs"
disabled={!terminalAvailable}
@@ -122,8 +122,8 @@ export const ChatHeader = memo(function ChatHeader({
{!terminalAvailable
? "Terminal is unavailable until this thread has an active project."
: terminalToggleShortcutLabel
- ? `Toggle terminal drawer (${terminalToggleShortcutLabel})`
- : "Toggle terminal drawer"}
+ ? `Toggle terminal panel (${terminalToggleShortcutLabel})`
+ : "Toggle terminal panel"}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index e3e620030b..78c4847b20 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -18,7 +18,12 @@ import {
type ServerProviderModel,
ThreadId,
} from "@t3tools/contracts";
-import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
+import {
+ DEFAULT_TERMINAL_BOTTOM_SCOPE,
+ DEFAULT_TERMINAL_POSITION,
+ DEFAULT_TERMINAL_RIGHT_RAIL_WIDTH_MODE,
+ DEFAULT_UNIFIED_SETTINGS,
+} from "@t3tools/contracts/settings";
import { normalizeModelSlug } from "@t3tools/shared/model";
import { Equal } from "effect";
import { APP_VERSION } from "../../branding";
@@ -85,6 +90,21 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;
+const TERMINAL_BOTTOM_SCOPE_LABELS = {
+ chat: "Match chat",
+ workspace: "Span workspace",
+} as const;
+
+const TERMINAL_POSITION_LABELS = {
+ bottom: "Bottom",
+ right: "Right",
+} as const;
+
+const TERMINAL_RIGHT_RAIL_WIDTH_MODE_LABELS = {
+ linked: "Share one width",
+ independent: "Remember separately",
+} as const;
+
type InstallProviderSettings = {
provider: ProviderKind;
title: string;
@@ -462,6 +482,17 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
+ ...(settings.terminalPosition === "bottom" &&
+ settings.terminalBottomScope !== DEFAULT_UNIFIED_SETTINGS.terminalBottomScope
+ ? ["Terminal width"]
+ : []),
+ ...(settings.terminalPosition !== DEFAULT_UNIFIED_SETTINGS.terminalPosition
+ ? ["Terminal position"]
+ : []),
+ ...(settings.terminalPosition === "right" &&
+ settings.terminalRightRailWidthMode !== DEFAULT_UNIFIED_SETTINGS.terminalRightRailWidthMode
+ ? ["Right rail width"]
+ : []),
...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
? ["Diff line wrapping"]
: []),
@@ -488,6 +519,9 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.diffWordWrap,
settings.enableAssistantStreaming,
+ settings.terminalBottomScope,
+ settings.terminalPosition,
+ settings.terminalRightRailWidthMode,
settings.timestampFormat,
theme,
],
@@ -813,6 +847,132 @@ export function GeneralSettingsPanel() {
}
/>
+
+ updateSettings({
+ terminalPosition: DEFAULT_TERMINAL_POSITION,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
+ {settings.terminalPosition === "bottom" ? (
+
+ updateSettings({
+ terminalBottomScope: DEFAULT_TERMINAL_BOTTOM_SCOPE,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+ ) : null}
+
+ {settings.terminalPosition === "right" ? (
+
+ updateSettings({
+ terminalRightRailWidthMode: DEFAULT_TERMINAL_RIGHT_RAIL_WIDTH_MODE,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+ ) : null}
+
(
- () => ({ side, resizable: resolvedResizable }),
- [resolvedResizable, side],
+ () => ({ desktopMode, side, resizable: resolvedResizable }),
+ [desktopMode, resolvedResizable, side],
);
if (collapsible === "none") {
@@ -255,7 +258,10 @@ function Sidebar({
return (
.scrollbar.vertical {
+.thread-terminal-panel .xterm .xterm-scrollable-element > .scrollbar.vertical {
width: 6px !important;
}
-.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar > .slider {
+.thread-terminal-panel .xterm .xterm-scrollable-element > .scrollbar > .slider {
border-radius: 3px;
}
-.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical > .slider {
+.thread-terminal-panel .xterm .xterm-scrollable-element > .scrollbar.vertical > .slider {
width: 6px !important;
left: 0 !important;
}
diff --git a/apps/web/src/lib/terminalFocus.test.ts b/apps/web/src/lib/terminalFocus.test.ts
index 832324ff14..e612054c51 100644
--- a/apps/web/src/lib/terminalFocus.test.ts
+++ b/apps/web/src/lib/terminalFocus.test.ts
@@ -11,7 +11,7 @@ class MockHTMLElement {
};
closest(selector: string): MockHTMLElement | null {
- return selector === ".thread-terminal-drawer .xterm" && this.isConnected ? this : null;
+ return selector === ".thread-terminal-panel .xterm" && this.isConnected ? this : null;
}
}
diff --git a/apps/web/src/lib/terminalFocus.ts b/apps/web/src/lib/terminalFocus.ts
index d4edd9b14e..de36b1673f 100644
--- a/apps/web/src/lib/terminalFocus.ts
+++ b/apps/web/src/lib/terminalFocus.ts
@@ -3,5 +3,5 @@ export function isTerminalFocused(): boolean {
if (!(activeElement instanceof HTMLElement)) return false;
if (!activeElement.isConnected) return false;
if (activeElement.classList.contains("xterm-helper-textarea")) return true;
- return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
+ return activeElement.closest(".thread-terminal-panel .xterm") !== null;
}
diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx
index b95d1ef7b0..6f9d5bfd4a 100644
--- a/apps/web/src/routes/_chat.$threadId.tsx
+++ b/apps/web/src/routes/_chat.$threadId.tsx
@@ -1,6 +1,15 @@
import { ThreadId } from "@t3tools/contracts";
import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
-import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react";
+import {
+ Suspense,
+ lazy,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import ChatView from "../components/ChatView";
import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
@@ -10,23 +19,24 @@ import {
DiffPanelShell,
type DiffPanelMode,
} from "../components/DiffPanelShell";
+import { WorkspaceRightSidebar } from "../components/WorkspaceRightSidebar";
import { useComposerDraftStore } from "../composerDraftStore";
-import {
- type DiffRouteSearch,
- parseDiffRouteSearch,
- stripDiffSearchParams,
-} from "../diffRouteSearch";
+import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch";
import { useMediaQuery } from "../hooks/useMediaQuery";
+import { useSettings } from "../hooks/useSettings";
import { useStore } from "../store";
+import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { Sheet, SheetPopup } from "../components/ui/sheet";
-import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
+import { SidebarInset } from "~/components/ui/sidebar";
+import { WorkspaceTerminalPortalTargetsContext } from "../workspaceTerminalPortal";
+import { cn } from "~/lib/utils";
+import { resolveWorkspacePanels, WORKSPACE_PANEL_STORAGE_KEYS } from "../workspacePanels";
const DiffPanel = lazy(() => import("../components/DiffPanel"));
+
const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
-const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;
-const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
const DiffPanelSheet = (props: {
children: ReactNode;
@@ -54,6 +64,25 @@ const DiffPanelSheet = (props: {
);
};
+const TerminalPanelSheet = (props: {
+ children: ReactNode;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) => {
+ return (
+
+
+ {props.children}
+
+
+ );
+};
+
const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
return (
}>
@@ -72,101 +101,89 @@ const LazyDiffPanel = (props: { mode: DiffPanelMode }) => {
);
};
-const DiffPanelInlineSidebar = (props: {
- diffOpen: boolean;
- onCloseDiff: () => void;
- onOpenDiff: () => void;
+const DiffPanelInlineSidebar = (props: { open: boolean; renderDiffContent: boolean }) => {
+ const { open, renderDiffContent } = props;
+
+ return (
+
+
+
+ {renderDiffContent ? : null}
+
+
+
+ );
+};
+
+const SharedRightWorkspaceRail = (props: {
+ activePanel: "diff" | "terminal" | null;
+ fallbackPanel: "diff" | "terminal";
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
renderDiffContent: boolean;
+ setTerminalPortalTarget: (element: HTMLElement | null) => void;
+ storageKey: string;
}) => {
- const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props;
- const onOpenChange = useCallback(
- (open: boolean) => {
- if (open) {
- onOpenDiff();
- return;
- }
- onCloseDiff();
- },
- [onCloseDiff, onOpenDiff],
- );
- const shouldAcceptInlineSidebarWidth = useCallback(
- ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => {
- const composerForm = document.querySelector
("[data-chat-composer-form='true']");
- if (!composerForm) return true;
- const composerViewport = composerForm.parentElement;
- if (!composerViewport) return true;
- const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width");
- wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
-
- const viewportStyle = window.getComputedStyle(composerViewport);
- const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0;
- const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0;
- const viewportContentWidth = Math.max(
- 0,
- composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
- );
- const formRect = composerForm.getBoundingClientRect();
- const composerFooter = composerForm.querySelector(
- "[data-chat-composer-footer='true']",
- );
- const composerRightActions = composerForm.querySelector(
- "[data-chat-composer-actions='right']",
- );
- const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0;
- const composerFooterGap = composerFooter
- ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) ||
- Number.parseFloat(window.getComputedStyle(composerFooter).gap) ||
- 0
- : 0;
- const minimumComposerWidth =
- COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
- const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
- const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
- const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;
-
- if (previousSidebarWidth.length > 0) {
- wrapper.style.setProperty("--sidebar-width", previousSidebarWidth);
- } else {
- wrapper.style.removeProperty("--sidebar-width");
- }
-
- return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth;
- },
- [],
- );
+ const {
+ activePanel,
+ fallbackPanel,
+ onOpenChange,
+ open,
+ renderDiffContent,
+ setTerminalPortalTarget,
+ storageKey,
+ } = props;
+ const renderedPanel = activePanel ?? fallbackPanel;
return (
-
-
- {renderDiffContent ? : null}
-
-
-
+
+
+
+ {renderDiffContent ? : null}
+
+
+
+
+
);
};
function ChatThreadRouteView() {
const bootstrapComplete = useStore((store) => store.bootstrapComplete);
const navigate = useNavigate();
+ const settings = useSettings();
const threadId = Route.useParams({
select: (params) => ThreadId.makeUnsafe(params.threadId),
});
const search = Route.useSearch();
+ const terminalState = useTerminalStateStore((state) =>
+ selectThreadTerminalState(state.terminalStateByThreadId, threadId),
+ );
+ const setTerminalOpen = useTerminalStateStore((state) => state.setTerminalOpen);
const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId));
const draftThreadExists = useComposerDraftStore((store) =>
Object.hasOwn(store.draftThreadsByThreadId, threadId),
@@ -174,9 +191,14 @@ function ChatThreadRouteView() {
const routeThreadExists = threadExists || draftThreadExists;
const diffOpen = search.diff === "1";
const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
- // TanStack Router keeps active route components mounted across param-only navigations
- // unless remountDeps are configured, so this stays warm across thread switches.
+ const [workspaceBottomTerminalPortalTarget, setWorkspaceBottomTerminalPortalTarget] =
+ useState(null);
+ const [workspaceRightTerminalPortalTarget, setWorkspaceRightTerminalPortalTarget] =
+ useState(null);
const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen);
+ const lastRightRailPanelRef = useRef<"diff" | "terminal">(
+ terminalState.terminalOpen ? "terminal" : "diff",
+ );
const closeDiff = useCallback(() => {
void navigate({
to: "/$threadId",
@@ -184,16 +206,6 @@ function ChatThreadRouteView() {
search: { diff: undefined },
});
}, [navigate, threadId]);
- const openDiff = useCallback(() => {
- void navigate({
- to: "/$threadId",
- params: { threadId },
- search: (previous) => {
- const rest = stripDiffSearchParams(previous);
- return { ...rest, diff: "1" };
- },
- });
- }, [navigate, threadId]);
useEffect(() => {
if (diffOpen) {
@@ -208,41 +220,143 @@ function ChatThreadRouteView() {
if (!routeThreadExists) {
void navigate({ to: "/", replace: true });
+ }
+ }, [bootstrapComplete, navigate, routeThreadExists]);
+
+ const workspacePanels = resolveWorkspacePanels({
+ terminalPosition: settings.terminalPosition,
+ terminalBottomScope: settings.terminalBottomScope,
+ shouldUseDiffSheet,
+ diffOpen,
+ terminalOpen: terminalState.terminalOpen,
+ });
+ const activeRightRailPanel = workspacePanels.rightRailPanel;
+
+ useEffect(() => {
+ if (activeRightRailPanel === null) {
return;
}
- }, [bootstrapComplete, navigate, routeThreadExists, threadId]);
+ lastRightRailPanelRef.current = activeRightRailPanel;
+ }, [activeRightRailPanel]);
+
+ const rightRailStorageKey =
+ settings.terminalRightRailWidthMode === "linked"
+ ? WORKSPACE_PANEL_STORAGE_KEYS.sharedRight
+ : (activeRightRailPanel ?? lastRightRailPanelRef.current) === "terminal"
+ ? WORKSPACE_PANEL_STORAGE_KEYS.terminalRight
+ : WORKSPACE_PANEL_STORAGE_KEYS.diffRight;
+ const chatViewLayoutState = useMemo(
+ () => ({
+ diffToggleActive: workspacePanels.diffToggleActive,
+ terminalDockTarget: workspacePanels.terminalDockTarget,
+ terminalToggleActive: workspacePanels.terminalToggleActive,
+ }),
+ [
+ workspacePanels.diffToggleActive,
+ workspacePanels.terminalDockTarget,
+ workspacePanels.terminalToggleActive,
+ ],
+ );
+ const portalTargets = useMemo(
+ () => ({
+ bottom: workspaceBottomTerminalPortalTarget,
+ right: workspaceRightTerminalPortalTarget,
+ }),
+ [workspaceBottomTerminalPortalTarget, workspaceRightTerminalPortalTarget],
+ );
if (!bootstrapComplete || !routeThreadExists) {
return null;
}
const shouldRenderDiffContent = diffOpen || hasOpenedDiff;
+ const shouldMountInlineDiffRail = workspacePanels.supportsInlineDiffRail && hasOpenedDiff;
+ const chatWorkspace = (
+
+
+
+ );
- if (!shouldUseDiffSheet) {
- return (
- <>
-
-
-
-
- >
- );
- }
+ const rightWorkspacePanel =
+ settings.terminalPosition === "right" && !workspacePanels.showTerminalSheet ? (
+ {
+ if (open) {
+ if (lastRightRailPanelRef.current === "terminal") {
+ setTerminalOpen(threadId, true);
+ return;
+ }
+ void navigate({
+ to: "/$threadId",
+ params: { threadId },
+ search: (previous) => ({ ...previous, diff: "1" }),
+ });
+ return;
+ }
+
+ if (activeRightRailPanel === "terminal") {
+ setTerminalOpen(threadId, false);
+ return;
+ }
+
+ void closeDiff();
+ }}
+ renderDiffContent={shouldRenderDiffContent}
+ setTerminalPortalTarget={setWorkspaceRightTerminalPortalTarget}
+ storageKey={rightRailStorageKey}
+ />
+ ) : shouldMountInlineDiffRail ? (
+
+ ) : null;
+
+ const workspaceRow = (
+
+ {chatWorkspace}
+ {rightWorkspacePanel}
+
+ );
return (
- <>
-
-
-
-
- {shouldRenderDiffContent ? : null}
-
- >
+
+
+ {shouldUseDiffSheet ? (
+
+ {shouldRenderDiffContent ? : null}
+
+ ) : null}
+ {workspacePanels.showTerminalSheet ? (
+ {
+ if (!open) {
+ setTerminalOpen(threadId, false);
+ }
+ }}
+ >
+
+
+ ) : null}
+
);
}
diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts
index db0544fcef..9d7f77887a 100644
--- a/apps/web/src/terminal-links.test.ts
+++ b/apps/web/src/terminal-links.test.ts
@@ -8,8 +8,7 @@ import {
describe("extractTerminalLinks", () => {
it("finds http urls and path tokens", () => {
- const line =
- "failed at https://example.com/docs and src/components/ThreadTerminalDrawer.tsx:42";
+ const line = "failed at https://example.com/docs and src/components/ThreadTerminalPanel.tsx:42";
expect(extractTerminalLinks(line)).toEqual([
{
kind: "url",
@@ -19,9 +18,9 @@ describe("extractTerminalLinks", () => {
},
{
kind: "path",
- text: "src/components/ThreadTerminalDrawer.tsx:42",
+ text: "src/components/ThreadTerminalPanel.tsx:42",
start: 39,
- end: 81,
+ end: 80,
},
]);
});
@@ -74,11 +73,8 @@ describe("extractTerminalLinks", () => {
describe("resolvePathLinkTarget", () => {
it("resolves relative paths against cwd", () => {
expect(
- resolvePathLinkTarget(
- "src/components/ThreadTerminalDrawer.tsx:42:7",
- "/Users/julius/project",
- ),
- ).toBe("/Users/julius/project/src/components/ThreadTerminalDrawer.tsx:42:7");
+ resolvePathLinkTarget("src/components/ThreadTerminalPanel.tsx:42:7", "/Users/julius/project"),
+ ).toBe("/Users/julius/project/src/components/ThreadTerminalPanel.tsx:42:7");
});
it("keeps absolute paths unchanged", () => {
diff --git a/apps/web/src/workspacePanels.test.ts b/apps/web/src/workspacePanels.test.ts
new file mode 100644
index 0000000000..daf61361c6
--- /dev/null
+++ b/apps/web/src/workspacePanels.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveActiveRightRailPanel, resolveWorkspacePanels } from "./workspacePanels";
+
+describe("resolveActiveRightRailPanel", () => {
+ it("prefers diff when both diff and terminal are open on the right", () => {
+ expect(
+ resolveActiveRightRailPanel({
+ terminalPosition: "right",
+ diffOpen: true,
+ terminalOpen: true,
+ }),
+ ).toBe("diff");
+ });
+
+ it("falls back to the terminal when diff is closed", () => {
+ expect(
+ resolveActiveRightRailPanel({
+ terminalPosition: "right",
+ diffOpen: false,
+ terminalOpen: true,
+ }),
+ ).toBe("terminal");
+ });
+});
+
+describe("resolveWorkspacePanels", () => {
+ it("does not show the inline diff rail when diff is closed in bottom mode", () => {
+ expect(
+ resolveWorkspacePanels({
+ terminalPosition: "bottom",
+ terminalBottomScope: "chat",
+ shouldUseDiffSheet: false,
+ diffOpen: false,
+ terminalOpen: true,
+ }),
+ ).toEqual({
+ diffToggleActive: false,
+ rightRailPanel: null,
+ showDiffSheet: false,
+ showInlineDiffRail: false,
+ showTerminalSheet: false,
+ supportsInlineDiffRail: true,
+ terminalDockTarget: "bottom-inline",
+ terminalToggleActive: true,
+ });
+ });
+
+ it("keeps a chat-scoped bottom terminal inline and leaves diff independent", () => {
+ expect(
+ resolveWorkspacePanels({
+ terminalPosition: "bottom",
+ terminalBottomScope: "chat",
+ shouldUseDiffSheet: false,
+ diffOpen: true,
+ terminalOpen: true,
+ }),
+ ).toEqual({
+ diffToggleActive: true,
+ rightRailPanel: null,
+ showDiffSheet: false,
+ showInlineDiffRail: true,
+ showTerminalSheet: false,
+ supportsInlineDiffRail: true,
+ terminalDockTarget: "bottom-inline",
+ terminalToggleActive: true,
+ });
+ });
+
+ it("uses the workspace bottom slot when the bottom terminal should span the full workspace", () => {
+ expect(
+ resolveWorkspacePanels({
+ terminalPosition: "bottom",
+ terminalBottomScope: "workspace",
+ shouldUseDiffSheet: false,
+ diffOpen: false,
+ terminalOpen: true,
+ }),
+ ).toEqual({
+ diffToggleActive: false,
+ rightRailPanel: null,
+ showDiffSheet: false,
+ showInlineDiffRail: false,
+ showTerminalSheet: false,
+ supportsInlineDiffRail: true,
+ terminalDockTarget: "bottom-workspace",
+ terminalToggleActive: true,
+ });
+ });
+
+ it("uses the shared right rail when the terminal is positioned on the right", () => {
+ expect(
+ resolveWorkspacePanels({
+ terminalPosition: "right",
+ terminalBottomScope: "chat",
+ shouldUseDiffSheet: false,
+ diffOpen: true,
+ terminalOpen: true,
+ }),
+ ).toEqual({
+ diffToggleActive: true,
+ rightRailPanel: "diff",
+ showDiffSheet: false,
+ showInlineDiffRail: false,
+ showTerminalSheet: false,
+ supportsInlineDiffRail: false,
+ terminalDockTarget: "right",
+ terminalToggleActive: false,
+ });
+ });
+
+ it("falls back to the diff sheet on narrow right layouts and preserves terminal docking state", () => {
+ expect(
+ resolveWorkspacePanels({
+ terminalPosition: "right",
+ terminalBottomScope: "chat",
+ shouldUseDiffSheet: true,
+ diffOpen: true,
+ terminalOpen: true,
+ }),
+ ).toEqual({
+ diffToggleActive: true,
+ rightRailPanel: null,
+ showDiffSheet: true,
+ showInlineDiffRail: false,
+ showTerminalSheet: false,
+ supportsInlineDiffRail: false,
+ terminalDockTarget: "right",
+ terminalToggleActive: false,
+ });
+ });
+
+ it("falls back to a terminal sheet on narrow right layouts when diff is closed", () => {
+ expect(
+ resolveWorkspacePanels({
+ terminalPosition: "right",
+ terminalBottomScope: "chat",
+ shouldUseDiffSheet: true,
+ diffOpen: false,
+ terminalOpen: true,
+ }),
+ ).toEqual({
+ diffToggleActive: false,
+ rightRailPanel: null,
+ showDiffSheet: false,
+ showInlineDiffRail: false,
+ showTerminalSheet: true,
+ supportsInlineDiffRail: false,
+ terminalDockTarget: "right",
+ terminalToggleActive: true,
+ });
+ });
+});
diff --git a/apps/web/src/workspacePanels.ts b/apps/web/src/workspacePanels.ts
new file mode 100644
index 0000000000..3565f25756
--- /dev/null
+++ b/apps/web/src/workspacePanels.ts
@@ -0,0 +1,77 @@
+import type { TerminalBottomScope, TerminalPosition } from "@t3tools/contracts/settings";
+
+export type RightRailPanel = "diff" | "terminal" | null;
+
+export const WORKSPACE_PANEL_STORAGE_KEYS = {
+ diffRight: "chat_diff_sidebar_width",
+ sharedRight: "chat_shared_right_sidebar_width",
+ terminalRight: "chat_terminal_right_sidebar_width",
+} as const;
+
+export type TerminalDockTarget = "bottom-inline" | "bottom-workspace" | "right" | null;
+
+export function resolveActiveRightRailPanel(input: {
+ terminalPosition: TerminalPosition;
+ diffOpen: boolean;
+ terminalOpen: boolean;
+}): RightRailPanel {
+ if (input.terminalPosition !== "right") {
+ return null;
+ }
+
+ if (input.diffOpen) {
+ return "diff";
+ }
+
+ if (input.terminalOpen) {
+ return "terminal";
+ }
+
+ return null;
+}
+
+export function resolveWorkspacePanels(input: {
+ terminalPosition: TerminalPosition;
+ terminalBottomScope: TerminalBottomScope;
+ shouldUseDiffSheet: boolean;
+ diffOpen: boolean;
+ terminalOpen: boolean;
+}): {
+ diffToggleActive: boolean;
+ rightRailPanel: RightRailPanel;
+ showDiffSheet: boolean;
+ showInlineDiffRail: boolean;
+ showTerminalSheet: boolean;
+ supportsInlineDiffRail: boolean;
+ terminalDockTarget: TerminalDockTarget;
+ terminalToggleActive: boolean;
+} {
+ const visibleRightTool = resolveActiveRightRailPanel({
+ terminalPosition: input.terminalPosition,
+ diffOpen: input.diffOpen,
+ terminalOpen: input.terminalOpen,
+ });
+ const supportsInlineDiffRail = !input.shouldUseDiffSheet && input.terminalPosition !== "right";
+ const rightRailPanel = input.shouldUseDiffSheet ? null : visibleRightTool;
+
+ const terminalDockTarget: TerminalDockTarget = !input.terminalOpen
+ ? null
+ : input.terminalPosition === "bottom"
+ ? input.terminalBottomScope === "workspace"
+ ? "bottom-workspace"
+ : "bottom-inline"
+ : input.terminalPosition;
+
+ return {
+ diffToggleActive:
+ input.terminalPosition === "right" ? visibleRightTool === "diff" : input.diffOpen,
+ rightRailPanel,
+ showDiffSheet: input.shouldUseDiffSheet && visibleRightTool === "diff",
+ showInlineDiffRail: input.diffOpen && supportsInlineDiffRail,
+ showTerminalSheet: input.shouldUseDiffSheet && visibleRightTool === "terminal",
+ supportsInlineDiffRail,
+ terminalDockTarget,
+ terminalToggleActive:
+ input.terminalPosition === "right" ? visibleRightTool === "terminal" : input.terminalOpen,
+ };
+}
diff --git a/apps/web/src/workspaceTerminalPortal.ts b/apps/web/src/workspaceTerminalPortal.ts
new file mode 100644
index 0000000000..65651b055a
--- /dev/null
+++ b/apps/web/src/workspaceTerminalPortal.ts
@@ -0,0 +1,19 @@
+import { createContext, useContext } from "react";
+
+type WorkspaceTerminalPortalTargets = {
+ bottom: HTMLElement | null;
+ right: HTMLElement | null;
+};
+
+const EMPTY_WORKSPACE_TERMINAL_PORTAL_TARGETS: WorkspaceTerminalPortalTargets = {
+ bottom: null,
+ right: null,
+};
+
+export const WorkspaceTerminalPortalTargetsContext = createContext(
+ EMPTY_WORKSPACE_TERMINAL_PORTAL_TARGETS,
+);
+
+export function useWorkspaceTerminalPortalTargets(): WorkspaceTerminalPortalTargets {
+ return useContext(WorkspaceTerminalPortalTargetsContext);
+}
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
new file mode 100644
index 0000000000..df09855897
--- /dev/null
+++ b/packages/contracts/src/settings.test.ts
@@ -0,0 +1,58 @@
+import { assert, it } from "@effect/vitest";
+import { Effect, Schema } from "effect";
+
+import {
+ ClientSettingsSchema,
+ DEFAULT_TERMINAL_BOTTOM_SCOPE,
+ DEFAULT_TERMINAL_POSITION,
+ DEFAULT_TERMINAL_RIGHT_RAIL_WIDTH_MODE,
+ DEFAULT_UNIFIED_SETTINGS,
+} from "./settings";
+
+it.effect("defaults bottom terminal scope to the chat column", () =>
+ Effect.gen(function* () {
+ const settings = yield* Schema.decodeUnknownEffect(ClientSettingsSchema)({});
+
+ assert.strictEqual(settings.terminalBottomScope, DEFAULT_TERMINAL_BOTTOM_SCOPE);
+ assert.strictEqual(settings.terminalPosition, DEFAULT_TERMINAL_POSITION);
+ assert.strictEqual(settings.terminalRightRailWidthMode, DEFAULT_TERMINAL_RIGHT_RAIL_WIDTH_MODE);
+ assert.strictEqual(DEFAULT_UNIFIED_SETTINGS.terminalBottomScope, DEFAULT_TERMINAL_BOTTOM_SCOPE);
+ assert.strictEqual(DEFAULT_UNIFIED_SETTINGS.terminalPosition, DEFAULT_TERMINAL_POSITION);
+ assert.strictEqual(
+ DEFAULT_UNIFIED_SETTINGS.terminalRightRailWidthMode,
+ DEFAULT_TERMINAL_RIGHT_RAIL_WIDTH_MODE,
+ );
+ }),
+);
+
+it.effect("accepts the workspace bottom terminal scope", () =>
+ Effect.gen(function* () {
+ const settings = yield* Schema.decodeUnknownEffect(ClientSettingsSchema)({
+ terminalBottomScope: "workspace",
+ });
+
+ assert.strictEqual(settings.terminalBottomScope, "workspace");
+ }),
+);
+
+it.effect("accepts the right terminal position and independent right rail widths", () =>
+ Effect.gen(function* () {
+ const settings = yield* Schema.decodeUnknownEffect(ClientSettingsSchema)({
+ terminalPosition: "right",
+ terminalRightRailWidthMode: "independent",
+ });
+
+ assert.strictEqual(settings.terminalPosition, "right");
+ assert.strictEqual(settings.terminalRightRailWidthMode, "independent");
+ }),
+);
+
+it.effect("migrates the removed left terminal position back to bottom", () =>
+ Effect.gen(function* () {
+ const settings = yield* Schema.decodeUnknownEffect(ClientSettingsSchema)({
+ terminalPosition: "left",
+ });
+
+ assert.strictEqual(settings.terminalPosition, "bottom");
+ }),
+);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index c28b566daa..ad183a63f8 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -15,6 +15,29 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"])
export type TimestampFormat = typeof TimestampFormat.Type;
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
+export const TerminalBottomScope = Schema.Literals(["chat", "workspace"]);
+export type TerminalBottomScope = typeof TerminalBottomScope.Type;
+export const DEFAULT_TERMINAL_BOTTOM_SCOPE: TerminalBottomScope = "chat";
+
+const LegacyTerminalPosition = Schema.Literals(["bottom", "left", "right"]);
+const CurrentTerminalPosition = Schema.Literals(["bottom", "right"]);
+export const TerminalPosition = LegacyTerminalPosition.pipe(
+ Schema.decodeTo(
+ CurrentTerminalPosition,
+ SchemaTransformation.transformOrFail({
+ decode: (value) =>
+ Effect.succeed((value === "left" ? "bottom" : value) as "bottom" | "right"),
+ encode: (value) => Effect.succeed(value),
+ }),
+ ),
+);
+export type TerminalPosition = typeof TerminalPosition.Type;
+export const DEFAULT_TERMINAL_POSITION: TerminalPosition = "bottom";
+
+export const TerminalRightRailWidthMode = Schema.Literals(["linked", "independent"]);
+export type TerminalRightRailWidthMode = typeof TerminalRightRailWidthMode.Type;
+export const DEFAULT_TERMINAL_RIGHT_RAIL_WIDTH_MODE: TerminalRightRailWidthMode = "linked";
+
export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
@@ -33,6 +56,15 @@ export const ClientSettingsSchema = Schema.Struct({
sidebarThreadSortOrder: SidebarThreadSortOrder.pipe(
Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER),
),
+ terminalBottomScope: TerminalBottomScope.pipe(
+ Schema.withDecodingDefault(() => DEFAULT_TERMINAL_BOTTOM_SCOPE),
+ ),
+ terminalPosition: TerminalPosition.pipe(
+ Schema.withDecodingDefault(() => DEFAULT_TERMINAL_POSITION),
+ ),
+ terminalRightRailWidthMode: TerminalRightRailWidthMode.pipe(
+ Schema.withDecodingDefault(() => DEFAULT_TERMINAL_RIGHT_RAIL_WIDTH_MODE),
+ ),
timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)),
});
export type ClientSettings = typeof ClientSettingsSchema.Type;
From 279ce206036694e98e56ce48bb0bde7a9d0ebb01 Mon Sep 17 00:00:00 2001
From: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Date: Thu, 2 Apr 2026 16:06:47 +0100
Subject: [PATCH 2/3] Avoid persisting workspace right rail state
---
apps/web/src/components/WorkspaceRightSidebar.tsx | 1 +
apps/web/src/components/ui/sidebar.tsx | 8 +++++++-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/WorkspaceRightSidebar.tsx b/apps/web/src/components/WorkspaceRightSidebar.tsx
index a015748b01..2418a96ebe 100644
--- a/apps/web/src/components/WorkspaceRightSidebar.tsx
+++ b/apps/web/src/components/WorkspaceRightSidebar.tsx
@@ -82,6 +82,7 @@ export function WorkspaceRightSidebar({
defaultOpen={false}
open={open}
onOpenChange={handleOpenChange}
+ persistState={false}
className="w-auto min-h-0 flex-none bg-transparent"
style={{ "--sidebar-width": defaultWidth } as CSSProperties}
>
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
index 79ee898c3a..6178e8fd95 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -90,6 +90,7 @@ function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
+ persistState = true,
className,
style,
children,
@@ -98,6 +99,7 @@ function SidebarProvider({
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
+ persistState?: boolean;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
@@ -115,6 +117,10 @@ function SidebarProvider({
_setOpen(openState);
}
+ if (!persistState) {
+ return;
+ }
+
// This sets the cookie to keep the sidebar state.
await cookieStore.set({
expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000,
@@ -123,7 +129,7 @@ function SidebarProvider({
value: String(openState),
});
},
- [setOpenProp, open],
+ [persistState, setOpenProp, open],
);
// Helper to toggle the sidebar.
From 4bd0a85a8313dd1b6cf5ae54b65f4d94bdf6d002 Mon Sep 17 00:00:00 2001
From: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Date: Thu, 2 Apr 2026 18:06:00 +0100
Subject: [PATCH 3/3] Refactor workspace terminal panel controls
---
apps/web/src/components/ChatView.tsx | 82 ++------
apps/web/src/components/WorkspacePanels.tsx | 70 +++++++
.../src/components/WorkspaceRightSidebar.tsx | 52 +----
.../src/hooks/useWorkspacePanelController.ts | 137 +++++++++++++
apps/web/src/routes/_chat.$threadId.tsx | 185 ++++++------------
apps/web/src/workspaceSidebarSizing.ts | 50 +++++
6 files changed, 326 insertions(+), 250 deletions(-)
create mode 100644 apps/web/src/components/WorkspacePanels.tsx
create mode 100644 apps/web/src/hooks/useWorkspacePanelController.ts
create mode 100644 apps/web/src/workspaceSidebarSizing.ts
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 5296aac1c6..18fe357be6 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -126,6 +126,7 @@ import {
resolveSelectableProvider,
} from "../providerModels";
import { useSettings } from "../hooks/useSettings";
+import { useWorkspacePanelController } from "../hooks/useWorkspacePanelController";
import { resolveAppModelSelection } from "../modelSelection";
import type { TerminalDockTarget } from "../workspacePanels";
import { isTerminalFocused } from "../lib/terminalFocus";
@@ -1389,58 +1390,23 @@ export default function ChatView({ layoutState, threadId }: ChatViewProps) {
);
const terminalToggleActive = layoutState.terminalToggleActive;
const diffToggleActive = layoutState.diffToggleActive;
- const closeDiffPanel = useCallback(() => {
- void navigate({
- to: "/$threadId",
- params: { threadId },
- replace: true,
- search: (previous) => ({
- ...stripDiffSearchParams(previous),
- diff: undefined,
- }),
- });
- }, [navigate, threadId]);
- const openDiffPanel = useCallback(() => {
- void navigate({
- to: "/$threadId",
- params: { threadId },
- replace: true,
- search: (previous) => {
- const rest = stripDiffSearchParams(previous);
- return { ...rest, diff: "1" };
- },
- });
- }, [navigate, threadId]);
const setTerminalOpen = useCallback(
(open: boolean) => {
- if (!activeThreadId) return;
- storeSetTerminalOpen(activeThreadId, open);
+ storeSetTerminalOpen(threadId, open);
},
- [activeThreadId, storeSetTerminalOpen],
+ [storeSetTerminalOpen, threadId],
);
- const onToggleDiff = useCallback(() => {
- if (terminalPosition === "right") {
- if (diffToggleActive) {
- closeDiffPanel();
- return;
- }
- setTerminalOpen(false);
- openDiffPanel();
- return;
- }
- if (diffOpen) {
- closeDiffPanel();
- return;
- }
- openDiffPanel();
- }, [
- closeDiffPanel,
+ const panelController = useWorkspacePanelController({
diffOpen,
diffToggleActive,
- openDiffPanel,
+ replaceHistory: true,
setTerminalOpen,
+ terminalOpen: terminalState.terminalOpen,
terminalPosition,
- ]);
+ terminalToggleActive,
+ threadId,
+ });
+ const onToggleDiff = panelController.toggleDiffPanel;
const envLocked = Boolean(
activeThread &&
@@ -1534,33 +1500,7 @@ export default function ChatView({ layoutState, threadId }: ChatViewProps) {
},
[activeThreadId, storeSetTerminalHeight],
);
- const toggleTerminalVisibility = useCallback(() => {
- if (!activeThreadId) return;
- if (terminalPosition === "right") {
- if (terminalToggleActive) {
- setTerminalOpen(false);
- return;
- }
- if (diffOpen) {
- setTerminalOpen(true);
- closeDiffPanel();
- return;
- }
- if (!terminalState.terminalOpen) {
- setTerminalOpen(true);
- }
- return;
- }
- setTerminalOpen(!terminalState.terminalOpen);
- }, [
- activeThreadId,
- closeDiffPanel,
- diffOpen,
- setTerminalOpen,
- terminalToggleActive,
- terminalPosition,
- terminalState.terminalOpen,
- ]);
+ const toggleTerminalVisibility = panelController.toggleTerminalPanel;
const splitTerminal = useCallback(() => {
if (!activeThreadId || hasReachedSplitLimit) return;
const terminalId = `terminal-${randomUUID()}`;
diff --git a/apps/web/src/components/WorkspacePanels.tsx b/apps/web/src/components/WorkspacePanels.tsx
new file mode 100644
index 0000000000..73d4dedcca
--- /dev/null
+++ b/apps/web/src/components/WorkspacePanels.tsx
@@ -0,0 +1,70 @@
+import type { ReactNode } from "react";
+import { Sheet, SheetPopup } from "./ui/sheet";
+import { cn } from "~/lib/utils";
+import { WorkspaceRightSidebar } from "./WorkspaceRightSidebar";
+
+type WorkspaceSideSheetProps = {
+ children: ReactNode;
+ onOpenChange: (open: boolean) => void;
+ open: boolean;
+};
+
+type WorkspaceRightRailProps = {
+ children: ReactNode;
+ defaultWidth: string;
+ minWidth: number;
+ onOpenChange?: (open: boolean) => void;
+ open: boolean;
+ storageKey: string;
+};
+
+type WorkspacePanelLayoutProps = {
+ bodyClassName?: string;
+ children: ReactNode;
+};
+
+export function WorkspaceSideSheet({ children, onOpenChange, open }: WorkspaceSideSheetProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function WorkspaceRightRail({
+ children,
+ defaultWidth,
+ minWidth,
+ onOpenChange,
+ open,
+ storageKey,
+}: WorkspaceRightRailProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function WorkspacePanelLayout({ bodyClassName, children }: WorkspacePanelLayoutProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/WorkspaceRightSidebar.tsx b/apps/web/src/components/WorkspaceRightSidebar.tsx
index 2418a96ebe..7a7dadac85 100644
--- a/apps/web/src/components/WorkspaceRightSidebar.tsx
+++ b/apps/web/src/components/WorkspaceRightSidebar.tsx
@@ -1,6 +1,7 @@
import type { CSSProperties, ReactNode } from "react";
import { useCallback } from "react";
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
+import { shouldAcceptWorkspaceSidebarWidth } from "../workspaceSidebarSizing";
type WorkspaceRightSidebarProps = {
children: ReactNode;
@@ -11,57 +12,6 @@ type WorkspaceRightSidebarProps = {
storageKey: string;
};
-const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
-
-function shouldAcceptWorkspaceSidebarWidth({
- nextWidth,
- wrapper,
-}: {
- nextWidth: number;
- wrapper: HTMLElement;
-}) {
- const composerForm = document.querySelector("[data-chat-composer-form='true']");
- if (!composerForm) return true;
- const composerViewport = composerForm.parentElement;
- if (!composerViewport) return true;
- const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width");
- wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
-
- const viewportStyle = window.getComputedStyle(composerViewport);
- const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0;
- const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0;
- const viewportContentWidth = Math.max(
- 0,
- composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
- );
- const formRect = composerForm.getBoundingClientRect();
- const composerFooter = composerForm.querySelector(
- "[data-chat-composer-footer='true']",
- );
- const composerRightActions = composerForm.querySelector(
- "[data-chat-composer-actions='right']",
- );
- const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0;
- const composerFooterGap = composerFooter
- ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) ||
- Number.parseFloat(window.getComputedStyle(composerFooter).gap) ||
- 0
- : 0;
- const minimumComposerWidth =
- COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
- const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
- const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
- const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;
-
- if (previousSidebarWidth.length > 0) {
- wrapper.style.setProperty("--sidebar-width", previousSidebarWidth);
- } else {
- wrapper.style.removeProperty("--sidebar-width");
- }
-
- return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth;
-}
-
export function WorkspaceRightSidebar({
children,
defaultWidth,
diff --git a/apps/web/src/hooks/useWorkspacePanelController.ts b/apps/web/src/hooks/useWorkspacePanelController.ts
new file mode 100644
index 0000000000..07cc400a95
--- /dev/null
+++ b/apps/web/src/hooks/useWorkspacePanelController.ts
@@ -0,0 +1,137 @@
+import type { TerminalPosition, ThreadId } from "@t3tools/contracts";
+import { useNavigate } from "@tanstack/react-router";
+import { useCallback } from "react";
+import { stripDiffSearchParams } from "../diffRouteSearch";
+import type { RightRailPanel } from "../workspacePanels";
+
+type UseWorkspacePanelControllerInput = {
+ diffOpen: boolean;
+ diffToggleActive: boolean;
+ replaceHistory?: boolean;
+ terminalOpen: boolean;
+ terminalPosition: TerminalPosition;
+ terminalToggleActive: boolean;
+ setTerminalOpen: (open: boolean) => void;
+ threadId: ThreadId;
+};
+
+export function useWorkspacePanelController(input: UseWorkspacePanelControllerInput) {
+ const navigate = useNavigate();
+ const replace = input.replaceHistory ?? false;
+ const {
+ diffOpen,
+ diffToggleActive,
+ setTerminalOpen,
+ terminalOpen,
+ terminalPosition,
+ terminalToggleActive,
+ threadId,
+ } = input;
+
+ const closeDiffPanel = useCallback(() => {
+ void navigate({
+ to: "/$threadId",
+ params: { threadId },
+ ...(replace ? { replace: true } : {}),
+ search: (previous) => ({
+ ...stripDiffSearchParams(previous),
+ diff: undefined,
+ }),
+ });
+ }, [navigate, replace, threadId]);
+
+ const openDiffPanel = useCallback(() => {
+ void navigate({
+ to: "/$threadId",
+ params: { threadId },
+ ...(replace ? { replace: true } : {}),
+ search: (previous) => {
+ const rest = stripDiffSearchParams(previous);
+ return { ...rest, diff: "1" };
+ },
+ });
+ }, [navigate, replace, threadId]);
+
+ const toggleDiffPanel = useCallback(() => {
+ if (terminalPosition === "right") {
+ if (diffToggleActive) {
+ closeDiffPanel();
+ return;
+ }
+ setTerminalOpen(false);
+ openDiffPanel();
+ return;
+ }
+ if (diffOpen) {
+ closeDiffPanel();
+ return;
+ }
+ openDiffPanel();
+ }, [
+ closeDiffPanel,
+ diffOpen,
+ diffToggleActive,
+ openDiffPanel,
+ setTerminalOpen,
+ terminalPosition,
+ ]);
+
+ const toggleTerminalPanel = useCallback(() => {
+ if (terminalPosition === "right") {
+ if (terminalToggleActive) {
+ setTerminalOpen(false);
+ return;
+ }
+ if (diffOpen) {
+ setTerminalOpen(true);
+ closeDiffPanel();
+ return;
+ }
+ if (!terminalOpen) {
+ setTerminalOpen(true);
+ }
+ return;
+ }
+ setTerminalOpen(!terminalOpen);
+ }, [
+ closeDiffPanel,
+ diffOpen,
+ setTerminalOpen,
+ terminalOpen,
+ terminalPosition,
+ terminalToggleActive,
+ ]);
+
+ const reopenRightRailPanel = useCallback(
+ (panel: Exclude) => {
+ if (panel === "terminal") {
+ setTerminalOpen(true);
+ return;
+ }
+ openDiffPanel();
+ },
+ [openDiffPanel, setTerminalOpen],
+ );
+
+ const closeRightRailPanel = useCallback(
+ (panel: RightRailPanel) => {
+ if (panel === "terminal") {
+ setTerminalOpen(false);
+ return;
+ }
+ if (panel === "diff") {
+ closeDiffPanel();
+ }
+ },
+ [closeDiffPanel, setTerminalOpen],
+ );
+
+ return {
+ closeDiffPanel,
+ closeRightRailPanel,
+ openDiffPanel,
+ reopenRightRailPanel,
+ toggleDiffPanel,
+ toggleTerminalPanel,
+ };
+}
diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx
index 6f9d5bfd4a..232eef0b5e 100644
--- a/apps/web/src/routes/_chat.$threadId.tsx
+++ b/apps/web/src/routes/_chat.$threadId.tsx
@@ -1,15 +1,6 @@
import { ThreadId } from "@t3tools/contracts";
import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
-import {
- Suspense,
- lazy,
- type ReactNode,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
import ChatView from "../components/ChatView";
import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
@@ -19,14 +10,18 @@ import {
DiffPanelShell,
type DiffPanelMode,
} from "../components/DiffPanelShell";
-import { WorkspaceRightSidebar } from "../components/WorkspaceRightSidebar";
+import {
+ WorkspacePanelLayout,
+ WorkspaceRightRail,
+ WorkspaceSideSheet,
+} from "../components/WorkspacePanels";
import { useComposerDraftStore } from "../composerDraftStore";
import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { useSettings } from "../hooks/useSettings";
+import { useWorkspacePanelController } from "../hooks/useWorkspacePanelController";
import { useStore } from "../store";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
-import { Sheet, SheetPopup } from "../components/ui/sheet";
import { SidebarInset } from "~/components/ui/sidebar";
import { WorkspaceTerminalPortalTargetsContext } from "../workspaceTerminalPortal";
import { cn } from "~/lib/utils";
@@ -38,51 +33,6 @@ const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;
-const DiffPanelSheet = (props: {
- children: ReactNode;
- diffOpen: boolean;
- onCloseDiff: () => void;
-}) => {
- return (
- {
- if (!open) {
- props.onCloseDiff();
- }
- }}
- >
-
- {props.children}
-
-
- );
-};
-
-const TerminalPanelSheet = (props: {
- children: ReactNode;
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}) => {
- return (
-
-
- {props.children}
-
-
- );
-};
-
const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
return (
}>
@@ -101,25 +51,6 @@ const LazyDiffPanel = (props: { mode: DiffPanelMode }) => {
);
};
-const DiffPanelInlineSidebar = (props: { open: boolean; renderDiffContent: boolean }) => {
- const { open, renderDiffContent } = props;
-
- return (
-
-
-
- {renderDiffContent ? : null}
-
-
-
- );
-};
-
const SharedRightWorkspaceRail = (props: {
activePanel: "diff" | "terminal" | null;
fallbackPanel: "diff" | "terminal";
@@ -141,34 +72,32 @@ const SharedRightWorkspaceRail = (props: {
const renderedPanel = activePanel ?? fallbackPanel;
return (
-
-
-
-
- {renderDiffContent ? : null}
-
-
+
+
+ {renderDiffContent ? : null}
-
-
+
+
+
);
};
@@ -199,14 +128,6 @@ function ChatThreadRouteView() {
const lastRightRailPanelRef = useRef<"diff" | "terminal">(
terminalState.terminalOpen ? "terminal" : "diff",
);
- const closeDiff = useCallback(() => {
- void navigate({
- to: "/$threadId",
- params: { threadId },
- search: { diff: undefined },
- });
- }, [navigate, threadId]);
-
useEffect(() => {
if (diffOpen) {
setHasOpenedDiff(true);
@@ -231,6 +152,15 @@ function ChatThreadRouteView() {
terminalOpen: terminalState.terminalOpen,
});
const activeRightRailPanel = workspacePanels.rightRailPanel;
+ const panelController = useWorkspacePanelController({
+ diffOpen,
+ diffToggleActive: workspacePanels.diffToggleActive,
+ setTerminalOpen: (open) => setTerminalOpen(threadId, open),
+ terminalOpen: terminalState.terminalOpen,
+ terminalPosition: settings.terminalPosition,
+ terminalToggleActive: workspacePanels.terminalToggleActive,
+ threadId,
+ });
useEffect(() => {
if (activeRightRailPanel === null) {
@@ -285,34 +215,26 @@ function ChatThreadRouteView() {
open={activeRightRailPanel !== null}
onOpenChange={(open) => {
if (open) {
- if (lastRightRailPanelRef.current === "terminal") {
- setTerminalOpen(threadId, true);
- return;
- }
- void navigate({
- to: "/$threadId",
- params: { threadId },
- search: (previous) => ({ ...previous, diff: "1" }),
- });
+ panelController.reopenRightRailPanel(lastRightRailPanelRef.current);
return;
}
-
- if (activeRightRailPanel === "terminal") {
- setTerminalOpen(threadId, false);
- return;
- }
-
- void closeDiff();
+ panelController.closeRightRailPanel(activeRightRailPanel);
}}
renderDiffContent={shouldRenderDiffContent}
setTerminalPortalTarget={setWorkspaceRightTerminalPortalTarget}
storageKey={rightRailStorageKey}
/>
) : shouldMountInlineDiffRail ? (
-
+ storageKey={WORKSPACE_PANEL_STORAGE_KEYS.diffRight}
+ >
+
+ {shouldRenderDiffContent ? : null}
+
+
) : null;
const workspaceRow = (
@@ -336,12 +258,19 @@ function ChatThreadRouteView() {
/>
{shouldUseDiffSheet ? (
-
+ {
+ if (!open) {
+ panelController.closeDiffPanel();
+ }
+ }}
+ >
{shouldRenderDiffContent ? : null}
-
+
) : null}
{workspacePanels.showTerminalSheet ? (
- {
if (!open) {
@@ -354,7 +283,7 @@ function ChatThreadRouteView() {
className="flex h-full min-h-0 min-w-0 overflow-hidden"
data-workspace-terminal-slot="right-sheet"
/>
-
+
) : null}
);
diff --git a/apps/web/src/workspaceSidebarSizing.ts b/apps/web/src/workspaceSidebarSizing.ts
new file mode 100644
index 0000000000..ee3ae90b41
--- /dev/null
+++ b/apps/web/src/workspaceSidebarSizing.ts
@@ -0,0 +1,50 @@
+const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
+
+export function shouldAcceptWorkspaceSidebarWidth({
+ nextWidth,
+ wrapper,
+}: {
+ nextWidth: number;
+ wrapper: HTMLElement;
+}) {
+ const composerForm = document.querySelector("[data-chat-composer-form='true']");
+ if (!composerForm) return true;
+ const composerViewport = composerForm.parentElement;
+ if (!composerViewport) return true;
+ const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width");
+ wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
+
+ const viewportStyle = window.getComputedStyle(composerViewport);
+ const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0;
+ const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0;
+ const viewportContentWidth = Math.max(
+ 0,
+ composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
+ );
+ const formRect = composerForm.getBoundingClientRect();
+ const composerFooter = composerForm.querySelector(
+ "[data-chat-composer-footer='true']",
+ );
+ const composerRightActions = composerForm.querySelector(
+ "[data-chat-composer-actions='right']",
+ );
+ const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0;
+ const composerFooterGap = composerFooter
+ ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) ||
+ Number.parseFloat(window.getComputedStyle(composerFooter).gap) ||
+ 0
+ : 0;
+ const minimumComposerWidth =
+ COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
+ const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
+ const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
+ const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;
+
+ if (previousSidebarWidth.length > 0) {
+ wrapper.style.setProperty("--sidebar-width", previousSidebarWidth);
+ } else {
+ wrapper.style.removeProperty("--sidebar-width");
+ }
+
+ return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth;
+}