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
128 changes: 74 additions & 54 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -404,18 +414,20 @@ 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);
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 @@ -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 &&
Expand Down Expand Up @@ -1474,24 +1494,14 @@ 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;
storeSetTerminalHeight(activeThreadId, height);
},
[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()}`;
Expand Down Expand Up @@ -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 ? (
<ThreadTerminalPanel
key={activeThread.id}
threadId={activeThread.id}
cwd={gitCwd ?? activeProject.cwd}
runtimeEnv={threadTerminalRuntimeEnv}
height={terminalState.terminalHeight}
bottomScope={layoutState.terminalDockTarget === "bottom-workspace" ? "workspace" : "chat"}
layout={layoutState.terminalDockTarget?.startsWith("bottom") ? "bottom" : "side"}
terminalIds={terminalState.terminalIds}
activeTerminalId={terminalState.activeTerminalId}
terminalGroups={terminalState.terminalGroups}
activeTerminalGroupId={terminalState.activeTerminalGroupId}
focusRequestId={terminalFocusRequestId}
onSplitTerminal={splitTerminal}
onNewTerminal={createNewTerminal}
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
onActiveTerminalChange={activateTerminal}
onCloseTerminal={closeTerminal}
onHeightChange={setTerminalHeight}
onAddTerminalContext={addTerminalContextToDraft}
/>
) : null;
const shouldRenderInlineBottomTerminal = 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 @@ -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);
}}
Expand Down Expand Up @@ -4284,34 +4328,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
</div>
{/* end horizontal flex container */}

{(() => {
if (!terminalState.terminalOpen || !activeProject) {
return null;
}
return (
<ThreadTerminalDrawer
key={activeThread.id}
threadId={activeThread.id}
cwd={gitCwd ?? activeProject.cwd}
runtimeEnv={threadTerminalRuntimeEnv}
height={terminalState.terminalHeight}
terminalIds={terminalState.terminalIds}
activeTerminalId={terminalState.activeTerminalId}
terminalGroups={terminalState.terminalGroups}
activeTerminalGroupId={terminalState.activeTerminalGroupId}
focusRequestId={terminalFocusRequestId}
onSplitTerminal={splitTerminal}
onNewTerminal={createNewTerminal}
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
onActiveTerminalChange={activateTerminal}
onCloseTerminal={closeTerminal}
onHeightChange={setTerminalHeight}
onAddTerminalContext={addTerminalContextToDraft}
/>
);
})()}
{terminalPortalTarget && terminalPanel
? createPortal(terminalPanel, terminalPortalTarget)
: null}
{shouldRenderInlineBottomTerminal ? terminalPanel : null}

{expandedImage && expandedImageItem && (
<div
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,10 +1,49 @@
import { describe, expect, it } from "vitest";

import {
clampTerminalPanelHeight,
resolveTerminalPanelMaxHeight,
resolveTerminalSelectionActionPosition,
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("resolveTerminalSelectionActionPosition", () => {
it("prefers the selection rect over the last pointer position", () => {
Expand Down
Loading
Loading