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
@@ -3,15 +3,16 @@
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error); double-click
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
* carrying its session id) opens that tmux pane on the Workspace page
* (`?lane=<id>&run=<runId>`, Ctrl/Cmd+click `&pane=1` to open it in a second
* split pane) — attaching, never starting or resuming anything;
* any other session opens its read-only transcript (`/sessions/:id`), where
* Resume is a separate, explicit action, because a resume re-reads the whole
* transcript into context.
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +24,8 @@ import { api, type RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
export const SESSION_LIMIT = 20;
@@ -164,6 +167,7 @@ export function WorkspaceSessionList({
liveRuns,
query,
selectedRunId,
selectedSessionId = null,
onMatches,
hidden = false,
}: {
@@ -174,6 +178,8 @@ export function WorkspaceSessionList({
query: string;
/** The `run` the Workspace page is attached to, to mark its row. */
selectedRunId: string | null;
/** The ended session the Workspace page is showing (`session`), likewise. */
selectedSessionId?: string | null;
/** How many sessions match `query`, once they are loaded — lets the
* sidebar search find a workspace by a session's name. */
onMatches?: (count: number) => void;
@@ -182,6 +188,7 @@ export function WorkspaceSessionList({
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const tools = useAiTools();
// Bumped after a rename or an archive, so the list re-reads.
const [version, setVersion] = useState(0);
const [renaming, setRenaming] = useState<string | null>(null);
@@ -276,7 +283,7 @@ export function WorkspaceSessionList({
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
{shown.map((s) => {
const run = runFor(s);
const selected = !!run && run.id === selectedRunId;
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
if (renaming === s.id) {
return (
<li key={s.id} className="py-0.5 pl-7 pr-2">
@@ -314,7 +321,7 @@ export function WorkspaceSessionList({
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
e.ctrlKey || e.metaKey ? "&pane=1" : ""
}`
: `/sessions/${s.id}`
: `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`
)
}
onDoubleClick={() => {
@@ -326,7 +333,7 @@ export function WorkspaceSessionList({
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -344,6 +351,10 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
/>
</button>
<button
type="button"