From c3d283f05ec3905d15e416cd1a3c51782b5d86da Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 2 Aug 2026 01:46:38 +0700 Subject: [PATCH 1/3] feat: let projects switch between Claude and OpenAI --- apps/gui/frontend/src/api/client.ts | 3 + apps/gui/frontend/src/api/fixtures.ts | 3 + apps/gui/frontend/src/api/mock.ts | 9 +- .../frontend/src/features/draft/DraftTab.tsx | 17 +- .../src/features/project/Composer.test.tsx | 41 ++++- .../src/features/project/Composer.tsx | Bin 21336 -> 21889 bytes .../src/features/project/NotesEditor.test.tsx | 1 + .../src/features/project/ProjectTab.tsx | 31 +++- .../src/features/settings/SettingsTab.tsx | 7 +- apps/gui/frontend/src/stores/models.test.tsx | 65 +++++--- apps/gui/frontend/src/stores/workspace.tsx | 70 +++++--- apps/gui/frontend/src/types/index.ts | 6 +- apps/gui/src/projects.rs | 156 +++++++++++++++--- docs/gui-element-inventory.md | 2 +- 14 files changed, 316 insertions(+), 95 deletions(-) diff --git a/apps/gui/frontend/src/api/client.ts b/apps/gui/frontend/src/api/client.ts index 61092e8..07ad5c1 100644 --- a/apps/gui/frontend/src/api/client.ts +++ b/apps/gui/frontend/src/api/client.ts @@ -1,4 +1,5 @@ import type { + Agent, AgentIoEntry, AgentModels, AgentStatus, @@ -51,6 +52,7 @@ export interface AgencyZeroApi { /** The name and the initial items are parsed out of the agent's first reply. */ createProject(input: { firstMessage: string; + agent?: Agent; model?: string; permission?: Permission; /** Reasoning effort, as `Request::effort`. Absent means the CLI's default. */ @@ -93,6 +95,7 @@ export interface AgencyZeroApi { projectId: string; body: string; itemId?: string | null; + agent?: Agent; model?: string; permission?: Permission; /** Reasoning effort, as `Request::effort`. Absent means the CLI's default. */ diff --git a/apps/gui/frontend/src/api/fixtures.ts b/apps/gui/frontend/src/api/fixtures.ts index 6315c2e..6097c69 100644 --- a/apps/gui/frontend/src/api/fixtures.ts +++ b/apps/gui/frontend/src/api/fixtures.ts @@ -67,6 +67,7 @@ export const PROJECTS: Project[] = [ moderatorEnabled: true, forkedFrom: null, sessionId: null, + sessions: {}, lastActivityAt: ago(2 * 60_000), }, { @@ -79,6 +80,7 @@ export const PROJECTS: Project[] = [ moderatorEnabled: true, forkedFrom: null, sessionId: null, + sessions: {}, lastActivityAt: ago(9 * 60_000), }, { @@ -91,6 +93,7 @@ export const PROJECTS: Project[] = [ moderatorEnabled: true, forkedFrom: null, sessionId: null, + sessions: {}, lastActivityAt: ago(26 * 60 * 60_000), }, ]; diff --git a/apps/gui/frontend/src/api/mock.ts b/apps/gui/frontend/src/api/mock.ts index e35a14c..d9259b5 100644 --- a/apps/gui/frontend/src/api/mock.ts +++ b/apps/gui/frontend/src/api/mock.ts @@ -143,6 +143,7 @@ export function createMockApi(): AgencyZeroApi { forkedFrom: null, // The mock never runs an agent, so there is no session to report. sessionId: null, + sessions: {}, lastActivityAt: new Date().toISOString(), }; projects.push(project); @@ -152,9 +153,9 @@ export function createMockApi(): AgencyZeroApi { projectId: project.id, itemId: null, author: "user", - agent: settings.defaultAgent, + agent: input.agent ?? settings.defaultAgent, moderation: null, - model: input.model ?? settings.models[settings.defaultAgent].default, + model: input.model ?? settings.models[input.agent ?? settings.defaultAgent].default, permission: input.permission ?? settings.defaultPermission, usage: null, stop: "completed", @@ -350,9 +351,9 @@ export function createMockApi(): AgencyZeroApi { projectId: input.projectId, itemId: input.itemId ?? null, author: "user", - agent: settings.defaultAgent, + agent: input.agent ?? settings.defaultAgent, moderation: null, - model: input.model ?? settings.models[settings.defaultAgent].default, + model: input.model ?? settings.models[input.agent ?? settings.defaultAgent].default, permission: input.permission ?? settings.defaultPermission, usage: null, stop: "completed", diff --git a/apps/gui/frontend/src/features/draft/DraftTab.tsx b/apps/gui/frontend/src/features/draft/DraftTab.tsx index 7fd0c08..1193d42 100644 --- a/apps/gui/frontend/src/features/draft/DraftTab.tsx +++ b/apps/gui/frontend/src/features/draft/DraftTab.tsx @@ -26,19 +26,28 @@ export function DraftTab(props: { tab: Tab }): JSX.Element { size="lg" autofocus placeholder="Type to start your new project…" + agent={props.tab.agent} model={props.tab.model} modelOptions={promptModels()} - efforts={effortsFor(props.tab.model)} + efforts={effortsFor(props.tab.agent, props.tab.model)} effort={props.tab.effort} permission={props.tab.permission} - onModelChange={(model) => actions.setTabModel(props.tab.key, model, props.tab.permission)} + onModelChange={(agent, model) => + actions.setTabModel(props.tab.key, agent, model, props.tab.permission) + } onPermissionChange={(permission) => - actions.setTabModel(props.tab.key, props.tab.model, permission) + actions.setTabModel(props.tab.key, props.tab.agent, props.tab.model, permission) } // The same omission the project tab had: the effort menu called an // optional handler nobody passed, so a picked level never stuck. onEffortChange={(effort) => - actions.setTabModel(props.tab.key, props.tab.model, props.tab.permission, effort) + actions.setTabModel( + props.tab.key, + props.tab.agent, + props.tab.model, + props.tab.permission, + effort, + ) } onSend={(body) => actions.createProject(body, props.tab.key)} /> diff --git a/apps/gui/frontend/src/features/project/Composer.test.tsx b/apps/gui/frontend/src/features/project/Composer.test.tsx index 8b78c38..1da6613 100644 --- a/apps/gui/frontend/src/features/project/Composer.test.tsx +++ b/apps/gui/frontend/src/features/project/Composer.test.tsx @@ -19,10 +19,11 @@ function mount(overrides: Partial[0]> = {}) { { it("offers nothing beyond what it was given", async () => { const { getByLabelText } = mount({ model: "sonnet", - modelOptions: [{ value: "sonnet", label: "Sonnet" }], + modelOptions: [ + { value: "claude:sonnet", label: "Claude · Sonnet", agent: "claude", model: "sonnet" }, + ], }); const pill = getByLabelText("Model"); expect(pill.textContent?.match(/Sonnet/g) ?? []).toHaveLength(1); expect(pill).not.toHaveTextContent("fable"); }); + + it("routes an OpenAI model command to Codex", async () => { + const onModelChange = vi.fn(); + const { field, onSend } = mount({ + onModelChange, + modelOptions: [ + { value: "claude:sonnet", label: "Claude · Sonnet", agent: "claude", model: "sonnet" }, + { + value: "codex:gpt-5.6-sol", + label: "OpenAI · GPT-5.6-Sol", + agent: "codex", + model: "gpt-5.6-sol", + }, + ], + }); + type(field, "/model gpt-5.6-sol"); + + field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(onModelChange).toHaveBeenCalledWith("codex", "gpt-5.6-sol"); + expect(onSend).not.toHaveBeenCalled(); + }); }); /* @@ -209,8 +234,11 @@ describe("a draft belongs to its own tab", () => { { w%vc_liVH#4#@^CW`URtjLD&6`=07=^7Bkj1POa`RI*bF*z@ zXxYD9OyvQ?Nym zQcx%;$}cF^18emMt0>k>%gjsBKvSlmppl%Imy(&1Sdyx#U~8uU=jnl+rC@7o3pFb- zy&mW#1vND!HDL3>YLfGFbAbjyY}7%sMN`4v9;b_NIsn7)$y2q2?GzL;(-bryzSe{q znxE$j3M1!?#Ju!Ws2s%oI?(WexJ%P|@08b~}TmS$7 delta 150 zcmZo%&3I!O - + {(limit) => ( @@ -216,13 +222,18 @@ export function ProjectTab(props: { tab: Tab; project: Project }): JSX.Element { actions.compactProject(props.project.id)} + onCompact={ + props.tab.agent === "claude" + ? () => actions.compactProject(props.project.id) + : undefined + } available={state.commands[props.project.id]} autofocus placeholder="Ask, or type / for commands…" + agent={props.tab.agent} model={props.tab.model} modelOptions={promptModels()} - efforts={effortsFor(props.tab.model)} + efforts={effortsFor(props.tab.agent, props.tab.model)} effort={props.tab.effort} /* * This was missing entirely: the effort menu rendered and called @@ -230,7 +241,13 @@ export function ProjectTab(props: { tab: Tab; project: Project }): JSX.Element { * nothing. The bug read as "low cannot be selected". */ onEffortChange={(effort) => - actions.setTabModel(props.tab.key, props.tab.model, props.tab.permission, effort) + actions.setTabModel( + props.tab.key, + props.tab.agent, + props.tab.model, + props.tab.permission, + effort, + ) } permission={props.tab.permission} usage={contextLabel()} @@ -258,11 +275,11 @@ export function ProjectTab(props: { tab: Tab; project: Project }): JSX.Element { }) : undefined } - onModelChange={(model) => - actions.setTabModel(props.tab.key, model, props.tab.permission) + onModelChange={(agent, model) => + actions.setTabModel(props.tab.key, agent, model, props.tab.permission) } onPermissionChange={(permission) => - actions.setTabModel(props.tab.key, props.tab.model, permission) + actions.setTabModel(props.tab.key, props.tab.agent, props.tab.model, permission) } onSend={(body) => actions.send(props.project.id, body)} /> diff --git a/apps/gui/frontend/src/features/settings/SettingsTab.tsx b/apps/gui/frontend/src/features/settings/SettingsTab.tsx index 4ca8d7c..2072741 100644 --- a/apps/gui/frontend/src/features/settings/SettingsTab.tsx +++ b/apps/gui/frontend/src/features/settings/SettingsTab.tsx @@ -142,7 +142,10 @@ export function SettingsTab(): JSX.Element { const effortOptions = (): string[] => { const settings = state.settings; if (!settings) return []; - const ladder = effortsFor(settings.models[settings.defaultAgent]?.default ?? ""); + const ladder = effortsFor( + settings.defaultAgent, + settings.models[settings.defaultAgent]?.default ?? "", + ); return ladder.length > 0 ? ladder : [settings.defaultEffort]; }; @@ -150,7 +153,7 @@ export function SettingsTab(): JSX.Element { const taskManagerEfforts = (): string[] => { const settings = state.settings; if (!settings) return []; - const ladder = effortsFor(settings.taskManager.model); + const ladder = effortsFor("claude", settings.taskManager.model); return ladder.length > 0 ? ladder : [settings.taskManager.effort]; }; diff --git a/apps/gui/frontend/src/stores/models.test.tsx b/apps/gui/frontend/src/stores/models.test.tsx index 10f1fb0..af37db6 100644 --- a/apps/gui/frontend/src/stores/models.test.tsx +++ b/apps/gui/frontend/src/stores/models.test.tsx @@ -153,43 +153,64 @@ describe("choosing models", () => { }); describe("what the prompt offers", () => { - it("offers the enabled Claude models, in catalogue order", async () => { + it("offers the enabled Claude and OpenAI models, in catalogue order", async () => { const workspace = await mountWorkspace(); expect(workspace.promptModels().map((option) => option.value)).toEqual([ - "default", - "opus", - "sonnet", - "haiku", + "claude:default", + "claude:opus", + "claude:sonnet", + "claude:haiku", + "codex:gpt-5.6-sol", + "codex:gpt-5.6-terra", + "codex:gpt-5.5", ]); }); - /** The pill shows vendor names, not the raw ids that go on the command line. */ - it("labels an option with the vendor's display name", async () => { + /** The pill names both provider and model so a mixed list stays legible. */ + it("labels options with their provider and display name", async () => { const workspace = await mountWorkspace(); - const sonnet = workspace.promptModels().find((option) => option.value === "sonnet"); - expect(sonnet?.label).toBe("Sonnet"); + const sonnet = workspace.promptModels().find((option) => option.value === "claude:sonnet"); + const sol = workspace.promptModels().find((option) => option.value === "codex:gpt-5.6-sol"); + expect(sonnet?.label).toBe("Claude · Sonnet"); + expect(sol?.label).toBe("OpenAI · GPT-5.6-Sol"); }); it("follows the selection as it changes", async () => { const workspace = await mountWorkspace(); await workspace.actions.toggleModel("claude", "haiku", false); - expect(workspace.promptModels().map((option) => option.value)).not.toContain("haiku"); + expect(workspace.promptModels().map((option) => option.value)).not.toContain("claude:haiku"); }); - /* - * Claude only for now. Codex and Copilot are selectable in Settings so the - * code review UI has something to open with, but nothing sends to them yet, - * and a Codex id reaching the prompt would be sent to Claude. - */ - it("never offers a model belonging to another agent", async () => { + it("does not offer Copilot yet", async () => { const workspace = await mountWorkspace(); await workspace.actions.toggleModel("codex", "gpt-5.4", true); await workspace.actions.toggleModel("copilot", "gemini-3.6-flash", true); const offered = workspace.promptModels().map((option) => option.value); - expect(offered).not.toContain("gpt-5.4"); - expect(offered).not.toContain("gemini-3.6-flash"); + expect(offered).toContain("codex:gpt-5.4"); + expect(offered).not.toContain("copilot:gemini-3.6-flash"); + }); + + it("sends an OpenAI selection through the Codex agent", async () => { + const workspace = await mountWorkspace(); + workspace.actions.setTabModel("worktable", "codex", "gpt-5.6-sol", "read_only"); + + await workspace.actions.send("worktable", "Use OpenAI for this turn"); + + const sent = workspace.state.messages.worktable.at(-1); + expect(sent?.agent).toBe("codex"); + expect(sent?.model).toBe("gpt-5.6-sol"); + }); + + it("moves Ask to read-only when switching to OpenAI", async () => { + const workspace = await mountWorkspace(); + workspace.actions.setTabModel("worktable", "claude", "sonnet", "ask"); + workspace.actions.setTabModel("worktable", "codex", "gpt-5.6-sol", "ask"); + + const tab = workspace.state.tabs.find((candidate) => candidate.key === "worktable"); + expect(tab?.agent).toBe("codex"); + expect(tab?.permission).toBe("read_only"); }); }); @@ -211,7 +232,7 @@ describe("settings own the defaults", () => { it("does not let a per-tab override seed the next tab", async () => { const workspace = await mountWorkspace(); - workspace.actions.setTabModel("worktable", "haiku", "read_only"); + workspace.actions.setTabModel("worktable", "claude", "haiku", "read_only"); workspace.actions.openDraft(); @@ -245,7 +266,7 @@ describe("settings own the defaults", () => { */ it("leaves a tab alone when its model is still offered", async () => { const workspace = await mountWorkspace(); - workspace.actions.setTabModel("worktable", "haiku", "read_only"); + workspace.actions.setTabModel("worktable", "claude", "haiku", "read_only"); await workspace.actions.toggleModel("claude", "fable", true); @@ -258,7 +279,7 @@ describe("settings own the defaults", () => { await workspace.actions.setDefaultModel("claude", "opus"); await workspace.actions.toggleModel("claude", "sonnet", false); - expect(workspace.promptModels().map((option) => option.value)).not.toContain("sonnet"); + expect(workspace.promptModels().map((option) => option.value)).not.toContain("claude:sonnet"); }); }); @@ -279,7 +300,7 @@ describe("posture follows Settings too", () => { it("does not let a per-tab posture seed the next tab", async () => { const workspace = await mountWorkspace(); - workspace.actions.setTabModel("worktable", "sonnet", "bypass"); + workspace.actions.setTabModel("worktable", "claude", "sonnet", "bypass"); workspace.actions.openDraft(); diff --git a/apps/gui/frontend/src/stores/workspace.tsx b/apps/gui/frontend/src/stores/workspace.tsx index 9b5a055..2502043 100644 --- a/apps/gui/frontend/src/stores/workspace.tsx +++ b/apps/gui/frontend/src/stores/workspace.tsx @@ -251,6 +251,7 @@ const HOME_TAB: Tab = { kind: "home", projectId: null, label: "Home", + agent: "claude", model: "sonnet", effort: FALLBACK_EFFORT, permission: "read_only", @@ -427,23 +428,21 @@ function createWorkspace() { return project?.status === "active" ? "ready" : "quiet"; } - /** - * What the prompt's model pill offers, as `{ value, label }` pairs. - * - * Claude only: the prompt sends to Claude today, and the Codex and Copilot - * selections in Settings are collected for the code review UI rather than - * consumed here. Ordered by the catalogue rather than by the saved selection, - * so the menu reads in the vendor's own ranking. - * - * Empty until boot finishes, which the composer covers by keeping the tab's - * own model as an option rather than rendering an empty menu. - */ + /** Models enabled in Settings for the two project-capable agents. */ const promptModels = createMemo(() => { - const catalogue = state.models.find((entry) => entry.agent === "claude"); - const enabled = state.settings?.models.claude.enabled ?? []; - return (catalogue?.models ?? []) - .filter((model) => enabled.includes(model.id)) - .map((model) => ({ value: model.id, label: model.name })); + return (["claude", "codex"] as const).flatMap((agent) => { + const catalogue = state.models.find((entry) => entry.agent === agent); + const enabled = state.settings?.models[agent].enabled ?? []; + const provider = agent === "claude" ? "Claude" : "OpenAI"; + return (catalogue?.models ?? []) + .filter((model) => enabled.includes(model.id)) + .map((model) => ({ + value: `${agent}:${model.id}`, + label: `${provider} · ${model.name}`, + agent, + model: model.id, + })); + }); }); /** @@ -470,8 +469,8 @@ function createWorkspace() { * the crate establishes no ladder for that model, and the composer hides the * control rather than guessing at one. */ - function effortsFor(modelId: string): string[] { - const catalogue = state.models.find((entry) => entry.agent === "claude"); + function effortsFor(agent: Agent, modelId: string): string[] { + const catalogue = state.models.find((entry) => entry.agent === agent); return catalogue?.models.find((model) => model.id === modelId)?.efforts ?? []; } @@ -691,12 +690,17 @@ function createWorkspace() { return settings.models[settings.defaultAgent]?.default ?? ""; } + function defaultAgent(): Agent { + return state.settings?.defaultAgent ?? "claude"; + } + function projectTab(project: Project): Tab { return { key: project.id, kind: "project", projectId: project.id, label: project.name, + agent: defaultAgent(), model: defaultModel(), effort: defaultEffort(), permission: state.settings?.defaultPermission ?? "read_only", @@ -1132,6 +1136,7 @@ function createWorkspace() { kind: "draft", projectId: null, label: "Untitled", + agent: defaultAgent(), model: defaultModel(), effort: defaultEffort(), permission: state.settings?.defaultPermission ?? "read_only", @@ -1201,7 +1206,8 @@ function createWorkspace() { * editing an unrelated setting should not silently reset it. */ function reconcileTabModels(settings: GlobalSettings): void { - const selection = settings.models[settings.defaultAgent]; + const defaultAgent = settings.defaultAgent; + const selection = settings.models[defaultAgent]; if (!selection || selection.enabled.length === 0) return; state.tabs.forEach((tab, index) => { @@ -1213,6 +1219,7 @@ function createWorkspace() { */ if (tab.kind === "draft") { setState("tabs", index, { + agent: defaultAgent, model: selection.default, permission: settings.defaultPermission, effort: settings.defaultEffort, @@ -1225,25 +1232,38 @@ function createWorkspace() { * on has actually been withdrawn. An unrelated settings edit must not * silently reset a deliberate override. */ - if (selection.enabled.includes(tab.model)) return; - setState("tabs", index, { model: selection.default }); + const tabSelection = settings.models[tab.agent]; + if (tabSelection?.enabled.includes(tab.model)) return; + setState("tabs", index, { agent: defaultAgent, model: selection.default }); // The backend keeps per-tab state, so a migration has to reach it too, or // the next send would use the model the frontend just moved away from. void client().setTabModel(tab.key, selection.default, tab.permission); }); } - function setTabModel(key: string, model: string, permission: Permission, effort?: string): void { + function setTabModel( + key: string, + agent: Agent, + model: string, + permission: Permission, + effort?: string, + ): void { const index = state.tabs.findIndex((tab) => tab.key === key); if (index < 0) return; + // Codex has no mid-run approval channel. Moving an Ask tab to OpenAI also + // moves its visible posture to read-only, instead of failing only on send. + const compatiblePermission = + agent !== "claude" && permission === "ask" ? "read_only" : permission; // Effort only when the caller sent one: the model and permission pills // must not clobber a level someone picked a moment ago. setState( "tabs", index, - effort === undefined ? { model, permission } : { model, permission, effort }, + effort === undefined + ? { agent, model, permission: compatiblePermission } + : { agent, model, permission: compatiblePermission, effort }, ); - void client().setTabModel(key, model, permission); + void client().setTabModel(key, model, compatiblePermission); } // — mutations ———————————————————————————————————————————————————— @@ -1255,6 +1275,7 @@ function createWorkspace() { const tab = state.tabs.find((candidate) => candidate.key === tabKey); const created = await client().createProject({ firstMessage, + agent: tab?.agent, model: tab?.model, permission: tab?.permission, effort: tab?.effort, @@ -1329,6 +1350,7 @@ function createWorkspace() { await client().sendMessage({ projectId, body, + agent: tab?.agent, model: tab?.model, permission: tab?.permission, // The tab's effort, which was being dropped here: every run reached the diff --git a/apps/gui/frontend/src/types/index.ts b/apps/gui/frontend/src/types/index.ts index 222e516..78b1bd0 100644 --- a/apps/gui/frontend/src/types/index.ts +++ b/apps/gui/frontend/src/types/index.ts @@ -55,7 +55,7 @@ export type ProjectStatus = */ export type TabStatus = "running" | "blocked" | "error" | "ready" | "quiet"; -/** `Agent` in the crate. Claude in practice today. */ +/** `Agent` in the crate. Projects currently expose Claude and Codex. */ export type Agent = "claude" | "codex" | "copilot"; /** `Permission` in the crate. `read_only` is the default and widens deliberately. */ @@ -134,6 +134,8 @@ export interface Project { * the project rather than to any one message. */ sessionId: string | null; + /** Native session ids kept separately so changing providers can resume either conversation. */ + sessions: Partial>; /** ISO 8601. Orders the Recent list. */ lastActivityAt: string; } @@ -569,6 +571,8 @@ export interface Tab { /** → `Project.id`; null only for home and an uncreated draft. */ projectId: string | null; label: string; + /** Which CLI receives this tab's prompts. */ + agent: Agent; /** The tab's model. Swapping it in the composer sticks until changed again. */ model: string; /** diff --git a/apps/gui/src/projects.rs b/apps/gui/src/projects.rs index 2af2e6c..d10ce24 100644 --- a/apps/gui/src/projects.rs +++ b/apps/gui/src/projects.rs @@ -57,12 +57,23 @@ pub struct ProjectDto { /// Filled from `kv` by [`with_session`] rather than read off the row: it is /// not a column, deliberately. See [`session_key`]. pub session_id: Option, + /// Native session ids by provider. Separate keys let a project switch + /// agents and later resume either conversation without crossing them. + pub sessions: std::collections::BTreeMap, pub last_activity_at: String, } /// Attach the project's session id, which lives in `kv` rather than on the row. fn with_session(mut dto: ProjectDto, tables: &crate::db::tables::Tables) -> ProjectDto { dto.session_id = tables.kv_get(&session_key(&dto.id)); + for agent in [Agent::Claude, Agent::Codex] { + if let Some(session) = tables + .kv_get(&agent_session_key(&dto.id, agent)) + .filter(|session| !session.is_empty()) + { + dto.sessions.insert(agent_wire_name(agent).into(), session); + } + } dto } @@ -79,6 +90,7 @@ impl From for ProjectDto { forked_from: serde_json::from_str(&row.forked_from).ok(), // Filled by `with_session`, which has the tables to look it up in. session_id: None, + sessions: std::collections::BTreeMap::new(), last_activity_at: row.last_activity_at, } } @@ -233,6 +245,7 @@ pub struct SendMessageInput { pub project_id: String, pub body: String, pub item_id: Option, + pub agent: Option, pub model: Option, pub permission: Option, pub effort: Option, @@ -242,6 +255,7 @@ pub struct SendMessageInput { #[serde(rename_all = "camelCase")] pub struct CreateProjectInput { pub first_message: String, + pub agent: Option, pub model: Option, pub permission: Option, /// Reasoning effort, as `Request::effort`. `None` means the CLI's default. @@ -271,6 +285,24 @@ fn session_key(project_id: &str) -> String { format!("session:{project_id}") } +/// Provider-specific session storage. Claude keeps the legacy key so every +/// existing project resumes exactly where it did before this feature. +fn agent_session_key(project_id: &str, agent: Agent) -> String { + match agent { + Agent::Claude => session_key(project_id), + Agent::Codex => format!("session:codex:{project_id}"), + Agent::Copilot => format!("session:copilot:{project_id}"), + } +} + +fn agent_wire_name(agent: Agent) -> &'static str { + match agent { + Agent::Claude => "claude", + Agent::Codex => "codex", + Agent::Copilot => "copilot", + } +} + pub(crate) fn id(prefix: &str) -> String { format!("{prefix}-{}", uuid::Uuid::new_v4()) } @@ -677,6 +709,19 @@ fn parse_permission(raw: Option<&str>) -> Permission { } } +fn parse_agent(raw: Option<&str>) -> Result { + match raw.unwrap_or("claude") { + "claude" => Ok(Agent::Claude), + "codex" => Ok(Agent::Codex), + "copilot" => Err("Copilot projects are not available yet".into()), + other => Err(format!("unknown project agent: {other}")), + } +} + +fn can_inject(running: Agent, requested: Agent) -> bool { + running == Agent::Claude && requested == Agent::Claude +} + // — read path —————————————————————————————————————————————————————— #[tauri::command] @@ -2278,6 +2323,9 @@ const APPROVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 /// mid-run correction an interruption rather than a queued afterthought. pub struct ActiveRun { pub cancel: tokio::sync::watch::Sender, + /// Provider owning the live session. A tab may switch providers while it + /// runs, but its next message must not be injected into the old provider. + pub agent: Agent, /// `None` when the run has no conversation to interrupt. /// /// A command turn — `/compact` — rewrites the session instead of answering @@ -2890,6 +2938,7 @@ pub async fn compact_project( project_id.clone(), ActiveRun { cancel: cancel_tx, + agent: Agent::Claude, // Nothing to say into: see `ActiveRun::inject`. inject: None, }, @@ -3420,6 +3469,7 @@ pub async fn delete_project( .map_err(|error| failed("the pull request rows", &error))?; for key in [ session_key(&id), + agent_session_key(&id, Agent::Codex), io_persist_key(&id), partial_reply_key(&id), // The notes kept across compactions. Ids are not recycled, so this is @@ -3644,6 +3694,7 @@ pub async fn create_project( project_id, body: input.first_message, item_id: None, + agent: input.agent, model: input.model, permission: input.permission, effort: input.effort, @@ -3675,11 +3726,19 @@ pub async fn send_message( input: SendMessageInput, state: State<'_, AppState>, ) -> Result { + let agent = parse_agent(input.agent.as_deref())?; + let agent_name = agent_wire_name(agent); let model = input.model.clone().unwrap_or_default(); let permission = input .permission .clone() .unwrap_or_else(|| "read_only".into()); + if agent != Agent::Claude && permission == "ask" { + return Err(format!( + "{} cannot ask for approval during a run; choose read only, edit, auto, or bypass", + agent_wire_name(agent) + )); + } /* * One run per project, enforced here rather than in the composer. A second @@ -3698,6 +3757,10 @@ pub async fn send_message( .lock() .map_err(|_| "the run registry is unavailable".to_string())?; if let Some(running) = active.get(&input.project_id) { + if !can_inject(running.agent, agent) { + drop(active); + return Err(BUSY_WITH_RUN.into()); + } /* * A command turn takes no passengers. Refused *before* the row is * written, unlike the injection below: the words were not said to @@ -3716,7 +3779,7 @@ pub async fn send_message( project_id: input.project_id.clone(), item_id: input.item_id.clone().unwrap_or_default(), author: "user".into(), - agent: "claude".into(), + agent: agent_name.into(), moderation: String::new(), model: model.clone(), permission: permission.clone(), @@ -3759,6 +3822,9 @@ pub async fn send_message( input.project_id.clone(), ActiveRun { cancel: cancel_tx, + agent, + // Kept for the receiver's lifetime. The provider check above + // exposes live injection only for Claude. inject: Some(inject_tx), }, ); @@ -3777,7 +3843,7 @@ pub async fn send_message( project_id: input.project_id.clone(), item_id: input.item_id.clone().unwrap_or_default(), author: "user".into(), - agent: "claude".into(), + agent: agent_name.into(), moderation: String::new(), model: model.clone(), permission: permission.clone(), @@ -3848,7 +3914,9 @@ pub async fn send_message( // The agent's own session id for this project, when a turn has produced // one. Without it every turn starts a fresh conversation. - let resume = state.tables.kv_get(&session_key(&input.project_id)); + let resume = state + .tables + .kv_get(&agent_session_key(&input.project_id, agent)); /* * Where this project's knowledge checkpoints go, or `None` for the projects @@ -3941,6 +4009,7 @@ pub async fn send_message( inject_rx, project_id, input.body, + agent, model, permission, effort, @@ -3984,6 +4053,7 @@ async fn drive_run( mut inject_rx: tokio::sync::mpsc::UnboundedReceiver, project_id: String, prompt: String, + agent: Agent, model: String, permission: String, effort: Option, @@ -4026,14 +4096,14 @@ async fn drive_run( let prompt_echo = prompt.clone(); let effort_echo = effort.clone().filter(|value| !value.is_empty()); - let mut request = Request::new(Agent::Claude, prompt) + let mut request = Request::new(agent, prompt) .permission(parse_permission(Some(&permission))) .cwd(&cwd); /* - * Every directory after the first widens the working tree via Claude's - * `--add-dir`. Passed through `unchecked_args` — the crate has no unified - * spelling for this yet — which is safe here because the values are the - * user's own configured directories, not model output. + * Every directory after the first widens the working tree. Passed through + * `unchecked_args` because the crate has no unified spelling for it yet. + * Verified as `--add-dir ` against claude 2.1.212 and codex-cli + * 0.145.0 (`codex exec --help`) on 2026-08-02. */ for dir in &extra_dirs { request = request.unchecked_args(["--add-dir", dir]); @@ -4041,13 +4111,15 @@ async fn drive_run( // `ask`: every gated call — a write, a command, a read outside the working // tree — arrives as an approval question instead of a silent pre-decision. let asks = permission == "ask"; - if asks { + if asks && agent == Agent::Claude { request = request.approvals(); } // Always, not only under `ask` (approvals implies it anyway): the open // stdin is what lets a message typed mid-turn reach the model at its next // step boundary instead of waiting out the whole turn. - request = request.interactive(); + if agent == Agent::Claude { + request = request.interactive(); + } if !model.is_empty() { request = request.model(&model); } @@ -4228,7 +4300,8 @@ async fn drive_run( crate::log!( crate::log::Level::Info, "run", - "{project_id}: starting claude model={} permission={permission} cwd={cwd} resume={}", + "{project_id}: starting {} model={} permission={permission} cwd={cwd} resume={}", + agent_wire_name(agent), if model.is_empty() { "" } else { @@ -4244,7 +4317,8 @@ async fn drive_run( "sent", "request", format!( - "claude model={} permission={permission} effort={} cwd={cwd}{}\n\n{prompt_echo}", + "{} model={} permission={permission} effort={} cwd={cwd}{}\n\n{prompt_echo}", + agent_wire_name(agent), if model.is_empty() { "" } else { @@ -4909,7 +4983,7 @@ async fn drive_run( // agent is free to hand back a new id and the stale one would // resume the wrong conversation. if let Err(error) = tables - .kv_put(&session_key(&project_id), session.clone()) + .kv_put(&agent_session_key(&project_id, agent), session.clone()) .await { crate::log!( @@ -5020,7 +5094,7 @@ async fn drive_run( project_id: project_id.clone(), item_id: String::new(), author: "agent".into(), - agent: "claude".into(), + agent: agent_wire_name(agent).into(), moderation: String::new(), model: model.clone(), permission, @@ -5245,7 +5319,7 @@ async fn drive_run( project_id: project_id.clone(), item_id: String::new(), author: "agent".into(), - agent: "claude".into(), + agent: agent_wire_name(agent).into(), moderation: String::new(), model: model.clone(), permission, @@ -5313,7 +5387,7 @@ async fn drive_run( project_id: project_id.clone(), item_id: String::new(), author: "agent".into(), - agent: "claude".into(), + agent: agent_wire_name(agent).into(), moderation: String::new(), model: model.clone(), permission, @@ -5366,15 +5440,17 @@ async fn drive_run( * would not be now. */ drop(inject_rx); - checkpoint_if_due( - &app, - &tables, - &project_id, - &cwd, - &turn_usage, - checkpoint_dir.as_deref(), - ) - .await; + if agent == Agent::Claude { + checkpoint_if_due( + &app, + &tables, + &project_id, + &cwd, + &turn_usage, + checkpoint_dir.as_deref(), + ) + .await; + } } /// Take a knowledge sample if this turn pushed the conversation past a mark. @@ -5681,6 +5757,36 @@ mod tests { assert_eq!(parse_permission(None), Permission::ReadOnly); } + #[test] + fn project_agents_are_explicit_and_copilot_stays_out() { + assert_eq!(parse_agent(None), Ok(Agent::Claude)); + assert_eq!(parse_agent(Some("claude")), Ok(Agent::Claude)); + assert_eq!(parse_agent(Some("codex")), Ok(Agent::Codex)); + assert!(parse_agent(Some("copilot")).is_err()); + assert!(parse_agent(Some("unknown")).is_err()); + } + + #[test] + fn provider_sessions_cannot_cross() { + assert_eq!(agent_session_key("proj-1", Agent::Claude), "session:proj-1"); + assert_eq!( + agent_session_key("proj-1", Agent::Codex), + "session:codex:proj-1" + ); + assert_ne!( + agent_session_key("proj-1", Agent::Claude), + agent_session_key("proj-1", Agent::Codex) + ); + } + + #[test] + fn only_a_claude_turn_accepts_a_live_follow_up() { + assert!(can_inject(Agent::Claude, Agent::Claude)); + assert!(!can_inject(Agent::Codex, Agent::Codex)); + assert!(!can_inject(Agent::Claude, Agent::Codex)); + assert!(!can_inject(Agent::Codex, Agent::Claude)); + } + /// The arithmetic that read "60 tokens" on a ten-minute run. /// /// Numbers from the crate's own parser fixture: two calls in one turn, diff --git a/docs/gui-element-inventory.md b/docs/gui-element-inventory.md index 0ea2878..8434b05 100644 --- a/docs/gui-element-inventory.md +++ b/docs/gui-element-inventory.md @@ -91,7 +91,7 @@ fields the UI never reads. The inert and missing rows are the actual backlog. | Element | Status | Notes | | --- | --- | --- | | Text area, Enter to send, Shift+Enter newline | Local | Grows to 168px then scrolls. | -| Model pill | **Live** | Per tab, sticky. Offers the Claude models enabled in Settings, from `agent-abstraction`'s catalogue via `list_models`. Claude only until the code review UI exists. A tab keeps a model the selection later drops, rather than silently switching. See [`agent-model-surface.md`](agent-model-surface.md). | +| Model pill | **Live** | Per tab, sticky. Offers the Claude and OpenAI models enabled in Settings, from `agent-abstraction`'s catalogue via `list_models`. The provider and model move together; Copilot remains out of scope. A tab keeps a model the selection later drops, rather than silently switching. See [`agent-model-surface.md`](agent-model-surface.md). | | Permission pill | **Live** | Per tab, per session. `read_only` default. | | Usage readout | Display | Last message that reported usage. | | Send | **Live** | | From f76ea6ca22f140215d543de1df7e2fd82a7d1d92 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 2 Aug 2026 02:11:29 +0700 Subject: [PATCH 2/3] fix: restore project provider selection --- Cargo.lock | 14 +-- Cargo.toml | 2 +- apps/gui/frontend/src/api/client.ts | 2 - apps/gui/frontend/src/api/index.ts | 1 - apps/gui/frontend/src/api/mock.ts | 2 - apps/gui/frontend/src/api/tauri.ts | 2 - .../src/features/project/ProjectTab.tsx | 7 +- apps/gui/frontend/src/stores/models.test.tsx | 36 ++++++ apps/gui/frontend/src/stores/workspace.tsx | 112 ++++++++++++++---- apps/gui/frontend/src/types/index.ts | 2 +- apps/gui/src/projects.rs | 17 ++- docs/gui-wiring-plan.md | 2 +- 12 files changed, 147 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f51a09..8f57d8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,25 +142,25 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "az-agent" -version = "0.1.53" +version = "0.1.54" dependencies = [ "az-core", ] [[package]] name = "az-agent-proxy" -version = "0.1.53" +version = "0.1.54" dependencies = [ "az-core", ] [[package]] name = "az-core" -version = "0.1.53" +version = "0.1.54" [[package]] name = "az-gui" -version = "0.1.53" +version = "0.1.54" dependencies = [ "agent-abstraction", "az-core", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "az-mcp-proxy" -version = "0.1.53" +version = "0.1.54" dependencies = [ "az-core", ] @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "wt-migrate" -version = "0.1.53" +version = "0.1.54" dependencies = [ "derive_more 2.1.1", "eyre", @@ -5565,7 +5565,7 @@ dependencies = [ [[package]] name = "wt-tools" -version = "0.1.53" +version = "0.1.54" dependencies = [ "derive_more 2.1.1", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 0c93950..eadf94c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.53" +version = "0.1.54" edition = "2024" publish = false diff --git a/apps/gui/frontend/src/api/client.ts b/apps/gui/frontend/src/api/client.ts index 07ad5c1..a9a60fb 100644 --- a/apps/gui/frontend/src/api/client.ts +++ b/apps/gui/frontend/src/api/client.ts @@ -163,8 +163,6 @@ export interface AgencyZeroApi { createWorkspaceRoot(): Promise; // — Runs and tasks —————————————————————————————————————————— - /** The tab's model and posture stick until changed again. */ - setTabModel(tabKey: string, model: string, permission: Permission): Promise; /** `Run::cancel` — resolves once the process group is gone. */ cancelRun(projectId: string): Promise; diff --git a/apps/gui/frontend/src/api/index.ts b/apps/gui/frontend/src/api/index.ts index 3e77c9a..0eb2752 100644 --- a/apps/gui/frontend/src/api/index.ts +++ b/apps/gui/frontend/src/api/index.ts @@ -51,7 +51,6 @@ const COMMAND_FOR: Partial> = { chooseAttachments: "choose_attachments", getWorkspaceRoot: "get_workspace_root", createWorkspaceRoot: "create_workspace_root", - setTabModel: "set_tab_model", cancelRun: "cancel_run", compactProject: "compact_project", getCheckpoints: "get_checkpoints", diff --git a/apps/gui/frontend/src/api/mock.ts b/apps/gui/frontend/src/api/mock.ts index d9259b5..410d70f 100644 --- a/apps/gui/frontend/src/api/mock.ts +++ b/apps/gui/frontend/src/api/mock.ts @@ -455,8 +455,6 @@ export function createMockApi(): AgencyZeroApi { getWorkspaceRoot: () => settle({ path: "(no filesystem)", exists: false, isDefault: true }), createWorkspaceRoot: () => settle({ path: "(no filesystem)", exists: false, isDefault: true }), - setTabModel: () => settle(undefined), - async cancelRun(projectId) { for (let i = running.length - 1; i >= 0; i--) { if (running[i].projectId !== projectId) continue; diff --git a/apps/gui/frontend/src/api/tauri.ts b/apps/gui/frontend/src/api/tauri.ts index 49620d1..c7027ba 100644 --- a/apps/gui/frontend/src/api/tauri.ts +++ b/apps/gui/frontend/src/api/tauri.ts @@ -81,8 +81,6 @@ export function createTauriApi(): AgencyZeroApi { getWorkspaceRoot: () => call("get_workspace_root"), createWorkspaceRoot: () => call("create_workspace_root"), - setTabModel: (tabKey, model, permission) => - call("set_tab_model", { tabKey, model, permission }), cancelRun: (projectId) => call("cancel_run", { projectId }), compactProject: (projectId) => call("compact_project", { projectId }), getCheckpoints: (projectId) => call("get_checkpoints", { projectId }), diff --git a/apps/gui/frontend/src/features/project/ProjectTab.tsx b/apps/gui/frontend/src/features/project/ProjectTab.tsx index 1b07d26..7454ff8 100644 --- a/apps/gui/frontend/src/features/project/ProjectTab.tsx +++ b/apps/gui/frontend/src/features/project/ProjectTab.tsx @@ -30,13 +30,12 @@ export function ProjectTab(props: { tab: Tab; project: Project }): JSX.Element { /* * The agent that actually ran, not a hardcoded name: the last message that - * recorded one, falling back to the configured default. Settings can select - * Codex or Copilot, and a header that always said "Claude" would be lying. + * recorded one, falling back to the tab's next provider. A project can be + * switched before its next reply, and a header hardcoded to Claude would lie. */ const agent = () => [...messages()].reverse().find((message) => message.author === "agent")?.agent ?? - state.settings?.defaultAgent ?? - "claude"; + props.tab.agent; const running = () => state.running[props.project.id] ?? []; /* * A refusal, or a warning that one is coming. Not the heartbeat: the provider diff --git a/apps/gui/frontend/src/stores/models.test.tsx b/apps/gui/frontend/src/stores/models.test.tsx index af37db6..3280b5a 100644 --- a/apps/gui/frontend/src/stores/models.test.tsx +++ b/apps/gui/frontend/src/stores/models.test.tsx @@ -212,6 +212,20 @@ describe("what the prompt offers", () => { expect(tab?.agent).toBe("codex"); expect(tab?.permission).toBe("read_only"); }); + + it("restores the last provider when a project is closed and reopened", async () => { + const workspace = await mountWorkspace(); + workspace.actions.setTabModel("worktable", "codex", "gpt-5.6-sol", "auto"); + await workspace.actions.send("worktable", "Keep this project on OpenAI"); + + workspace.actions.closeTab("worktable"); + workspace.actions.openProject("worktable"); + + const tab = workspace.state.tabs.find((candidate) => candidate.key === "worktable"); + expect(tab?.agent).toBe("codex"); + expect(tab?.model).toBe("gpt-5.6-sol"); + expect(tab?.permission).toBe("auto"); + }); }); describe("settings own the defaults", () => { @@ -307,6 +321,28 @@ describe("posture follows Settings too", () => { await waitFor(() => expect(workspace.activeTab().kind).toBe("draft")); expect(workspace.activeTab().permission).toBe("read_only"); }); + + it("opens a Codex draft as read-only when Settings says Ask", async () => { + const workspace = await mountWorkspace(); + await workspace.actions.saveSettings({ defaultAgent: "codex", defaultPermission: "ask" }); + + workspace.actions.openDraft(); + + await waitFor(() => expect(workspace.activeTab().kind).toBe("draft")); + expect(workspace.activeTab().agent).toBe("codex"); + expect(workspace.activeTab().permission).toBe("read_only"); + }); + + it("keeps Copilot out of project tabs until project runs support it", async () => { + const workspace = await mountWorkspace(); + await workspace.actions.saveSettings({ defaultAgent: "copilot" }); + + workspace.actions.openDraft(); + + await waitFor(() => expect(workspace.activeTab().kind).toBe("draft")); + expect(workspace.activeTab().agent).toBe("claude"); + expect(workspace.activeTab().model).toBe("sonnet"); + }); }); describe("an open draft tracks the defaults", () => { diff --git a/apps/gui/frontend/src/stores/workspace.tsx b/apps/gui/frontend/src/stores/workspace.tsx index 2502043..88ccceb 100644 --- a/apps/gui/frontend/src/stores/workspace.tsx +++ b/apps/gui/frontend/src/stores/workspace.tsx @@ -258,6 +258,16 @@ const HOME_TAB: Tab = { status: "quiet", }; +/** Project runs support these two providers; Copilot remains Settings-only. */ +function isProjectAgent(agent: Agent): agent is "claude" | "codex" { + return agent === "claude" || agent === "codex"; +} + +/** Codex has no live approval channel, so Ask cannot be a visible Codex posture. */ +function compatiblePermission(agent: Agent, permission: Permission): Permission { + return agent !== "claude" && permission === "ask" ? "read_only" : permission; +} + function createWorkspace() { const [state, setState] = createStore({ projects: [], @@ -498,6 +508,8 @@ function createWorkspace() { backend.listAgentIo(projectId), backend.listPullRequests(projectId), ]); + const project = state.projects.find((candidate) => candidate.id === projectId); + const hydratedTab = project ? projectTab(project, messages) : null; batch(() => { setState("items", projectId, reconcile(items)); setState("messages", projectId, reconcile(messages)); @@ -506,6 +518,16 @@ function createWorkspace() { setState("logTotals", projectId, log.total); setState("agentIo", projectId, reconcile(io)); setState("pullRequests", projectId, reconcile(prs)); + if (hydratedTab) { + const tabIndex = state.tabs.findIndex((tab) => tab.key === projectId); + if (tabIndex >= 0) { + setState("tabs", tabIndex, { + agent: hydratedTab.agent, + model: hydratedTab.model, + permission: hydratedTab.permission, + }); + } + } }); /* * Ask about this project's open pull requests now rather than at the next @@ -589,7 +611,9 @@ function createWorkspace() { const remembered = new Set(prefs.openTabKeys); setState("tabs", [ HOME_TAB, - ...projects.filter((project) => remembered.has(project.id)).map(projectTab), + ...projects + .filter((project) => remembered.has(project.id)) + .map((project) => projectTab(project)), ]); const restored = state.tabs.some((tab) => tab.key === prefs.lastTabKey); setState("activeKey", restored ? prefs.lastTabKey : "home"); @@ -672,7 +696,7 @@ function createWorkspace() { } /** - * What a new tab starts on: the default agent's default model from Settings. + * What a new tab starts on: the default project-capable agent and model. * * Read from settings rather than from `prefs.lastModel`, which used to seed * this and silently won. Two places claiming to own "the default model" meant @@ -684,26 +708,64 @@ function createWorkspace() { return state.settings?.defaultEffort ?? FALLBACK_EFFORT; } + function defaultAgent(): "claude" | "codex" { + return state.settings?.defaultAgent === "codex" ? "codex" : "claude"; + } + function defaultModel(): string { const settings = state.settings; if (!settings) return ""; - return settings.models[settings.defaultAgent]?.default ?? ""; + return settings.models[defaultAgent()]?.default ?? ""; } - function defaultAgent(): Agent { - return state.settings?.defaultAgent ?? "claude"; + /** + * Restore the provider that most recently owned this conversation. + * + * Messages are authoritative because they preserve ordering. The session map + * is only the pre-hydration fallback, and only when exactly one provider has + * a session; two session ids cannot say which one ran last. + */ + function projectSelection(project: Project, transcript: readonly Message[]) { + const last = [...transcript] + .reverse() + .find( + (message) => + (message.author === "user" || message.author === "agent") && + isProjectAgent(message.agent), + ); + const lastAgent = last && isProjectAgent(last.agent) ? last.agent : null; + const hasClaude = Boolean(project.sessions.claude ?? project.sessionId); + const hasCodex = Boolean(project.sessions.codex); + const agent = lastAgent + ? lastAgent + : hasClaude !== hasCodex + ? hasCodex + ? "codex" + : "claude" + : defaultAgent(); + const selection = state.settings?.models[agent]; + const model = + last?.model && selection?.enabled.includes(last.model) + ? last.model + : (selection?.default ?? ""); + const permission = compatiblePermission( + agent, + last?.permission ?? state.settings?.defaultPermission ?? "read_only", + ); + return { agent, model, permission }; } - function projectTab(project: Project): Tab { + function projectTab(project: Project, transcript = state.messages[project.id] ?? []): Tab { + const selection = projectSelection(project, transcript); return { key: project.id, kind: "project", projectId: project.id, label: project.name, - agent: defaultAgent(), - model: defaultModel(), + agent: selection.agent, + model: selection.model, effort: defaultEffort(), - permission: state.settings?.defaultPermission ?? "read_only", + permission: selection.permission, status: "quiet", }; } @@ -1129,6 +1191,7 @@ function createWorkspace() { return; } const key = `draft-${Date.now()}`; + const agent = defaultAgent(); setState("tabs", (tabs) => [ ...tabs, { @@ -1136,10 +1199,10 @@ function createWorkspace() { kind: "draft", projectId: null, label: "Untitled", - agent: defaultAgent(), + agent, model: defaultModel(), effort: defaultEffort(), - permission: state.settings?.defaultPermission ?? "read_only", + permission: compatiblePermission(agent, state.settings?.defaultPermission ?? "read_only"), status: "quiet", }, ]); @@ -1206,7 +1269,7 @@ function createWorkspace() { * editing an unrelated setting should not silently reset it. */ function reconcileTabModels(settings: GlobalSettings): void { - const defaultAgent = settings.defaultAgent; + const defaultAgent = settings.defaultAgent === "codex" ? "codex" : "claude"; const selection = settings.models[defaultAgent]; if (!selection || selection.enabled.length === 0) return; @@ -1221,7 +1284,7 @@ function createWorkspace() { setState("tabs", index, { agent: defaultAgent, model: selection.default, - permission: settings.defaultPermission, + permission: compatiblePermission(defaultAgent, settings.defaultPermission), effort: settings.defaultEffort, }); return; @@ -1233,11 +1296,12 @@ function createWorkspace() { * silently reset a deliberate override. */ const tabSelection = settings.models[tab.agent]; - if (tabSelection?.enabled.includes(tab.model)) return; - setState("tabs", index, { agent: defaultAgent, model: selection.default }); - // The backend keeps per-tab state, so a migration has to reach it too, or - // the next send would use the model the frontend just moved away from. - void client().setTabModel(tab.key, selection.default, tab.permission); + if (isProjectAgent(tab.agent) && tabSelection?.enabled.includes(tab.model)) return; + setState("tabs", index, { + agent: defaultAgent, + model: selection.default, + permission: compatiblePermission(defaultAgent, tab.permission), + }); }); } @@ -1250,20 +1314,18 @@ function createWorkspace() { ): void { const index = state.tabs.findIndex((tab) => tab.key === key); if (index < 0) return; - // Codex has no mid-run approval channel. Moving an Ask tab to OpenAI also - // moves its visible posture to read-only, instead of failing only on send. - const compatiblePermission = - agent !== "claude" && permission === "ask" ? "read_only" : permission; + // The selection is frontend state. A sent message records it durably, and + // reopening the project restores it from that ordered conversation. + const nextPermission = compatiblePermission(agent, permission); // Effort only when the caller sent one: the model and permission pills // must not clobber a level someone picked a moment ago. setState( "tabs", index, effort === undefined - ? { agent, model, permission: compatiblePermission } - : { agent, model, permission: compatiblePermission, effort }, + ? { agent, model, permission: nextPermission } + : { agent, model, permission: nextPermission, effort }, ); - void client().setTabModel(key, model, compatiblePermission); } // — mutations ———————————————————————————————————————————————————— diff --git a/apps/gui/frontend/src/types/index.ts b/apps/gui/frontend/src/types/index.ts index 78b1bd0..207fc64 100644 --- a/apps/gui/frontend/src/types/index.ts +++ b/apps/gui/frontend/src/types/index.ts @@ -55,7 +55,7 @@ export type ProjectStatus = */ export type TabStatus = "running" | "blocked" | "error" | "ready" | "quiet"; -/** `Agent` in the crate. Projects currently expose Claude and Codex. */ +/** `Agent` in the crate. Settings covers all three; project tabs expose Claude and Codex. */ export type Agent = "claude" | "codex" | "copilot"; /** `Permission` in the crate. `read_only` is the default and widens deliberately. */ diff --git a/apps/gui/src/projects.rs b/apps/gui/src/projects.rs index d10ce24..5471f10 100644 --- a/apps/gui/src/projects.rs +++ b/apps/gui/src/projects.rs @@ -65,7 +65,6 @@ pub struct ProjectDto { /// Attach the project's session id, which lives in `kv` rather than on the row. fn with_session(mut dto: ProjectDto, tables: &crate::db::tables::Tables) -> ProjectDto { - dto.session_id = tables.kv_get(&session_key(&dto.id)); for agent in [Agent::Claude, Agent::Codex] { if let Some(session) = tables .kv_get(&agent_session_key(&dto.id, agent)) @@ -74,6 +73,8 @@ fn with_session(mut dto: ProjectDto, tables: &crate::db::tables::Tables) -> Proj dto.sessions.insert(agent_wire_name(agent).into(), session); } } + // The legacy field is the same Claude key, kept for older frontends. + dto.session_id = dto.sessions.get("claude").cloned(); dto } @@ -3467,16 +3468,18 @@ pub async fn delete_project( .delete_by_project(id.clone()) .await .map_err(|error| failed("the pull request rows", &error))?; - for key in [ - session_key(&id), - agent_session_key(&id, Agent::Codex), + let mut keys = [Agent::Claude, Agent::Codex, Agent::Copilot] + .map(|agent| agent_session_key(&id, agent)) + .to_vec(); + keys.extend([ io_persist_key(&id), partial_reply_key(&id), // The notes kept across compactions. Ids are not recycled, so this is // only an orphan — but it is an orphan that would be fed to an agent as // standing instructions if one ever were. crate::notes::notes_key(&id), - ] { + ]); + for key in keys { if let Err(error) = state.tables.kv_put(&key, String::new()).await { crate::log!( crate::log::Level::Warn, @@ -3736,7 +3739,7 @@ pub async fn send_message( if agent != Agent::Claude && permission == "ask" { return Err(format!( "{} cannot ask for approval during a run; choose read only, edit, auto, or bypass", - agent_wire_name(agent) + agent_name )); } @@ -4104,6 +4107,8 @@ async fn drive_run( * `unchecked_args` because the crate has no unified spelling for it yet. * Verified as `--add-dir ` against claude 2.1.212 and codex-cli * 0.145.0 (`codex exec --help`) on 2026-08-02. + * Each directory is one argv value passed directly to the CLI; no shell + * parses it, so metacharacters remain path characters rather than syntax. */ for dir in &extra_dirs { request = request.unchecked_args(["--add-dir", dir]); diff --git a/docs/gui-wiring-plan.md b/docs/gui-wiring-plan.md index 968e2c4..2790e5b 100644 --- a/docs/gui-wiring-plan.md +++ b/docs/gui-wiring-plan.md @@ -104,7 +104,7 @@ emits its event. Tauri dialog plugin at the same time — the panel currently takes a typed path because there is no native folder picker. - [ ] `create_item`, `delete_item`, `set_item_status`, `reorder_items`. -- [ ] `set_tab_model(tab_key, model, permission)` — persistence for `UiPrefs`-adjacent tab +- [ ] `set_tab_model(tab_key, agent, model, permission)` — persist an unsent per-tab selection state. The frontend keeps this in memory and in `localStorage`; decide whether Rust should own it at all, or whether this command should be dropped. From e47a0af0e21dbc77774925a082be1d773dac7005 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 2 Aug 2026 03:11:15 +0700 Subject: [PATCH 3/3] feat: decouple project flows from Claude --- Cargo.lock | 18 +- Cargo.toml | 2 +- apps/gui/Cargo.toml | 2 +- apps/gui/frontend/src/api/client.ts | 25 +- apps/gui/frontend/src/api/fixtures.ts | 39 +- apps/gui/frontend/src/api/mock.ts | 15 +- apps/gui/frontend/src/api/tauri.ts | 2 +- .../frontend/src/features/draft/DraftTab.tsx | 3 +- .../frontend/src/features/home/HomeTab.tsx | 32 +- .../src/features/project/Composer.test.tsx | 11 + .../src/features/project/Composer.tsx | 26 +- .../src/features/project/ProjectTab.tsx | 22 +- .../features/project/RunStatusLine.test.tsx | 3 + .../src/features/project/TranscriptPane.tsx | 10 +- .../src/features/settings/SettingsTab.tsx | 124 ++++-- .../frontend/src/stores/workspace.test.tsx | 7 + apps/gui/frontend/src/stores/workspace.tsx | 161 ++++++-- apps/gui/frontend/src/types/index.ts | 19 +- apps/gui/src/agents.rs | 46 +++ apps/gui/src/notes.rs | 23 +- apps/gui/src/projects.rs | 388 +++++++++++++----- apps/gui/src/settings.rs | 20 +- 22 files changed, 769 insertions(+), 229 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f57d8c..0649d9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,9 +23,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agent-abstraction" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d99b0b79c81ca75b3b3c2bbdcc9b7fa2b6a587477311766dbe1a4ef37d7f34fd" +checksum = "85c0f538036df42b4c6f62500d9242f9ea168bd2268e7333d365d9049760f42b" dependencies = [ "libc", "serde", @@ -142,25 +142,25 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "az-agent" -version = "0.1.54" +version = "0.1.55" dependencies = [ "az-core", ] [[package]] name = "az-agent-proxy" -version = "0.1.54" +version = "0.1.55" dependencies = [ "az-core", ] [[package]] name = "az-core" -version = "0.1.54" +version = "0.1.55" [[package]] name = "az-gui" -version = "0.1.54" +version = "0.1.55" dependencies = [ "agent-abstraction", "az-core", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "az-mcp-proxy" -version = "0.1.54" +version = "0.1.55" dependencies = [ "az-core", ] @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "wt-migrate" -version = "0.1.54" +version = "0.1.55" dependencies = [ "derive_more 2.1.1", "eyre", @@ -5565,7 +5565,7 @@ dependencies = [ [[package]] name = "wt-tools" -version = "0.1.54" +version = "0.1.55" dependencies = [ "derive_more 2.1.1", "dirs", diff --git a/Cargo.toml b/Cargo.toml index eadf94c..2c6f3fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.54" +version = "0.1.55" edition = "2024" publish = false diff --git a/apps/gui/Cargo.toml b/apps/gui/Cargo.toml index ecd0ab7..288abe6 100644 --- a/apps/gui/Cargo.toml +++ b/apps/gui/Cargo.toml @@ -11,7 +11,7 @@ tauri-build = { version = "2", features = [] } [dependencies] # Carries the store forward when a column changes; see crates/wt-migrate. wt-migrate = { path = "../../crates/wt-migrate" } -agent-abstraction = "0.4.3" +agent-abstraction = "0.4.4" az-core.workspace = true worktable = "0.9" # The `worktable!` macro emits code that names these by bare path rather than diff --git a/apps/gui/frontend/src/api/client.ts b/apps/gui/frontend/src/api/client.ts index a9a60fb..8464150 100644 --- a/apps/gui/frontend/src/api/client.ts +++ b/apps/gui/frontend/src/api/client.ts @@ -66,7 +66,7 @@ export interface AgencyZeroApi { setProjectPinned(id: string, pinned: boolean): Promise; /** Per-session override of the global moderator setting. */ setProjectModerator(id: string, enabled: boolean): Promise; - /** Claude only — `Error::Unsupported` on Codex and Copilot. */ + /** Requires the provider's fork capability; unsupported providers return an error. */ forkProject(projectId: string, messageId: string): Promise; addDir(projectId: string, path: string): Promise; removeDir(projectId: string, path: string): Promise; @@ -175,7 +175,7 @@ export interface AgencyZeroApi { * with the agent's own reason when it will not compact; a conversation too * short to summarise is the common one, and is an answer rather than a fault. */ - compactProject(projectId: string): Promise; + compactProject(projectId: string, agent: Agent): Promise; /** * What this project's agent keeps across compactions. @@ -309,7 +309,12 @@ export interface AppEvents { * the transcript's status line; `run:stopped` ends it. The mock never emits * this, which is correct — it fakes no run. */ - "run:accepted": { projectId: string }; + "run:accepted": { + projectId: string; + agent: Agent; + model: string; + permission: Permission; + }; /** * A message sent into a live run could not be delivered — the turn settled * in the race window. The words are already in the transcript; this hands @@ -360,7 +365,7 @@ export interface AppEvents { * make the set per-machine. Arrives once per run, so a session that has not * run yet has none and the composer falls back to what it knows itself. */ - "run:commands": { projectId: string; all: string[]; skills: string[] }; + "run:commands": { projectId: string; agent: Agent; all: string[]; skills: string[] }; /** * A conversation being rewritten into a summary of itself. * @@ -379,6 +384,7 @@ export interface AppEvents { */ "run:compaction": { projectId: string; + agent: Agent; driver: "command" | "agent"; phase: "learning" | "started" | "finished"; ok?: boolean; @@ -390,7 +396,7 @@ export interface AppEvents { * blocked for the rest of the session, since `resetsAt` passing is not * something the window can observe on its own. */ - "run:rate_limit_cleared": { projectId: string }; + "run:rate_limit_cleared": { projectId: string; agent: Agent }; /** * A tool call is waiting on the user. The run is blocked mid-turn until * `resolveApproval` answers, so this must render somewhere it will be seen. @@ -398,7 +404,14 @@ export interface AppEvents { "run:approval": { projectId: string } & PendingApproval; /** The question above was answered (by the user, or denied on timeout). */ "run:approval_resolved": { projectId: string; approvalId: string; allow: boolean }; - "run:stopped": { projectId: string; stop: string; exitCode: number | null }; + "run:stopped": { + projectId: string; + agent: Agent; + model: string; + permission: Permission; + stop: string; + exitCode: number | null; + }; } export type AppEvent = keyof AppEvents; diff --git a/apps/gui/frontend/src/api/fixtures.ts b/apps/gui/frontend/src/api/fixtures.ts index 6097c69..bd2bdc0 100644 --- a/apps/gui/frontend/src/api/fixtures.ts +++ b/apps/gui/frontend/src/api/fixtures.ts @@ -1,4 +1,5 @@ import type { + Agent, AgentModels, AgentStatus, GlobalSettings, @@ -371,6 +372,15 @@ export const AGENT_STATUS: AgentStatus[] = [ version: "2.1.205", minVersion: "2.1.100", caps: ["fork", "session id"], + capabilities: { + session: true, + fork: true, + events: true, + nativeSystem: true, + commands: true, + liveFollowUp: true, + approvals: true, + }, checkedAt: ago(2 * 60_000), }, { @@ -379,6 +389,15 @@ export const AGENT_STATUS: AgentStatus[] = [ version: "1.0.61", minVersion: "1.0.75", caps: ["session id"], + capabilities: { + session: true, + fork: false, + events: true, + nativeSystem: false, + commands: false, + liveFollowUp: false, + approvals: false, + }, checkedAt: ago(2 * 60_000), }, { @@ -387,6 +406,15 @@ export const AGENT_STATUS: AgentStatus[] = [ version: null, minVersion: "0.9.0", caps: ["thread id"], + capabilities: { + session: true, + fork: false, + events: true, + nativeSystem: false, + commands: false, + liveFollowUp: false, + approvals: false, + }, checkedAt: ago(2 * 60_000), }, ]; @@ -547,7 +575,13 @@ export const SETTINGS: GlobalSettings = { }, // Deliberately not the prompt's model: a list keeper running unattended // wants a cheap fast model far more often than a frontier one. - taskManager: { model: "haiku", effort: "medium", dirs: [] }, + taskManager: { + agent: "claude", + model: "haiku", + effort: "medium", + permission: "ask", + dirs: [], + }, envPolicy: "minimal", forwardProxyVars: false, completedItems: "resolve", @@ -567,10 +601,11 @@ export const SETTINGS: GlobalSettings = { */ export const RATE_LIMITS: Record< string, - { isBlocking: boolean; isWarning: boolean; message: string; resetsAt: string } + { agent: Agent; isBlocking: boolean; isWarning: boolean; message: string; resetsAt: string } > = { cafe: { // A real refusal, not the "allowed" heartbeat the provider also emits. + agent: "claude", isBlocking: true, isWarning: false, message: "Rate limited", diff --git a/apps/gui/frontend/src/api/mock.ts b/apps/gui/frontend/src/api/mock.ts index 410d70f..a3f3192 100644 --- a/apps/gui/frontend/src/api/mock.ts +++ b/apps/gui/frontend/src/api/mock.ts @@ -460,7 +460,17 @@ export function createMockApi(): AgencyZeroApi { if (running[i].projectId !== projectId) continue; emit("task:finished", finishTask(running.splice(i, 1)[0], false)); } - emit("run:stopped", { projectId, stop: "canceled", exitCode: null }); + const last = [...messages] + .reverse() + .find((message) => message.projectId === projectId && message.author === "user"); + emit("run:stopped", { + projectId, + agent: last?.agent ?? "claude", + model: last?.model ?? "", + permission: last?.permission ?? "read_only", + stop: "canceled", + exitCode: null, + }); return settle(undefined); }, @@ -565,7 +575,8 @@ export function createMockApi(): AgencyZeroApi { // A fixture session id, so the design shows the faded "session" line the // way a real first prompt would produce it. - getTaskManager: () => settle({ sessionId: taskManagerSession }), + getTaskManager: () => + settle({ agent: settings.taskManager.agent, sessionId: taskManagerSession }), async resetTaskManager() { taskManagerSession = null; diff --git a/apps/gui/frontend/src/api/tauri.ts b/apps/gui/frontend/src/api/tauri.ts index c7027ba..6345f60 100644 --- a/apps/gui/frontend/src/api/tauri.ts +++ b/apps/gui/frontend/src/api/tauri.ts @@ -82,7 +82,7 @@ export function createTauriApi(): AgencyZeroApi { createWorkspaceRoot: () => call("create_workspace_root"), cancelRun: (projectId) => call("cancel_run", { projectId }), - compactProject: (projectId) => call("compact_project", { projectId }), + compactProject: (projectId, agent) => call("compact_project", { projectId, agent }), getCheckpoints: (projectId) => call("get_checkpoints", { projectId }), setCheckpoints: (projectId, enabled) => call("set_checkpoints", { projectId, enabled }), getProjectNotes: (projectId) => call("get_project_notes", { projectId }), diff --git a/apps/gui/frontend/src/features/draft/DraftTab.tsx b/apps/gui/frontend/src/features/draft/DraftTab.tsx index 1193d42..9a48a91 100644 --- a/apps/gui/frontend/src/features/draft/DraftTab.tsx +++ b/apps/gui/frontend/src/features/draft/DraftTab.tsx @@ -16,7 +16,7 @@ import type { Tab } from "~/types"; * promise resolves, so a failed create leaves the draft exactly as typed. */ export function DraftTab(props: { tab: Tab }): JSX.Element { - const { actions, promptModels, effortsFor } = useWorkspace(); + const { actions, promptModels, effortsFor, permissionsFor } = useWorkspace(); return ( @@ -32,6 +32,7 @@ export function DraftTab(props: { tab: Tab }): JSX.Element { efforts={effortsFor(props.tab.agent, props.tab.model)} effort={props.tab.effort} permission={props.tab.permission} + permissions={permissionsFor(props.tab.agent)} onModelChange={(agent, model) => actions.setTabModel(props.tab.key, agent, model, props.tab.permission) } diff --git a/apps/gui/frontend/src/features/home/HomeTab.tsx b/apps/gui/frontend/src/features/home/HomeTab.tsx index f6ec8db..f0c4955 100644 --- a/apps/gui/frontend/src/features/home/HomeTab.tsx +++ b/apps/gui/frontend/src/features/home/HomeTab.tsx @@ -7,7 +7,7 @@ import { ApprovalCard } from "~/features/project/ApprovalCard"; import { AttachmentPills } from "~/features/project/Composer"; import { AgentIoList } from "~/features/project/ProjectPanel"; import { relativeTime } from "~/lib/format"; -import { nextStatus, statusSuffix } from "~/lib/labels"; +import { AGENT_LABELS, nextStatus, statusSuffix } from "~/lib/labels"; import { describeError, log } from "~/lib/log"; import { prefs, setPrefs, togglePanelSection } from "~/stores/prefs"; import { TASK_MANAGER_ID, useWorkspace } from "~/stores/workspace"; @@ -225,7 +225,7 @@ export function HomeTab(): JSX.Element { * backend failure must not swallow a prompt someone spent minutes writing. */ function TaskManagerComposer(): JSX.Element { - const { state, actions, isLive } = useWorkspace(); + const { state, actions, capabilitiesFor, isLive } = useWorkspace(); const [draft, setDraft] = createSignal(""); const [isSending, setIsSending] = createSignal(false); const [error, setError] = createSignal(null); @@ -250,8 +250,19 @@ function TaskManagerComposer(): JSX.Element { }; const isRunning = () => + TASK_MANAGER_ID in state.runStatus || (state.running[TASK_MANAGER_ID] ?? []).length > 0 || (state.streaming[TASK_MANAGER_ID] ?? "") !== ""; + const canFollowUp = () => { + const selectedAgent = state.settings?.taskManager.agent ?? "claude"; + const runningAgent = state.runStatus[TASK_MANAGER_ID]?.agent; + const agent = runningAgent ?? selectedAgent; + return ( + (runningAgent === undefined || runningAgent === selectedAgent) && + (capabilitiesFor(agent)?.liveFollowUp ?? false) + ); + }; + const waitsForRun = () => isRunning() && !canFollowUp(); const submit = async (): Promise => { // The pills become prose on the way out; a file alone is a sendable @@ -259,7 +270,7 @@ function TaskManagerComposer(): JSX.Element { const body = [draft().trim(), attachments().join("\n")] .filter((part) => part.length > 0) .join("\n\n"); - if (!body || isSending()) return; + if (!body || isSending() || waitsForRun()) return; setError(null); setIsSending(true); @@ -274,10 +285,13 @@ function TaskManagerComposer(): JSX.Element { } }; - const placeholder = () => - state.taskManagerSession - ? `Tell the task manager… · ${state.taskManagerSession}` - : "Tell the task manager…"; + const placeholder = () => { + const label = AGENT_LABELS[state.settings?.taskManager.agent ?? "claude"]; + if (waitsForRun()) return `${label} task manager is finishing its current turn…`; + return state.taskManagerSession + ? `Tell ${label} task manager… · ${state.taskManagerSession}` + : `Tell ${label} task manager…`; + }; return (
@@ -307,7 +321,7 @@ function TaskManagerComposer(): JSX.Element { */ placeholder={placeholder()} aria-label="Task manager prompt" - disabled={isSending()} + disabled={isSending() || waitsForRun()} class="min-w-0 flex-1 bg-transparent text-[12.5px] text-base-content placeholder:text-az-muted focus:outline-none disabled:opacity-60" /> } @@ -326,7 +340,7 @@ function TaskManagerComposer(): JSX.Element { }} placeholder={placeholder()} aria-label="Task manager prompt" - disabled={isSending()} + disabled={isSending() || waitsForRun()} class="az-scroll min-w-0 flex-1 resize-none bg-transparent text-[12.5px] text-base-content leading-[1.5] placeholder:text-az-muted focus:outline-none disabled:opacity-60" /> diff --git a/apps/gui/frontend/src/features/project/Composer.test.tsx b/apps/gui/frontend/src/features/project/Composer.test.tsx index 1da6613..fcdc56f 100644 --- a/apps/gui/frontend/src/features/project/Composer.test.tsx +++ b/apps/gui/frontend/src/features/project/Composer.test.tsx @@ -102,6 +102,17 @@ describe("Composer", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("labels a busy send from the provider follow-up capability", async () => { + const queued = mount({ isRunning: true, canFollowUp: false }); + expect(queued.getByLabelText("Queue after the running turn")).toBeTruthy(); + await queued.booted(); + queued.unmount(); + + const live = mount({ isRunning: true, canFollowUp: true }); + expect(live.getByLabelText("Send into the running turn")).toBeTruthy(); + await live.booted(); + }); + it("treats Shift+Enter as a newline rather than a send", () => { const { field, onSend } = mount(); type(field, "first line"); diff --git a/apps/gui/frontend/src/features/project/Composer.tsx b/apps/gui/frontend/src/features/project/Composer.tsx index 8ee2dd2..4b51a7b 100644 --- a/apps/gui/frontend/src/features/project/Composer.tsx +++ b/apps/gui/frontend/src/features/project/Composer.tsx @@ -48,6 +48,8 @@ export type ComposerProps = { effort: string; onEffortChange?: (effort: string) => void; permission: Permission; + /** Postures this provider can execute without silently degrading them. */ + permissions?: Permission[]; onModelChange: (agent: Agent, model: string) => void; onPermissionChange: (permission: Permission) => void; /** @@ -77,6 +79,8 @@ export type ComposerProps = { usage?: string; /** A run is in flight: the send button becomes Stop. */ isRunning?: boolean; + /** Whether a send during that run enters it instead of waiting for the slot. */ + canFollowUp?: boolean; onStop?: () => void; /** Larger prompt text, centred layout — the new-project variant. */ size?: "md" | "lg"; @@ -421,7 +425,7 @@ export function Composer(props: ComposerProps): JSX.Element { label="Permission" icon="lock" value={props.permission} - options={PERMISSION_ORDER.map((permission) => ({ + options={(props.permissions ?? PERMISSION_ORDER).map((permission) => ({ value: permission, label: PERMISSION_LABELS[permission], hint: PERMISSION_HINTS[permission], @@ -481,20 +485,24 @@ export function Composer(props: ComposerProps): JSX.Element { /> - {/* - While a run is live the pair reads: speak into the turn, or stop - it. A message sent now is delivered into the open turn and the - model takes it at its next step boundary — a real interruption, - so the button keeps its ordinary send face. - */} + {/* While a run is live, the provider capability decides whether this + interrupts the open turn or queues for the next one. */}