9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
1173 lines
44 KiB
TypeScript
1173 lines
44 KiB
TypeScript
/**
|
||
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
|
||
* Claude Desktop project page: the name, a prompt box that starts a NEW
|
||
* session in the first free slot (0–9, `POST /api/lanes/:id/start`) and
|
||
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
|
||
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
||
* `ConsoleTabs.tsx`). Inside
|
||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
||
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
|
||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { Checkbox } from "../Checkbox";
|
||
import { copyText } from "../../lib/clipboard";
|
||
import { isSubmitEnter } from "../../lib/keys";
|
||
import { confirmDialog } from "../../lib/confirm";
|
||
import { useDialog } from "../../hooks/useDialog";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useNavigate } from "react-router-dom";
|
||
import {
|
||
ArchiveRestore,
|
||
ShieldAlert,
|
||
FileText,
|
||
Folder,
|
||
GitBranch,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
ArrowUp,
|
||
Plus,
|
||
X,
|
||
} from "lucide-react";
|
||
import {
|
||
api,
|
||
type CodexApproval,
|
||
type CodexSandbox,
|
||
type EffortLevel,
|
||
type LaneMemoryFile,
|
||
type LaneSource,
|
||
type LaneSourcesView,
|
||
type ModelChoice,
|
||
type PermissionMode,
|
||
type RunHandle,
|
||
} from "../../lib/api";
|
||
import type { Lane, Project, Session } from "../../lib/types";
|
||
import { fmtCost, timeAgo } from "../../lib/format";
|
||
import { useAiTools } from "../../hooks/useAiTools";
|
||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||
import { AiToolIcon } from "../AiToolIcon";
|
||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||
import { Select } from "../Select";
|
||
import { MarkdownPreview } from "../MarkdownPreview";
|
||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||
import { workspaceName } from "./WorkspaceRow";
|
||
import { SessionMenu } from "./SessionMenu";
|
||
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
|
||
import {
|
||
liveRunFor,
|
||
SessionFold,
|
||
sessionDot,
|
||
sessionDotLabel,
|
||
sessionWorktreeName,
|
||
useSessionPage,
|
||
} from "./WorkspaceSessionList";
|
||
|
||
export {
|
||
freeSlot,
|
||
laneOfRun,
|
||
paneRunId,
|
||
startInFreeTab,
|
||
startInFreeTabDetailed,
|
||
} from "./startSession";
|
||
import { startInFreeTab } from "./startSession";
|
||
import { PathPickButton } from "../PathPickButton";
|
||
|
||
function fmtBytes(n: number | null): string {
|
||
if (!n) return "";
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
/** Recents' page size; Show more adds another. */
|
||
const RECENT_PAGE = 30;
|
||
|
||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||
|
||
type HomeProps = Parameters<typeof HomeBody>[0];
|
||
|
||
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
||
* model, an open folder form, a late upload's answer) belongs to ONE
|
||
* workspace, and the page swaps `lane` without unmounting. */
|
||
export function WorkspaceHome(props: HomeProps) {
|
||
return <HomeBody key={props.lane.id} {...props} />;
|
||
}
|
||
|
||
function HomeBody({
|
||
lane,
|
||
onMenuAction,
|
||
onRenamed,
|
||
}: {
|
||
lane: Lane;
|
||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||
onMenuAction: (action: WorkspaceMenuAction) => void;
|
||
/** Called after a rename, so the page re-reads the lane list. */
|
||
onRenamed?: () => void;
|
||
}) {
|
||
const { t } = useTranslation("lanes");
|
||
const navigate = useNavigate();
|
||
const aiTools = useAiTools();
|
||
const prefs = useProjectTreePrefs();
|
||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||
const [tool, setTool] = useState(lane.tool);
|
||
const entry = aiTools.byId(tool);
|
||
const claude = tool === "claude";
|
||
const codex = tool === "codex";
|
||
// The half-typed prompt survives leaving the page or the workspace.
|
||
const draftKey = `agenthub.workspace.draft.${lane.id}`;
|
||
const [prompt, setPrompt] = useState(() => {
|
||
try {
|
||
return localStorage.getItem(draftKey) ?? "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
});
|
||
useEffect(() => {
|
||
try {
|
||
if (prompt) localStorage.setItem(draftKey, prompt);
|
||
else localStorage.removeItem(draftKey);
|
||
} catch {
|
||
/* storage unavailable: the draft just does not survive */
|
||
}
|
||
}, [draftKey, prompt]);
|
||
const [images, setImages] = useState<PromptImage[]>([]);
|
||
const [proxy, setProxy] = useSessionProxy();
|
||
const proxies = useAiProxies();
|
||
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||
const profile = proxyCapable
|
||
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||
: null;
|
||
const proxyModels: ModelChoice[] | undefined = profile
|
||
? [
|
||
...new Set(
|
||
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
||
(m): m is string => !!m
|
||
)
|
||
),
|
||
].map((id) => ({ id, label: id }))
|
||
: undefined;
|
||
// The tool's default model (also when the catalog arrives after mount); on
|
||
// a proxy, the proxy's own default. A new tool or proxy drops the pick.
|
||
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
||
const [model, setModel] = useState(defaultModel);
|
||
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
||
// Claude and Codex always take a model and an effort; a catalog tool a
|
||
// model only when its file names the flag, an effort never.
|
||
const hasModel = claude || codex || !!entry?.modelFlag;
|
||
const [mode, setMode] = useState<PermissionMode>("auto");
|
||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||
// Per session, never remembered.
|
||
const [bypass, setBypass] = useState(false);
|
||
const canBypass = !claude && !!entry?.bypass;
|
||
const [effort, setEffort] = useState<EffortLevel>("");
|
||
const hasEffort = claude || codex;
|
||
// Claude Desktop's worktree toggle: the new session gets a git worktree of
|
||
// its own (`claude --worktree`), made and cleaned up by Claude Code.
|
||
const [inWorktree, setInWorktree] = useState(false);
|
||
const [starting, setStarting] = useState(false);
|
||
const startingRef = useRef(false);
|
||
// An image still uploading: its path is not in the prompt yet.
|
||
const [uploading, setUploading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||
const [sessionTotal, setSessionTotal] = useState(0);
|
||
const [recentLimit, setRecentLimit] = useState(RECENT_PAGE);
|
||
// A Recents row being renamed (its session id) and the draft.
|
||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||
const [nameDraft, setNameDraft] = useState("");
|
||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||
const [editing, setEditing] = useState<string | null>(null);
|
||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||
const [memoryTab, setMemoryTab] = useState(0);
|
||
const [folderOpen, setFolderOpen] = useState(false);
|
||
const [folderPath, setFolderPath] = useState("");
|
||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||
const [title, setTitle] = useState<string | null>(null);
|
||
const fileInput = useRef<HTMLInputElement>(null);
|
||
const [ctxDrag, setCtxDrag] = useState(false);
|
||
const addFiles = async (picked: File[]) => {
|
||
for (const f of picked) {
|
||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||
}
|
||
};
|
||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||
const recentCtx = useContextMenu<Session>();
|
||
const sourceCtx = useContextMenu<LaneSource>();
|
||
|
||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||
// Bumped after a session is unarchived, so the lists re-read.
|
||
const [version, setVersion] = useState(0);
|
||
useEffect(() => {
|
||
let live = true;
|
||
api.run
|
||
.list()
|
||
.then((r) => live && setLiveRuns(r.items))
|
||
.catch(() => undefined);
|
||
api.sessions
|
||
.list({ lane: lane.id, archived: false, limit: recentLimit })
|
||
.then((r) => {
|
||
if (!live) return;
|
||
setSessions(r.sessions);
|
||
setSessionTotal(r.total ?? r.sessions.length);
|
||
})
|
||
.catch(() => live && setSessions([]));
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [lane.id, runKey, version, recentLimit]);
|
||
const archived = useSessionPage(
|
||
{ lane: lane.id, archived: true, limit: 30 },
|
||
`${runKey}:${version}`
|
||
);
|
||
|
||
useEffect(() => {
|
||
let live = true;
|
||
setView(null);
|
||
setMemory(null);
|
||
setEditing(null);
|
||
setError(null);
|
||
api.lanes
|
||
.sources(lane.id)
|
||
.then((v) => live && setView(v))
|
||
.catch((err) => live && setError(message(err)));
|
||
api.lanes
|
||
.memory(lane.id)
|
||
.then((r) => live && setMemory(r.files))
|
||
.catch(() => live && setMemory([]));
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [lane.id]);
|
||
|
||
// The repository this workspace belongs to (Settings → Projects used to
|
||
// show this): read once per visit, from the server's cached project list.
|
||
const [project, setProject] = useState<Project | null>(null);
|
||
useEffect(() => {
|
||
let live = true;
|
||
api.projects
|
||
.list()
|
||
.then(({ projects }) => {
|
||
if (live) setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null);
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [lane.id]);
|
||
|
||
// Suggestions are read when the folder form opens, not on every visit.
|
||
useEffect(() => {
|
||
if (!folderOpen) return;
|
||
let live = true;
|
||
api.projects
|
||
.list()
|
||
.then(({ projects }) => {
|
||
if (!live) return;
|
||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||
setSuggestions(
|
||
!p || !prefs.showUntracked
|
||
? []
|
||
: p.worktrees
|
||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||
.map((w) => w.path)
|
||
);
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||
|
||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||
setError(null);
|
||
try {
|
||
setView(await fn());
|
||
return true;
|
||
} catch (err) {
|
||
setError(message(err));
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const text = promptWithImages(prompt, images);
|
||
const start = async () => {
|
||
// Enter reaches here past the disabled button: the same guards again.
|
||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||
if (startingRef.current || uploading || !text) return;
|
||
startingRef.current = true;
|
||
setStarting(true);
|
||
setError(null);
|
||
try {
|
||
const runId = await startInFreeTab(lane.id, {
|
||
prompt: text,
|
||
// Only a different tool is named: the workspace's own keeps starting
|
||
// even after it was disabled in Settings.
|
||
...(tool !== lane.tool ? { tool } : {}),
|
||
...proxyArg(proxyCapable, proxy),
|
||
...(hasModel && model ? { model } : {}),
|
||
...(claude ? { permissionMode: mode } : {}),
|
||
...(codex ? { sandbox, approval } : {}),
|
||
...(canBypass && bypass ? { bypass: true } : {}),
|
||
...(hasEffort && effort ? { effort } : {}),
|
||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||
});
|
||
setPrompt("");
|
||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||
setImages([]);
|
||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||
} catch (err) {
|
||
setError(message(err));
|
||
} finally {
|
||
startingRef.current = false;
|
||
setStarting(false);
|
||
}
|
||
};
|
||
|
||
// The sidebar row's Rename and Archive, for a Recents row.
|
||
const patchSession = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||
try {
|
||
await api.sessions.patch(s.id, body);
|
||
setVersion((v) => v + 1);
|
||
} catch (err) {
|
||
setError(message(err));
|
||
}
|
||
};
|
||
const archiveSession = async (s: Session) => {
|
||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||
const wt = sessionWorktreeName(lane, s);
|
||
const ok = await confirmDialog(
|
||
wt && !live
|
||
? {
|
||
title: t("tree.archiveWorktreeTitle"),
|
||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||
}
|
||
: live
|
||
? {
|
||
title: t("tree.archiveLiveTitle"),
|
||
message: t("tree.archiveLiveBody"),
|
||
confirmLabel: t("sessionMenu.archive"),
|
||
destructive: false,
|
||
}
|
||
: {
|
||
title: t("tree.archiveSession"),
|
||
confirmLabel: t("sessionMenu.archive"),
|
||
destructive: false,
|
||
}
|
||
);
|
||
if (!ok) return;
|
||
if (wt && !live) {
|
||
try {
|
||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||
});
|
||
} catch (err) {
|
||
setError(message(err));
|
||
return;
|
||
}
|
||
}
|
||
await patchSession(s, { archived: true });
|
||
};
|
||
|
||
const openSession = (s: Session) => {
|
||
const r = liveRunFor(lane, liveRuns, s.id);
|
||
navigate(
|
||
`/workspace?lane=${lane.id}&${r ? `run=${encodeURIComponent(r.id)}` : `session=${encodeURIComponent(s.id)}`}`
|
||
);
|
||
};
|
||
|
||
// The tools a session may start on: installed and enabled, plus the
|
||
// workspace's own whatever its state.
|
||
const toolOptions = [
|
||
...new Set([
|
||
lane.tool,
|
||
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
|
||
]),
|
||
];
|
||
const danger =
|
||
claude && mode === "bypassPermissions"
|
||
? t("run:hint.permissionWarning")
|
||
: canBypass && bypass
|
||
? t("run:hint.bypassWarning", { tool: aiTools.label(tool) })
|
||
: codex && sandbox === "danger-full-access"
|
||
? t("run:hint.sandboxWarning")
|
||
: null;
|
||
|
||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||
const name = workspaceName(lane);
|
||
const card = "border-b border-border px-4 py-4 last:border-b-0";
|
||
const iconBtn =
|
||
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||
|
||
return (
|
||
<div
|
||
data-testid="workspace-home"
|
||
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
|
||
>
|
||
<div className="min-w-0 flex-1 basis-[28rem]">
|
||
<p className="text-xs text-fg-muted">
|
||
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
|
||
</p>
|
||
<div className="mt-3 flex items-center gap-2">
|
||
{title === null ? (
|
||
<h1
|
||
title={lane.cwd}
|
||
onContextMenu={(e) => setMenuAt(menuPoint(e))}
|
||
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
|
||
>
|
||
{name}
|
||
</h1>
|
||
) : (
|
||
<input
|
||
data-testid="workspace-home-rename"
|
||
aria-label={t("renameLabel")}
|
||
autoFocus
|
||
value={title}
|
||
maxLength={200}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
onBlur={() => setTitle(null)}
|
||
onKeyDown={async (e) => {
|
||
if (e.key === "Escape") setTitle(null);
|
||
if (!isSubmitEnter(e)) return;
|
||
const next = title.trim();
|
||
setTitle(null);
|
||
if (next === (lane.title || "")) return;
|
||
try {
|
||
await api.lanes.update(lane.id, { title: next });
|
||
onRenamed?.();
|
||
} catch (err) {
|
||
setError(message(err));
|
||
}
|
||
}}
|
||
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-1 text-2xl font-semibold text-fg-primary"
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
data-testid="workspace-home-menu"
|
||
aria-label={t("row.actions")}
|
||
title={t("row.actions")}
|
||
onClick={(e) => {
|
||
const r = e.currentTarget.getBoundingClientRect();
|
||
setMenuAt({ x: r.left - 200, y: r.bottom + 4 });
|
||
}}
|
||
className={iconBtn}
|
||
>
|
||
<MoreHorizontal className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
{project?.root && (
|
||
<p
|
||
data-testid="workspace-home-facts"
|
||
className="mt-1 flex flex-wrap gap-x-3 font-mono text-[11px] text-fg-muted"
|
||
>
|
||
<span title={t("home.repoRoot")}>{project.root}</span>
|
||
{project.worktrees.find((w) => w.main)?.branch && (
|
||
<span title={t("home.mainBranch")}>
|
||
{project.worktrees.find((w) => w.main)?.branch}
|
||
</span>
|
||
)}
|
||
<span>{t("home.worktreeCount", { count: project.worktrees.length })}</span>
|
||
</p>
|
||
)}
|
||
|
||
<div
|
||
data-testid="workspace-home-composer"
|
||
className="mt-5 rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50"
|
||
>
|
||
<PromptEditor
|
||
value={prompt}
|
||
onChange={setPrompt}
|
||
onSubmit={() => void start()}
|
||
placeholder={t("home.placeholder")}
|
||
rows={3}
|
||
slash={claude}
|
||
fileCwd={lane.cwd}
|
||
images={images}
|
||
onImagesChange={setImages}
|
||
onBusyChange={setUploading}
|
||
bare
|
||
/>
|
||
<div className="mt-1 flex items-center gap-1">
|
||
<div data-testid="workspace-home-tool">
|
||
<Select<string>
|
||
variant="ghost"
|
||
label={t("home.ai")}
|
||
value={tool}
|
||
onChange={(v) => {
|
||
setTool(v);
|
||
setBypass(false);
|
||
}}
|
||
options={toolOptions.map((id) => ({
|
||
value: id,
|
||
label: aiTools.label(id),
|
||
icon: (
|
||
<AiToolIcon
|
||
tool={{ id, label: aiTools.label(id) }}
|
||
className="h-4 w-4 !p-0.5"
|
||
/>
|
||
),
|
||
}))}
|
||
/>
|
||
</div>
|
||
<div className="ml-auto flex items-center gap-1">
|
||
{hasModel && (
|
||
<ModelMenu
|
||
value={model}
|
||
onChange={setModel}
|
||
tool={tool}
|
||
choices={proxyModels}
|
||
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
data-testid="workspace-home-start"
|
||
onClick={() => void start()}
|
||
disabled={starting || uploading || !text}
|
||
aria-label={t("home.start")}
|
||
title={t("home.start")}
|
||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||
>
|
||
<ArrowUp className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* The session's quieter choices, under the box like Claude Desktop's. */}
|
||
<div
|
||
data-testid="workspace-home-options"
|
||
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
||
>
|
||
<ProxyPicker
|
||
variant="ghost"
|
||
tool={tool}
|
||
proxies={proxies}
|
||
value={proxy}
|
||
onChange={setProxy}
|
||
/>
|
||
{claude && (
|
||
<Select<PermissionMode>
|
||
variant="ghost"
|
||
label={t("run:fields.permissionMode")}
|
||
value={mode}
|
||
onChange={setMode}
|
||
options={(
|
||
[
|
||
["auto", "Auto"],
|
||
["acceptEdits", "AcceptEdits"],
|
||
["default", "Default"],
|
||
["plan", "Plan"],
|
||
["bypassPermissions", "Bypass"],
|
||
] as const
|
||
).map(([value, key]) => ({
|
||
value,
|
||
label: t(`home.permission${key}`),
|
||
hint: t(`run:fields.permission${key}`),
|
||
}))}
|
||
/>
|
||
)}
|
||
{codex && (
|
||
<>
|
||
<Select<CodexSandbox>
|
||
variant="ghost"
|
||
label={t("run:fields.sandbox")}
|
||
value={sandbox}
|
||
onChange={setSandbox}
|
||
options={[
|
||
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
|
||
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
|
||
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
|
||
]}
|
||
/>
|
||
<Select<CodexApproval>
|
||
variant="ghost"
|
||
label={t("run:fields.approval")}
|
||
value={approval}
|
||
onChange={setApproval}
|
||
options={[
|
||
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
|
||
{ value: "never", label: t("run:fields.approvalNever") },
|
||
]}
|
||
/>
|
||
</>
|
||
)}
|
||
{claude && project?.root && (
|
||
<div data-testid="workspace-home-worktree">
|
||
<Select<"local" | "worktree">
|
||
variant="ghost"
|
||
label={t("home.worktreeHint")}
|
||
value={inWorktree ? "worktree" : "local"}
|
||
onChange={(v) => setInWorktree(v === "worktree")}
|
||
options={[
|
||
{
|
||
value: "local",
|
||
label: t("home.local"),
|
||
hint: t("home.localHint"),
|
||
icon: <Folder className="h-3.5 w-3.5" />,
|
||
},
|
||
{
|
||
value: "worktree",
|
||
label: t("home.worktree"),
|
||
hint: t("home.worktreeHint"),
|
||
icon: <GitBranch className="h-3.5 w-3.5" />,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
)}
|
||
{canBypass && (
|
||
<span className="px-2">
|
||
<Checkbox
|
||
tone="danger"
|
||
checked={bypass}
|
||
onChange={setBypass}
|
||
label={t("run:fields.bypass")}
|
||
labelClassName="text-xs text-status-danger"
|
||
/>
|
||
</span>
|
||
)}
|
||
</div>
|
||
{danger && (
|
||
<p className="mt-2 flex items-start gap-2 text-xs text-status-danger">
|
||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||
{danger}
|
||
</p>
|
||
)}
|
||
{error && (
|
||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<h2 className="mt-8 text-sm font-medium text-fg-muted">{t("home.recents")}</h2>
|
||
{sessions !== null && sessions.length === 0 && (
|
||
<p className="mt-2 text-sm text-fg-muted">{t("home.noRecents")}</p>
|
||
)}
|
||
<ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60">
|
||
{(sessions ?? []).map((s) => {
|
||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||
if (renamingId === s.id) {
|
||
return (
|
||
<li key={s.id} className="py-1.5">
|
||
<input
|
||
data-testid={`workspace-recent-rename-${s.id}`}
|
||
aria-label={t("tree.renameSession")}
|
||
autoFocus
|
||
value={nameDraft}
|
||
maxLength={200}
|
||
onChange={(e) => setNameDraft(e.target.value)}
|
||
onBlur={() => setRenamingId(null)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") setRenamingId(null);
|
||
if (!isSubmitEnter(e)) return;
|
||
setRenamingId(null);
|
||
const name = nameDraft.trim();
|
||
if (name && name !== s.name) void patchSession(s, { name });
|
||
}}
|
||
className="w-full rounded border border-accent bg-surface-1 px-2 py-1 text-sm text-fg-primary"
|
||
/>
|
||
</li>
|
||
);
|
||
}
|
||
return (
|
||
<li key={s.id}>
|
||
<button
|
||
type="button"
|
||
data-testid={`workspace-recent-${s.id}`}
|
||
data-live={live ? "true" : undefined}
|
||
onClick={() => openSession(s)}
|
||
onContextMenu={(e) => recentCtx.open(e, s)}
|
||
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
|
||
>
|
||
<span
|
||
aria-hidden
|
||
className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`}
|
||
/>
|
||
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, live)}`)}</span>
|
||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||
{s.name || s.id.slice(0, 8)}
|
||
</span>
|
||
{sessionWorktreeName(lane, s) && (
|
||
<span
|
||
title={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
|
||
className="inline-flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||
>
|
||
<GitBranch className="h-3 w-3" />
|
||
{sessionWorktreeName(lane, s)}
|
||
</span>
|
||
)}
|
||
{!!s.cost && s.cost > 0 && (
|
||
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
|
||
{fmtCost(s.cost)}
|
||
</span>
|
||
)}
|
||
<span className="shrink-0 text-xs text-fg-muted">
|
||
{timeAgo(s.last_activity ?? s.started_at)}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
{sessions !== null && sessionTotal > sessions.length && (
|
||
<button
|
||
type="button"
|
||
data-testid="workspace-recents-more"
|
||
onClick={() => setRecentLimit((n) => n + RECENT_PAGE)}
|
||
className="mt-1 text-xs text-fg-muted hover:text-fg-primary"
|
||
>
|
||
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
|
||
</button>
|
||
)}
|
||
<div className="mt-2">
|
||
<SessionFold
|
||
testId={`workspace-archived-${lane.id}`}
|
||
page={archived}
|
||
label={(count) => t("tree.archived", { count })}
|
||
actionLabel={t("tree.unarchive")}
|
||
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
|
||
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
|
||
onDone={() => setVersion((v) => v + 1)}
|
||
laneId={lane.id}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<aside
|
||
data-testid="workspace-context"
|
||
aria-label={t("home.contextPanel")}
|
||
className="card w-80 shrink-0 self-start p-0"
|
||
>
|
||
{view && !view.supported && (
|
||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||
</p>
|
||
)}
|
||
|
||
<section className={card}>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
|
||
{editing === null && (
|
||
<button
|
||
type="button"
|
||
aria-label={t("home.edit")}
|
||
title={t("home.edit")}
|
||
onClick={() => setEditing(view?.instructions ?? "")}
|
||
disabled={!view}
|
||
className={iconBtn}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{editing === null ? (
|
||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
|
||
{view?.instructions || t("home.instructionsEmpty")}
|
||
</p>
|
||
) : (
|
||
<div className="mt-2 space-y-2">
|
||
<textarea
|
||
data-testid="workspace-instructions"
|
||
aria-label={t("home.instructions")}
|
||
autoFocus
|
||
value={editing}
|
||
maxLength={view?.maxInstructions}
|
||
onChange={(e) => setEditing(e.target.value)}
|
||
rows={6}
|
||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||
/>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditing(null)}
|
||
className="btn-ghost text-xs"
|
||
>
|
||
{t("home.cancel")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||
setEditing(null);
|
||
}}
|
||
className="btn-primary text-xs"
|
||
>
|
||
{t("home.save")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className={card}>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMemoryTab(0);
|
||
setMemoryOpen(true);
|
||
}}
|
||
disabled={!memory?.length}
|
||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||
>
|
||
{t("home.viewMemory")}
|
||
</button>
|
||
</div>
|
||
<p className="mt-1 text-xs text-fg-muted">
|
||
{memory?.length
|
||
? t("home.memoryCount", { count: memory.length })
|
||
: t("home.memoryEmpty")}
|
||
</p>
|
||
</section>
|
||
|
||
<section
|
||
data-testid="workspace-context-files"
|
||
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||
// Files dragged in from the OS are added like the + button's pick.
|
||
onDragOver={(e) => {
|
||
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "copy";
|
||
setCtxDrag(true);
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||
}}
|
||
onDrop={(e) => {
|
||
setCtxDrag(false);
|
||
if (!view || e.dataTransfer.files.length === 0) return;
|
||
e.preventDefault();
|
||
void addFiles([...e.dataTransfer.files]);
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||
<button
|
||
type="button"
|
||
aria-label={t("home.addFile")}
|
||
title={t("home.addFile")}
|
||
onClick={() => fileInput.current?.click()}
|
||
disabled={!view}
|
||
className={iconBtn}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</button>
|
||
<input
|
||
ref={fileInput}
|
||
data-testid="workspace-file-input"
|
||
type="file"
|
||
multiple
|
||
hidden
|
||
onChange={(e) => {
|
||
const picked = [...(e.target.files ?? [])];
|
||
e.target.value = "";
|
||
void addFiles(picked);
|
||
}}
|
||
/>
|
||
</div>
|
||
{files.length === 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInput.current?.click()}
|
||
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
|
||
>
|
||
{t("home.contextEmpty")}
|
||
</button>
|
||
) : (
|
||
<ul className="mt-2 space-y-1">
|
||
{files.map((f) => (
|
||
<li
|
||
key={f.id}
|
||
className="group flex items-center gap-2 text-xs"
|
||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||
>
|
||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||
{f.name}
|
||
</span>
|
||
<span className="shrink-0 text-fg-muted">
|
||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`${t("home.remove")} ${f.name}`}
|
||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section className={card}>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
|
||
<button
|
||
type="button"
|
||
aria-label={t("home.addFolder")}
|
||
title={t("home.addFolder")}
|
||
onClick={() => setFolderOpen((v) => !v)}
|
||
disabled={!view}
|
||
className={iconBtn}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<ul className="mt-2 space-y-1.5">
|
||
<li className="flex items-start gap-2 text-xs">
|
||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||
<span className="min-w-0">
|
||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||
</span>
|
||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||
</span>
|
||
</li>
|
||
{folders.map((f) => (
|
||
<li
|
||
key={f.id}
|
||
className="group flex items-center gap-2 text-xs"
|
||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||
>
|
||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||
{f.name}
|
||
{f.missing && (
|
||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||
)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`${t("home.remove")} ${f.name}`}
|
||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{folderOpen && (
|
||
<form
|
||
className="mt-3 space-y-2"
|
||
onSubmit={async (e) => {
|
||
e.preventDefault();
|
||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||
setFolderPath("");
|
||
setFolderOpen(false);
|
||
}
|
||
}}
|
||
>
|
||
<div className="flex gap-2">
|
||
<input
|
||
data-testid="workspace-folder-input"
|
||
aria-label={t("home.folderPath")}
|
||
placeholder={t("home.folderPath")}
|
||
autoFocus
|
||
value={folderPath}
|
||
onChange={(e) => setFolderPath(e.target.value)}
|
||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||
/>
|
||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||
{t("home.add")}
|
||
</button>
|
||
</div>
|
||
{suggestions.length > 0 && (
|
||
<div>
|
||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||
{t("home.suggested")}
|
||
</p>
|
||
<ul className="mt-1 space-y-0.5">
|
||
{suggestions.map((p) => (
|
||
<li key={p}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFolderPath(p)}
|
||
title={p}
|
||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||
>
|
||
{p}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</form>
|
||
)}
|
||
</section>
|
||
</aside>
|
||
|
||
{memoryOpen && memory && (
|
||
<div
|
||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||
role="presentation"
|
||
onClick={() => setMemoryOpen(false)}
|
||
>
|
||
<div
|
||
ref={memoryPanel}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={t("home.memory")}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||
>
|
||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||
<button
|
||
type="button"
|
||
autoFocus
|
||
aria-label={t("home.close")}
|
||
onClick={() => setMemoryOpen(false)}
|
||
className={iconBtn}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div
|
||
role="tablist"
|
||
aria-label={t("home.memory")}
|
||
// Wraps, so every file is reachable without a horizontal wheel (a
|
||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||
>
|
||
{memory.map((f, i) => (
|
||
<button
|
||
key={f.path}
|
||
type="button"
|
||
role="tab"
|
||
id={`workspace-memory-tab-${i}`}
|
||
aria-controls="workspace-memory-panel"
|
||
tabIndex={i === memoryTab ? 0 : -1}
|
||
title={f.path}
|
||
aria-selected={i === memoryTab}
|
||
onClick={() => setMemoryTab(i)}
|
||
// APG tabs: arrows move (and select), one tab stop.
|
||
onKeyDown={(e) => {
|
||
const n = memory.length;
|
||
const to =
|
||
e.key === "ArrowRight"
|
||
? (i + 1) % n
|
||
: e.key === "ArrowLeft"
|
||
? (i - 1 + n) % n
|
||
: e.key === "Home"
|
||
? 0
|
||
: e.key === "End"
|
||
? n - 1
|
||
: null;
|
||
if (to === null) return;
|
||
e.preventDefault();
|
||
setMemoryTab(to);
|
||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||
}}
|
||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||
i === memoryTab
|
||
? "border-accent/30 bg-accent/15 text-accent"
|
||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{memory[memoryTab] && (
|
||
<div
|
||
role="tabpanel"
|
||
id="workspace-memory-panel"
|
||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||
className="min-h-0 flex-1 overflow-y-auto"
|
||
>
|
||
<MarkdownPreview text={memory[memoryTab].content} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{recentCtx.menu && (
|
||
<SessionMenu
|
||
x={recentCtx.menu.x}
|
||
y={recentCtx.menu.y}
|
||
laneId={lane.id}
|
||
session={recentCtx.menu.item}
|
||
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
|
||
onClose={recentCtx.close}
|
||
onRename={() => {
|
||
const s = recentCtx.menu!.item;
|
||
setNameDraft(s.name || "");
|
||
setRenamingId(s.id);
|
||
}}
|
||
onArchive={() => void archiveSession(recentCtx.menu!.item)}
|
||
/>
|
||
)}
|
||
{sourceCtx.menu && (
|
||
<ContextMenu
|
||
x={sourceCtx.menu.x}
|
||
y={sourceCtx.menu.y}
|
||
onClose={sourceCtx.close}
|
||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||
>
|
||
<MenuItem
|
||
onSelect={() => {
|
||
void copyText(sourceCtx.menu!.item.path);
|
||
sourceCtx.close();
|
||
}}
|
||
>
|
||
{t("menu.copyPath")}
|
||
</MenuItem>
|
||
<MenuSeparator />
|
||
<MenuItem
|
||
danger
|
||
onSelect={() => {
|
||
const id = sourceCtx.menu!.item.id;
|
||
sourceCtx.close();
|
||
void run(() => api.lanes.removeSource(lane.id, id));
|
||
}}
|
||
>
|
||
{t("home.remove")}
|
||
</MenuItem>
|
||
</ContextMenu>
|
||
)}
|
||
{menuAt && (
|
||
<WorkspaceRowMenu
|
||
lane={lane}
|
||
x={menuAt.x}
|
||
y={menuAt.y}
|
||
onClose={() => setMenuAt(null)}
|
||
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|