c507d37baf
AI Router is a session's choice, not a workspace's: - an AI Router select beside the model picker in the workspace home and a pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only Native; last pick remembered in agenthub.session.router) - `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets --settings=<data dir>/router-settings/<name>.json (0600, outranks project and user settings, key never in argv), Codex -p <name> + key via tmux -e - dashboard_runs.router; a resume naming none goes back to it - profile PATCH rewrites the session files, DELETE removes them - gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace router radio, the claude-/codex-home `routers` answers; clearLaneRouters takes the old keys out of each workspace's settings.local.json at start Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked on the rail) instead of in the brand's corner. Icons: shipped SVG marks for claude, codex, openclaw and hermes (a black/white pair swapped by theme) in client/public/ai-tools/, used by AiToolIcon before the server's favicon.
152 lines
4.6 KiB
TypeScript
152 lines
4.6 KiB
TypeScript
/**
|
|
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
|
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
|
* existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`),
|
|
* Rename (the row's inline field), Create/update readme (claude only, the same run
|
|
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
|
|
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
|
* managed lane. The server's guards are the real ones; the menu only hides
|
|
* what the lane's kind or tool makes meaningless. Closes on Escape, an outside
|
|
* click or after an item runs.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api } from "../../lib/api";
|
|
import type { Lane } from "../../lib/types";
|
|
|
|
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
|
|
|
|
export function WorkspaceRowMenu({
|
|
lane,
|
|
x,
|
|
y,
|
|
onClose,
|
|
onAction,
|
|
}: {
|
|
lane: Lane;
|
|
x: number;
|
|
y: number;
|
|
onClose: () => void;
|
|
/** Items the tree itself carries out (rename field, modals, selection). */
|
|
onAction: (action: WorkspaceMenuAction) => void;
|
|
}) {
|
|
const { t } = useTranslation("lanes");
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!ref.current?.contains(e.target as Node)) onClose();
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDown);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [onClose]);
|
|
|
|
const run = async (fn: () => Promise<unknown>, then?: WorkspaceMenuAction) => {
|
|
setError(null);
|
|
try {
|
|
await fn();
|
|
if (then) onAction(then);
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
const live = !!lane.run_id || lane.status === "running";
|
|
const item =
|
|
"block w-full rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
role="menu"
|
|
data-testid={`workspace-row-menu-${lane.id}`}
|
|
style={{
|
|
left: Math.min(x, window.innerWidth - 232),
|
|
top: Math.min(y, window.innerHeight - 320),
|
|
}}
|
|
className="fixed z-50 w-56 rounded-lg border border-border bg-surface-2 p-1 shadow-lg"
|
|
>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={item}
|
|
onClick={() =>
|
|
live
|
|
? void run(() => api.lanes.action(lane.id, "stop"))
|
|
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
|
|
}
|
|
>
|
|
{live ? t("menu.stop") : t("menu.start")}
|
|
</button>
|
|
<button type="button" role="menuitem" className={item} onClick={() => onAction("rename")}>
|
|
{t("menu.rename")}
|
|
</button>
|
|
{lane.tool === "claude" && (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={item}
|
|
onClick={() =>
|
|
void run(
|
|
() =>
|
|
api.run.start({
|
|
laneId: lane.id,
|
|
cwd: lane.cwd,
|
|
initialPrompt: "/sg-all:sg-md-writing",
|
|
}),
|
|
"started"
|
|
)
|
|
}
|
|
>
|
|
{t("actions.writeDocs")}
|
|
</button>
|
|
)}
|
|
<button type="button" role="menuitem" className={item} onClick={() => onAction("new-here")}>
|
|
{t("menu.newHere")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={item}
|
|
onClick={() => void run(() => navigator.clipboard.writeText(lane.cwd))}
|
|
>
|
|
{t("menu.copyPath")}
|
|
</button>
|
|
<div className="my-1 border-t border-border" />
|
|
{lane.kind === "managed" && (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={`${item} !text-status-danger`}
|
|
onClick={() => onAction("reset")}
|
|
>
|
|
{t("menu.reset")}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={`${item} !text-status-danger`}
|
|
onClick={() => onAction("remove")}
|
|
>
|
|
{t("menu.remove")}
|
|
</button>
|
|
{error && (
|
|
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|