diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 07561e8e64..180dd7bcf3 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -26,6 +26,8 @@ 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 { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { isElectron } from "../env";
@@ -89,7 +91,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,
@@ -125,7 +127,9 @@ 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";
import {
type ComposerImageAttachment,
@@ -197,6 +201,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 +333,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 +414,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 +422,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 +1389,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions),
[keybindings, nonTerminalShortcutLabelOptions],
);
- const onToggleDiff = useCallback(() => {
- void navigate({
- to: "/$threadId",
- params: { threadId },
- replace: true,
- search: (previous) => {
- const rest = stripDiffSearchParams(previous);
- return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
- },
- });
- }, [diffOpen, navigate, threadId]);
+ const terminalToggleActive = layoutState.terminalToggleActive;
+ const diffToggleActive = layoutState.diffToggleActive;
+ const setTerminalOpen = useCallback(
+ (open: boolean) => {
+ storeSetTerminalOpen(threadId, open);
+ },
+ [storeSetTerminalOpen, threadId],
+ );
+ const panelController = useWorkspacePanelController({
+ diffOpen,
+ diffToggleActive,
+ replaceHistory: true,
+ setTerminalOpen,
+ terminalOpen: terminalState.terminalOpen,
+ terminalPosition,
+ terminalToggleActive,
+ threadId,
+ });
+ const onToggleDiff = panelController.toggleDiffPanel;
const envLocked = Boolean(
activeThread &&
@@ -1474,13 +1494,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;
@@ -1488,10 +1501,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
},
[activeThreadId, storeSetTerminalHeight],
);
- const toggleTerminalVisibility = useCallback(() => {
- if (!activeThreadId) return;
- setTerminalOpen(!terminalState.terminalOpen);
- }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
+ const toggleTerminalVisibility = panelController.toggleTerminalPanel;
const splitTerminal = useCallback(() => {
if (!activeThreadId || hasReachedSplitLimit) return;
const terminalId = `terminal-${randomUUID()}`;
@@ -3749,6 +3759,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 +3815,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 +4328,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/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
new file mode 100644
index 0000000000..7a7dadac85
--- /dev/null
+++ b/apps/web/src/components/WorkspaceRightSidebar.tsx
@@ -0,0 +1,55 @@
+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;
+ defaultWidth: string;
+ minWidth: number;
+ onOpenChange?: (open: boolean) => void;
+ open: boolean;
+ storageKey: string;
+};
+
+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}
+
void;
+ persistState?: boolean;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
@@ -114,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,
@@ -122,7 +129,7 @@ function SidebarProvider({
value: String(openState),
});
},
- [setOpenProp, open],
+ [persistState, setOpenProp, open],
);
// Helper to toggle the sidebar.
@@ -174,6 +181,7 @@ function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
+ desktopMode = "viewport",
resizable = false,
className,
children,
@@ -182,6 +190,7 @@ function Sidebar({
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
+ desktopMode?: "inline" | "viewport";
resizable?: boolean | SidebarResizableOptions;
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
@@ -200,8 +209,8 @@ function Sidebar({
};
}, [collapsible, isMobile, resizable]);
const instanceContextValue = React.useMemo(
- () => ({ side, resizable: resolvedResizable }),
- [resolvedResizable, side],
+ () => ({ desktopMode, side, resizable: resolvedResizable }),
+ [desktopMode, resolvedResizable, side],
);
if (collapsible === "none") {
@@ -255,7 +264,10 @@ function Sidebar({
return (
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/index.css b/apps/web/src/index.css
index ea76f24fac..d0a87794c6 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -213,15 +213,15 @@ input {
}
/* Terminal drawer scrollbar parity with chat */
-.thread-terminal-drawer .xterm .xterm-scrollable-element > .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 31920cf40f..232eef0b5e 100644
--- a/apps/web/src/routes/_chat.$threadId.tsx
+++ b/apps/web/src/routes/_chat.$threadId.tsx
@@ -1,6 +1,6 @@
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, useEffect, useMemo, useRef, useState } from "react";
import ChatView from "../components/ChatView";
import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
@@ -10,49 +10,28 @@ import {
DiffPanelShell,
type DiffPanelMode,
} from "../components/DiffPanelShell";
-import { useComposerDraftStore } from "../composerDraftStore";
import {
- type DiffRouteSearch,
- parseDiffRouteSearch,
- stripDiffSearchParams,
-} from "../diffRouteSearch";
+ 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 { Sheet, SheetPopup } from "../components/ui/sheet";
-import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
+import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
+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;
- diffOpen: boolean;
- onCloseDiff: () => void;
-}) => {
- return (
- {
- if (!open) {
- props.onCloseDiff();
- }
- }}
- >
-
- {props.children}
-
-
- );
-};
const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
return (
@@ -72,101 +51,68 @@ const LazyDiffPanel = (props: { mode: DiffPanelMode }) => {
);
};
-const DiffPanelInlineSidebar = (props: {
- diffOpen: boolean;
- onCloseDiff: () => void;
- onOpenDiff: () => void;
+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,27 +120,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 closeDiff = useCallback(() => {
- void navigate({
- to: "/$threadId",
- params: { threadId },
- 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]);
-
+ const lastRightRailPanelRef = useRef<"diff" | "terminal">(
+ terminalState.terminalOpen ? "terminal" : "diff",
+ );
useEffect(() => {
if (diffOpen) {
setHasOpenedDiff(true);
@@ -208,41 +141,151 @@ 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;
+ 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) {
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) {
+ panelController.reopenRightRailPanel(lastRightRailPanelRef.current);
+ return;
+ }
+ panelController.closeRightRailPanel(activeRightRailPanel);
+ }}
+ renderDiffContent={shouldRenderDiffContent}
+ setTerminalPortalTarget={setWorkspaceRightTerminalPortalTarget}
+ storageKey={rightRailStorageKey}
+ />
+ ) : shouldMountInlineDiffRail ? (
+
+
+ {shouldRenderDiffContent ? : null}
+
+
+ ) : null;
+
+ const workspaceRow = (
+
+ {chatWorkspace}
+ {rightWorkspacePanel}
+
+ );
return (
- <>
-
-
-
-
- {shouldRenderDiffContent ? : null}
-
- >
+
+
+ {shouldUseDiffSheet ? (
+ {
+ if (!open) {
+ panelController.closeDiffPanel();
+ }
+ }}
+ >
+ {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/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;
+}
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;