diff --git a/design-qa.md b/design-qa.md index 0b3cdd9853..f55df3d5fa 100644 --- a/design-qa.md +++ b/design-qa.md @@ -2,7 +2,7 @@ status: active owner: @esengine backup: @SivanCola -reviewed: 2026-10-13 +reviewed: 2026-10-17 --- # Foundation palette verification @@ -84,3 +84,105 @@ did not specify a dark layout. Phone captures confirm no horizontal overflow. Windows native installer acceptance and hosted CI are separate from these local results. The production build retains its existing large-chunk warning. + +# Composer composition placement / 输入框组合布局 + +final result: passed + +The annotated request moves the existing composition selector and management +entry beside the run status. It preserves the existing screen, visual tokens +and session behavior. / 按标注将组合选择与管理入口移到运行状态旁,沿用现有界面。 + +## Evidence / 证据 + +- Source visual truth: author-approved composer annotation (1310 × 1826 pixels; browser chrome included, density unspecified). The user attachment remains private; the synthetic baseline below records the same controls. +- Matched source capture: `docs/images/agent-compositions/composer-before-light-wide.png`. +- Implementation: `docs/images/agent-compositions/composer-light-wide.png`. +- Full-view comparison: the matched before/after captures above were opened together, each 1440 × 1000 pixels, 1440 × 1000 CSS px, device scale factor 1. No density resampling. +- Focused comparison: composer controls from those captures were compared together at original pixel density; `composer-context-light.png` records the final control arrangement. +- Additional captures: `composer-light-390.png`, `composer-dark-390.png` (390 × 844 CSS/pixel dimensions), and `composer-dark-wide.png` (1440 × 1000), all in the same image directory. +- State: local Mock preview, Chinese, idle, General selected, existing input draft and missing-key notice. No provider request submitted. + +## Findings / 结论 + +No actionable P0/P1/P2 differences remain. / 无需修复的 P0/P1/P2 差异。 + +- Typography: existing UI family, 11 px medium-weight controls and status typography retained; long names remain bounded by the selector. +- Layout: selector begins 16 px after run status; controls occupy the previously unused top row. The bottom toolbar fits one row at 1440 px. At 390 px the selector, status and management entry remain visible, with document width exactly 390 px. +- Colors: existing semantic theme tokens and hover/focus styles reused in both light and dark mode; no new palette. +- Assets: logo and icons preserved; no generated or replacement imagery. +- Content: composition names, source labels, error notice and management action retained. The placement is the only intentional visual change. +- Accessibility: composition controls sit outside `role="status"` and its live announcements. Accessible names and existing lock behavior are retained. + +## Checks / 验证 + +- Switched General → Code review → General through the relocated selector. +- Opened Agent presets through the relocated management entry, then closed it. +- Confirmed the selector is absent from the bottom toolbar in the real DOM. +- Browser warning/error log after interaction: empty. +- Typecheck and `make check`: passed. +- Focused interaction tests: 92 passed; full frontend suite: 632 files, 5,220 tests passed. +- Locked-session/new-session flow covered by the existing picker interaction test. + +## Comparison history / 对照记录 + +First rendered comparison passed; no P0/P1/P2 visual repair iteration was needed. +The matched before/after captures demonstrate the requested move and reduced +bottom-toolbar wrapping. / 首轮对照通过,上移后底栏在宽屏减少一行。 + +## Limits and next check / 范围 + +Browser-owned layout only; native titlebar and shell behavior are unchanged and +were not re-tested. No provider execution or new CI result is claimed for this +layout edit. / 本次验证浏览器布局,不涉及原生窗口或模型执行。 + +Checklist complete: placement, selection, management, live-region separation, +light/dark, wide/narrow, typecheck, affected and full frontend tests. + +# Unified composition menu / 统一组合菜单 + +final result: passed + +This section supersedes the separate selector/management controls above. +The annotation asks to combine those entries without changing the surrounding +composer. / 本节替代上述独立选择器与管理按钮,按标注合并为一个入口。 + +## Source and capture / 来源与截图 + +- Source visual truth: author-approved annotation showing the composition selector and adjacent management button, 452 × 312 pixels; density and full viewport unspecified. The user attachment remains private. +- Current-run baseline: `docs/images/agent-compositions/composition-menu-before.png`, 1440 × 1000 CSS/pixel dimensions, scale factor 1. +- Implementation: `composition-menu-light-wide.png`, `composition-menu-dark-wide.png` (1440 × 1000) and `composition-menu-light-390.png`, `composition-menu-dark-390.png` (390 × 844), all in the same directory, scale factor 1. +- Focused implementation: `composition-menu-detail.png`, 344 × 364 pixels at scale factor 1, including menu and trigger. +- Full-view comparison: `composition-menu-before.png` and `composition-menu-light-wide.png` were opened together. Baseline is closed; implementation is open to assess the added menu and unchanged surrounding composer. +- Focused comparison: the annotation and `composition-menu-detail.png` were opened together without density resampling. Source typography cannot support a 1:1 pixel comparison; the matched viewport supplies the product typography/layout reference. +- State: local Mock preview, Chinese, idle, Code review selected. No provider execution. + +## Flow audit / 流程检查 + +1. Read the active composition: healthy after combining the entries. A single trigger shows name and localized source. The separate ellipsis had no visible explanation; its action is now explicit. +2. Choose a composition: healthy. Menu rows show capability notes and source, mark the current choice, and close after selection. Evidence: `composition-menu-light-wide.png` and `composition-menu-selected.png`. +3. Manage presets: healthy. A separated footer opens the existing management page. Evidence: `composition-menu-manage.png`. Management costs one extra click; frequent switching retains the same click count. + +## Fidelity and interaction / 视觉与交互 + +- Typography: existing UI family and weights; 13 px choice titles and 11 px notes. Source labels sit beside names rather than consuming another row. +- Spacing: existing composer-menu recipe, 320 px menu width, 12 px radius and 8 px inner padding. At 390 px, the menu spans x=64…384 and the document remains exactly 390 px wide. +- Colors: existing surface, overlay, text, muted and focus tokens work in both themes; no new palette. +- Assets: existing chevron and brand assets retained; no new image assets. +- Copy: localized Built in/Personal/Project labels replace raw source identifiers. Management is a named footer action. +- Accessibility: trigger name includes the active composition and lock state; selected rows expose current state. Keyboard arrows skip disabled rows; Enter selects, Escape closes and restores trigger focus. This is scoped interaction evidence, not a full accessibility compliance claim. +- Safety of interaction: started/busy conversations never rebind through this menu. The same menu offers explicitly labeled new-conversation actions and management; hosts without creation support still expose management. + +## Iteration and validation / 迭代与验证 + +- First inspection found source metadata on an extra line, making each choice unnecessarily tall. The item title now uses a flex row; revised captures show compact name/source pairs. No actionable P0/P1/P2 visual issues remain. +- A later resize check found a P2 shared positioning defect: after 390 → 1440 px, menu left remained 88 px while its trigger settled at 570 px. Window resize preceded fold publication and sidebar transition completion. +- Shared floating placement now responds to fold publication and transition completion. The revised dark-wide capture and browser rectangles show both left edges at 570 px. +- A deterministic real-Picker regression fails without the positioning fix and also checks cleanup after closing. +- Verified selection, management, Escape and responsive placement in the browser; warning/error log was empty. +- Regression tests cover keyboard focus, distinct action identities, failed/pending selections, locked sessions, management without creation support, equal names from different sources, and deleted bound presets. +- Final validation after the resize repair: typecheck passed; frontend full suite passed, 633 files and 5,225 tests. Repository checks passed without increasing a budget. +- Native shell behavior and provider execution are outside this layout change; hosted CI and publication status are reported separately in the pull request. + +Checklist complete: one entry, grouped choices/actions, clear current state, +retained locks, keyboard access, themes, narrow viewport and regression checks. diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index 9c5e491f20..e4993c89f8 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -51,7 +51,6 @@ export const ACTIONS: UIAction[] = [ {id:"agent-composition.select",kind:"kernel-mutation",target:"none",proof:"authority-effect"}, {id:"agent-composition.manage",kind:"view",target:"none",proof:"interaction"}, {id:"agent-composition.new",kind:"kernel-mutation",target:"none",proof:"authority-effect"}, - {id:"agent-composition.new-choice",kind:"view",target:"none",proof:"interaction"}, {id:"agent-preset.reload",kind:"view",target:"none",proof:"interaction"}, {id:"agent-preset.view",kind:"view",target:"none",proof:"interaction"}, {id:"agent-preset.create",kind:"view",target:"none",proof:"interaction"}, diff --git a/desktop/frontend-next/src/styles/LAYOUT.md b/desktop/frontend-next/src/styles/LAYOUT.md index 949f4e220e..03967b57ff 100644 --- a/desktop/frontend-next/src/styles/LAYOUT.md +++ b/desktop/frontend-next/src/styles/LAYOUT.md @@ -1581,3 +1581,10 @@ what exists. - The mark at the top of the rail is one row: the wordmark and a compact filled uppercase badge ("STUDIO" by CSS; the text and the aria label are unchanged), vertically centred on each other, gap 8px. The badge is `--text` on `--page` inverted, so it is dark on light and light on dark and never moves with the theme. - The badge is hidden at 760px and below. The wordmark box is cropped to its drawn width so the badge sits next to the letters, not next to the asset's padding. + +## Composer context + +- Run status and the Agent composition selector share the top row. Controls remain outside the live status region; the input toolbar keeps task actions, model and execution settings. +- The row and composition controls wrap independently when space is constrained. Long composition names truncate inside the selector; diagnostics wrap without widening the composer. +- Composition selection and management share one menu: choices show source and capability notes; the separated footer opens management. Started conversations keep the same trigger with a locked label, and choices explicitly create another conversation. +- Open floating controls follow responsive fold changes and recheck their anchor after layout transitions finish; window resize alone precedes the body observer's fold publication. diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index cda3175c23..8b121ff9b1 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -5596,8 +5596,13 @@ .composition-modules legend { font-weight: 600; padding-inline: 4px; } .composition-module { margin-block: 8px; } .composition-module .in { flex: 1; min-width: 100px; } -.composition-picker { display: inline-flex; align-items: center; min-width: 0; } -.composition-picker select { max-width: 150px; } +.composition-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; max-width: 100%; } +.composition-picker > .picker { min-width: 0; max-width: 100%; } +.composition-trigger { max-width: 100%; } +.composition-trigger .nm { max-width: 180px; } +.composition-source { color: var(--faint); font-size: var(--fs-meta); } +.composition-trigger > .studio-icon { width: 12px; height: 12px; flex: none; } +.composition-picker [role="alert"] { overflow-wrap: anywhere; } .composition-editor :is(input, select), .composition-choice select { width: 100%; min-height: 34px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); padding: 6px 9px; font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); @@ -5605,4 +5610,3 @@ .composition-editor :is(input, select):focus-visible, .composition-choice select:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } .composition-editor :is(input, select):disabled { opacity: .65; } .composition-editor h4 { margin: 10px 12px 0; font-size: var(--fs-ui); } -.composition-picker { flex-wrap: wrap; gap: 4px; } diff --git a/desktop/frontend-next/src/styles/composer-menu.css b/desktop/frontend-next/src/styles/composer-menu.css index 574b118bb5..320e4c19c3 100644 --- a/desktop/frontend-next/src/styles/composer-menu.css +++ b/desktop/frontend-next/src/styles/composer-menu.css @@ -18,6 +18,10 @@ } :root :is(.polmenu, .studio-model-menu).composer-menu { --choice-width: 340px; } :root :is(.worktree-panel, .studio-effort-menu).composer-menu { --choice-width: 320px; } +:root .composer-menu.composition-menu { --choice-width: 320px; } +:root .composition-menu .studio-item-line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; } +:root .composition-menu .mi :is(.lb, .ds) { white-space: normal; overflow-wrap: anywhere; } +:root .composition-menu .composition-menu-note { margin: 0 8px 6px; color: var(--muted); font: 400 11px/1.45 var(--ui); } :root .composer-menu :is(.studio-picker-title, .studio-policy-head, .worktree-heading), :root .composer-menu > .mi.head:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: auto; min-height: 30px; margin: 0 0 4px; padding: 2px 8px 8px; border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; color: var(--muted); font: 500 12px/1.4 var(--ui); } :root .composer-menu :is(.studio-picker-title b, .studio-policy-head b), :root .composer-menu > .mi.head:first-child .lb { color: var(--muted); font: inherit; letter-spacing: 0; } :root .composer-menu :is(.studio-picker-title small, .studio-policy-head small), :root .composer-menu > .mi.head:first-child .rt { margin-left: auto; color: var(--muted); font: 400 11px/1.4 var(--ui); text-align: right; } diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index 82c994b2b2..cc79f2ca97 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -3102,14 +3102,23 @@ body { font-size: 14px; line-height: 1.75; } font: 500 10px/1.35 var(--ui); letter-spacing: 0; } +.compose-context { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 16px; + min-height: 28px; + margin: -3px 0 4px; +} .studio-runstate { display: flex; align-items: center; gap: 7px; min-height: 26px; - width: 100%; + min-width: 0; + max-width: 100%; padding: 0; - margin: -3px 0 4px; + margin: 0; color: var(--muted); font: 550 10px/1.2 var(--ui); } diff --git a/desktop/frontend-next/src/ui/AgentCompositionPicker.interaction.test.tsx b/desktop/frontend-next/src/ui/AgentCompositionPicker.interaction.test.tsx index b4146cf0b2..fec44e2a44 100644 --- a/desktop/frontend-next/src/ui/AgentCompositionPicker.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/AgentCompositionPicker.interaction.test.tsx @@ -1,21 +1,108 @@ // @vitest-environment jsdom -import {cleanup,render,screen} from "@testing-library/react"; +import {cleanup,render,screen,waitFor} from "@testing-library/react"; +import "./testkit"; import userEvent from "@testing-library/user-event"; import {afterEach,expect,it,vi} from "vitest"; import {MockPort} from "../port/mock"; import {AgentCompositionPicker} from "./AgentCompositionPicker"; import {CompositionNewContext} from "./CompositionNewContext"; +import {RunLine} from "./RunLine"; afterEach(cleanup); +it("groups composition controls with the run state without announcing controls as live status",async()=>{ + const port=new MockPort();const changed=vi.fn();const settings=vi.fn(); + const user=userEvent.setup(); + render( + {}}/> + ); + const picker=await screen.findByRole("button",{name:/^Agent 组合/}); + const status=screen.getByRole("status"); + expect(status.parentElement?.contains(picker)).toBe(true); + expect(status.contains(picker)).toBe(false); + expect(screen.queryByRole("button",{name:"管理 Agent 预设"})).toBeNull(); + await user.click(picker); + const review=screen.getByRole("menuitem",{name:/代码审查/}); + expect(review.getAttribute("data-action")).toBe("agent-composition.select"); + await user.click(review); + expect(changed).toHaveBeenCalledOnce(); + expect(picker.getAttribute("aria-label")).toContain("代码审查"); + await user.click(picker); + const manage=screen.getByRole("menuitem",{name:"管理 Agent 预设…"}); + expect(manage.getAttribute("data-action")).toBe("agent-composition.manage"); + await user.click(manage); + expect(settings).toHaveBeenCalledOnce(); +}); + it("keeps started conversations locked and opens the chosen composition in a new conversation",async()=>{ const port=new MockPort();const create=vi.fn();const select=vi.spyOn(port,"selectAgentComposition"); const user=userEvent.setup(); render({}} onSettings={()=>{}} onError={()=>{}}/>); - const picker=await screen.findByRole("combobox",{name:"Agent 组合"}); - expect(picker.disabled).toBe(true); - await user.selectOptions(screen.getByRole("combobox",{name:"用其他组合新建会话"}),"builtin:review"); - await user.click(screen.getByRole("button",{name:"新建会话"})); + const picker=await screen.findByRole("button",{name:/^Agent 组合/}); + await user.click(picker); + expect(screen.getByText("该会话已开始,组合保持原版本。请用其他组合新建会话。")).toBeTruthy(); + const review=screen.getByRole("menuitem",{name:/代码审查/}); + expect(review.getAttribute("data-action")).toBe("agent-composition.new"); + await user.click(review); expect(create).toHaveBeenCalledWith("builtin:review"); expect(select).not.toHaveBeenCalled(); }); + +it("supports keyboard selection and Escape with focus returning to the one trigger",async()=>{ + const port=new MockPort();const select=vi.spyOn(port,"selectAgentComposition"); + const user=userEvent.setup(); + render({}} onSettings={()=>{}} onError={()=>{}}/>); + const picker=await screen.findByRole("button",{name:/^Agent 组合/}); + picker.focus();await user.keyboard("{Enter}{ArrowDown}{Enter}"); + await waitFor(()=>expect(select).toHaveBeenCalledWith("builtin:review")); + expect(document.activeElement).toBe(picker); + await user.click(picker);await user.keyboard("{Escape}"); + expect(screen.queryByRole("menu")).toBeNull(); + expect(document.activeElement).toBe(picker); +}); + +it("keeps a failed selection unchanged and prevents duplicate submissions while pending",async()=>{ + const port=new MockPort();const error=vi.fn();const changed=vi.fn(); + let reject!:(error:Error)=>void; + const select=vi.spyOn(port,"selectAgentComposition").mockImplementation(()=>new Promise((_,fail)=>{reject=fail;})); + const user=userEvent.setup(); + render({}} onError={error}/>); + const picker=await screen.findByRole("button",{name:/^Agent 组合/}); + await user.click(picker);await user.click(screen.getByRole("menuitem",{name:/代码审查/})); + await user.click(picker); + expect((screen.getByRole("menuitem",{name:/资料研究/}) as HTMLButtonElement).disabled).toBe(true); + expect(document.activeElement).toBe(screen.getByRole("menuitem",{name:"管理 Agent 预设…"})); + await user.click(screen.getByRole("menuitem",{name:/资料研究/})); + expect(select).toHaveBeenCalledOnce(); + reject(new Error("fixture failure")); + await waitFor(()=>expect(error).toHaveBeenCalledOnce()); + expect(picker.textContent).toContain("通用"); + expect(changed).not.toHaveBeenCalled(); +}); + +it("keeps management accessible when a locked host cannot create conversations",async()=>{ + const port=new MockPort();const settings=vi.fn();const select=vi.spyOn(port,"selectAgentComposition"); + const user=userEvent.setup(); + render({}} onSettings={settings} onError={()=>{}}/>); + await user.click(await screen.findByRole("button",{name:/^Agent 组合.*已锁定/})); + expect(screen.getAllByRole("menuitem")).toHaveLength(1); + await user.click(screen.getByRole("menuitem",{name:"管理 Agent 预设…"})); + expect(settings).toHaveBeenCalledOnce(); + expect(select).not.toHaveBeenCalled(); +}); + +it("distinguishes equal names by source and retains a deleted binding's name",async()=>{ + const port=new MockPort();const catalog=await port.agentPresets(); + const original=catalog.presets[0]; + vi.spyOn(port,"agentPresets").mockResolvedValue({...catalog,presets:[ + {...original,definition:{...original.definition,id:"user:review",name:"同名审查"}}, + {...original,definition:{...original.definition,id:"project:review",name:"同名审查"}}, + ]}); + vi.spyOn(port,"agentComposition").mockResolvedValue({id:"user:deleted",name:"已删除但绑定的预设",digest:"frozen",revision:"1",started:false}); + const user=userEvent.setup(); + render({}} onSettings={()=>{}} onError={()=>{}}/>); + const trigger=await screen.findByRole("button",{name:/^Agent 组合.*已删除但绑定的预设/}); + await user.click(trigger); + const rows=screen.getAllByRole("menuitem",{name:/同名审查/}); + expect(rows.map(row=>row.textContent)).toEqual([expect.stringContaining("个人"),expect.stringContaining("项目")]); +}); diff --git a/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx b/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx index 2fc890dd53..5ce09ae343 100644 --- a/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx +++ b/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx @@ -2,34 +2,78 @@ import { useContext, useEffect, useState } from "react"; import { CompositionNewContext } from "./CompositionNewContext"; import type { AgentPort } from "../port/port"; import type { AgentPresetCatalog, CompositionSummary } from "../port/composition"; +import { Picker, type MenuItem } from "./Menu"; +import { StudioIcon } from "./StudioIcon"; import { t } from "../i18n"; import { reason } from "../i18n/kernel"; -export function AgentCompositionPicker({ port, binding,locked:statusLocked,running, onChanged, onSettings, onError }: { port: AgentPort; binding?:CompositionSummary|null;locked?:boolean; running: boolean; onChanged: () => void; onSettings: () => void; onError: (e: unknown) => void }) { +const MANAGE = "__manage"; + +function compositionName(id: string, name: string): string { + return id.startsWith("builtin:") ? t(name) : name; +} + +function compositionSource(id: string): string { + return t(id.startsWith("builtin:") ? "内置" : id.startsWith("project:") ? "项目" : "个人"); +} + +export function AgentCompositionPicker({ port, binding, locked: statusLocked, running, onChanged, onSettings, onError }: { + port: AgentPort; + binding?: CompositionSummary | null; + locked?: boolean; + running: boolean; + onChanged: () => void; + onSettings: () => void; + onError: (e: unknown) => void; +}) { const [catalog, setCatalog] = useState(null); const [current, setCurrent] = useState(null); const [busy, setBusy] = useState(false); - const [next, setNext] = useState(""); const createNew = useContext(CompositionNewContext); useEffect(() => { let active = true; - Promise.all([port.agentPresets(), port.agentComposition()]).then(([list, binding]) => { if (active) { setCatalog(list); setCurrent(binding); } }).catch(() => {}); + Promise.all([port.agentPresets(), port.agentComposition()]).then(([list, binding]) => { + if (active) { setCatalog(list); setCurrent(binding); } + }).catch(() => {}); return () => { active = false; }; - }, [port, running,binding?.digest,binding?.started]); + }, [port, running, binding?.digest, binding?.started]); if (!catalog) return null; - const selected=catalog.presets.find((v)=>v.definition.id===current?.id)?.definition; + const selected = catalog.presets.find((v) => v.definition.id === current?.id)?.definition; const locked = running || statusLocked || !!current?.started; + const label = current ? compositionName(current.id, current.name) : t("原有会话配置"); + const items: MenuItem[] = locked && !createNew ? [] : catalog.presets.map(({ definition }) => ({ + value: definition.id, + label: compositionName(definition.id, definition.name), + meta: compositionSource(definition.id), + desc: definition.id.startsWith("builtin:") ? t(definition.description) : definition.description, + right: locked ? t("新建会话") : undefined, + disabled: busy, + })); + items.push({ value: MANAGE, label: `${t("管理 Agent 预设")}…`, plain: true, divide: true }); + const choose = (id: string) => { + if (id === MANAGE) { onSettings(); return; } + if (busy) return; + if (locked) { createNew?.(id); return; } + setBusy(true); + port.selectAgentComposition(id).then((binding) => { setCurrent(binding); onChanged(); }) + .catch((e) => onError(reason(e))).finally(() => setBusy(false)); + }; return - - {locked && createNew && <>} + id === MANAGE ? "agent-composition.manage" : locked ? "agent-composition.new" : "agent-composition.select"} + title={locked ? t("该会话已开始,组合保持原版本。请用其他组合新建会话。") : selected?.description ? (selected.id.startsWith("builtin:") ? t(selected.description) : selected.description) : t("首次提交前可切换 Agent 组合")} + pending={busy} + label={<>{label}{current && {compositionSource(current.id)}}{locked && {t("已锁定")}}} + menuTitle={<>{t("Agent 预设")}{t(locked ? "已锁定" : "首次提交前可切换")}} + head={locked &&

