mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 03:00:48 +00:00
* Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval - Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts and fix all type errors - Fix getAccessTokenForSession: read GitHub token directly from account record instead of calling Better Auth's internal /get-access-token endpoint which returns 403 on server-side calls - Re-implement workspaceAuth helper functions (workspaceAuthColumn, normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were accidentally deleted - Remove all retry logic (withRetries, isRetryableAppActorError) - Implement CORS origin allowlist from configured environment - Document cachedAppWorkspace singleton pattern - Add inline org sync fallback in buildAppSnapshot for post-OAuth flow - Add no-retry rule to CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add Foundry dev panel from fix-git-data branch Port the dev panel component that was left out when PR #243 was replaced by PR #247. Adapted to remove runtime/mock-debug references that don't exist on the current branch. - Toggle with Shift+D, persists visibility to localStorage - Shows context, session, GitHub sync status sections - Dev-only (import.meta.env.DEV) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add full Docker image defaults, fix actor deadlocks, and improve dev experience - Add Dockerfile.full and --all flag to install-agent CLI for pre-built images - Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full - Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example - Expand Docker docs with full runnable Dockerfile - Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning) - Audit and convert 12 task actions from wait:true to wait:false - Add bun --hot for dev backend hot reload - Remove --force from pnpm install in dev Dockerfile for faster startup - Add env_file support to compose.dev.yaml for automatic credential loading - Add mock frontend compose config and dev panel - Update CLAUDE.md with wait:true policy and dev environment setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * WIP: async action fixes and interest manager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation - Fix org display name using GitHub description instead of name field - Fix createWorkbenchSession hanging when sandbox is provisioning - Fix auto-session creation retry storm on errors - Fix task creation using wrong repo due to React state race conditions - Remove Bun hot-reload from backend Dockerfile (causes port drift) - Add GitHub sync/install status to dev panel Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
783 lines
25 KiB
TypeScript
783 lines
25 KiB
TypeScript
import { type SandboxProcessRecord, useInterest } from "@sandbox-agent/foundry-client";
|
|
import { ProcessTerminal } from "@sandbox-agent/react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useStyletron } from "baseui";
|
|
import { useFoundryTokens } from "../../app/theme";
|
|
import { ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { SandboxAgent } from "sandbox-agent";
|
|
import { backendClient } from "../../lib/backend";
|
|
import { interestManager } from "../../lib/interest";
|
|
|
|
interface TerminalPaneProps {
|
|
workspaceId: string;
|
|
taskId: string | null;
|
|
isExpanded?: boolean;
|
|
onExpand?: () => void;
|
|
onCollapse?: () => void;
|
|
onStartResize?: (e: React.PointerEvent) => void;
|
|
}
|
|
|
|
interface ProcessTab {
|
|
id: string;
|
|
processId: string;
|
|
title: string;
|
|
}
|
|
|
|
const MIN_TERMINAL_HEIGHT = 220;
|
|
|
|
function defaultShellRequest(cwd?: string | null) {
|
|
return {
|
|
command: "/bin/bash",
|
|
args: ["-lc", 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec "$SHELL" -l; fi; if [ -x /bin/zsh ]; then exec /bin/zsh -l; fi; exec /bin/bash -l'],
|
|
cwd: cwd ?? undefined,
|
|
interactive: true,
|
|
tty: true,
|
|
};
|
|
}
|
|
|
|
function formatProcessTabTitle(process: Pick<SandboxProcessRecord, "command" | "id">, fallbackIndex: number): string {
|
|
const label = process.command.split("/").pop()?.trim();
|
|
return label && label.length > 0 ? label : `Terminal ${fallbackIndex}`;
|
|
}
|
|
|
|
function formatCommandSummary(process: Pick<SandboxProcessRecord, "command" | "args">): string {
|
|
return [process.command, ...process.args].join(" ").trim();
|
|
}
|
|
|
|
function HeaderIconButton({
|
|
css,
|
|
t,
|
|
label,
|
|
disabled,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
css: ReturnType<typeof useStyletron>[0];
|
|
t: ReturnType<typeof useFoundryTokens>;
|
|
label: string;
|
|
disabled?: boolean;
|
|
onClick?: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={label}
|
|
onClick={() => {
|
|
if (!disabled) onClick?.();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if ((e.key === "Enter" || e.key === " ") && !disabled) onClick?.();
|
|
}}
|
|
className={css({
|
|
width: "26px",
|
|
height: "26px",
|
|
borderRadius: "6px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: t.textTertiary,
|
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.4 : 1,
|
|
transition: "background 200ms ease, color 200ms ease",
|
|
":hover": disabled
|
|
? undefined
|
|
: {
|
|
backgroundColor: t.interactiveHover,
|
|
color: t.textSecondary,
|
|
},
|
|
})}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onCollapse, onStartResize }: TerminalPaneProps) {
|
|
const [css] = useStyletron();
|
|
const t = useFoundryTokens();
|
|
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
|
const [processTabs, setProcessTabs] = useState<ProcessTab[]>([]);
|
|
const [creatingProcess, setCreatingProcess] = useState(false);
|
|
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null);
|
|
const [terminalClient, setTerminalClient] = useState<SandboxAgent | null>(null);
|
|
const [customTabNames, setCustomTabNames] = useState<Record<string, string>>({});
|
|
const [editingTabId, setEditingTabId] = useState<string | null>(null);
|
|
const editInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Drag-to-reorder state
|
|
const [tabDrag, setTabDrag] = useState<{ fromIdx: number; overIdx: number | null } | null>(null);
|
|
const tabDragRef = useRef<{ fromIdx: number; overIdx: number | null } | null>(null);
|
|
const tabDragStartY = useRef(0);
|
|
const didTabDrag = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!tabDrag) return;
|
|
const onMove = (e: MouseEvent) => {
|
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
if (!el) return;
|
|
const tabEl = (el as HTMLElement).closest?.("[data-terminal-idx]") as HTMLElement | null;
|
|
if (tabEl) {
|
|
const overIdx = Number(tabEl.dataset.terminalIdx);
|
|
if (overIdx !== tabDrag.overIdx) {
|
|
setTabDrag({ ...tabDrag, overIdx });
|
|
tabDragRef.current = { ...tabDrag, overIdx };
|
|
}
|
|
}
|
|
if (Math.abs(e.clientY - tabDragStartY.current) > 4) {
|
|
didTabDrag.current = true;
|
|
}
|
|
};
|
|
const onUp = () => {
|
|
const d = tabDragRef.current;
|
|
if (d && didTabDrag.current && d.overIdx !== null && d.fromIdx !== d.overIdx) {
|
|
setProcessTabs((prev) => {
|
|
const next = [...prev];
|
|
const [moved] = next.splice(d.fromIdx, 1);
|
|
if (!moved) {
|
|
return prev;
|
|
}
|
|
next.splice(d.overIdx!, 0, moved);
|
|
return next;
|
|
});
|
|
}
|
|
tabDragRef.current = null;
|
|
didTabDrag.current = false;
|
|
setTabDrag(null);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
return () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
};
|
|
}, [tabDrag]);
|
|
|
|
// Horizontal splitter for terminal list width
|
|
const DEFAULT_LIST_WIDTH = 180;
|
|
const MIN_LIST_WIDTH = 40;
|
|
const MAX_LIST_WIDTH = 360;
|
|
const [listWidth, setListWidth] = useState(DEFAULT_LIST_WIDTH);
|
|
const splitterRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
|
|
const onSplitterPointerDown = useCallback(
|
|
(e: React.PointerEvent) => {
|
|
e.preventDefault();
|
|
splitterRef.current = { startX: e.clientX, startWidth: listWidth };
|
|
const onMove = (ev: PointerEvent) => {
|
|
if (!splitterRef.current) return;
|
|
// Dragging left = increase list width, dragging right = decrease
|
|
const delta = splitterRef.current.startX - ev.clientX;
|
|
const next = Math.min(MAX_LIST_WIDTH, Math.max(MIN_LIST_WIDTH, splitterRef.current.startWidth + delta));
|
|
setListWidth(next);
|
|
};
|
|
const onUp = () => {
|
|
splitterRef.current = null;
|
|
document.removeEventListener("pointermove", onMove);
|
|
document.removeEventListener("pointerup", onUp);
|
|
};
|
|
document.addEventListener("pointermove", onMove);
|
|
document.addEventListener("pointerup", onUp);
|
|
},
|
|
[listWidth],
|
|
);
|
|
|
|
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
|
const taskSummary = useMemo(
|
|
() => (taskId ? (workspaceState.data?.taskSummaries.find((task) => task.id === taskId) ?? null) : null),
|
|
[taskId, workspaceState.data?.taskSummaries],
|
|
);
|
|
const taskState = useInterest(
|
|
interestManager,
|
|
"task",
|
|
taskSummary
|
|
? {
|
|
workspaceId,
|
|
repoId: taskSummary.repoId,
|
|
taskId: taskSummary.id,
|
|
}
|
|
: null,
|
|
);
|
|
|
|
const activeSandbox = useMemo(() => {
|
|
const task = taskState.data;
|
|
if (!task?.activeSandboxId) {
|
|
return null;
|
|
}
|
|
|
|
return task.sandboxes.find((sandbox) => sandbox.sandboxId === task.activeSandboxId) ?? null;
|
|
}, [taskState.data]);
|
|
|
|
const connectionQuery = useQuery({
|
|
queryKey: ["mock-layout", "sandbox-agent-connection", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""],
|
|
enabled: Boolean(activeSandbox?.sandboxId),
|
|
staleTime: 30_000,
|
|
refetchOnWindowFocus: false,
|
|
queryFn: async () => {
|
|
if (!activeSandbox) {
|
|
throw new Error("Cannot load a sandbox connection without an active sandbox.");
|
|
}
|
|
|
|
return await backendClient.getSandboxAgentConnection(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId);
|
|
},
|
|
});
|
|
|
|
const processesState = useInterest(
|
|
interestManager,
|
|
"sandboxProcesses",
|
|
activeSandbox
|
|
? {
|
|
workspaceId,
|
|
providerId: activeSandbox.providerId,
|
|
sandboxId: activeSandbox.sandboxId,
|
|
}
|
|
: null,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!connectionQuery.data) {
|
|
setTerminalClient((current) => {
|
|
if (current) {
|
|
void current.dispose();
|
|
}
|
|
return null;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (connectionQuery.data.endpoint.startsWith("mock://")) {
|
|
setTerminalClient((current) => {
|
|
if (current) {
|
|
void current.dispose();
|
|
}
|
|
return null;
|
|
});
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
void SandboxAgent.connect({
|
|
baseUrl: connectionQuery.data.endpoint,
|
|
token: connectionQuery.data.token,
|
|
waitForHealth: false,
|
|
})
|
|
.then((client) => {
|
|
if (cancelled) {
|
|
void client.dispose();
|
|
return;
|
|
}
|
|
|
|
setTerminalClient((current) => {
|
|
if (current) {
|
|
void current.dispose();
|
|
}
|
|
return client;
|
|
});
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setTerminalClient((current) => {
|
|
if (current) {
|
|
void current.dispose();
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [connectionQuery.data]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (terminalClient) {
|
|
void terminalClient.dispose();
|
|
}
|
|
};
|
|
}, [terminalClient]);
|
|
|
|
useEffect(() => {
|
|
setActiveTabId(null);
|
|
setProcessTabs([]);
|
|
}, [taskId]);
|
|
|
|
const processes = processesState.data ?? [];
|
|
|
|
const openTerminalTab = useCallback((process: SandboxProcessRecord) => {
|
|
setProcessTabs((current) => {
|
|
const existing = current.find((tab) => tab.processId === process.id);
|
|
if (existing) {
|
|
setActiveTabId(existing.id);
|
|
return current;
|
|
}
|
|
|
|
const nextTab: ProcessTab = {
|
|
id: `terminal:${process.id}`,
|
|
processId: process.id,
|
|
title: formatProcessTabTitle(process, current.length + 1),
|
|
};
|
|
setActiveTabId(nextTab.id);
|
|
return [...current, nextTab];
|
|
});
|
|
}, []);
|
|
|
|
const closeTerminalTab = useCallback((tabId: string) => {
|
|
setProcessTabs((current) => {
|
|
const next = current.filter((tab) => tab.id !== tabId);
|
|
setActiveTabId((currentActive) => {
|
|
if (currentActive === tabId) {
|
|
return next.length > 0 ? next[next.length - 1]!.id : null;
|
|
}
|
|
return currentActive;
|
|
});
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const spawnTerminal = useCallback(async () => {
|
|
if (!activeSandbox?.sandboxId) {
|
|
return;
|
|
}
|
|
|
|
setCreatingProcess(true);
|
|
try {
|
|
const created = await backendClient.createSandboxProcess({
|
|
workspaceId,
|
|
providerId: activeSandbox.providerId,
|
|
sandboxId: activeSandbox.sandboxId,
|
|
request: defaultShellRequest(activeSandbox.cwd),
|
|
});
|
|
openTerminalTab(created);
|
|
} finally {
|
|
setCreatingProcess(false);
|
|
}
|
|
}, [activeSandbox, openTerminalTab, workspaceId]);
|
|
|
|
const processTabsById = useMemo(() => new Map(processTabs.map((tab) => [tab.id, tab])), [processTabs]);
|
|
const activeProcessTab = activeTabId ? (processTabsById.get(activeTabId) ?? null) : null;
|
|
const activeTerminalProcess = useMemo(
|
|
() => (activeProcessTab ? (processes.find((process) => process.id === activeProcessTab.processId) ?? null) : null),
|
|
[activeProcessTab, processes],
|
|
);
|
|
|
|
const emptyBodyClassName = css({
|
|
flex: 1,
|
|
minHeight: `${MIN_TERMINAL_HEIGHT}px`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: "24px",
|
|
backgroundColor: t.surfacePrimary,
|
|
});
|
|
|
|
const emptyCopyClassName = css({
|
|
maxWidth: "340px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "10px",
|
|
color: t.textSecondary,
|
|
fontSize: "12px",
|
|
lineHeight: 1.6,
|
|
textAlign: "center",
|
|
});
|
|
|
|
const renderTerminalView = () => {
|
|
if (!activeProcessTab) {
|
|
return (
|
|
<div className={emptyBodyClassName}>
|
|
<div className={emptyCopyClassName}>
|
|
<SquareTerminal size={24} style={{ margin: "0 auto 4px", opacity: 0.4 }} />
|
|
<strong>No terminal open.</strong>
|
|
<span>Click + to open a new terminal session.</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!activeTerminalProcess) {
|
|
return (
|
|
<div className={emptyBodyClassName}>
|
|
<div className={emptyCopyClassName}>
|
|
<strong>Process not found.</strong>
|
|
<span>This terminal tab points at a process that no longer exists.</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!terminalClient) {
|
|
return (
|
|
<div className={emptyBodyClassName}>
|
|
<div className={emptyCopyClassName}>
|
|
<strong>Interactive terminal transport is unavailable.</strong>
|
|
<span>Mock mode does not open a live terminal transport.</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", backgroundColor: t.surfacePrimary })}>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: "10px",
|
|
padding: "10px 14px",
|
|
borderBottom: `1px solid ${t.borderDefault}`,
|
|
fontSize: "11px",
|
|
color: t.textMuted,
|
|
})}
|
|
>
|
|
<span>{formatCommandSummary(activeTerminalProcess)}</span>
|
|
<span>{activeTerminalProcess.id.slice(0, 8)}</span>
|
|
</div>
|
|
<ProcessTerminal
|
|
key={activeTerminalProcess.id}
|
|
client={terminalClient}
|
|
processId={activeTerminalProcess.id}
|
|
height="100%"
|
|
showStatusBar={false}
|
|
style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
border: "none",
|
|
borderRadius: 0,
|
|
background: t.surfacePrimary,
|
|
}}
|
|
terminalStyle={{
|
|
minHeight: 0,
|
|
height: "100%",
|
|
padding: "18px 16px 14px",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderBody = () => {
|
|
if (!taskId) {
|
|
return (
|
|
<div className={emptyBodyClassName}>
|
|
<div className={emptyCopyClassName}>
|
|
<strong>Select a task to open a terminal.</strong>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (taskState.status === "loading") {
|
|
return (
|
|
<div className={emptyBodyClassName}>
|
|
<div className={emptyCopyClassName}>
|
|
<strong>Loading sandbox state...</strong>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (taskState.error) {
|
|
return (
|
|
<div className={emptyBodyClassName}>
|
|
<div className={emptyCopyClassName}>
|
|
<strong>Could not load task state.</strong>
|
|
<span>{taskState.error.message}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!activeSandbox?.sandboxId) {
|
|
return (
|
|
<div className={emptyBodyClassName}>
|
|
<div className={emptyCopyClassName}>
|
|
<strong>Waiting for sandbox...</strong>
|
|
<span>The active task does not have a sandbox runtime yet.</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return renderTerminalView();
|
|
};
|
|
|
|
return (
|
|
<section
|
|
className={css({
|
|
flex: 1,
|
|
minHeight: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: t.surfacePrimary,
|
|
overflow: "hidden",
|
|
})}
|
|
>
|
|
{/* Resize handle */}
|
|
<div
|
|
onPointerDown={onStartResize}
|
|
className={css({
|
|
height: "3px",
|
|
flexShrink: 0,
|
|
cursor: "ns-resize",
|
|
position: "relative",
|
|
"::before": {
|
|
content: '""',
|
|
position: "absolute",
|
|
top: "-2px",
|
|
left: 0,
|
|
right: 0,
|
|
height: "7px",
|
|
},
|
|
})}
|
|
/>
|
|
{/* Full-width header bar */}
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
minHeight: "39px",
|
|
maxHeight: "39px",
|
|
padding: "0 14px",
|
|
borderTop: `1px solid ${t.borderDefault}`,
|
|
backgroundColor: t.surfacePrimary,
|
|
flexShrink: 0,
|
|
})}
|
|
>
|
|
<SquareTerminal size={14} color={t.textTertiary} />
|
|
<span className={css({ fontSize: "12px", fontWeight: 600, color: t.textSecondary })}>Terminal</span>
|
|
<div className={css({ flex: 1 })} />
|
|
<div className={css({ display: "flex", alignItems: "center", gap: "2px" })}>
|
|
<HeaderIconButton
|
|
css={css}
|
|
t={t}
|
|
label="New terminal"
|
|
disabled={!activeSandbox?.sandboxId || creatingProcess}
|
|
onClick={() => {
|
|
if (activeSandbox?.sandboxId && !creatingProcess) void spawnTerminal();
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
</HeaderIconButton>
|
|
<HeaderIconButton
|
|
css={css}
|
|
t={t}
|
|
label="Kill terminal"
|
|
disabled={!activeTabId}
|
|
onClick={() => {
|
|
if (activeTabId) closeTerminalTab(activeTabId);
|
|
}}
|
|
>
|
|
<Trash2 size={13} />
|
|
</HeaderIconButton>
|
|
<HeaderIconButton css={css} t={t} label={isExpanded ? "Collapse terminal" : "Expand terminal"} onClick={isExpanded ? onCollapse : onExpand}>
|
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
|
</HeaderIconButton>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Two-column body: terminal left, list right — hidden when no tabs */}
|
|
{processTabs.length > 0 && (
|
|
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "row" })}>
|
|
{/* Left: terminal content */}
|
|
<div className={css({ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" })}>{renderBody()}</div>
|
|
|
|
{/* Splitter */}
|
|
<div
|
|
onPointerDown={onSplitterPointerDown}
|
|
className={css({
|
|
width: "1px",
|
|
flexShrink: 0,
|
|
cursor: "col-resize",
|
|
backgroundColor: t.borderDefault,
|
|
position: "relative",
|
|
"::before": {
|
|
content: '""',
|
|
position: "absolute",
|
|
top: 0,
|
|
bottom: 0,
|
|
left: "-3px",
|
|
width: "7px",
|
|
},
|
|
})}
|
|
/>
|
|
|
|
{/* Right: vertical terminal list */}
|
|
<div
|
|
className={css({
|
|
width: `${listWidth}px`,
|
|
flexShrink: 0,
|
|
backgroundColor: t.surfacePrimary,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflowY: "auto",
|
|
})}
|
|
>
|
|
{processTabs.map((tab, tabIndex) => {
|
|
const isActive = activeTabId === tab.id;
|
|
const isHovered = hoveredTabId === tab.id;
|
|
const isDropTarget = tabDrag !== null && tabDrag.overIdx === tabIndex && tabDrag.fromIdx !== tabIndex;
|
|
const isBeingDragged = tabDrag !== null && tabDrag.fromIdx === tabIndex && didTabDrag.current;
|
|
return (
|
|
<div
|
|
key={tab.id}
|
|
data-terminal-idx={tabIndex}
|
|
onMouseEnter={() => setHoveredTabId(tab.id)}
|
|
onMouseLeave={() => setHoveredTabId((cur) => (cur === tab.id ? null : cur))}
|
|
onMouseDown={(e) => {
|
|
if (e.button !== 0 || editingTabId === tab.id) return;
|
|
tabDragStartY.current = e.clientY;
|
|
didTabDrag.current = false;
|
|
const state = { fromIdx: tabIndex, overIdx: null };
|
|
tabDragRef.current = state;
|
|
setTabDrag(state);
|
|
}}
|
|
onClick={() => {
|
|
if (!didTabDrag.current) setActiveTabId(tab.id);
|
|
}}
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: listWidth < 80 ? "center" : "flex-start",
|
|
gap: "8px",
|
|
padding: listWidth < 80 ? "8px 0" : "8px 12px",
|
|
margin: "2px 4px",
|
|
borderRadius: "8px",
|
|
cursor: "pointer",
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
"::before": {
|
|
content: '""',
|
|
position: "absolute",
|
|
top: "-2px",
|
|
left: 0,
|
|
right: 0,
|
|
height: "2px",
|
|
backgroundColor: isDropTarget ? t.textPrimary : "transparent",
|
|
transition: "background-color 100ms ease",
|
|
},
|
|
backgroundColor: isActive ? t.interactiveHover : "transparent",
|
|
opacity: isBeingDragged ? 0.4 : 1,
|
|
color: isActive ? t.textPrimary : t.textTertiary,
|
|
fontWeight: isActive ? 600 : 400,
|
|
fontSize: "12px",
|
|
transition: "all 150ms ease",
|
|
":hover": {
|
|
backgroundColor: t.interactiveHover,
|
|
},
|
|
})}
|
|
>
|
|
<SquareTerminal size={14} style={{ flexShrink: 0 }} />
|
|
{listWidth >= 80 &&
|
|
(editingTabId === tab.id ? (
|
|
<input
|
|
ref={editInputRef}
|
|
defaultValue={customTabNames[tab.id] ?? tab.title}
|
|
autoFocus
|
|
onClick={(e) => e.stopPropagation()}
|
|
onBlur={(e) => {
|
|
const val = e.currentTarget.value.trim();
|
|
if (val) {
|
|
setCustomTabNames((prev) => ({ ...prev, [tab.id]: val }));
|
|
}
|
|
setEditingTabId(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.currentTarget.blur();
|
|
} else if (e.key === "Escape") {
|
|
setEditingTabId(null);
|
|
}
|
|
}}
|
|
className={css({
|
|
flex: 1,
|
|
minWidth: 0,
|
|
background: "transparent",
|
|
border: "none",
|
|
outline: "none",
|
|
color: "inherit",
|
|
font: "inherit",
|
|
fontSize: "12px",
|
|
padding: 0,
|
|
margin: 0,
|
|
})}
|
|
/>
|
|
) : (
|
|
<span
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingTabId(tab.id);
|
|
}}
|
|
className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}
|
|
>
|
|
{customTabNames[tab.id] ?? tab.title}
|
|
</span>
|
|
))}
|
|
{listWidth >= 80 && (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Close ${tab.title}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
closeTerminalTab(tab.id);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") closeTerminalTab(tab.id);
|
|
}}
|
|
className={css({
|
|
width: "18px",
|
|
height: "18px",
|
|
borderRadius: "4px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: t.textMuted,
|
|
flexShrink: 0,
|
|
opacity: isHovered ? 1 : 0,
|
|
pointerEvents: isHovered ? "auto" : "none",
|
|
transition: "opacity 150ms ease, background 200ms ease, color 200ms ease",
|
|
":hover": {
|
|
backgroundColor: "rgba(255, 255, 255, 0.20)",
|
|
color: t.textSecondary,
|
|
},
|
|
})}
|
|
>
|
|
<Trash2 size={11} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Bottom drop zone for dragging to end of list */}
|
|
<div
|
|
data-terminal-idx={processTabs.length}
|
|
className={css({
|
|
flex: 1,
|
|
minHeight: "8px",
|
|
position: "relative",
|
|
"::before": {
|
|
content: '""',
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: "2px",
|
|
backgroundColor:
|
|
tabDrag !== null && tabDrag.overIdx === processTabs.length && tabDrag.fromIdx !== processTabs.length ? t.textPrimary : "transparent",
|
|
transition: "background-color 100ms ease",
|
|
},
|
|
})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|