feat(studio): unify Agent presets in the composer status row
Problem: Session composition controls competed with task actions in the bottom toolbar, and the adjacent ellipsis did not explain how to manage presets. Root cause: Composition selection and management were rendered as separate controls below the input. Floating menu placement also missed fold changes published after window resize and the end of layout transitions. Fix: Place one Agent presets menu beside the run status, show sources and capability notes, and group management below a separator. Preserve locked bindings and explicit new-conversation actions. Reuse the existing Picker with per-choice action identities, disabled-row keyboard navigation and current-state semantics; refresh shared floating anchors after reflow. Update bilingual guides, release notes and before/after screenshot evidence. Verification: All 633 frontend test files / 5,225 tests pass, including selection/management, keyboard focus, pending/failure, lock/new-session, source identity and responsive positioning regressions. Typecheck, production build, gofmt, go vet, make lint, make check, and required boot/builtin suites pass. Browser light/dark, wide/390px and resize recovery were verified with synthetic fixtures.
No files matched your search
@@ -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.
|
||||
@@ -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"},
|
||||
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(<RunLine label="空闲" running={false} blocked={false} sent={0} received={0} estimated={false}>
|
||||
<AgentCompositionPicker port={port} running={false} onChanged={changed} onSettings={settings} onError={()=>{}}/>
|
||||
</RunLine>);
|
||||
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(<CompositionNewContext.Provider value={create}><AgentCompositionPicker port={port} locked running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/></CompositionNewContext.Provider>);
|
||||
const picker=await screen.findByRole<HTMLSelectElement>("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(<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} 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(<AgentCompositionPicker port={port} running={false} onChanged={changed} onSettings={()=>{}} 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(<AgentCompositionPicker port={port} locked running={false} onChanged={()=>{}} 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(<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} 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("项目")]);
|
||||
});
|
||||
@@ -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<AgentPresetCatalog | null>(null);
|
||||
const [current, setCurrent] = useState<CompositionSummary | null>(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 <span className="composition-picker">
|
||||
<select className="mode" aria-label={t("Agent 组合")} data-action-change="agent-composition.select" disabled={busy || locked} value={current?.id ?? ""} title={locked ? t("该会话已开始,组合保持原版本。请用其他组合新建会话。") : selected?.description?t(selected.description):t("首次提交前可切换 Agent 组合")} onChange={(e) => {
|
||||
const id = e.target.value; setBusy(true);
|
||||
port.selectAgentComposition(id).then((binding) => { setCurrent(binding); onChanged(); }).catch((e) => onError(reason(e))).finally(() => setBusy(false));
|
||||
}}>
|
||||
{!current && <option value="">{t("原有会话配置")}</option>}
|
||||
{current && !catalog.presets.some((v) => v.definition.id === current.id) && <option value={current.id}>{current.name}</option>}
|
||||
{catalog.presets.map((v) => <option key={v.definition.id} value={v.definition.id}>{v.definition.id.startsWith("builtin:")?t(v.definition.name):v.definition.name} · {v.definition.id.split(":")[0]}</option>)}
|
||||
</select>
|
||||
{locked && createNew && <><select className="mode" aria-label={t("用其他组合新建会话")} data-action-change="agent-composition.new-choice" value={next} onChange={(e) => setNext(e.target.value)}><option value="">{t("用其他组合新建会话")}</option>{catalog.presets.map((v) => <option key={v.definition.id} value={v.definition.id}>{v.definition.id.startsWith("builtin:")?t(v.definition.name):v.definition.name}</option>)}</select><button className="mode" type="button" data-action="agent-composition.new" disabled={!next} onClick={() => createNew(next)}>{t("新建会话")}</button></>}
|
||||
<Picker
|
||||
className="mode composition-trigger"
|
||||
menuClassName="composer-menu composition-menu"
|
||||
ariaLabel={`${t("Agent 组合")}:${label}${current ? ` · ${compositionSource(current.id)}` : ""}${locked ? ` · ${t("已锁定")}` : ""}`}
|
||||
data-action={(id) => 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={<><span className="nm">{label}</span>{current && <span className="composition-source">{compositionSource(current.id)}</span>}{locked && <span className="composition-source">{t("已锁定")}</span>}<StudioIcon name="down" /></>}
|
||||
menuTitle={<><b>{t("Agent 预设")}</b><small>{t(locked ? "已锁定" : "首次提交前可切换")}</small></>}
|
||||
head={locked && <p className="composition-menu-note">{t("该会话已开始,组合保持原版本。请用其他组合新建会话。")}</p>}
|
||||
items={items}
|
||||
current={locked ? undefined : current?.id}
|
||||
onPick={choose}
|
||||
place="bottom"
|
||||
/>
|
||||
{current?.unavailable && <span role="alert" title={current.unavailable}>{t("组合资源不可用,当前会话仅可查看历史。")}</span>}
|
||||
<button type="button" className="mode plain" data-action="agent-composition.manage" onClick={onSettings} aria-label={t("管理 Agent 预设")}>⋯</button>
|
||||
</span>;
|
||||
}
|
||||
@@ -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,
|
||||
<span className="lb">{t("执行计划中")}</span>
|
||||
</span>
|
||||
)}
|
||||
<SessionControls port={port} status={status} running={running} onChanged={onChanged} onSettings={onSettings} onError={onError} />
|
||||
<Policy port={port} status={status} onChanged={onChanged} onBoundary={() => onSettings("tools:sandbox")} />
|
||||
<div className="studio-model-group">
|
||||
<Picker
|
||||
wrapClassName="studio-model-control"
|
||||
|
||||
@@ -73,7 +73,7 @@ interface Props {
|
||||
// answer this menu gives is the item's own value.
|
||||
export function Picker({
|
||||
label, items, current, onPick, place, align = "start", className, title, pending, triggerAction, ariaPressed, ariaLabel, ariaDescribedBy, wrapClassName, menuClassName, menuTitle, onOpen, searchAlways, searchPlaceholder, head, ...id
|
||||
}: Props & { [K in `data-${string}`]?: string }) {
|
||||
}: Props & { [K in `data-${string}`]?: string | ((value: string) => string) }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const wrap = useRef<HTMLDivElement>(null);
|
||||
@@ -95,7 +95,7 @@ export function Picker({
|
||||
return;
|
||||
}
|
||||
if (filtering) find.current?.focus();
|
||||
else menu.current?.querySelector<HTMLElement>("button.mi")?.focus();
|
||||
else menu.current?.querySelector<HTMLElement>("button.mi:not(:disabled)")?.focus();
|
||||
menu.current?.querySelector<HTMLElement>("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<HTMLElement>("button.mi") ?? [])];
|
||||
const all = [...(menu.current?.querySelectorAll<HTMLElement>("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({
|
||||
) : (
|
||||
<button
|
||||
{...id}
|
||||
data-action={typeof id["data-action"] === "function" ? id["data-action"](it.value) : id["data-action"]}
|
||||
data-value={it.value}
|
||||
className={it.plain ? "mi plain" : "mi"}
|
||||
role={it.toggle === undefined ? "menuitem" : "menuitemcheckbox"}
|
||||
aria-checked={it.toggle}
|
||||
aria-current={it.value === current ? true : undefined}
|
||||
disabled={it.disabled}
|
||||
data-on={it.value === current ? "" : undefined}
|
||||
data-lead={it.value === lead ? "" : undefined}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { MeterRail } from "./MeterRail";
|
||||
import { PlanFold } from "./PlanFold";
|
||||
import { useBroughtResults } from "./useBroughtResults";
|
||||
import { RunLine } from "./RunLine";
|
||||
import { AgentCompositionPicker } from "./AgentCompositionPicker";
|
||||
import type { PaneProps, PaneReport } from "./panetypes";
|
||||
import { useRate, useTrail } from "./num";
|
||||
import { useCompactNow } from "./CompactNow";
|
||||
@@ -565,7 +566,9 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
{/* Everything stacked above the input box shares one ceiling, so no
|
||||
child of this region may grow without bound. */}
|
||||
<PlanFold plan={s.plan} goal={status?.goal} shown={tab === "flow"} paused={blocked} />
|
||||
{tab === "flow" && <RunLine label={chipLabel(s, running)} running={running} blocked={blocked} since={s.running ? s.runSince : undefined} sent={sent} received={received} estimated={s.outLive > 0} />}
|
||||
{tab === "flow" && <RunLine label={chipLabel(s, running)} running={running} blocked={blocked} since={s.running ? s.runSince : undefined} sent={sent} received={received} estimated={s.outLive > 0}>
|
||||
<AgentCompositionPicker port={port} binding={status?.agentComposition} locked={status?.agentCompositionLocked} running={running} onChanged={refreshStatus} onSettings={() => onSettings("persona")} onError={notify} />
|
||||
</RunLine>}
|
||||
<div className="composeaux">
|
||||
<Queue
|
||||
queue={queue}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { RMark } from "./RMark";
|
||||
import { RunClock } from "./RunClock";
|
||||
import { RunTokens } from "./RunTokens";
|
||||
|
||||
export function RunLine({ label, running, blocked, since, sent, received, estimated }: {
|
||||
export function RunLine({ label, running, blocked, since, sent, received, estimated, children }: {
|
||||
label: string;
|
||||
running: boolean;
|
||||
blocked: boolean;
|
||||
@@ -11,18 +12,22 @@ export function RunLine({ label, running, blocked, since, sent, received, estima
|
||||
sent: number;
|
||||
received: number;
|
||||
estimated: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="studio-runstate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-running={running && !blocked ? "" : undefined}
|
||||
data-waiting={blocked ? "" : undefined}
|
||||
data-idle={running || blocked ? undefined : ""}
|
||||
>
|
||||
<span className="studio-runlabel"><RMark /><span>{t(label)}</span>{running && since !== undefined && <RunClock since={since} />}</span>
|
||||
<RunTokens sent={sent} received={received} estimated={estimated} />
|
||||
<div className="compose-context">
|
||||
<div
|
||||
className="studio-runstate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-running={running && !blocked ? "" : undefined}
|
||||
data-waiting={blocked ? "" : undefined}
|
||||
data-idle={running || blocked ? undefined : ""}
|
||||
>
|
||||
<span className="studio-runlabel"><RMark /><span>{t(label)}</span>{running && since !== undefined && <RunClock since={since} />}</span>
|
||||
<RunTokens sent={sent} received={received} estimated={estimated} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { AgentPort, SessionStatus } from "../port/port";
|
||||
import { Policy } from "./Policy";
|
||||
import { AgentCompositionPicker } from "./AgentCompositionPicker";
|
||||
|
||||
export function SessionControls({ port, status, running, onChanged, onSettings, onError }: { port: AgentPort; status: SessionStatus | null; running: boolean; onChanged: () => void; onSettings: (at: string) => void; onError: (e: unknown) => void }) {
|
||||
return <>
|
||||
<Policy port={port} status={status} onChanged={onChanged} onBoundary={() => onSettings("tools:sandbox")} />
|
||||
<AgentCompositionPicker port={port} binding={status?.agentComposition} locked={status?.agentCompositionLocked} running={running} onChanged={onChanged} onSettings={() => onSettings("persona")} onError={onError} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
import "./testkit";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Picker } from "./Menu";
|
||||
import { refresh } from "./viewport";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
delete document.documentElement.dataset.fold;
|
||||
Reflect.deleteProperty(document.body, "clientWidth");
|
||||
Reflect.deleteProperty(document.body, "clientHeight");
|
||||
});
|
||||
|
||||
it("repositions an open menu after the body observer publishes a responsive fold", async () => {
|
||||
document.documentElement.dataset.fold = "rail side scene";
|
||||
Object.defineProperty(document.body, "clientWidth", { configurable: true, value: 1440 });
|
||||
Object.defineProperty(document.body, "clientHeight", { configurable: true, value: 1000 });
|
||||
const real = HTMLElement.prototype.getBoundingClientRect;
|
||||
let widePosition = 250;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
if (this.getAttribute("aria-haspopup") !== "menu") return real.call(this);
|
||||
const x = document.documentElement.dataset.fold ? 85 : widePosition;
|
||||
return { x, y: 600, left: x, right: x + 112, top: 600, bottom: 628, width: 112, height: 28, toJSON: () => ({}) };
|
||||
});
|
||||
render(<Picker label="Choose" items={[{ value: "a", label: "A" }]} onPick={() => {}} place="bottom" />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Choose" }));
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu.style.left).toBe("85px");
|
||||
fireEvent.resize(window);
|
||||
expect(menu.style.left).toBe("85px");
|
||||
act(() => refresh());
|
||||
expect(menu.style.left).toBe("250px");
|
||||
widePosition = 570;
|
||||
fireEvent.transitionEnd(document.body);
|
||||
expect(menu.style.left).toBe("570px");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const closedPosition = menu.style.left;
|
||||
Object.defineProperty(document.body, "clientWidth", { configurable: true, value: 390 });
|
||||
act(() => refresh());
|
||||
expect(menu.style.left).toBe(closedPosition);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { onFolds } from "./viewport";
|
||||
|
||||
// Placing a fixed element while the interface is zoomed puts two coordinate
|
||||
// systems into one calculation. Measured in Chromium with html{zoom:1.5}: a
|
||||
@@ -68,11 +69,15 @@ export function useFollow(open: boolean, place: () => void, key?: unknown): void
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
place();
|
||||
const stopFolds = onFolds(place);
|
||||
addEventListener("scroll", place, true);
|
||||
addEventListener("resize", place);
|
||||
addEventListener("transitionend", place, true);
|
||||
return () => {
|
||||
stopFolds();
|
||||
removeEventListener("scroll", place, true);
|
||||
removeEventListener("resize", place);
|
||||
removeEventListener("transitionend", place, true);
|
||||
};
|
||||
}, [open, place, key]);
|
||||
}
|
||||
@@ -14,7 +14,9 @@ It uses Reasonix's runtime; it does not load Harness's Cordis plugins.
|
||||
|
||||
## Choosing and editing
|
||||
|
||||
Choose **Agent** beside the composer before the first prompt. Once a prompt is
|
||||
Choose the composition beside the run status at the top of the composer before
|
||||
the first prompt. One menu lists compositions with their sources and capabilities;
|
||||
**Manage Agent presets…** opens the management page below the choices. Once a prompt is
|
||||
accepted, the conversation keeps its binding.
|
||||
|
||||
Queued work, pending questions and
|
||||
|
||||
@@ -13,7 +13,8 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
|
||||
|
||||
## 选择和修改
|
||||
|
||||
首次提交前,在输入框旁选择 Agent。接纳首条消息后,会话保留原绑定;
|
||||
首次提交前,在输入框顶部的运行状态旁打开 Agent 组合菜单;列表显示来源
|
||||
和能力说明,分隔线下的「管理 Agent 预设…」打开管理页。接纳首条消息后,会话保留原绑定;
|
||||
排队、待回答和后台工作期间也不能切换。锁定后可用其他组合新建会话。
|
||||
清空或回退已开始的组合会话不会解除锁定。
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
|
||||
| Turn navigator | Have differently | Scroll rail with marks in `ui/Transcript.tsx` |
|
||||
| Model picker with vendor icons, search and favourites | Missing | #12354, #11403 |
|
||||
| Reasoning effort picker | Have differently | Names plus one-line descriptions, only levels the model supports; do not revert to raw `high` and `max` |
|
||||
| Agent composition / Agent 组合 | Have differently | Composer selector and Settings → Agent presets (`ui/AgentPresets.tsx`). 输入框选择组合,「Agent 预设」整合人设页并保留四个字段。 |
|
||||
| Agent composition / Agent 组合 | Have differently | One composer status-row menu for selection and management; Settings → Agent presets (`ui/AgentPresets.tsx`). 输入框顶部统一菜单选择和管理组合,「Agent 预设」整合人设页并保留四个字段。 |
|
||||
| Approval card offering "allow matching actions this session" | Unverified | Mock fixture shows two buttons only; #12020 (closed) |
|
||||
| Plan and todo panel | Have | `ui/Plan.tsx` |
|
||||
| Subagents panel | Have differently | `ui/Pane.tsx`, `ui/delegation.ts`; running-state gaps in #12410, #12284 |
|
||||
|
||||
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 695 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 725 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 242 KiB |
@@ -1,7 +1,9 @@
|
||||
- 新增会话级 Agent 预设管理与选择,人设、工具及模块按会话绑定版本;基于人设页 #13588,见 #13613。
|
||||
- 兼容:保留旧人设适配接口与无绑定历史,组合会话用新日志版本保护。#13613
|
||||
- Agent 组合选择与管理整合为输入框顶部状态行的统一菜单,展示来源和能力说明。#13613
|
||||
|
||||
<!-- lang:en -->
|
||||
|
||||
- Add session Agent presets with versioned personas, tools and modules, building on #13588. #13613
|
||||
- Keep legacy persona adapters and unbound history; protect composed sessions with a new log version. #13613
|
||||
- Combine composition selection and management in one menu beside the composer run status, with source and capability notes. #13613
|
||||