feat(studio): add preset cards and explicit new-task defaults

Problem: Built-in presets use flat rows, choosing a future task mode requires
repetition, and persona fields are hidden behind an advanced disclosure.

Root cause: Management navigation and new-task launch intent have no shared
explicit preference; the editor groups basic persona fields with identifiers.

Fix: Match the selected two-column card hierarchy with badges, IDs, descriptions
and separate details, usage and copy actions. Persist an explicit built-in card
choice in a new machine-local key and inject it through the local/remote open
owners only for new tasks. Explicit request choices override it; restoring
history keeps its binding. Existing server preferences alone remain inert.
Expose both persona textareas, runtime context, scope and ID directly, rename
settings navigation to Persona, and keep literal persona fields authoritative.

Verification: Full frontend suite plus real MockHub launch effects, blank-pane
reuse, local/remote history, explicit overrides, storage compatibility/refusal,
cross-window refresh and independent footer actions. Production build/typecheck,
UI census/fixtures, format, vet, lint, changed-file and builtin/boot checks.
Browser-rendered visual comparison remains blocked by the URL policy, as
recorded in design-qa.md.
This commit is contained in:
SivanCola committed 2026-10-20 00:15:23 +08:00
1 parent 9023835220
commit 17327b4875
23 files changed
+418 -78

No files matched your search

