.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..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 6633ce42a6..0ff6330679 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 af7f1fdb42d63b4ca7fe3998bf533d452118cbc2 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/7] 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 7c26f6cd9c146d58a75e59a1de19543c90c90724 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/7] 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 e3cca53ce3..e7faebbad4 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -125,6 +125,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";
@@ -1604,58 +1605,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 &&
@@ -1743,33 +1709,7 @@ export default function ChatView({ layoutState, threadId }: ChatViewProps) {
},
[activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext],
);
- 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;
+}
From 0a65b837ef89c942889626bc3e076d20b2763969 Mon Sep 17 00:00:00 2001
From: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Date: Sat, 4 Apr 2026 21:51:17 +0100
Subject: [PATCH 4/7] Fix terminal panel event subscription after rebase
---
apps/web/src/components/ThreadTerminalPanel.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/ThreadTerminalPanel.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx
index 878c96fae6..1b1f5717a3 100644
--- a/apps/web/src/components/ThreadTerminalPanel.tsx
+++ b/apps/web/src/components/ThreadTerminalPanel.tsx
@@ -544,8 +544,6 @@ function TerminalViewport({
});
const applyTerminalEvent = (event: TerminalEvent) => {
- const unsubscribe = api?.terminal.onEvent((event) => {
- if (event.threadId !== threadId || event.terminalId !== terminalId) return;
const activeTerminal = terminalRef.current;
if (!activeTerminal) {
return;
@@ -605,6 +603,10 @@ function TerminalViewport({
}, 0);
}
};
+ const unsubscribe = api?.terminal.onEvent((event) => {
+ if (event.threadId !== threadId || event.terminalId !== terminalId) return;
+ applyTerminalEvent(event);
+ });
const applyPendingTerminalEvents = (
terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>,
) => {
From ab93ba49d3b97b9fde0453eddcba792ef21be3c6 Mon Sep 17 00:00:00 2001
From: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Date: Sat, 4 Apr 2026 22:04:58 +0100
Subject: [PATCH 5/7] Adjust terminal splits for side panel layout
---
.../components/ThreadTerminalPanel.test.ts | 15 ++++++++++
.../src/components/ThreadTerminalPanel.tsx | 28 +++++++++++++++----
2 files changed, 37 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/components/ThreadTerminalPanel.test.ts b/apps/web/src/components/ThreadTerminalPanel.test.ts
index 51e4c68338..9d622527ab 100644
--- a/apps/web/src/components/ThreadTerminalPanel.test.ts
+++ b/apps/web/src/components/ThreadTerminalPanel.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
clampTerminalPanelHeight,
resolveTerminalPanelMaxHeight,
+ resolveTerminalSplitViewGridStyle,
resolveTerminalSelectionActionPosition,
selectPendingTerminalEventEntries,
selectTerminalEventEntriesAfterSnapshot,
@@ -47,6 +48,20 @@ describe("clampTerminalPanelHeight", () => {
});
});
+describe("resolveTerminalSplitViewGridStyle", () => {
+ it("uses columns for bottom-docked terminal splits", () => {
+ expect(resolveTerminalSplitViewGridStyle("bottom", 3)).toEqual({
+ gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
+ });
+ });
+
+ it("uses rows for right-docked terminal splits", () => {
+ expect(resolveTerminalSplitViewGridStyle("side", 3)).toEqual({
+ gridTemplateRows: "repeat(3, minmax(0, 1fr))",
+ });
+ });
+});
+
describe("resolveTerminalSelectionActionPosition", () => {
it("prefers the selection rect over the last pointer position", () => {
expect(
diff --git a/apps/web/src/components/ThreadTerminalPanel.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx
index 1b1f5717a3..cf04ab3a1c 100644
--- a/apps/web/src/components/ThreadTerminalPanel.tsx
+++ b/apps/web/src/components/ThreadTerminalPanel.tsx
@@ -79,6 +79,17 @@ export function clampTerminalPanelHeight(
return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);
}
+export function resolveTerminalSplitViewGridStyle(
+ layout: "bottom" | "side",
+ terminalCount: number,
+): {
+ gridTemplateColumns?: string;
+ gridTemplateRows?: string;
+} {
+ const template = `repeat(${terminalCount}, minmax(0, 1fr))`;
+ return layout === "side" ? { gridTemplateRows: template } : { gridTemplateColumns: template };
+}
+
function writeSystemMessage(terminal: Terminal, message: string): void {
terminal.write(`\r\n[terminal] ${message}\r\n`);
}
@@ -1087,6 +1098,10 @@ export default function ThreadTerminalPanel({
resolvedTerminalGroups.length > 1 ||
resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
+ const splitViewGridStyle = useMemo(
+ () => resolveTerminalSplitViewGridStyle(layout, visibleTerminalIds.length),
+ [layout, visibleTerminalIds.length],
+ );
const terminalLabelById = useMemo(
() =>
new Map(
@@ -1105,6 +1120,7 @@ export default function ThreadTerminalPanel({
const closeTerminalActionLabel = closeShortcutLabel
? `Close Terminal (${closeShortcutLabel})`
: "Close Terminal";
+ const splitTerminalIconClassName = isSideLayout ? "size-3.25 rotate-90" : "size-3.25";
const onSplitTerminalAction = useCallback(() => {
if (hasReachedSplitLimit) return;
onSplitTerminal();
@@ -1252,7 +1268,7 @@ export default function ThreadTerminalPanel({
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
>
-
+
{visibleTerminalIds.map((terminalId) => (
{
@@ -1351,7 +1367,7 @@ export default function ThreadTerminalPanel({
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
>
-
+
Date: Mon, 6 Apr 2026 18:43:22 +0100
Subject: [PATCH 6/7] fix linting issues from bas merge
---
apps/web/src/components/ChatView.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 27c52ec689..815beb832d 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -29,7 +29,6 @@ import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { createPortal } from "react-dom";
import type { TerminalBottomScope } from "@t3tools/contracts/settings";
-import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
import { useGitStatus } from "~/lib/gitStatusState";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { isElectron } from "../env";
@@ -3735,7 +3734,9 @@ export default function ChatView({ layoutState, threadId }: ChatViewProps) {
trigger.rangeStart,
replacementRangeEnd,
replacement,
- { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
+ {
+ expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd),
+ },
);
if (applied) {
setComposerHighlightedItemId(null);
@@ -3754,7 +3755,9 @@ export default function ChatView({ layoutState, threadId }: ChatViewProps) {
trigger.rangeStart,
replacementRangeEnd,
replacement,
- { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
+ {
+ expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd),
+ },
);
if (applied) {
setComposerHighlightedItemId(null);
From d422b590b5d57cbbfa678b54f7f061952ad05fae Mon Sep 17 00:00:00 2001
From: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Date: Mon, 6 Apr 2026 18:43:22 +0100
Subject: [PATCH 7/7] fix linting issues from bas merge
---
apps/web/src/components/ChatView.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 27c52ec689..815beb832d 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -29,7 +29,6 @@ import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { createPortal } from "react-dom";
import type { TerminalBottomScope } from "@t3tools/contracts/settings";
-import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
import { useGitStatus } from "~/lib/gitStatusState";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { isElectron } from "../env";
@@ -3735,7 +3734,9 @@ export default function ChatView({ layoutState, threadId }: ChatViewProps) {
trigger.rangeStart,
replacementRangeEnd,
replacement,
- { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
+ {
+ expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd),
+ },
);
if (applied) {
setComposerHighlightedItemId(null);
@@ -3754,7 +3755,9 @@ export default function ChatView({ layoutState, threadId }: ChatViewProps) {
trigger.rangeStart,
replacementRangeEnd,
replacement,
- { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
+ {
+ expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd),
+ },
);
if (applied) {
setComposerHighlightedItemId(null);