Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 104 additions & 55 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useQuery } from "@tanstack/react-query";
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";
Expand Down Expand Up @@ -90,7 +93,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,
Expand Down Expand Up @@ -123,7 +126,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,
Expand Down Expand Up @@ -198,6 +203,7 @@ import {
useServerKeybindings,
} from "~/rpc/serverState";
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
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`;
Expand Down Expand Up @@ -329,6 +335,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;
}

Expand Down Expand Up @@ -407,7 +418,7 @@ function useLocalDispatchState(input: {
};
}

interface PersistentThreadTerminalDrawerProps {
interface PersistentThreadTerminalPanelProps {
threadId: ThreadId;
visible: boolean;
launchContext: PersistentTerminalLaunchContext | null;
Expand All @@ -416,9 +427,13 @@ interface PersistentThreadTerminalDrawerProps {
newShortcutLabel: string | undefined;
closeShortcutLabel: string | undefined;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
bottomScope: TerminalBottomScope;
layout: "bottom" | "side";
portalTarget: HTMLElement | null;
renderInline: boolean;
}

function PersistentThreadTerminalDrawer({
function PersistentThreadTerminalPanel({
threadId,
visible,
launchContext,
Expand All @@ -427,7 +442,11 @@ function PersistentThreadTerminalDrawer({
newShortcutLabel,
closeShortcutLabel,
onAddTerminalContext,
}: PersistentThreadTerminalDrawerProps) {
bottomScope,
layout,
portalTarget,
renderInline,
}: PersistentThreadTerminalPanelProps) {
const serverThread = useThreadById(threadId);
const draftThread = useComposerDraftStore(
(store) => store.draftThreadsByThreadId[threadId] ?? null,
Expand Down Expand Up @@ -546,45 +565,60 @@ function PersistentThreadTerminalDrawer({
return null;
}

return (
<div className={visible ? undefined : "hidden"}>
<ThreadTerminalDrawer
threadId={threadId}
cwd={cwd}
worktreePath={effectiveWorktreePath}
runtimeEnv={runtimeEnv}
visible={visible}
height={terminalState.terminalHeight}
terminalIds={terminalState.terminalIds}
activeTerminalId={terminalState.activeTerminalId}
terminalGroups={terminalState.terminalGroups}
activeTerminalGroupId={terminalState.activeTerminalGroupId}
focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
onSplitTerminal={splitTerminal}
onNewTerminal={createNewTerminal}
splitShortcutLabel={visible ? splitShortcutLabel : undefined}
newShortcutLabel={visible ? newShortcutLabel : undefined}
closeShortcutLabel={visible ? closeShortcutLabel : undefined}
onActiveTerminalChange={activateTerminal}
onCloseTerminal={closeTerminal}
onHeightChange={setTerminalHeight}
onAddTerminalContext={handleAddTerminalContext}
/>
</div>
const panel = (
<ThreadTerminalPanel
threadId={threadId}
cwd={cwd}
worktreePath={effectiveWorktreePath}
runtimeEnv={runtimeEnv}
visible={visible}
height={terminalState.terminalHeight}
bottomScope={bottomScope}
layout={layout}
terminalIds={terminalState.terminalIds}
activeTerminalId={terminalState.activeTerminalId}
terminalGroups={terminalState.terminalGroups}
activeTerminalGroupId={terminalState.activeTerminalGroupId}
focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
onSplitTerminal={splitTerminal}
onNewTerminal={createNewTerminal}
splitShortcutLabel={visible ? splitShortcutLabel : undefined}
newShortcutLabel={visible ? newShortcutLabel : undefined}
closeShortcutLabel={visible ? closeShortcutLabel : undefined}
onActiveTerminalChange={activateTerminal}
onCloseTerminal={closeTerminal}
onHeightChange={setTerminalHeight}
onAddTerminalContext={handleAddTerminalContext}
/>
);

if (!visible) {
return <div className="hidden">{panel}</div>;
}

if (!renderInline) {
if (!portalTarget) {
return <div className="hidden">{panel}</div>;
}
return createPortal(panel, portalTarget);
}

return panel;
}

export default function ChatView({ threadId }: ChatViewProps) {
export default function ChatView({ layoutState, threadId }: ChatViewProps) {
const serverThread = useThreadById(threadId);
const setStoreThreadError = useStore((store) => store.setError);
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
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({
Expand Down Expand Up @@ -1571,17 +1605,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 &&
Expand Down Expand Up @@ -1669,17 +1711,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
},
[activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext],
);
const setTerminalOpen = useCallback(
(open: boolean) => {
if (!activeThreadId) return;
storeSetTerminalOpen(activeThreadId, open);
},
[activeThreadId, storeSetTerminalOpen],
);
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()}`;
Expand Down Expand Up @@ -3918,6 +3950,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
}

const activeTerminalPortalTarget =
layoutState.terminalDockTarget === "bottom-workspace"
? workspaceTerminalPortalTargets.bottom
: layoutState.terminalDockTarget === "right"
? workspaceTerminalPortalTargets.right
: null;
const activeTerminalLayout = layoutState.terminalDockTarget?.startsWith("bottom")
? "bottom"
: "side";
const activeTerminalBottomScope: TerminalBottomScope =
layoutState.terminalDockTarget === "bottom-workspace" ? "workspace" : "chat";
const renderActiveTerminalInline = layoutState.terminalDockTarget === "bottom-inline";

return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
{/* Top bar */}
Expand All @@ -3940,11 +3985,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);
}}
Expand Down Expand Up @@ -4455,7 +4500,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* end horizontal flex container */}

{mountedTerminalThreadIds.map((mountedThreadId) => (
<PersistentThreadTerminalDrawer
<PersistentThreadTerminalPanel
key={mountedThreadId}
threadId={mountedThreadId}
visible={mountedThreadId === activeThreadId && terminalState.terminalOpen}
Expand All @@ -4467,6 +4512,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
onAddTerminalContext={addTerminalContextToDraft}
bottomScope={activeTerminalBottomScope}
layout={activeTerminalLayout}
portalTarget={mountedThreadId === activeThreadId ? activeTerminalPortalTarget : null}
renderInline={renderActiveTerminalInline}
/>
))}

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/DiffPanelShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar";
function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
const shouldUseDragRegion = isElectron && mode !== "sheet";
return cn(
"flex items-center justify-between gap-2 px-4",
"flex min-w-0 items-center gap-2 overflow-hidden px-4",
shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12",
);
}
Expand All @@ -25,7 +25,7 @@ export function DiffPanelShell(props: {
return (
<div
className={cn(
"flex h-full min-w-0 flex-col bg-background",
"flex h-full min-w-0 flex-col overflow-hidden bg-background",
props.mode === "inline"
? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
: "w-full",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,66 @@
import { describe, expect, it } from "vitest";

import {
clampTerminalPanelHeight,
resolveTerminalPanelMaxHeight,
resolveTerminalSplitViewGridStyle,
resolveTerminalSelectionActionPosition,
selectPendingTerminalEventEntries,
selectTerminalEventEntriesAfterSnapshot,
shouldHandleTerminalSelectionMouseUp,
terminalSelectionActionDelayForClickCount,
} from "./ThreadTerminalDrawer";
} from "./ThreadTerminalPanel";

describe("resolveTerminalPanelMaxHeight", () => {
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("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", () => {
Expand Down
Loading
Loading