feat(workspace): AI tool per session, one place to start a session, ended sessions open like Claude Desktop

- A session picks its own AI tool: `tool` on POST /api/lanes/:id/start
  (a catalog id, known + enabled, else 400 EBADTOOL/EDISABLED); a resume
  keeps the tool its session ran on (`sessions.tool`). The workspace's
  `lanes.tool` is now only the default.
- The workspace home's prompt box is the one place a session starts. Its
  fields run in order: AI → AI Router (Native or a profile) → Model (the
  tool's own list, or the router's models) → Permission (Claude mode /
  Codex sandbox+approval / catalog bypass) → Effort.
- The pane's run form (RunSetup) is gone: New run, a single pane's exit and
  an empty split pane's New session all go to the home. RunSetup.tsx keeps
  PromptEditor, CwdAutocomplete and ModelPicker (new `choices` prop).
- The tool icon moved from the sidebar's workspace row to each session row
  (right edge, `session.tool`); the split-pane header shows the run's tool.
- An ended session opens in its workspace (`&session=`, SessionReplay.tsx):
  its conversation read-only plus a prompt box; only a SENT message resumes
  it (resumeSessionId + prompt, first free slot). The console is only
  hidden behind it.
- Analyst button beside Files/Changes in the right panel opens
  /sessions/:id for the opened session.
- Gemini CLI ships its own icon (client/public/ai-tools/gemini.svg, from
  zonalogo.com).
- RouterPicker always shows (Native disabled for tools without router
  support); useAiRouters() exported.
- Tests: server start-with-tool cases; SessionReplay, home field order/tool/
  router models, empty pane, Analyst; dropped the cwd-field suites
  (Run.defaultCwd, Workspace.laneCwd). Docs: CLAUDE.md rules, API.md,
  LANES.md, README, ARCHITECTURE, INSTALL.
This commit is contained in:
2026-10-02 15:29:38 +07:00
parent c507d37baf
commit 4802684d2f
34 changed files with 1137 additions and 2504 deletions
+41 -34
View File
@@ -1,14 +1,15 @@
/**
* @file RouterPicker.tsx
* @description The AI Router a NEW session starts on — a session's choice, not
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`).
* Shown only for a tool that can use one (Claude, Codex) and only when a
* profile beyond Native exists. The last pick is remembered for the next
* session (`agenthub.session.router`).
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
* Native or one of the saved profiles (`useAiRouters`, which the caller also
* reads to list the router's models). A tool that cannot use a router (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.router`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiRouter } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
@@ -36,28 +37,9 @@ export function useSessionRouter(): [string, (v: string) => void] {
return [value, set];
}
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
value,
onChange,
wrap = (el) => el,
}: {
tool: string;
value: string;
onChange: (v: string) => void;
/** Puts the select in the caller's labelled field; not called while hidden. */
wrap?: (select: ReactNode) => ReactNode;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
/** The saved profiles (Native included), null until they arrive. */
export function useAiRouters(): AiRouter[] | null {
const [routers, setRouters] = useState<AiRouter[] | null>(null);
useEffect(() => {
let alive = true;
api.aiRouters
@@ -68,6 +50,30 @@ export function RouterPicker({
alive = false;
};
}, []);
return routers;
}
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
routers,
value,
onChange,
}: {
tool: string;
/** From `useAiRouters`. */
routers: AiRouter[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const native = t("router.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a router the user cannot see.
@@ -75,16 +81,17 @@ export function RouterPicker({
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
}, [routers, value, onChange]);
if (!capable || !routers || routers.length <= 1) return null;
return wrap(
<div className="w-44" data-testid="router-picker" title={t("router.label")}>
const options =
capable && routers?.length
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="router-picker" title={t("router.label")}>
<Select<string>
value={value}
value={capable ? value : "native"}
onChange={onChange}
options={routers.map((r) => ({
value: r.name,
label: r.builtin ? t("router.native", { tool: aiTools.label(tool) }) : r.label,
}))}
options={options}
disabled={!capable}
/>
</div>
);