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:
@@ -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" />
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @file WorkspaceHome.test.tsx
|
||||
* @description The workspace home: a new session starts in the first free
|
||||
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
||||
* default), with its fields in order — AI, AI Router, Model (the router's
|
||||
* models on a router), Permission, Effort —, all-busy is refused, Recents open a live pane or the
|
||||
* default), AI and Model in the box and AI Router / Permission / Worktree
|
||||
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
|
||||
* transcript (never a resume), instructions save, context files upload and
|
||||
* remove, a folder is added with the repo's untracked worktrees suggested
|
||||
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
||||
@@ -32,7 +32,7 @@ vi.mock("../../run/RunSetup", () => ({
|
||||
}) => (
|
||||
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
ModelPicker: ({
|
||||
ModelMenu: ({
|
||||
value,
|
||||
onChange,
|
||||
choices,
|
||||
@@ -210,14 +210,21 @@ describe("WorkspaceHome", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lays its fields out in order: AI, AI Router, Model, Permission mode, Thinking effort", async () => {
|
||||
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
const box = screen.getByTestId("workspace-home-composer");
|
||||
const labels = [
|
||||
...box.querySelectorAll("button[aria-haspopup='listbox'], input[aria-label='model']"),
|
||||
].map((x) => x.getAttribute("aria-label"));
|
||||
expect(labels).toEqual(["AI", "AI Router", "model", "Permission mode", "Thinking effort"]);
|
||||
const labels = (id: string) =>
|
||||
[
|
||||
...screen
|
||||
.getByTestId(id)
|
||||
.querySelectorAll("button[aria-haspopup='listbox'], input[aria-label='model']"),
|
||||
].map((x) => x.getAttribute("aria-label"));
|
||||
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
||||
expect(labels("workspace-home-options")).toEqual([
|
||||
"AI Router",
|
||||
"Permission mode",
|
||||
expect.stringContaining("own git worktree"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts the session on the AI tool picked for it, with that tool's own fields", async () => {
|
||||
@@ -282,11 +289,14 @@ describe("WorkspaceHome", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
|
||||
it("starts the session in its own worktree when New worktree is picked (a repo only)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
await user.click(await screen.findByRole("checkbox", { name: "Worktree" }));
|
||||
await user.click(
|
||||
within(await screen.findByTestId("workspace-home-worktree")).getByRole("button")
|
||||
);
|
||||
await user.click(screen.getByText("New worktree"));
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
|
||||
Reference in New Issue
Block a user