+37 -2
View File
@@ -18,6 +18,11 @@ source is the user's attached `exec-e196c6a9-d8d9-437d-a6ee-0e99a3651845.png`,
The image specifies a catalog followed by a separate content editor, not a drawer. The existing The image specifies a catalog followed by a separate content editor, not a drawer. The existing
Settings shell remains the application-owned frame. Settings shell remains the application-owned frame.
The latest editor instruction supersedes the original advanced disclosure:
the user's `codex-clipboard-c3951359-4571-4572-ad30-9f4517a2f7db.png` reference
places both textareas and the runtime-context switch directly in the editor.
Scope and ID are also exposed. Settings navigation now reads Persona / 人设.
本节取代下方旧预设卡片的视觉验收。采用用户重新指定的首张效果图,列表与独立 本节取代下方旧预设卡片的视觉验收。采用用户重新指定的首张效果图,列表与独立
编辑页切换,保留现有设置外框。编辑态为新建、名称与用途已填写、能力及高级设置 编辑页切换,保留现有设置外框。编辑态为新建、名称与用途已填写、能力及高级设置
收起、个人范围、取消与保存操作固定在底部。 收起、个人范围、取消与保存操作固定在底部。
@@ -35,8 +40,8 @@ Settings shell remains the application-owned frame.
contrast remains unverified. contrast remains unverified.
- Assets: existing product icons retained; no generated product assets or new - Assets: existing product icons retained; no generated product assets or new
handcrafted icon geometry. Source brand is supplied by the existing shell. handcrafted icon geometry. Source brand is supplied by the existing shell.
- Copy: name, purpose, persona, capabilities, scope and advanced hierarchy follows - Copy: name, purpose, persona, capabilities, scope and ID follow the selected
the selected source, with Chinese/English labels. Existing backend limits remain references, with Chinese/English labels. Existing backend limits remain
authoritative rather than adopting illustrative mockup character limits. authoritative rather than adopting illustrative mockup character limits.
- Interaction regressions cover creation, copy, literal persona fields, invalid - Interaction regressions cover creation, copy, literal persona fields, invalid
parameters, failed saves, navigation/close confirmation, pending saves and focus. parameters, failed saves, navigation/close confirmation, pending saves and focus.
@@ -61,6 +66,36 @@ Earlier passing sections below are historical evidence for their own changes.
待办:恢复浏览器访问后,在相同编辑状态比较整页与局部,并检查深浅主题、窄屏 待办:恢复浏览器访问后,在相同编辑状态比较整页与局部,并检查深浅主题、窄屏
滚动及控制台;解决 P0–P2 问题后再将视觉验收标记为通过。 滚动及控制台;解决 P0–P2 问题后再将视觉验收标记为通过。
## Built-in cards and new-task default / 内置卡片与新任务默认
final result: blocked
Source: the user's selected Harness screenshot
`codex-clipboard-be3c8df5-ada5-4683-a66d-81cdf31010e7.png`, 1178 × 760 image pixels.
It is a section crop; viewport, CSS size and density are unspecified.
Implementation capture and full-view/focused comparison remain unavailable
because browser observation was rejected by the URL security policy. No
alternative browser surface was used to bypass that refusal.
- Typography: inherited Studio font, scale and weight tokens. Rendering remains
unverified; source-specific font substitution was not introduced.
- Layout: responsive two-column cards, large rounded borders, equal-height bodies,
separate footer actions and single-column narrow layouts. Rendered rhythm and
scrolling remain unverified.
- Color: existing surface, border, text and selected tokens. The selected badge
uses the text/page pair. Theme contrast still needs browser inspection.
- Assets: supplied product icon library reused for copy; no new raster assets
or handcrafted icon geometry.
- Content: Standard/Minimal remain the shipped modes; IDs remain canonical.
Details, usage and copy remain separate from the card's default selection.
- Behavior: card clicks persist a machine-local built-in choice. New local and
remote requests carry it explicitly; history and explicit per-task choices
retain priority. Old server preferences alone remain inert.
待浏览器恢复后,对照所选卡片截图检查字体、留白、颜色、图标和文案,并覆盖
深浅主题、窄屏、默认状态及展开的使用说明。当前功能测试不能替代视觉验收。
## Historical palette verification / 历史配色验收 ## Historical palette verification / 历史配色验收
final result: passed final result: passed
+2
View File
@@ -44,6 +44,8 @@ export const ACTIONS: UIAction[] = [
{id:"agent-composition.new",kind:"kernel-mutation",target:"none",proof:"authority-effect"}, {id:"agent-composition.new",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.reload",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.view",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.new-task-default",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.usage",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.create",kind:"view",target:"none",proof:"interaction"}, {id:"agent-preset.create",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.leave",kind:"view",target:"none",proof:"interaction"}, {id:"agent-preset.leave",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.leave-cancel",kind:"view",target:"none",proof:"interaction"}, {id:"agent-preset.leave-cancel",kind:"view",target:"none",proof:"interaction"},
@@ -1,4 +1,11 @@
export const EN_COMPOSITION: Record<string,string> = { export const EN_COMPOSITION: Record<string,string> = {
"新任务默认": "New task default",
"将{name}设为新任务默认": "Set {name} as the new task default",
"如何使用 {name}": "How to use {name}",
"点击卡片,设置本机新任务的默认模式;已有会话保持当前配置。": "Click a card to set the default for new tasks on this machine. Existing conversations keep their configuration.",
"无法保存新任务默认模式,请检查本地存储权限。": "Could not save the new task default. Check local storage permissions.",
"标准模式默认启用,可直接输入任务;如需自定义,先复制此预设。": "Standard mode is the initial default. Enter a task directly, or duplicate this preset to customize it.",
"在新任务输入框选择精简模式,再发送首条消息。需要支持读图的模型;终端操作继续遵守权限设置。": "Choose Minimal mode in a new task's composer before sending the first message. A vision-capable model is required; terminal operations follow existing permissions.",
"预设版本": "Preset version", "预设版本": "Preset version",
"继承当前配置": "Inherit current configuration", "继承当前配置": "Inherit current configuration",
"返回预设列表": "Back to presets", "返回预设列表": "Back to presets",
@@ -19,7 +26,7 @@ export const EN_COMPOSITION: Record<string,string> = {
"放弃更改": "Discard changes", "放弃更改": "Discard changes",
"编辑标准模式人设": "Edit Standard-mode persona", "编辑标准模式人设": "Edit Standard-mode persona",
"显式默认配置": "Explicit default configuration", "显式默认配置": "Explicit default configuration",
"仅在显式选择 default 时使用,不改变新会话的标准模式。": "Used only when explicitly selecting default. New conversations still start in Standard mode.", "仅在显式选择 default 时使用,与本机卡片设置的新任务默认独立。": "Used only when explicitly selecting default, separately from this machine's new-task card selection.",
"预设已删除,已有会话保持原版本。": "Preset deleted. Existing conversations keep their version.", "预设已删除,已有会话保持原版本。": "Preset deleted. Existing conversations keep their version.",
"导入的预设": "Imported preset", "导入的预设": "Imported preset",
"新会话默认使用标准模式。选择其他模式后才会启用对应预设。": "New conversations start in Standard mode. Other presets apply only when selected.", "新会话默认使用标准模式。选择其他模式后才会启用对应预设。": "New conversations start in Standard mode. Other presets apply only when selected.",
+1 -1
View File
@@ -22,7 +22,7 @@ export const EN_PERSONA: Record<string, string> = {
"补充要求": "Additional instructions", "补充要求": "Additional instructions",
"放在内置系统指令之后,留空则不添加": "Placed after the built-in system instructions. Leave blank to add nothing.", "放在内置系统指令之后,留空则不添加": "Placed after the built-in system instructions. Leave blank to add nothing.",
"不注入可选运行时上下文": "Do not inject optional runtime context", "不注入可选运行时上下文": "Do not inject optional runtime context",
"关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.", "开启后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
"写入用户配置,在新会话及现有会话下一次重建时生效;保存本身不会重建会话。": "Writes the user config; applies to new sessions and the next rebuild of an existing session. Saving itself does not rebuild it.", "写入用户配置,在新会话及现有会话下一次重建时生效;保存本身不会重建会话。": "Writes the user config; applies to new sessions and the next rebuild of an existing session. Saving itself does not rebuild it.",
"已保存,下次会话构建时生效": "Saved; applies at the next session build", "已保存,下次会话构建时生效": "Saved; applies at the next session build",
}; };
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, renderHook } from "@testing-library/react";
import { NEW_TASK_PRESET_KEY, newTaskComposition, newTaskPreset, setNewTaskPreset, useNewTaskPreset } from "./newTaskPreset";
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.removeItem(NEW_TASK_PRESET_KEY); });
it("leaves old installations unselected and ignores unsupported stored identities", () => {
expect(newTaskComposition()).toBeUndefined();
for (const raw of ["", "default", "user:review", "project:review", "builtin:retired", "{bad"]) {
localStorage.setItem(NEW_TASK_PRESET_KEY, raw);
expect(newTaskPreset()).toBeUndefined();
}
expect(setNewTaskPreset("project:review")).toBe(false);
});
it("round-trips explicit built-in choices while preserving request and history choices", () => {
for (const id of ["builtin:general", "builtin:minimal"]) {
expect(setNewTaskPreset(id)).toBe(true);
expect(newTaskComposition()).toBe(id);
expect(newTaskComposition("user:explicit")).toBe("user:explicit");
expect(newTaskComposition(undefined, "/history/task.jsonl")).toBeUndefined();
}
});
it("publishes local changes and cross-window storage changes to the card", () => {
const { result } = renderHook(useNewTaskPreset);
act(() => { setNewTaskPreset("builtin:minimal"); });
expect(result.current).toBe("builtin:minimal");
act(() => {
localStorage.setItem(NEW_TASK_PRESET_KEY, "builtin:general");
window.dispatchEvent(new StorageEvent("storage", { key: NEW_TASK_PRESET_KEY }));
});
expect(result.current).toBe("builtin:general");
});
it("refuses an unpersisted selection when local storage is unavailable", () => {
setNewTaskPreset("builtin:general");
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage denied"); });
expect(setNewTaskPreset("builtin:minimal")).toBe(false);
expect(newTaskPreset()).toBe("builtin:general");
});
@@ -0,0 +1,37 @@
import { useSyncExternalStore } from "react";
export const NEW_TASK_PRESET_KEY = "rx-new-task-agent-preset";
const listeners = new Set<() => void>();
type BuiltinPreset = "builtin:general" | "builtin:minimal";
const valid = (value: unknown): value is BuiltinPreset =>
value === "builtin:general" || value === "builtin:minimal";
export function newTaskPreset(): BuiltinPreset | undefined {
try {
const value = localStorage.getItem(NEW_TASK_PRESET_KEY);
return valid(value) ? value : undefined;
} catch { return undefined; }
}
export function setNewTaskPreset(id: string): boolean {
if (!valid(id)) return false;
try { localStorage.setItem(NEW_TASK_PRESET_KEY, id); }
catch { return false; }
listeners.forEach(fn => fn());
return true;
}
function subscribe(fn: () => void) {
listeners.add(fn);
const refresh = (event: StorageEvent) => {
if (event.key === NEW_TASK_PRESET_KEY || event.key === null) fn();
};
window.addEventListener("storage", refresh);
return () => { listeners.delete(fn); window.removeEventListener("storage", refresh); };
}
export const useNewTaskPreset = () => useSyncExternalStore(subscribe, newTaskPreset, newTaskPreset);
export function newTaskComposition(explicit?: string, sessionPath?: string): string | undefined {
return explicit !== undefined || sessionPath ? explicit : newTaskPreset();
}
@@ -11,6 +11,24 @@
.composition-default-note { color: var(--muted); margin: 0 0 28px; line-height: 1.7; } .composition-default-note { color: var(--muted); margin: 0 0 28px; line-height: 1.7; }
.composition-list-group { margin-bottom: 28px; } .composition-list-group { margin-bottom: 28px; }
.composition-list-group h3 { color: var(--muted); font-size: var(--fs-ui); font-weight: var(--w-med); margin: 0 0 12px; } .composition-list-group h3 { color: var(--muted); font-size: var(--fs-ui); font-weight: var(--w-med); margin: 0 0 12px; }
.composition-builtin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 20px; }
.composition-builtin-card { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--border); border-radius: 22px; overflow: hidden; background: var(--surface); }
.composition-builtin-card[data-selected] { background: var(--overlay); border-color: var(--faint); }
.composition-builtin-card:focus-within { border-color: var(--focus); }
.composition-builtin-select { flex: 1; min-width: 0; padding: 24px; border: 0; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.composition-builtin-select:hover { background: var(--overlay); }
.composition-builtin-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.composition-builtin-heading strong { font-size: var(--fs-title); font-weight: var(--w-semi); }
.composition-builtin-heading code { margin-left: auto; color: var(--ghost); font: var(--w-reg) var(--fs-meta)/1.5 var(--mono); }
.composition-builtin-badge { padding: 2px 9px; border: 1px solid var(--border); border-radius: var(--r-pill); color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); white-space: nowrap; }
.composition-builtin-card[data-selected] .composition-builtin-badge { background: var(--text); border-color: var(--text); color: var(--page); }
.composition-builtin-description { display: block; margin-top: 20px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.8; overflow-wrap: anywhere; }
.composition-builtin-footer { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; padding: 16px 24px; border-top: 1px solid var(--hair); }
.composition-builtin-footer button { border: 0; padding: 4px 0; background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); cursor: pointer; }
.composition-builtin-footer button:hover { color: var(--text); }
.composition-builtin-footer .composition-builtin-copy { display: grid; place-items: center; margin-left: auto; width: 28px; min-height: 28px; padding: 4px; border-radius: var(--r-sm); }
.composition-builtin-copy svg { width: 18px; height: 18px; }
.composition-builtin-usage { margin: 0; padding: 16px 24px; border-top: 1px solid var(--hair); color: var(--muted); font-size: var(--fs-meta); line-height: 1.7; }
.composition-row { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); padding-right: 12px; } .composition-row { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); padding-right: 12px; }
.composition-row:first-of-type { border-top: 1px solid var(--hair); } .composition-row:first-of-type { border-top: 1px solid var(--hair); }
.composition-row-main { display: flex; align-items: center; gap: 16px; flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text); padding: 20px 12px; cursor: pointer; text-align: left; } .composition-row-main { display: flex; align-items: center; gap: 16px; flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text); padding: 20px 12px; cursor: pointer; text-align: left; }
@@ -48,16 +66,14 @@
.composition-capabilities > details { background: var(--overlay); padding-inline: 16px; } .composition-capabilities > details { background: var(--overlay); padding-inline: 16px; }
.composition-capabilities > details:first-of-type { border-radius: var(--r-md) var(--r-md) 0 0; } .composition-capabilities > details:first-of-type { border-radius: var(--r-md) var(--r-md) 0 0; }
.composition-capabilities > details:last-of-type { border-radius: 0 0 var(--r-md) var(--r-md); } .composition-capabilities > details:last-of-type { border-radius: 0 0 var(--r-md) var(--r-md); }
.composition-capabilities summary, .composition-advanced > summary { display: flex; align-items: center; gap: 12px; min-height: 52px; cursor: pointer; list-style: none; font-weight: var(--w-med); } .composition-capabilities summary { display: flex; align-items: center; gap: 12px; min-height: 52px; cursor: pointer; list-style: none; font-weight: var(--w-med); }
.composition-capabilities summary::-webkit-details-marker, .composition-advanced > summary::-webkit-details-marker { display: none; } .composition-capabilities summary::-webkit-details-marker { display: none; }
.composition-capabilities summary > svg:last-child, .composition-advanced > summary > svg:last-child { margin-left: auto; color: var(--muted); } .composition-capabilities summary > svg:last-child { margin-left: auto; color: var(--muted); }
.composition-capabilities details[open] > summary > svg:last-child, .composition-advanced[open] > summary > svg:last-child { transform: rotate(90deg); } .composition-capabilities details[open] > summary > svg:last-child { transform: rotate(90deg); }
.composition-capabilities summary svg, .composition-advanced > summary svg { width: 18px; height: 18px; flex-shrink: 0; } .composition-capabilities summary svg { width: 18px; height: 18px; flex-shrink: 0; }
.composition-capabilities summary span { margin-left: auto; color: var(--muted); font-size: var(--fs-meta); font-weight: var(--w-reg); } .composition-capabilities summary span { margin-left: auto; color: var(--muted); font-size: var(--fs-meta); font-weight: var(--w-reg); }
.composition-capabilities summary:has(span) > svg:last-child { margin-left: 4px; } .composition-capabilities summary:has(span) > svg:last-child { margin-left: 4px; }
.composition-capabilities > details + details { border-top: 1px solid var(--hair); } .composition-capabilities > details + details { border-top: 1px solid var(--hair); }
.composition-advanced { border-top: 1px solid var(--hair); }
.composition-advanced .pers-field { margin-block: 16px; }
.composition-modules { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 12px; margin: 0 0 16px; min-width: 0; } .composition-modules { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 12px; margin: 0 0 16px; min-width: 0; }
.composition-modules legend { font-weight: var(--w-med); padding-inline: 4px; } .composition-modules legend { font-weight: var(--w-med); padding-inline: 4px; }
.composition-module { margin-block: 8px; } .composition-module { margin-block: 8px; }
@@ -77,6 +93,9 @@
.preset-workspace :is(button, summary, input, select, textarea):focus-visible, .preset-confirm button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } .preset-workspace :is(button, summary, input, select, textarea):focus-visible, .preset-confirm button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.preset-workspace :is(input, select):disabled { opacity: .65; } .preset-workspace :is(input, select):disabled { opacity: .65; }
@media (max-width: 700px) { @media (max-width: 700px) {
.composition-builtin-grid { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.composition-builtin-select { padding: 20px; }
.composition-builtin-footer { padding: 14px 20px; }
.composition-list-heading { column-gap: 12px; } .composition-list-heading { column-gap: 12px; }
.composition-row-main { gap: 10px; padding: 16px 4px; } .composition-row-main { gap: 10px; padding: 16px 4px; }
.composition-row-icon { width: 32px; height: 32px; } .composition-row-icon { width: 32px; height: 32px; }
@@ -62,7 +62,9 @@ it("starts with a catalog and opens creation in a separate editor", async () =>
expect(within(editor).getByRole("textbox", { name: "预设名称" })).toBe(document.activeElement); expect(within(editor).getByRole("textbox", { name: "预设名称" })).toBe(document.activeElement);
expect(within(editor).getByText("基于标准模式创建")).toBeTruthy(); expect(within(editor).getByText("基于标准模式创建")).toBeTruthy();
expect(within(editor).getByRole<HTMLButtonElement>("button", { name: "保存预设" }).disabled).toBe(true); expect(within(editor).getByRole<HTMLButtonElement>("button", { name: "保存预设" }).disabled).toBe(true);
expect(within(editor).getByLabelText("预设 ID").closest("details")?.open).toBe(false); expect(within(editor).getByLabelText("预设 ID").closest("details")).toBeNull();
expect(within(editor).getByRole("textbox", { name: "补充要求" })).toBeTruthy();
expect(within(editor).queryByText("高级设置")).toBeNull();
}); });
it("saves a named preset, returns to its catalog and leaves the conversation unbound", async () => { it("saves a named preset, returns to its catalog and leaves the conversation unbound", async () => {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import "./testkit"; import "./testkit";
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, it, expect, vi } from "vitest"; import { afterEach, describe, it, expect, vi } from "vitest";
import { MockPort } from "../port/mock"; import { MockPort } from "../port/mock";
@@ -8,8 +8,9 @@ import { AgentPresets } from "./AgentPresets";
import { boot, STORAGE, t } from "../i18n"; import { boot, STORAGE, t } from "../i18n";
import { AgentCompositionPicker } from "./AgentCompositionPicker"; import { AgentCompositionPicker } from "./AgentCompositionPicker";
import { RunLine } from "./RunLine"; import { RunLine } from "./RunLine";
import { NEW_TASK_PRESET_KEY } from "../state/newTaskPreset";
afterEach(() => { cleanup(); localStorage.setItem(STORAGE, "zh"); boot(); }); afterEach(() => { cleanup(); localStorage.removeItem(NEW_TASK_PRESET_KEY); localStorage.setItem(STORAGE, "zh"); boot(); });
it("keeps the mode picker visible without a visibility setting or automatic selection", async () => { it("keeps the mode picker visible without a visibility setting or automatic selection", async () => {
const port = new MockPort(); const port = new MockPort();
@@ -75,7 +76,7 @@ describe("Agent preset management", () => {
render(<AgentPresets port={port} />); render(<AgentPresets port={port} />);
expect(await screen.findByRole("button", { name: "查看 标准模式" })).toBeTruthy(); expect(await screen.findByRole("button", { name: "查看 标准模式" })).toBeTruthy();
const minimal = screen.getByRole("button", { name: "查看 精简模式" }); const minimal = screen.getByRole("button", { name: "查看 精简模式" });
expect(document.querySelectorAll(".composition-row")).toHaveLength(2); expect(within(screen.getByRole("region", { name: "内置" })).getAllByRole("article")).toHaveLength(2);
expect(screen.queryByText("代码审查")).toBeNull(); expect(screen.queryByText("代码审查")).toBeNull();
expect(screen.queryByText("资料研究")).toBeNull(); expect(screen.queryByText("资料研究")).toBeNull();
await user.click(minimal); await user.click(minimal);
@@ -101,10 +102,9 @@ describe("Agent preset management", () => {
await user.clear(screen.getByLabelText("预设名称")); await user.clear(screen.getByLabelText("预设名称"));
await user.type(screen.getByLabelText("预设名称"), "My agent"); await user.type(screen.getByLabelText("预设名称"), "My agent");
await user.type(screen.getByLabelText("人设与要求"), "Literal prefix"); await user.type(screen.getByLabelText("人设与要求"), "Literal prefix");
await user.click(screen.getByText("高级设置", { selector: "summary" }));
await user.type(screen.getByLabelText("补充要求"), "Literal suffix"); await user.type(screen.getByLabelText("补充要求"), "Literal suffix");
expect(screen.queryByRole("switch", { name: /complete/ })).toBeNull(); expect(screen.queryByRole("switch", { name: /complete/ })).toBeNull();
await user.click(screen.getByRole("switch", { name: "注入运行环境与离线提示" })); await user.click(screen.getByRole("switch", { name: "不注入可选运行时上下文" }));
await user.click(screen.getByRole("button", { name: "保存预设" })); await user.click(screen.getByRole("button", { name: "保存预设" }));
expect((await screen.findByRole("status")).textContent).toContain("新会话使用新版"); expect((await screen.findByRole("status")).textContent).toContain("新会话使用新版");
expect((await port.agentPresets()).presets.some((v) => v.definition.name === "My agent")).toBe(true); expect((await port.agentPresets()).presets.some((v) => v.definition.name === "My agent")).toBe(true);
@@ -0,0 +1,36 @@
import { useId, useState } from "react";
import type { AgentPresetVersion } from "../port/composition";
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
export function BuiltinPresetCard({ version: { definition: d }, selected, onDefault, onSelect, onCopy }: {
version: AgentPresetVersion;
selected: boolean;
onDefault: () => void;
onSelect: () => void;
onCopy: () => void;
}) {
const [usage, setUsage] = useState(false);
const helpId = useId();
const name = t(d.name);
return <article className="composition-builtin-card" data-selected={selected ? "" : undefined}>
<button type="button" className="composition-builtin-select" data-action="agent-preset.new-task-default"
data-target={d.id} aria-pressed={selected} aria-label={t("将{name}设为新任务默认", { name })} onClick={onDefault}>
<span className="composition-builtin-heading"><strong>{name}</strong>
<span className="composition-builtin-badge">{t(selected ? "新任务默认" : "内置")}</span>
<code title={d.id}>{d.id.slice("builtin:".length)}</code></span>
<span className="composition-builtin-description">{t(d.description)}</span>
</button>
<footer className="composition-builtin-footer">
<button type="button" data-action="agent-preset.view" data-target={d.id}
aria-label={t("查看 {name}", { name })} onClick={onSelect}>{t("模式说明")}</button>
<button type="button" data-action="agent-preset.usage" data-target={d.id} aria-expanded={usage}
aria-controls={helpId} aria-label={t("如何使用 {name}", { name })} onClick={() => setUsage(v => !v)}>{t("如何使用")}</button>
<button type="button" className="composition-builtin-copy" data-action="agent-preset.copy" data-target={d.id}
aria-label={t("复制 {name}", { name })} title={t("复制为自定义预设")} onClick={onCopy}><StudioIcon name="copy" /></button>
</footer>
<p className="composition-builtin-usage" id={helpId} hidden={!usage}>{t(d.id === "builtin:general"
? "标准模式默认启用,可直接输入任务;如需自定义,先复制此预设。"
: "在新任务输入框选择精简模式,再发送首条消息。需要支持读图的模型;终端操作继续遵守权限设置。")}</p>
</article>;
}
@@ -1,6 +1,9 @@
import { useState } from "react";
import type { AgentPresetCatalog, AgentPresetVersion } from "../port/composition"; import type { AgentPresetCatalog, AgentPresetVersion } from "../port/composition";
import { t } from "../i18n"; import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon"; import { StudioIcon } from "./StudioIcon";
import { BuiltinPresetCard } from "./BuiltinPresetCard";
import { setNewTaskPreset, useNewTaskPreset } from "../state/newTaskPreset";
import "../styles/composition-presets.css"; import "../styles/composition-presets.css";
export function CompositionCatalog({ catalog, onSelect, onCreate, onCopy }: { export function CompositionCatalog({ catalog, onSelect, onCreate, onCopy }: {
@@ -9,19 +12,26 @@ export function CompositionCatalog({ catalog, onSelect, onCreate, onCopy }: {
onCreate: () => void; onCreate: () => void;
onCopy: (version: AgentPresetVersion) => void; onCopy: (version: AgentPresetVersion) => void;
}) { }) {
const preferred = useNewTaskPreset() ?? "builtin:general";
const [error, setError] = useState("");
return <div className="composition-catalog"> return <div className="composition-catalog">
<header className="composition-list-heading"> <header className="composition-list-heading">
<h2>{t("Agent 预设")}</h2> <h2>{t("Agent 预设")}</h2>
<button className="btn" data-primary="" data-action="agent-preset.create" onClick={onCreate}><StudioIcon name="plus" />{t("新建预设")}</button> <button className="btn" data-primary="" data-action="agent-preset.create" onClick={onCreate}><StudioIcon name="plus" />{t("新建预设")}</button>
<p>{t("为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。")}</p> <p>{t("为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。")}</p>
</header> </header>
<p className="composition-default-note">{t("新会话默认使用标准模式。选择其他模式后才会启用对应预设。")}</p> <p className="composition-default-note">{t("点击卡片,设置本机新任务的默认模式;已有会话保持当前配置。")}</p>
{error && <p className="find" role="alert">{error}</p>}
{(["builtin", "user", "project"] as const).map((scope) => { {(["builtin", "user", "project"] as const).map((scope) => {
const items = catalog.presets.filter(v => v.definition.id.startsWith(scope + ":")); const items = catalog.presets.filter(v => v.definition.id.startsWith(scope + ":"));
if (!items.length) return null; if (!items.length) return null;
return <section className="composition-list-group" key={scope} aria-label={t(scope === "builtin" ? "内置" : scope === "user" ? "个人" : "项目共享")}> return <section className="composition-list-group" key={scope} aria-label={t(scope === "builtin" ? "内置" : scope === "user" ? "个人" : "项目共享")}>
<h3>{t(scope === "builtin" ? "内置" : scope === "user" ? "个人" : "项目共享")}</h3> <h3>{t(scope === "builtin" ? "内置" : scope === "user" ? "个人" : "项目共享")}</h3>
{items.map(v => <PresetRow key={v.definition.id} version={v} onSelect={() => onSelect(v)} onCopy={() => onCopy(v)} />)} {scope === "builtin" ? <div className="composition-builtin-grid">{items.map(v =>
<BuiltinPresetCard key={v.definition.id} version={v} selected={preferred === v.definition.id}
onDefault={() => setError(setNewTaskPreset(v.definition.id) ? "" : t("无法保存新任务默认模式,请检查本地存储权限。"))}
onSelect={() => onSelect(v)} onCopy={() => onCopy(v)} />)}</div>
: items.map(v => <PresetRow key={v.definition.id} version={v} onSelect={() => onSelect(v)} onCopy={() => onCopy(v)} />)}
</section>; </section>;
})} })}
<details className="composition-how"><summary>{t("如何使用")}</summary><p>{t("在输入框顶部选择 Agent 模式,首次提交前可切换。开始对话后,请用其他模式新建会话。精简模式的终端仍可修改文件,并非只读模式。")}</p></details> <details className="composition-how"><summary>{t("如何使用")}</summary><p>{t("在输入框顶部选择 Agent 模式,首次提交前可切换。开始对话后,请用其他模式新建会话。精简模式的终端仍可修改文件,并非只读模式。")}</p></details>
@@ -66,18 +66,45 @@ export function CompositionEditor({
</label> </label>
<label className="pers-field"> <label className="pers-field">
<span className="lb">{t("人设与要求")}</span> <span className="lb">{t("人设与要求")}</span>
<span className="ds" id={`${helpId}-persona`}>{t("描述预设的角色、工作方式与要求;内容写在系统提示词开头。")}</span>
<textarea <textarea
aria-label={t("人设与要求")} aria-label={t("人设与要求")}
aria-describedby={`${helpId}-persona`} aria-describedby={`${helpId}-persona`}
data-action-change="persona.prefix" data-action-change="persona.prefix"
rows={4} rows={5}
maxLength={32768} maxLength={32768}
disabled={disabled} disabled={disabled}
value={persona.prefix} value={persona.prefix}
onChange={(e) => updatePersona({ prefix: e.target.value })} onChange={(e) => updatePersona({ prefix: e.target.value })}
/> />
<span className="ds" id={`${helpId}-persona`}>{t("描述预设的角色、工作方式与要求;内容写在系统提示词开头。")}</span>
</label> </label>
<label className="pers-field">
<span className="lb">{t("补充要求")}</span>
<span className="ds" id={`${helpId}-suffix`}>{t("放在内置系统指令之后,留空则不添加")}</span>
<textarea
aria-label={t("补充要求")}
aria-describedby={`${helpId}-suffix`}
data-action-change="persona.suffix"
rows={5}
maxLength={32768}
disabled={disabled}
value={persona.suffix}
onChange={(e) => updatePersona({ suffix: e.target.value })}
/>
</label>
<div className="lrow">
<span className="tx">
<span className="lb">{t("不注入可选运行时上下文")}</span>
<span className="ds">{t("开启后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。")}</span>
</span>
<Switch
data-action="persona.runtime-context"
label={t("不注入可选运行时上下文")}
on={!persona.include_runtime_context}
busy={disabled}
onClick={() => updatePersona({ include_runtime_context: !persona.include_runtime_context })}
/>
</div>
<div className="composition-capabilities"> <div className="composition-capabilities">
<h3>{t("能力配置")}</h3> <h3>{t("能力配置")}</h3>
<p className="ds">{t("继承当前的工具、技能与扩展配置,可按需单独调整。")}</p> <p className="ds">{t("继承当前的工具、技能与扩展配置,可按需单独调整。")}</p>
@@ -105,41 +132,12 @@ export function CompositionEditor({
</select> </select>
<span className="ds" id={`${helpId}-scope`}>{t("个人:保存在本机,可跨项目使用。项目共享:保存在当前项目,可随仓库共享。")}</span> <span className="ds" id={`${helpId}-scope`}>{t("个人:保存在本机,可跨项目使用。项目共享:保存在当前项目,可随仓库共享。")}</span>
</label> </label>
<details className="composition-advanced">
<summary><StudioIcon name="settings" />{t("高级设置")}<StudioIcon name="chevron" /></summary>
<label className="pers-field">
<span className="lb">{t("预设 ID")}</span>
<input className="in" aria-label={t("预设 ID")} aria-describedby={`${helpId}-id`} disabled={disabled || !creating} data-action-change="agent-preset.id"
value={draft.id.split(":")[1]} onChange={(e) => onChange({ ...draft, id: `${draft.id.split(":")[0]}:${e.target.value}` })} />
<span className="ds" id={`${helpId}-id`}>{t("自动生成,用于保存和引用预设;保存后不可修改。")}</span>
</label>
<label className="pers-field"> <label className="pers-field">
<span className="lb">{t("补充要求")}</span> <span className="lb">{t("预设 ID")}</span>
<textarea <input className="in" aria-label={t("预设 ID")} aria-describedby={`${helpId}-id`} disabled={disabled || !creating} data-action-change="agent-preset.id"
aria-label={t("补充要求")} value={draft.id.split(":")[1]} onChange={(e) => onChange({ ...draft, id: `${draft.id.split(":")[0]}:${e.target.value}` })} />
aria-describedby={`${helpId}-suffix`} <span className="ds" id={`${helpId}-id`}>{t("自动生成,用于保存和引用预设;保存后不可修改。")}</span>
data-action-change="persona.suffix"
rows={3}
maxLength={32768}
disabled={disabled}
value={persona.suffix}
onChange={(e) => updatePersona({ suffix: e.target.value })}
/>
<span className="ds" id={`${helpId}-suffix`}>{t("放在内置系统指令之后,留空则不添加")}</span>
</label> </label>
<div className="lrow">
<span className="tx">
<span className="lb">{t("注入运行环境与离线提示")}</span>
</span>
<Switch
data-action="persona.runtime-context"
label={t("注入运行环境与离线提示")}
on={persona.include_runtime_context}
busy={disabled}
onClick={() => updatePersona({ include_runtime_context: !persona.include_runtime_context })}
/>
</div>
</details>
</div> </div>
); );
} }
+1 -1
View File
@@ -176,7 +176,7 @@ export function Persona({ port, onDirtyChange, onBusyChange, onSaved }: { port:
<div className="lrow"> <div className="lrow">
<span className="tx"> <span className="tx">
<span className="lb">{t("不注入可选运行时上下文")}</span> <span className="lb">{t("不注入可选运行时上下文")}</span>
<span className="ds">{t("关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。")}</span> <span className="ds">{t("开启后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。")}</span>
</span> </span>
<Switch <Switch
data-action="persona.runtime-context" data-action="persona.runtime-context"
@@ -36,7 +36,7 @@ export function PresetActions({ port, version, busy, run, onDeleted, onPersona }
</div>} </div>}
<details> <details>
<summary>{t("显式默认配置")}</summary> <summary>{t("显式默认配置")}</summary>
<p className="ds">{t("仅在显式选择 default 时使用,不改变新会话的标准模式。")}</p> <p className="ds">{t("仅在显式选择 default 时使用,与本机卡片设置的新任务默认独立。")}</p>
<div className="composition-toolbar"> <div className="composition-toolbar">
<button type="button" className="act" data-action="agent-preset.default" disabled={busy || d.id.startsWith("project:")} <button type="button" className="act" data-action="agent-preset.default" disabled={busy || d.id.startsWith("project:")}
onClick={() => void run(async () => { await port.setDefaultAgentPreset(d.id, false); setNote(t("已设为个人首选预设")); })}>{t("设为个人首选")}</button> onClick={() => void run(async () => { await port.setDefaultAgentPreset(d.id, false); setNote(t("已设为个人首选预设")); })}>{t("设为个人首选")}</button>
+1 -1
View File
@@ -107,7 +107,7 @@ export const NAV: [string, [Section, string][]][] = [
"本轮执行", "本轮执行",
[ [
["session", "会话"], ["session", "会话"],
["persona", "Agent 预设"], ["persona", "人设"],
["model", "模型偏好"], ["model", "模型偏好"],
["providers", "模型服务"], ["providers", "模型服务"],
["tools", "工具与权限"], ["tools", "工具与权限"],
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import "./testkit";
import { act, cleanup, render, renderHook, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { AgentPresets } from "./AgentPresets";
import { useOpenPane } from "./useOpenPane";
import { useOpenRemotePane } from "./useOpenRemotePane";
import { MockPort } from "../port/mock";
import { MockHub } from "../port/mock_hub";
import { NEW_TASK_PRESET_KEY, newTaskPreset, setNewTaskPreset } from "../state/newTaskPreset";
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.removeItem(NEW_TASK_PRESET_KEY); });
const sw = () => ({ path: "", pending: false, opening: () => false, begin: () => 1,
current: () => true, landed: vi.fn(), end: vi.fn(), cancel: vi.fn() });
it("uses the clicked card for fresh local and remote tasks, with explicit choices and history preserved", async () => {
const hub = new MockHub();
const localOpen = vi.spyOn(hub, "open");
const remoteOpen = vi.spyOn(hub, "openRemote");
const port = new MockPort();
await port.setDefaultAgentPreset("builtin:minimal", false);
render(<AgentPresets port={port} />);
const user = userEvent.setup();
const general = await screen.findByRole("button", { name: "将标准模式设为新任务默认" });
expect(general.getAttribute("aria-pressed")).toBe("true");
expect(newTaskPreset()).toBeUndefined();
await user.click(screen.getByRole("button", { name: "将精简模式设为新任务默认" }));
expect(screen.getByRole("button", { name: "将精简模式设为新任务默认" }).getAttribute("aria-pressed")).toBe("true");
expect(await port.agentComposition()).toBeNull();
const local = renderHook(() => useOpenPane({ hub, runtimes: [], panePorts: new Map(), active: "",
reportsRef: { current: {} }, runsRef: { current: {} }, setRuntimes: vi.fn(), setActive: vi.fn(),
setTakeover: vi.fn(), reloadPanes: async () => {}, sw: sw() }));
await act(async () => { await local.result.current({ root: "/task" }); });
expect(localOpen).toHaveBeenLastCalledWith({ root: "/task", agentComposition: "builtin:minimal" });
const created = (await hub.runtimes()).find(v => v.root === "/task")!;
expect((await hub.portFor(created).agentComposition())?.id).toBe("builtin:minimal");
const remote = renderHook(() => useOpenRemotePane({ hub, reloadPanes: async () => {},
reloadRemotes: async () => {}, sw: sw(), setActive: vi.fn(), setOpening: vi.fn() }));
await act(async () => { await remote.result.current("gpu-box", "/task"); });
expect(remoteOpen).toHaveBeenLastCalledWith({ host: "gpu-box", workspace: "/task", sessionPath: undefined, agentComposition: "builtin:minimal" });
const remoteCreated = (await hub.runtimes()).find(v => v.host === "gpu-box")!;
expect((await hub.portFor(remoteCreated).agentComposition())?.id).toBe("builtin:minimal");
await act(async () => { await local.result.current({ root: "/task", agentComposition: "builtin:general" }); });
expect(localOpen).toHaveBeenLastCalledWith({ root: "/task", agentComposition: "builtin:general" });
await user.click(screen.getByRole("button", { name: "将标准模式设为新任务默认" }));
await act(async () => { await local.result.current({ root: "/reset" }); });
const reset = (await hub.runtimes()).find(v => v.root === "/reset")!;
expect(await hub.portFor(reset).agentComposition()).toBeNull();
expect((await hub.portFor(created).agentComposition())?.id).toBe("builtin:minimal");
await act(async () => { await local.result.current({ root: "/task", sessionPath: "/history.jsonl" }); });
expect(localOpen).toHaveBeenLastCalledWith({ root: "/task", sessionPath: "/history.jsonl" });
await act(async () => { await remote.result.current("gpu-box", "/task", "/history.jsonl"); });
expect(remoteOpen).toHaveBeenLastCalledWith({ host: "gpu-box", workspace: "/task", sessionPath: "/history.jsonl", agentComposition: undefined });
});
it("keeps usage, details and copy actions separate from selecting the new task default", async () => {
const user = userEvent.setup();
setNewTaskPreset("builtin:general");
render(<AgentPresets port={new MockPort()} />);
const usage = await screen.findByRole("button", { name: "如何使用 精简模式" });
await user.click(usage);
expect(usage.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByText(/在新任务输入框选择精简模式/)).toBeTruthy();
expect(newTaskPreset()).toBe("builtin:general");
await user.click(screen.getByRole("button", { name: "查看 精简模式" }));
await screen.findByRole("form", { name: "预设详情" });
expect(newTaskPreset()).toBe("builtin:general");
await user.click(screen.getByRole("button", { name: "返回预设列表" }));
await user.click(screen.getByRole("button", { name: "复制 精简模式" }));
expect(await screen.findByRole("form", { name: "新建预设" })).toBeTruthy();
expect(newTaskPreset()).toBe("builtin:general");
});
it("keeps the old default badge and reports a storage refusal", async () => {
const user = userEvent.setup();
render(<AgentPresets port={new MockPort()} />);
await screen.findByRole("button", { name: "将精简模式设为新任务默认" });
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage denied"); });
await user.click(screen.getByRole("button", { name: "将精简模式设为新任务默认" }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.getByRole("button", { name: "将标准模式设为新任务默认" }).getAttribute("aria-pressed")).toBe("true");
});
@@ -22,6 +22,7 @@ function draw(port = new MockPort()) {
it.each(["close", "navigation", "search"])("protects a draft on settings %s", async (destination) => { it.each(["close", "navigation", "search"])("protects a draft on settings %s", async (destination) => {
const user = userEvent.setup(); const user = userEvent.setup();
const { onClose } = draw(); const { onClose } = draw();
expect(screen.getByRole("tab", { name: "人设" })).toBeTruthy();
await user.click(await screen.findByRole("button", { name: "新建预设" })); await user.click(await screen.findByRole("button", { name: "新建预设" }));
await user.type(screen.getByLabelText("预设名称"), "保留草稿"); await user.type(screen.getByLabelText("预设名称"), "保留草稿");
const leave = async () => { const leave = async () => {
@@ -6,13 +6,15 @@ import { HttpError } from "../port/http_error";
import type { HubPort, RuntimeView } from "../port/hub"; import type { HubPort, RuntimeView } from "../port/hub";
import { clearToasts, readToasts } from "../state/toasts"; import { clearToasts, readToasts } from "../state/toasts";
import { useOpenPane } from "./useOpenPane"; import { useOpenPane } from "./useOpenPane";
import { NEW_TASK_PRESET_KEY, setNewTaskPreset } from "../state/newTaskPreset";
afterEach(() => { cleanup(); clearToasts(); }); afterEach(() => { cleanup(); clearToasts(); localStorage.removeItem(NEW_TASK_PRESET_KEY); });
function blankPane() { function blankPane() {
const port = { const port = {
agentPresets: vi.fn().mockRejectedValue(new Error("presets unavailable")), agentPresets: vi.fn().mockRejectedValue(new Error("presets unavailable")),
selectAgentComposition: vi.fn().mockResolvedValue(null), selectAgentComposition: vi.fn().mockResolvedValue(null),
resume: vi.fn().mockResolvedValue(null),
}; };
const setActive = vi.fn(); const setActive = vi.fn();
const hub = { open: vi.fn(), close: vi.fn() }; const hub = { open: vi.fn(), close: vi.fn() };
@@ -21,12 +23,12 @@ function blankPane() {
runtimes: [{ id: "blank", root: "/w" } as RuntimeView], runtimes: [{ id: "blank", root: "/w" } as RuntimeView],
panePorts: new Map([["blank", port as unknown as AgentPort]]), panePorts: new Map([["blank", port as unknown as AgentPort]]),
active: "other", reportsRef: { current: {} }, runsRef: { current: {} }, active: "other", reportsRef: { current: {} }, runsRef: { current: {} },
setRuntimes: vi.fn(), setActive, setTakeover: vi.fn(), reloadPanes: vi.fn(), setRuntimes: vi.fn(), setActive, setTakeover: vi.fn(), reloadPanes: vi.fn(async () => {}),
sw: { path: "", pending: false, opening: () => false, begin: () => 1, sw: { path: "", pending: false, opening: () => false, begin: () => 1,
current: () => true, landed: vi.fn(), end: vi.fn(), cancel: vi.fn() }, current: () => true, landed: vi.fn(), end: vi.fn(), cancel: vi.fn() },
})); }));
return { port, setActive, hub, open: async (agentComposition?: string) => { return { port, setActive, hub, open: async (agentComposition?: string, sessionPath?: string) => {
await act(async () => { await hook.result.current({ root: "/w", agentComposition }); }); await act(async () => { await hook.result.current({ root: "/w", agentComposition, sessionPath }); });
} }; } };
} }
@@ -40,6 +42,25 @@ it("keeps an existing blank pane's chosen mode without reading the catalog", asy
expect(pane.hub.open).not.toHaveBeenCalled(); expect(pane.hub.open).not.toHaveBeenCalled();
}); });
it("applies a newly chosen default when New reuses an unused pane", async () => {
const pane = blankPane();
setNewTaskPreset("builtin:minimal");
await pane.open();
expect(pane.port.selectAgentComposition).toHaveBeenLastCalledWith("builtin:minimal");
setNewTaskPreset("builtin:general");
await pane.open();
expect(pane.port.selectAgentComposition).toHaveBeenLastCalledWith("builtin:general");
expect(pane.port.agentPresets).not.toHaveBeenCalled();
});
it("restores history into a blank pane without applying the new-task default", async () => {
const pane = blankPane();
setNewTaskPreset("builtin:minimal");
await pane.open(undefined, "/w/history.jsonl");
expect(pane.port.resume).toHaveBeenCalledWith("/w/history.jsonl");
expect(pane.port.selectAgentComposition).not.toHaveBeenCalled();
});
it("still opens the pane and reports a refused explicit mode without rebuilding it", async () => { it("still opens the pane and reports a refused explicit mode without rebuilding it", async () => {
const pane = blankPane(); const pane = blankPane();
pane.port.selectAgentComposition.mockRejectedValue(new HttpError(409, "locked", { code: "agent_composition.locked" })); pane.port.selectAgentComposition.mockRejectedValue(new HttpError(409, "locked", { code: "agent_composition.locked" }));
@@ -4,6 +4,7 @@ import type { AgentPort } from "../port/port";
import type { PaneReport } from "./Pane"; import type { PaneReport } from "./Pane";
import { t } from "../i18n"; import { t } from "../i18n";
import { openSessionLimit } from "../state/prefs"; import { openSessionLimit } from "../state/prefs";
import { newTaskComposition } from "../state/newTaskPreset";
import { pushNote, pushToast } from "../state/toasts"; import { pushNote, pushToast } from "../state/toasts";
import { reason } from "../i18n/kernel"; import { reason } from "../i18n/kernel";
import type { SessionSwitch } from "./sessionswitch"; import type { SessionSwitch } from "./sessionswitch";
@@ -33,6 +34,8 @@ interface Deps {
export function useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runsRef, setRuntimes, setActive, setTakeover, reloadPanes, sw }: Deps) { export function useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runsRef, setRuntimes, setActive, setTakeover, reloadPanes, sw }: Deps) {
const placePane = useCallback( const placePane = useCallback(
async (req: { root?: string; sessionPath?: string; agentComposition?: string }): Promise<Placed> => { async (req: { root?: string; sessionPath?: string; agentComposition?: string }): Promise<Placed> => {
const composition = newTaskComposition(req.agentComposition, req.sessionPath);
if (composition !== undefined && req.agentComposition === undefined) req = { ...req, agentComposition: composition };
// Blank means never written to and not working: a new session mid-turn has // Blank means never written to and not working: a new session mid-turn has
// no path in a stale pane list yet, and taking it over would hide its run. // no path in a stale pane list yet, and taking it over would hide its run.
const blank = runtimes.find( const blank = runtimes.find(
@@ -1,6 +1,7 @@
import { useCallback, useRef, type Dispatch, type SetStateAction } from "react"; import { useCallback, useRef, type Dispatch, type SetStateAction } from "react";
import type { HubPort, RuntimeView } from "../port/hub"; import type { HubPort, RuntimeView } from "../port/hub";
import type { SessionSwitch } from "./sessionswitch"; import type { SessionSwitch } from "./sessionswitch";
import { newTaskComposition } from "../state/newTaskPreset";
export function useOpenRemotePane({ hub, reloadPanes, reloadRemotes, sw, setActive, setOpening }: { export function useOpenRemotePane({ hub, reloadPanes, reloadRemotes, sw, setActive, setOpening }: {
hub: HubPort; hub: HubPort;
@@ -12,6 +13,7 @@ export function useOpenRemotePane({ hub, reloadPanes, reloadRemotes, sw, setActi
}) { }) {
const flights = useRef(new Map<string, Promise<RuntimeView>>()); const flights = useRef(new Map<string, Promise<RuntimeView>>());
return useCallback(async (host: string, workspace?: string, sessionPath?: string, agentComposition?: string) => { return useCallback(async (host: string, workspace?: string, sessionPath?: string, agentComposition?: string) => {
agentComposition = newTaskComposition(agentComposition, sessionPath);
const key = JSON.stringify(["remote", host, workspace, sessionPath, agentComposition]); const key = JSON.stringify(["remote", host, workspace, sessionPath, agentComposition]);
if (sessionPath && sw.opening(key)) return; if (sessionPath && sw.opening(key)) return;
const ticket = sw.begin(key); const ticket = sw.begin(key);
+30 -7
View File
@@ -21,9 +21,16 @@ to bind a version.
## Choosing and editing ## Choosing and editing
The mode picker is always visible above the input in every pane. New conversations The mode picker is always visible above the input in every pane. Standard is the
start in Standard mode, even when another preset is marked as preferred. Showing initial mode. Clicking a built-in card explicitly sets this machine's new-task
the picker reads the catalog without selecting or binding a composition. default, including local and remote tasks.
Saved server preferences alone do not
activate it. Showing the picker reads the catalog without binding a composition.
An explicit mode in a new-task request overrides the card default. Restoring
history retains its original binding. Clicking a card leaves existing tasks
unchanged; pressing New may reuse an unused pane with the chosen default.
Choose the mode at the left of the composer status row before Choose the mode at the left of the composer status row before
the first prompt. One menu lists compositions with their sources and capabilities; the first prompt. One menu lists compositions with their sources and capabilities;
@@ -41,6 +48,10 @@ composition does not unlock it.
The catalog groups built-in, personal and project presets. New and duplicate The catalog groups built-in, personal and project presets. New and duplicate
actions open a separate editor within Settings: actions open a separate editor within Settings:
Built-ins use responsive cards with a new-task-default badge, canonical ID,
description, details, usage and copy actions. Personal/project entries remain
editable rows. Usage, details and copy do not change the new-task default.
| Mode | Behavior | | Mode | Behavior |
| --- | --- | | --- | --- |
| Standard (`builtin:general`) | Inherit current configuration for everyday code, file and research work. The stable ID preserves existing defaults. | | Standard (`builtin:general`) | Inherit current configuration for everyday code, file and research work. The stable ID preserves existing defaults. |
@@ -63,11 +74,11 @@ when a conversation binds, or uses only the selected IDs. An empty explicit
selection disables the group. The editor lists locally available modules; selection disables the group. The editor lists locally available modules;
imports preview missing dependencies without installing or launching them. imports preview missing dependencies without installing or launching them.
The editor shows name, purpose and persona first. Tools and skills/extensions Settings lists the page as Persona. The editor shows name, purpose and both
expand on demand; storage scope follows them. persona text fields directly, followed by the optional-runtime-context switch.
Advanced settings hold the generated ID, persona suffix and runtime-context Tools and skills/extensions expand on demand. Storage scope and the generated
toggle. ID and scope become read-only after saving. ID remain visible; both become read-only after saving.
A persistent footer offers Cancel and Save. Returning, closing Settings or A persistent footer offers Cancel and Save. Returning, closing Settings or
navigating elsewhere confirms unsaved changes; save errors retain the draft. navigating elsewhere confirms unsaved changes; save errors retain the draft.
@@ -87,8 +98,20 @@ conflicts while preserving the draft. Models remain independently selectable.
| Personal definitions | `<state>/agent-presets/definitions/<id>.json` | | Personal definitions | `<state>/agent-presets/definitions/<id>.json` |
| Shared definitions | `<workspace>/.reasonix/agent-presets/definitions/<id>.json` | | Shared definitions | `<workspace>/.reasonix/agent-presets/definitions/<id>.json` |
| Definition revisions and explicit defaults | `<state>/agent-presets/` | | Definition revisions and explicit defaults | `<state>/agent-presets/` |
| Studio new-task card choice | Browser-local `rx-new-task-agent-preset` |
| Local authorization records and retained files | `<state>/trusted/agent-presets/` | | Local authorization records and retained files | `<state>/trusted/agent-presets/` |
The new browser key accepts only the shipped Standard/Minimal identities.
It is separate from server preferences and never read by CLI or ACP.
| Stored choice | Current reader | Previous reader |
| --- | --- | --- |
| Missing, unreadable or unknown | Original Standard behavior | Ignores the key |
| Standard/Minimal | Explicit choice on new Studio requests | Ignores the key |
An unsuccessful storage write retains the previous choice and reports an error.
Existing server selection/admission permissions still apply to new requests.
`<state>` is Reasonix's resolved state root, including relocation. IDs start `<state>` is Reasonix's resolved state root, including relocation. IDs start
with `builtin:`, `user:` or `project:`. A project binding also records its with `builtin:`, `user:` or `project:`. A project binding also records its
workspace. Matching display names coexist; one scope never shadows another. workspace. Matching display names coexist; one scope never shadows another.
+22 -4
View File
@@ -13,8 +13,12 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
## 选择和修改 ## 选择和修改
模式选择器在所有窗格的输入框上方直接显示。新会话默认使用标准模式,即使其他预设 模式选择器在所有窗格的输入框上方直接显示,初始使用标准模式。点击内置卡片可明确
已标记为首选也不会自动启用。显示入口只读取目录,不会选择或绑定组合。 设置本机新任务默认模式,覆盖本地和远程新建任务;仅保存服务端首选项不会自动启用。
显示入口只读取目录,不会选择或绑定组合。
新建时明确指定的模式优先于卡片默认值,历史会话保留原绑定。点击卡片不切换已有
任务;按新建复用未使用的空白窗格时,应用所选默认模式。
首次提交前,在输入框顶部左侧打开 Agent 模式菜单;列表显示来源 首次提交前,在输入框顶部左侧打开 Agent 模式菜单;列表显示来源
和能力说明,分隔线下的「管理 Agent 预设…」打开管理页。接纳首条消息后,会话保留原绑定; 和能力说明,分隔线下的「管理 Agent 预设…」打开管理页。接纳首条消息后,会话保留原绑定;
@@ -26,6 +30,9 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
管理列表按内置、个人和项目共享分组;新建和复制均在设置内容区打开独立编辑页: 管理列表按内置、个人和项目共享分组;新建和复制均在设置内容区打开独立编辑页:
内置采用响应式卡片,展示新任务默认标记、真实 ID、说明、模式详情、使用方法与
复制入口;个人和项目预设保留可编辑列表。详情、使用方法与复制不会更改默认模式。
| 模式 | 行为 | | 模式 | 行为 |
| --- | --- | | --- | --- |
| 标准模式(`builtin:general`) | 创建会话时继承当前配置,处理日常代码、文件与资料任务;保留原稳定标识以兼容默认选择。 | | 标准模式(`builtin:general`) | 创建会话时继承当前配置,处理日常代码、文件与资料任务;保留原稳定标识以兼容默认选择。 |
@@ -42,8 +49,9 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
「仅使用所选项」;显式空列表表示禁用。编辑器提供本机模块目录,导入先预览 「仅使用所选项」;显式空列表表示禁用。编辑器提供本机模块目录,导入先预览
缺失依赖,不会安装依赖或启动程序。 缺失依赖,不会安装依赖或启动程序。
编辑页优先展示名称、用途与人设。工具、技能与扩展按需展开,其后选择存放范围。 设置导航显示为「人设」。编辑页直接展示名称、用途、两段人设文本和可选运行时
高级设置收纳自动生成的 ID、人设后缀和运行环境开关;保存后 ID 与范围不可修改。 上下文开关;工具、技能与扩展按需展开。存放范围与自动生成的 ID 也直接展示,
保存后 ID 与范围不可修改。
底部固定提供取消和保存,返回列表、关闭设置或切换设置时会确认未保存的更改, 底部固定提供取消和保存,返回列表、关闭设置或切换设置时会确认未保存的更改,
保存失败继续保留草稿。标准模式的用户人设使用独立编辑入口,可从标准模式详情 保存失败继续保留草稿。标准模式的用户人设使用独立编辑入口,可从标准模式详情
@@ -62,8 +70,18 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
| 用户声明 | `<用户状态根>/agent-presets/definitions/<id>.json` | | 用户声明 | `<用户状态根>/agent-presets/definitions/<id>.json` |
| 项目共享声明 | `<工作区>/.reasonix/agent-presets/definitions/<id>.json` | | 项目共享声明 | `<工作区>/.reasonix/agent-presets/definitions/<id>.json` |
| 定义版本和显式默认选择 | `<用户状态根>/agent-presets/` | | 定义版本和显式默认选择 | `<用户状态根>/agent-presets/` |
| Studio 卡片选择 | 浏览器本地 `rx-new-task-agent-preset` |
| 本机授权记录和保留文件 | `<用户状态根>/trusted/agent-presets/` | | 本机授权记录和保留文件 | `<用户状态根>/trusted/agent-presets/` |
本地键只接受标准和精简两种内置 ID,与服务端首选项独立,CLI 与 ACP 不读取它。
| 存储内容 | 当前版本读取 | 旧版本读取 |
| --- | --- | --- |
| 缺失、不可读或未知值 | 保留原标准模式行为 | 忽略此键 |
| 标准或精简 ID | 在 Studio 新建请求中明确携带选择 | 忽略此键 |
写入失败时保留原选择并显示错误。新建请求继续执行既有选择权限与准入检查。
状态根遵守存储迁移设置。身份使用 `builtin:`、`user:`、`project:` 前缀;项目 状态根遵守存储迁移设置。身份使用 `builtin:`、`user:`、`project:` 前缀;项目
绑定同时记录工作区。同名项按身份和来源并列显示。项目文件不能设置默认值, 绑定同时记录工作区。同名项按身份和来源并列显示。项目文件不能设置默认值,
项目默认组合必须由用户主动选择,并保存在个人状态中。 项目默认组合必须由用户主动选择,并保存在个人状态中。
@@ -1,28 +1,28 @@
- 新增会话级 Agent 预设管理与选择,人设、工具及模块按会话绑定版本;基于人设页 #13588,见 #13613。 - 新增会话级 Agent 预设管理与选择,人设、工具及模块按会话绑定版本;基于人设页 #13588,见 #13613。
- 兼容:默认不启用预设;未选择及标准模式不绑定、不迁移人设,保留原日志格式。仅显式选择的其他组合使用版本边界保护。#13613 - 兼容:默认不启用预设;未选择及标准模式不绑定、不迁移人设,保留原日志格式。仅显式选择的其他组合使用版本边界保护。#13613
- Agent 组合选择与管理整合为输入框顶部状态行的统一菜单,展示来源和能力说明。#13613 - Agent 组合选择与管理整合为输入框顶部状态行的统一菜单,展示来源和能力说明。#13613
- 内置模式收敛为标准与精简两种,管理列表按来源分组。精简模式保留终端、图片读取和上下文压缩,已有会话继续使用原绑定。#13613 - 内置标准与精简模式采用双列卡片,窄屏单列;卡片提供模式说明、使用方法与复制,已有会话继续使用原绑定。#13613
- 组合切换保留 ACP 额外工作目录;派发继承精确绑定,并明确拒绝跨工作区使用绑定。#13613 - 组合切换保留 ACP 额外工作目录;派发继承精确绑定,并明确拒绝跨工作区使用绑定。#13613
- 项目预设的 MCP/扩展参数仅可调整超时,不能改写启动参数、地址或凭据环境;旧的不安全绑定保留历史但拒绝执行。#13613 - 项目预设的 MCP/扩展参数仅可调整超时,不能改写启动参数、地址或凭据环境;旧的不安全绑定保留历史但拒绝执行。#13613
- 拒绝跨项目移动绑定组合的会话,保留原会话,避免丢失绑定后以其他能力续写。#13613 - 拒绝跨项目移动绑定组合的会话,保留原会话,避免丢失绑定后以其他能力续写。#13613
- 远程新会话透传所选模式,同时保留并发打开与重复点击的保护。#13613 - 远程新会话透传所选模式,同时保留并发打开与重复点击的保护。#13613
- 预设写入与组合切换要求配置编辑授权;安全 Hook 与权限规则不受组合选择影响,旧绑定恢复和会话内策略编辑同样保持保护。#13613 - 预设写入与组合切换要求配置编辑授权;安全 Hook 与权限规则不受组合选择影响,旧绑定恢复和会话内策略编辑同样保持保护。#13613
- 模式选择器默认直接显示,新会话保持标准模式;重复新建会话保留空白窗格的已选模式。#13613 - 模式选择器直接显示,初始使用标准模式;点击卡片可设置本机新任务默认,适用于本地与远程新建,历史会话保留原模式。#13613
- 原生归档直接交给后端解析,错误按码翻译;内置副本使用窗口语言,标识字段跟随主题。#13613 - 原生归档直接交给后端解析,错误按码翻译;内置副本使用窗口语言,标识字段跟随主题。#13613
- 新建和编辑预设使用独立编辑页,常用字段直接显示,能力与高级配置按需展开;提供固定保存栏、未保存退出确认及失败草稿保留。#13613 - 设置导航改为人设;独立编辑页直接展示两段提示词、运行环境、范围和 ID,能力按需展开;保留固定保存栏与未保存保护。#13613
<!-- lang:en --> <!-- lang:en -->
- Add session Agent presets with versioned personas, tools and modules, building on #13588. #13613 - Add session Agent presets with versioned personas, tools and modules, building on #13588. #13613
- Keep compositions opt-in: unselected and Standard sessions keep the existing format without binding or persona migration. Only explicitly bound sessions use the version boundary. #13613 - Keep compositions opt-in: unselected and Standard sessions keep the existing format without binding or persona migration. Only explicitly bound sessions use the version boundary. #13613
- Combine composition selection and management in one menu beside the composer run status, with source and capability notes. #13613 - Combine composition selection and management in one menu beside the composer run status, with source and capability notes. #13613
- Group built-in, personal and project presets in the management list. Minimal keeps terminal, image reading and compaction; existing conversations retain their binding. #13613 - Display Standard and Minimal as responsive cards with details, usage and copy actions; retain existing conversation bindings. #13613
- Preserve ACP additional directories across composition switches; dispatch carries the exact binding and refuses cross-workspace bindings. #13613 - Preserve ACP additional directories across composition switches; dispatch carries the exact binding and refuses cross-workspace bindings. #13613
- Limit project MCP/extension parameters to timeouts; refuse launch, endpoint or credential-environment overrides, including in old executable bindings, while keeping history readable. #13613 - Limit project MCP/extension parameters to timeouts; refuse launch, endpoint or credential-environment overrides, including in old executable bindings, while keeping history readable. #13613
- Refuse moving bound conversations to another project before copying or deleting them, preserving the original immutable composition. #13613 - Refuse moving bound conversations to another project before copying or deleting them, preserving the original immutable composition. #13613
- Carry the selected mode into new remote conversations while preserving concurrent-open ordering and duplicate-click protection. #13613 - Carry the selected mode into new remote conversations while preserving concurrent-open ordering and duplicate-click protection. #13613
- Require configuration-edit authority for preset writes and composition switches; keep safety hooks and permission rules in force across selection, old-binding restore and live policy edits. #13613 - Require configuration-edit authority for preset writes and composition switches; keep safety hooks and permission rules in force across selection, old-binding restore and live policy edits. #13613
- Show the mode picker by default and keep Standard as the new-conversation default; preserve blank-pane modes on repeated New session. #13613 - Start in Standard; click a card to persist this machine's default for new local/remote tasks. History retains its original mode. #13613
- Upload native archives to the host decoder; localize refusals, copied descriptions and themed identifier fields. #13613 - Upload native archives to the host decoder; localize refusals, copied descriptions and themed identifier fields. #13613
- Create/edit presets on a separate page, with expandable capabilities and advanced settings, persistent Save/Cancel, discard confirmation and retained drafts after failed saves. #13613 - Rename settings navigation to Persona; show prompt fields, runtime context, scope and ID directly in the editor, with expandable capabilities and persistent Save/Cancel. #13613