feat(workspace,settings): model menu chip in the composer, compact settings panels

The workspace home's composer takes ONE Model chip (ModelMenu: model list
plus Effort) and moves AI Router / Permission / Local-New worktree into the
row under it; Settings drops the General-page cost card and export row,
PricingSettings and the empty states are compacted for settings panels.
This commit is contained in:
2026-10-02 17:49:05 +07:00
parent da1b17778c
commit fadecaddd0
23 changed files with 925 additions and 640 deletions
+110 -94
View File
@@ -2,11 +2,12 @@
* @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 pane slot (0–3, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`) — the ONE place a session is started, its
* fields in order: AI (the session's own tool, the workspace's by default),
* AI Router (Native or a profile), Model (the tool's own list, or the
* router's models), Permission (Claude's mode, Codex's sandbox/approval, a
* catalog tool's bypass) and Effort — the workspace's Recents — the sessions it
* then opens it (`&run=<runId>`) — the ONE place a session is started. 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
* router's models, plus Effort); under it the quieter choices — AI Router
* (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),
* 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
@@ -34,7 +35,6 @@ import {
} from "lucide-react";
import {
api,
RUN_EFFORT_CHOICES,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
@@ -48,7 +48,7 @@ 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 { ModelPicker, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { AiToolIcon } from "../AiToolIcon";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
import { Select } from "../Select";
@@ -414,7 +414,7 @@ function HomeBody({
onImagesChange={setImages}
bare
/>
<div className="mt-1 flex flex-wrap items-center gap-0.5">
<div className="mt-1 flex items-center gap-1">
<div data-testid="workspace-home-tool">
<Select<string>
variant="ghost"
@@ -436,92 +436,14 @@ function HomeBody({
}))}
/>
</div>
<RouterPicker
variant="ghost"
tool={tool}
routers={routers}
value={router}
onChange={setRouter}
/>
{hasModel && (
<ModelPicker
variant="ghost"
value={model}
onChange={setModel}
tool={tool}
choices={routerModels}
/>
)}
{claude && (
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
onChange={setMode}
options={[
{ value: "auto", label: t("run:fields.permissionAuto") },
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
{ value: "default", label: t("run:fields.permissionDefault") },
{ value: "plan", label: t("run:fields.permissionPlan") },
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
]}
/>
)}
{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") },
]}
/>
</>
)}
{hasEffort && (
<Select<EffortLevel>
variant="ghost"
label={t("run:fields.effort")}
value={effort}
onChange={setEffort}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
)}
<div className="ml-auto flex items-center gap-3 pl-2">
{canBypass && (
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
/>
)}
{claude && project?.root && (
<Checkbox
checked={inWorktree}
onChange={setInWorktree}
label={t("home.worktree")}
title={t("home.worktreeHint")}
labelClassName="text-xs text-fg-secondary"
<div className="ml-auto flex items-center gap-1">
{hasModel && (
<ModelMenu
value={model}
onChange={setModel}
tool={tool}
choices={routerModels}
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
/>
)}
<button
@@ -538,6 +460,100 @@ function HomeBody({
</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"
>
<RouterPicker
variant="ghost"
tool={tool}
routers={routers}
value={router}
onChange={setRouter}
/>
{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" />