{t("该会话已开始,组合保持原版本。请用其他组合新建会话。")}

} + items={items} + current={locked ? undefined : current?.id} + onPick={choose} + place="bottom" + /> {current?.unavailable && {t("组合资源不可用,当前会话仅可查看历史。")}} -
; } diff --git a/desktop/frontend-next/src/ui/Composer.tsx b/desktop/frontend-next/src/ui/Composer.tsx index d07a631715..778a1b5993 100644 --- a/desktop/frontend-next/src/ui/Composer.tsx +++ b/desktop/frontend-next/src/ui/Composer.tsx @@ -8,7 +8,7 @@ import { Picker } from "./Menu"; import { BranchChip } from "./BranchChip"; import { WorktreeChip } from "./WorktreeChip"; import { useWorktreeSetup } from "./useWorktreeSetup"; -import { SessionControls } from "./SessionControls"; +import { Policy } from "./Policy"; import { chosenGroups, groupHeadings, modelMenu, narrowToGroups } from "./modelmenu"; import { ModelGroupChips } from "./ModelGroupChips"; import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort"; @@ -704,7 +704,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus, {t("执行计划中")} )} - + onSettings("tools:sandbox")} />
string) }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const wrap = useRef(null); @@ -95,7 +95,7 @@ export function Picker({ return; } if (filtering) find.current?.focus(); - else menu.current?.querySelector("button.mi")?.focus(); + else menu.current?.querySelector("button.mi:not(:disabled)")?.focus(); menu.current?.querySelector("button.mi[data-on]")?.scrollIntoView({ block: "nearest" }); // The menu is not inside the wrapper any more, so without asking it too a // press on one of its own rows would read as a press outside. @@ -159,7 +159,7 @@ export function Picker({ return; } if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; - const all = [...(menu.current?.querySelectorAll("button.mi") ?? [])]; + const all = [...(menu.current?.querySelectorAll("button.mi:not(:disabled)") ?? [])]; if (!all.length) return; const i = all.indexOf(document.activeElement as HTMLElement); // From the field, Down enters the list at the row Enter would have taken. @@ -250,10 +250,12 @@ export function Picker({ ) : (