feat: layer runtime and model selection across conversation, project and global

- Store runtime selection as { provider?, model? } layers and resolve them per
  field (conversation -> project -> global); drop the legacy auto provider.
- Migrate stored selections (database v49) and clear deleted profile ids.
- Re-resolve selections in Main per request and at queue dispatch.
- Add ProjectRuntimeSelector and a two-column RuntimeModelPicker; manage
  link opens model connections; restore per-runtime default model tabs.
- Stop duplicating trusted instructions and history across OpenCode,
  Continue and DeepSeek Harness runtimes.
- Document the layered design, global provider removal plan and planned
  purpose-specific models.
This commit is contained in:
mesalogo
2026-09-29 21:33:06 +08:00
parent d1b92deff3
commit 9c88ca20f3
50 changed files with 2879 additions and 1836 deletions
+173 -229
View File
@@ -36,7 +36,11 @@ import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
} from "../../shared/assistant-contracts";
import { agentRuntimeSelectionKey } from "../../shared/runtime-selection-contracts";
import type { RuntimeSelectionLayer } from "../../shared/runtime-selection-contracts";
/** Pre-layer stored selections, which the renderer and Main upgrade on read. */
const legacySelection = (value: Record<string, string>): RuntimeSelectionLayer =>
value as unknown as RuntimeSelectionLayer;
const defaultTestApplicationNavigation: ApplicationSettings["applicationNavigation"] = {
...defaultApplicationNavigation,
@@ -1096,6 +1100,33 @@ function selectComposerOption(
fireEvent.click(option);
}
function runtimeMenuColumns(): { menu: HTMLElement; providers: HTMLElement; models: HTMLElement } {
const menu = screen.getByRole("menu", { name: "执行方式和模型" });
return {
menu,
providers: within(menu).getByRole("group", { name: "执行方式" }),
models: within(menu).getByRole("group", { name: "模型" }),
};
}
/** Opens the composer picker and picks an execution mode, then optionally a model. */
function pickRuntime(
provider: "直连模型" | "OpenCode" | "Continue" | "DeepSeek Harness",
model?: RegExp,
): void {
if (!screen.queryByRole("menu", { name: "执行方式和模型" })) {
fireEvent.click(document.querySelector<HTMLButtonElement>(".composer__configuration > .runtime-picker:not(.composer-picker) > button")!);
}
const { providers, models } = runtimeMenuColumns();
const item = within(providers).getByRole("menuitemradio", { name: provider });
if (!model) {
fireEvent.click(item);
return;
}
fireEvent.mouseEnter(item);
fireEvent.click(within(models).getByRole("menuitemradio", { name: model }));
}
function selectProjectOption(projectName: string): void {
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
const menu = screen.getByRole("menu", { name: "当前项目" });
@@ -4721,8 +4752,10 @@ describe("App", () => {
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]?.[0];
expect(request?.prompt).toBe("帮我分析项目");
// The renderer always sends the concrete model it resolved from the layers.
expect(request?.runtimeSelection).toEqual({
provider: "model",
profileId: modelProfileId,
});
expect(
screen
@@ -5901,10 +5934,10 @@ describe("App", () => {
{
id: conversationId,
projectId,
runtimeSelection: {
runtimeSelection: legacySelection({
provider: "model",
profileId: profile.id,
},
}),
contextMetrics: {
runtimeSelectionKey: `model:${profile.id}`,
contextTokens: 9_000,
@@ -8022,10 +8055,10 @@ describe("App", () => {
{
id: "00000000-0000-4000-8000-000000000319",
projectId: remoteProject.id,
runtimeSelection: {
runtimeSelection: legacySelection({
provider: "model",
profileId: modelProfileId,
},
}),
title: "旧直连模型会话",
updatedAt: 1_775_000_000_000,
messages: [],
@@ -8033,10 +8066,10 @@ describe("App", () => {
{
id: "00000000-0000-4000-8000-000000000419",
projectId: remoteProject.id,
runtimeSelection: {
runtimeSelection: legacySelection({
provider: "continue",
profileId: modelProfileId,
},
}),
title: "旧 Continue 会话",
updatedAt: 1_774_000_000_000,
messages: [],
@@ -8053,30 +8086,29 @@ describe("App", () => {
);
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
// The saved direct-model conversation choice is ignored for the remote project, not rewritten.
const runtimeButton = await screen.findByRole("button", {
name: /OpenCode · 默认模型/u,
});
fireEvent.click(runtimeButton);
const runtimeMenu = screen.getByRole("menu", {
name: "Runtime 和模型",
name: "执行方式和模型",
});
const providers = within(runtimeMenu).getByRole("group", { name: "执行方式" });
expect(
within(runtimeMenu).getByRole("menuitemradio", {
name: /^OpenCode · 默认模型.*sonnet-5$/u,
}),
).toBeInTheDocument();
expect(within(runtimeMenu).getAllByRole("menuitemradio")).toHaveLength(2);
expect(within(runtimeMenu).queryByText("直连模型")).not.toBeInTheDocument();
within(providers).getAllByRole("menuitemradio").map((item) => item.textContent),
).toEqual(["OpenCode", "Continue"]);
const models = within(runtimeMenu).getByRole("group", { name: "模型" });
expect(
within(runtimeMenu).queryByText("Continue Runtime"),
).toBeInTheDocument();
// The conversation's saved model still applies to OpenCode; only its mode is ignored.
within(models).getByRole("menuitemradio", { name: /^默认模型 · sonnet-5/u }),
).toHaveAttribute("aria-checked", "true");
expect(within(models).queryByText(/自有配置/u)).not.toBeInTheDocument();
expect(within(runtimeMenu).getByRole("status")).toHaveTextContent(
"远程项目仅支持 OpenCode 和 Continue",
);
expect(
within(runtimeMenu).queryByText(/DeepSeek Harness/u),
).not.toBeInTheDocument();
expect(
within(runtimeMenu).getByRole("menuitem", {
name: "管理 Runtime 和模型连接",
}),
within(runtimeMenu).getByRole("menuitem", { name: "管理模型连接…" }),
).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
@@ -8117,7 +8149,7 @@ describe("App", () => {
await screen.findByRole("button", { name: "当前项目" });
selectProjectOption(remoteProject.name);
await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith(
remoteProject.runtimeSelection,
{ provider, profileId: modelProfileId },
));
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "Verify inherited model" },
@@ -8377,7 +8409,7 @@ describe("App", () => {
): ConversationSnapshot => ({
id: input.conversationId,
projectId,
runtimeSelection: { provider: "model", profileId: modelProfileId },
runtimeSelection: legacySelection({ provider: "model", profileId: modelProfileId }),
title: "断网期间继续执行",
updatedAt: 1_000,
messages: [
@@ -9111,11 +9143,9 @@ describe("App", () => {
within(dialog).getByLabelText("微信 ClawBot 项目说明");
expect(dialogDescription).toHaveFocus();
const dialogBackend =
within(dialog).getByLabelText("微信 ClawBot 消息处理后端");
within(dialog).getByLabelText("微信 ClawBot 执行方式");
expect(
within(dialogBackend).getByRole("option", {
name: "DeepSeek Harness(预览 · OpenAI 兼容)",
}),
within(dialogBackend).getByRole("option", { name: "DeepSeek Harness" }),
).toBeInTheDocument();
fireEvent.change(dialogDescription, { target: { value: "从左上角更新" } });
fireEvent.change(
@@ -9124,9 +9154,7 @@ describe("App", () => {
);
fireEvent.change(dialogBackend, {
target: {
value: agentRuntimeSelectionKey({
provider: "opencode",
}),
value: "opencode",
},
});
fireEvent.click(
@@ -9145,8 +9173,8 @@ describe("App", () => {
expect(screen.getByLabelText("微信 ClawBot 默认工作目录")).toHaveValue(
"C:\\FromSwitcher",
);
expect(screen.getByLabelText("微信 ClawBot 消息处理后端")).toHaveValue(
agentRuntimeSelectionKey({ provider: "opencode" }),
expect(screen.getByLabelText("微信 ClawBot 执行方式")).toHaveValue(
"opencode",
);
});
@@ -9156,11 +9184,9 @@ describe("App", () => {
fireEvent.change(screen.getByLabelText("微信 ClawBot 默认工作目录"), {
target: { value: "C:\\FromChannels" },
});
fireEvent.change(screen.getByLabelText("微信 ClawBot 消息处理后端"), {
fireEvent.change(screen.getByLabelText("微信 ClawBot 执行方式"), {
target: {
value: agentRuntimeSelectionKey({
provider: "continue",
}),
value: "continue",
},
});
fireEvent.click(
@@ -9196,8 +9222,8 @@ describe("App", () => {
within(dialog).getByLabelText("微信 ClawBot 默认工作目录"),
).toHaveValue("C:\\FromChannels");
expect(
within(dialog).getByLabelText("微信 ClawBot 消息处理后端"),
).toHaveValue(agentRuntimeSelectionKey({ provider: "continue" }));
within(dialog).getByLabelText("微信 ClawBot 执行方式"),
).toHaveValue("continue");
expect(
within(
within(dialog).getByRole("group", {
@@ -10046,7 +10072,8 @@ describe("App", () => {
requestId: expect.any(String),
conversationId,
projectId,
runtimeSelection: { provider },
// Main receives the concrete model the conversation resolves to.
runtimeSelection: { provider, profileId: modelProfileId },
history: messages.map(({ role, content }) => ({
role,
content,
@@ -10095,7 +10122,7 @@ describe("App", () => {
});
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
id: "00000000-0000-4000-8000-000000000738", projectId,
runtimeSelection: { provider: "model", profileId: imageProfile.id },
runtimeSelection: legacySelection({ provider: "model", profileId: imageProfile.id }),
title: "Image conversation", updatedAt: Date.now(), messages: [],
}]);
render(<App />);
@@ -10106,12 +10133,13 @@ describe("App", () => {
it("hides direct model manual compaction for remote channel conversations", async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
id: "00000000-0000-4000-8000-000000000740", projectId,
runtimeSelection: { provider: "model", profileId: modelProfileId },
runtimeSelection: legacySelection({ provider: "model", profileId: modelProfileId }),
title: "Remote model conversation", updatedAt: Date.now(), messages: [],
remote: { channel: "weixin", accountDisplay: "Remote account", conversationType: "direct" },
}]);
render(<App />);
await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith({ provider: "model", profileId: modelProfileId }));
// The resolved selection equals the startup default, so no extra status request is needed.
await screen.findByRole("button", { name: /sonnet-5/u });
expect(screen.queryByRole("button", { name: "压缩上下文" })).not.toBeInTheDocument();
expect(api.agent.compactConversation).not.toHaveBeenCalled();
});
@@ -10148,12 +10176,8 @@ describe("App", () => {
selectComposerOption("工作模式", "Execute · 完全权限");
expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限");
fireEvent.click(await screen.findByRole("button", { name: /OpenCode/u }));
fireEvent.click(
screen.getByRole("menuitemradio", {
name: /^默认模型.*sonnet-5$/u,
}),
);
await screen.findByRole("button", { name: /^OpenCode ·/u });
pickRuntime("直连模型");
await waitFor(() => {
expect(mode).toHaveAccessibleName("工作模式:Ask · 只读问答");
@@ -10357,37 +10381,23 @@ describe("App", () => {
render(<App />);
const runtimeButton = await screen.findByRole("button", {
name: /sonnet-5/u,
name: "直连模型 · 默认模型(sonnet-5)",
});
fireEvent.click(runtimeButton);
const { providers, models } = runtimeMenuColumns();
expect(screen.queryByText(/自动/u)).not.toBeInTheDocument();
expect(
await screen.findByRole("menu", { name: "Runtime 和模型" }),
).toBeInTheDocument();
expect(screen.queryByText("自动选择")).not.toBeInTheDocument();
within(providers).getAllByRole("menuitemradio").map((item) => item.textContent),
).toEqual(["直连模型", "OpenCode", "Continue", "DeepSeek Harness"]);
expect(
screen.getByRole("menuitemradio", {
name: /^默认模型.*sonnet-5$/u,
}),
).toBeInTheDocument();
within(providers).getByRole("menuitemradio", { name: "直连模型" }),
).toHaveAttribute("aria-checked", "true");
// With no conversation choice, "Default" is checked and names where it comes from.
expect(
screen.getByRole("menuitemradio", {
name: /^OpenCode · 默认模型.*sonnet-5$/u,
}),
).toBeInTheDocument();
expect(
screen.getByRole("menuitemradio", {
name: /^Continue · 默认模型.*sonnet-5$/u,
}),
).toBeInTheDocument();
expect(
screen.getByRole("menuitemradio", {
name: /^DeepSeek Harness · 自身配置/u,
}),
).toBeInTheDocument();
within(models).getByRole("menuitemradio", { name: "默认(默认模型) · 全局 · sonnet-5" }),
).toHaveAttribute("aria-checked", "true");
fireEvent.click(
screen.getByRole("menuitemradio", {
name: /^默认模型.*sonnet-5$/u,
}),
within(models).getByRole("menuitemradio", { name: /^默认模型 · sonnet-5/u }),
);
await waitFor(() =>
@@ -10404,17 +10414,10 @@ describe("App", () => {
it("shows Runtime switches globally without replacing composer guidance", async () => {
render(<App />);
const runtimeButton = await screen.findByRole("button", {
name: /sonnet-5/u,
});
await screen.findByRole("button", { name: /sonnet-5/u });
vi.useFakeTimers();
try {
fireEvent.click(runtimeButton);
fireEvent.click(
screen.getByRole("menuitemradio", {
name: /^OpenCode · 默认模型.*sonnet-5$/u,
}),
);
pickRuntime("OpenCode");
await act(async () => {
await Promise.resolve();
});
@@ -10429,16 +10432,7 @@ describe("App", () => {
}),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", {
name: /OpenCode · 默认模型/u,
}),
);
fireEvent.click(
screen.getByRole("menuitemradio", {
name: /^Continue · 默认模型.*sonnet-5$/u,
}),
);
pickRuntime("Continue");
await act(async () => {
await Promise.resolve();
});
@@ -10465,7 +10459,7 @@ describe("App", () => {
}
});
it("shows one configured choice per Agent Runtime in a flat keyboard menu", async () => {
it("navigates the two-column picker by keyboard and lists models for the focused mode", async () => {
const settings = await api.settings.getRuntime();
const secondProfileId = "00000000-0000-4000-8000-000000000002";
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
@@ -10492,94 +10486,57 @@ describe("App", () => {
name: /sonnet-5/u,
});
fireEvent.click(runtimeButton);
const runtimeMenu = screen.getByRole("menu", {
name: "Runtime 和模型",
});
const directModel = screen.getByRole("menuitemradio", {
name: /^默认模型.*sonnet-5$/u,
});
const secondDirectModel = screen.getByRole("menuitemradio", {
name: /^第二模型.*qwen3$/u,
});
const openCodeModel = screen.getByRole("menuitemradio", {
name: /^OpenCode · 默认模型.*sonnet-5$/u,
});
const continueModel = screen.getByRole("menuitemradio", {
name: /^Continue · 默认模型.*sonnet-5$/u,
});
const deepseekHarness = screen.getByRole("menuitemradio", {
name: /^DeepSeek Harness · 自身配置/u,
});
expect(directModel).toBeEnabled();
expect(secondDirectModel).toBeEnabled();
expect(openCodeModel).toBeEnabled();
expect(continueModel).toBeEnabled();
expect(deepseekHarness).toBeEnabled();
expect(screen.getAllByRole("menuitemradio")).toHaveLength(6);
expect(within(runtimeMenu).getAllByRole("separator")).toHaveLength(4);
expect(within(runtimeMenu).queryByRole("menu")).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitemradio", {
name: /^OpenCode · 第二模型/u,
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitemradio", {
name: /^Continue · 第二模型/u,
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /Agent Runtime/u }),
).not.toBeInTheDocument();
const { providers, models } = runtimeMenuColumns();
const direct = within(providers).getByRole("menuitemradio", { name: "直连模型" });
await waitFor(() => expect(direct).toHaveFocus());
expect(within(models).getAllByRole("menuitemradio")).toHaveLength(3);
expect(within(models).queryByText(/自有配置/u)).not.toBeInTheDocument();
const defaultDirect = screen.getByRole("menuitemradio", { name: /^直连 · 跟随全局默认模型/u });
await waitFor(() => expect(defaultDirect).toHaveFocus());
fireEvent.keyDown(defaultDirect, { key: "ArrowDown" });
expect(directModel).toHaveAttribute("tabindex", "0");
expect(secondDirectModel).toHaveAttribute("tabindex", "-1");
fireEvent.keyDown(directModel, { key: "ArrowDown" });
expect(secondDirectModel).toHaveFocus();
expect(directModel).toHaveAttribute("tabindex", "-1");
expect(secondDirectModel).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(secondDirectModel, { key: "ArrowDown" });
expect(openCodeModel).toHaveFocus();
fireEvent.keyDown(direct, { key: "ArrowDown" });
const openCode = within(providers).getByRole("menuitemradio", { name: "OpenCode" });
expect(openCode).toHaveFocus();
// Focusing a mode previews its models, including its own configuration.
expect(
within(models).getByRole("menuitemradio", { name: "使用 OpenCode 自有配置" }),
).toBeInTheDocument();
fireEvent.keyDown(openCode, { key: "ArrowRight" });
expect(
within(models).getByRole("menuitemradio", { name: /^默认(默认模型)/u }),
).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
expect(
within(models).getByRole("menuitemradio", { name: /^默认模型 · sonnet-5/u }),
).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "ArrowLeft" });
expect(direct).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(runtimeButton).toHaveFocus();
expect(
screen.queryByRole("menu", { name: "Runtime 和模型" }),
screen.queryByRole("menu", { name: "执行方式和模型" }),
).not.toBeInTheDocument();
fireEvent.click(runtimeButton);
fireEvent.click(
screen.getByRole("menuitemradio", {
name: /^OpenCode · 默认模型.*sonnet-5$/u,
}),
);
pickRuntime("OpenCode", /^第二模型 · qwen3/u);
expect(runtimeButton).toHaveFocus();
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: "opencode",
profileId: secondProfileId,
}),
);
const selectedRuntimeButton = await screen.findByRole("button", {
name: /OpenCode · 默认模型/u,
});
fireEvent.click(selectedRuntimeButton);
const selectedOpenCodeModel = screen.getByRole("menuitemradio", {
name: /^OpenCode · 默认模型.*sonnet-5$/u,
});
await waitFor(() => expect(selectedOpenCodeModel).toHaveFocus());
expect(selectedOpenCodeModel).toHaveAttribute("aria-checked", "true");
expect(selectedOpenCodeModel).toHaveAttribute("tabindex", "0");
fireEvent.click(await screen.findByRole("button", { name: /^OpenCode · 第二模型/u }));
const reopened = runtimeMenuColumns();
expect(
within(reopened.models).getByRole("menuitemradio", { name: /^第二模型 · qwen3/u }),
).toHaveAttribute("aria-checked", "true");
expect(within(reopened.menu).getByText("本会话单独选择")).toBeInTheDocument();
fireEvent.click(
screen.getByRole("menuitemradio", {
name: /^Continue · 默认模型.*sonnet-5$/u,
}),
within(reopened.menu).getByRole("menuitem", { name: "恢复为项目默认" }),
);
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: "continue",
provider: "model",
profileId: modelProfileId,
}),
);
});
@@ -10594,24 +10551,24 @@ describe("App", () => {
fireEvent.click(runtimeButton);
expect(
screen.getByRole("menu", { name: "Runtime 和模型" }),
screen.getByRole("menu", { name: "执行方式和模型" }),
).toBeInTheDocument();
fireEvent.pointerDown(composer);
expect(
screen.queryByRole("menu", { name: "Runtime 和模型" }),
screen.queryByRole("menu", { name: "执行方式和模型" }),
).not.toBeInTheDocument();
fireEvent.click(runtimeButton);
const selectedModel = screen.getByRole("menuitemradio", {
name: /^直连 · 跟随全局默认模型/u,
const selected = within(runtimeMenuColumns().providers).getByRole("menuitemradio", {
name: "直连模型",
});
await waitFor(() => expect(selectedModel).toHaveFocus());
fireEvent.keyDown(selectedModel, { key: "Tab" });
await waitFor(() => expect(selected).toHaveFocus());
fireEvent.keyDown(selected, { key: "Tab" });
composer.focus();
expect(composer).toHaveFocus();
await waitFor(() =>
expect(
screen.queryByRole("menu", { name: "Runtime 和模型" }),
screen.queryByRole("menu", { name: "执行方式和模型" }),
).not.toBeInTheDocument(),
);
});
@@ -10626,21 +10583,25 @@ describe("App", () => {
});
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: /sonnet-5/u }));
await screen.findByRole("button", { name: /sonnet-5/u });
fireEvent.click(screen.getByRole("button", { name: /sonnet-5/u }));
const { providers, models } = runtimeMenuColumns();
fireEvent.mouseEnter(within(providers).getByRole("menuitemradio", { name: "Continue" }));
expect(
screen.getByRole("menuitemradio", {
name: /^OpenCode · 自身配置.*使用 OpenCode 自身配置$/u,
within(models).getByRole("menuitemradio", {
name: "默认(自有配置) · 全局 · 使用 Continue 自有配置",
}),
).toBeInTheDocument();
const continueChoice = screen.getByRole("menuitemradio", {
name: /^Continue · 自身配置.*使用 Continue 自身配置$/u,
});
fireEvent.click(continueChoice);
fireEvent.click(within(providers).getByRole("menuitemradio", { name: "Continue" }));
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: "continue",
runtimeConfig: true,
}),
);
expect(
await screen.findByRole("button", { name: /^Continue · 自有配置/u }),
).toBeInTheDocument();
});
it("persists metadata-only retrieval and Runtime changes without rewriting messages", async () => {
@@ -10690,10 +10651,10 @@ describe("App", () => {
{
id: conversationId,
projectId,
runtimeSelection: {
runtimeSelection: legacySelection({
provider: "model",
profileId: modelProfileId,
},
}),
knowledgeLibraryIds: [libraryId],
knowledgeRetrievalMode: "auto",
title: "元数据会话",
@@ -10749,21 +10710,16 @@ describe("App", () => {
);
vi.mocked(api.conversations.saveLocal).mockClear();
fireEvent.click(screen.getByRole("button", { name: /sonnet-5/u }));
fireEvent.click(
screen.getByRole("menuitemradio", {
name: /^仅元数据模型.*qwen3$/u,
}),
);
pickRuntime("直连模型", /^仅元数据模型 · qwen3/u);
await waitFor(
() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
{
header: expect.objectContaining({
id: conversationId,
// The execution mode stays inherited; only the model is chosen here.
runtimeSelection: {
provider: "model",
profileId: secondProfileId,
model: { kind: "profile", profileId: secondProfileId },
},
knowledgeRetrievalMode: "always",
}),
@@ -10920,7 +10876,7 @@ describe("App", () => {
{
id: "00000000-0000-4000-8000-000000000020",
projectId,
runtimeSelection: { provider: "auto" },
runtimeSelection: legacySelection({ provider: "auto" }),
contextMetrics: {
runtimeSelectionKey: "auto:default",
contextTokens: 9_000,
@@ -10943,7 +10899,8 @@ describe("App", () => {
render(<App />);
expect(
await screen.findByRole("button", { name: /自动.*sonnet-5/u }),
// Legacy Auto now reads as "follow the project" and resolves to the direct model.
await screen.findByRole("button", { name: /^直连模型 · 默认模型/u }),
).toBeInTheDocument();
expect(screen.queryByText(/本次调用 9\.0K/u)).not.toBeInTheDocument();
expect(screen.queryByText(/压缩线/u)).not.toBeInTheDocument();
@@ -10971,10 +10928,7 @@ describe("App", () => {
});
expect(screen.getByText(/本次调用 9\.0K/u)).toBeInTheDocument();
expect(screen.queryByText(/压缩线/u)).not.toBeInTheDocument();
const currentRuntimeSelectionKey =
settings.opencodeModelSource.kind === "profile"
? `opencode:${settings.opencodeModelSource.profileId}`
: "opencode:platform";
const currentRuntimeSelectionKey = `model:${modelProfileId}`;
await waitFor(() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
expect.objectContaining({
@@ -10994,10 +10948,10 @@ describe("App", () => {
{
id: "00000000-0000-4000-8000-000000000022",
projectId,
runtimeSelection: {
runtimeSelection: legacySelection({
provider: "model",
profileId: removedProfileId,
},
}),
title: "旧模型对话",
updatedAt: 1,
messages: [
@@ -11014,9 +10968,11 @@ describe("App", () => {
render(<App />);
expect(await screen.findByText("旧消息")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /模型配置不可用/u }),
).toBeInTheDocument();
// The deleted model falls back visibly; the saved choice is kept until the user replaces it.
fireEvent.click(await screen.findByRole("button", { name: /^直连模型 · 默认模型/u }));
expect(within(runtimeMenuColumns().menu).getByRole("status")).toHaveTextContent(
"所选模型连接已删除,已回退到 默认模型",
);
expect(api.conversations.replace).not.toHaveBeenCalled();
expect(api.conversations.saveLocal).not.toHaveBeenCalled();
});
@@ -11054,19 +11010,12 @@ describe("App", () => {
}));
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: /sonnet-5/u }));
fireEvent.click(
screen.getByRole("menuitemradio", {
name: /^第二模型.*qwen3$/u,
}),
);
await screen.findByRole("button", { name: /sonnet-5/u });
pickRuntime("直连模型", /^第二模型 · qwen3/u);
expect(
await screen.findByRole("button", { name: /第二模型/u }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /新建对话/u }));
expect(screen.getByRole("button", { name: /第二模型/u })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "第二模型对话" },
});
@@ -11115,8 +11064,7 @@ describe("App", () => {
header: expect.objectContaining({
title: "第二模型对话",
runtimeSelection: {
provider: "model",
profileId: secondProfileId,
model: { kind: "profile", profileId: secondProfileId },
},
}),
messages: expect.any(Array),
@@ -11401,19 +11349,14 @@ describe("App", () => {
expect(within(dialog).getByLabelText("根目录")).toHaveValue(
project.rootPath,
);
expect(within(dialog).getByLabelText("新对话默认 Runtime")).toHaveValue(
agentRuntimeSelectionKey({
provider: "model",
}),
);
// Projects always show a concrete execution mode (older rows show what they resolve to).
expect(within(dialog).getByLabelText("执行方式")).toHaveValue("model");
fireEvent.change(within(dialog).getByLabelText("说明"), {
target: { value: "更新后的说明" },
});
fireEvent.change(within(dialog).getByLabelText("新对话默认 Runtime"), {
fireEvent.change(within(dialog).getByLabelText("执行方式"), {
target: {
value: agentRuntimeSelectionKey({
provider: "continue",
}),
value: "continue",
},
});
fireEvent.click(within(dialog).getByRole("button", { name: "保存项目" }));
@@ -11465,20 +11408,20 @@ describe("App", () => {
vi.mocked(api.projects.list).mockResolvedValueOnce([
{
...project,
runtimeSelection: {
runtimeSelection: legacySelection({
provider: "opencode",
profileId: modelProfileId,
},
}),
},
]);
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: "00000000-0000-4000-8000-000000000220",
projectId,
runtimeSelection: {
runtimeSelection: legacySelection({
provider: "model",
profileId: modelProfileId,
},
}),
title: "已有对话",
updatedAt: 1,
messages: [],
@@ -11489,8 +11432,9 @@ describe("App", () => {
await screen.findAllByText("已有对话");
fireEvent.click(screen.getByRole("button", { name: /新建对话/u }));
// The legacy project pin is read as a layer; the new conversation follows it.
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
expect(api.agent.getStatus).toHaveBeenCalledWith({
provider: "opencode",
profileId: modelProfileId,
}),
@@ -11651,13 +11595,13 @@ describe("App", () => {
]);
expect(run).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '继续修改' }));
fireEvent.click(screen.getByRole('button', { name: /sonnet-5/u }));
fireEvent.click(screen.getByRole('menuitemradio', { name: /^OpenCode · 默认模型/u }));
await waitFor(() => expect(api.agent.getStatus).toHaveBeenLastCalledWith({ provider: 'opencode' }));
pickRuntime('OpenCode');
const openCodeSelection = { provider: 'opencode', profileId: modelProfileId };
await waitFor(() => expect(api.agent.getStatus).toHaveBeenLastCalledWith(openCodeSelection));
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Turn the circle red' } });
fireEvent.click(screen.getByLabelText('发送'));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
expect(run.mock.calls[0]![0]).toMatchObject({ runtimeSelection: { provider: 'opencode' }, imageContextArtifactIds: [artifact.id] });
expect(run.mock.calls[0]![0]).toMatchObject({ runtimeSelection: openCodeSelection, imageContextArtifactIds: [artifact.id] });
expect(api.conversationQueue.enqueueUser).toHaveBeenCalledWith(expect.objectContaining({ imageContextArtifactIds: [artifact.id] }));
expect(api.context.addPastedImage).not.toHaveBeenCalled();
});
+118 -476
View File
@@ -119,14 +119,18 @@ import {
} from "../../shared/context-window";
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
getRuntimeSelectionProfileId,
type AgentRuntimeSelection,
compactRuntimeSelectionLayer,
resolveRuntimeChoice,
runtimeSelectionLayerSchema,
type ResolvedRuntimeChoice,
type RuntimeSelectionLayer,
} from "../../shared/runtime-selection-contracts";
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider,
runtimeModelDetail,
runtimeModelLabel,
runtimeProviderLabel,
} from "./runtime-selection";
import { RuntimeModelPicker } from "./RuntimeModelPicker";
import { mergeMessageImageState } from "./message-image-state";
import type {
ActivityHistorySnapshot,
@@ -756,7 +760,7 @@ function isErrorRepresentedByFailedTool(
function createConversation(
projectId?: string,
runtimeSelection?: AgentRuntimeSelection,
runtimeSelection?: RuntimeSelectionLayer,
greeting = "你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。",
): Conversation {
const now = Date.now();
@@ -912,7 +916,7 @@ function isConversation(value: unknown): value is Conversation {
return (
typeof item.id === "string" &&
(item.runtimeSelection === undefined ||
agentRuntimeSelectionSchema.safeParse(item.runtimeSelection).success) &&
runtimeSelectionLayerSchema.safeParse(item.runtimeSelection).success) &&
(item.knowledgeLibraryIds === undefined ||
(Array.isArray(item.knowledgeLibraryIds) &&
item.knowledgeLibraryIds.length <= 20 &&
@@ -1239,18 +1243,17 @@ function mergePersistedConversations(
return sortConversationsForDisplay(merged);
}
function getProjectDefaultRuntimeSelection(
/** Resolves global → project → conversation layers into the selection a run uses. */
function resolveConversationRuntime(
project: AssistantProject | undefined,
conversationLayer: RuntimeSelectionLayer | undefined,
settings: RuntimeSettings,
): AgentRuntimeSelection {
if (isManagedSshProject(project)) {
return project.runtimeSelection?.provider === "opencode" || project.runtimeSelection?.provider === "continue"
? project.runtimeSelection : getRuntimeSelectionForProvider("opencode", settings);
}
const selection = project?.runtimeSelection;
return !selection || selection.provider === "auto"
? getDefaultRuntimeSelection(settings)
: selection;
): ResolvedRuntimeChoice {
return resolveRuntimeChoice(
settings,
{ project: project?.runtimeSelection, conversation: conversationLayer },
{ remote: isManagedSshProject(project) },
);
}
type ManagedSshProject = AssistantProject & {
@@ -1292,109 +1295,22 @@ function isOrdinaryLocalProject(project: AssistantProject): boolean {
return project.kind === "user" && project.executionSpace.kind === "local";
}
function resolveContextMetricsRuntimeSelection(
selection: AgentRuntimeSelection,
settings: RuntimeSettings,
): AgentRuntimeSelection {
const effective = selection.provider !== "auto" ? selection : getRuntimeSelectionForProvider(
settings.provider === "auto" ? "opencode" : settings.provider,
settings,
);
const profileId = getRuntimeSelectionProfileId(effective, settings);
return profileId && effective.provider !== "auto" ? { ...effective, profileId } : effective;
}
function getRuntimeSelectionLabel(
selection: AgentRuntimeSelection | undefined,
/** The button label: "Execution mode · Model". */
function runtimeChoiceLabel(
resolved: ResolvedRuntimeChoice | undefined,
settings: RuntimeSettings | undefined,
status: AgentRuntimeStatus | undefined,
labels: {
directModel: string;
automatic: string;
automaticSelection: string;
modelUnavailable: string;
},
t: TFunction<"app">,
): string {
if (!selection || !settings) {
if (!resolved || !settings) {
return status?.label ?? "Runtime";
}
const profileId = getRuntimeSelectionProfileId(selection, settings);
const profile =
profileId
? settings.modelProfiles.find(
(candidate) => candidate.id === profileId,
)
: undefined;
const requestedProfileMissing =
"profileId" in selection &&
Boolean(selection.profileId) &&
profile === undefined;
if (selection.provider === "model") {
return profile
? `${profile.name} · ${profile.modelName}`
: requestedProfileMissing
? labels.modelUnavailable
: (status?.label ?? labels.directModel);
}
if (selection.provider === "opencode") {
return profile
? `OpenCode · ${profile.name}`
: requestedProfileMissing
? `OpenCode · ${labels.modelUnavailable}`
: "OpenCode";
}
if (selection.provider === "continue") {
return profile
? `Continue · ${profile.name}`
: requestedProfileMissing
? `Continue · ${labels.modelUnavailable}`
: "Continue";
}
if (selection.provider === "deepseek-harness") {
return profile
? `DeepSeek Harness · ${profile.name}`
: requestedProfileMissing
? `DeepSeek Harness · ${labels.modelUnavailable}`
: "DeepSeek Harness";
}
return status
? `${labels.automatic} · ${status.label}`
: labels.automaticSelection;
}
function getConfiguredAgentRuntimeSource(
settings: RuntimeSettings,
provider: "opencode" | "continue" | "deepseek-harness",
labels: {
modelUnavailable: string;
selectModel: string;
ownConfiguration: string;
useOwnConfiguration: (runtime: string) => string;
},
): { label: string; detail: string } {
const profileId = getRuntimeSelectionProfileId({ provider }, settings);
const profile =
profileId
? settings.modelProfiles.find(
(candidate) => candidate.id === profileId,
)
: undefined;
const runtimeLabel =
provider === "opencode"
? "OpenCode"
: provider === "continue"
? "Continue"
: "DeepSeek Harness";
if (profileId) {
return {
label: `${runtimeLabel} · ${profile?.name ?? labels.modelUnavailable}`,
detail: profile?.modelName ?? labels.selectModel,
};
}
return {
label: `${runtimeLabel} · ${labels.ownConfiguration}`,
detail: labels.useOwnConfiguration(runtimeLabel),
};
return `${runtimeProviderLabel(resolved.provider, t)} · ${runtimeModelLabel(
resolved.provider,
resolved.model,
settings,
t,
)}`;
}
function formatAttachmentSize(size: number): string {
@@ -3197,14 +3113,19 @@ function App(): React.JSX.Element {
});
}, [activeId, cachedConversationViews, conversations]);
const activeRuntimeSelection = useMemo(
const activeRuntimeResolution = useMemo(
() =>
activeConversation?.runtimeSelection ??
(runtimeSettings
? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings)
: undefined),
runtimeSettings
? resolveConversationRuntime(
activeProject,
activeConversation?.runtimeSelection,
runtimeSettings,
)
: undefined,
[activeConversation?.runtimeSelection, activeProject, runtimeSettings],
);
// Resolved selections are always concrete, so their key already reflects the model in effect.
const activeRuntimeSelection = activeRuntimeResolution?.selection;
const activeRuntimeSelectionKey = activeRuntimeSelection
? agentRuntimeSelectionKey(activeRuntimeSelection)
: "";
@@ -3212,61 +3133,22 @@ function App(): React.JSX.Element {
useEffect(() => {
activeRuntimeSelectionRef.current = activeRuntimeSelection;
}, [activeRuntimeSelection]);
const runtimeLabels = useMemo(
() => ({
directModel: t("runtime.directModel"),
automatic: t("runtime.automatic"),
automaticSelection: t("runtime.automaticSelection"),
modelUnavailable: t("runtime.modelUnavailable"),
}),
[t],
);
const configuredRuntimeLabels = useMemo(
() => ({
modelUnavailable: t("runtime.modelUnavailable"),
selectModel: t("runtime.selectModel"),
ownConfiguration: t("runtime.ownConfiguration"),
useOwnConfiguration: (runtimeLabel: string) =>
t("runtime.useOwnConfiguration", { runtime: runtimeLabel }),
}),
[t],
);
const activeRuntimeLabel = getRuntimeSelectionLabel(
activeRuntimeSelection,
const activeRuntimeLabel = runtimeChoiceLabel(
activeRuntimeResolution,
runtimeSettings,
runtime,
runtimeLabels,
t,
);
const openCodeMenuSelection = runtimeSettings
? getRuntimeSelectionForProvider("opencode", runtimeSettings)
: undefined;
const continueMenuSelection = runtimeSettings
? getRuntimeSelectionForProvider("continue", runtimeSettings)
: undefined;
const deepseekHarnessMenuSelection = runtimeSettings
? getRuntimeSelectionForProvider("deepseek-harness", runtimeSettings)
: undefined;
const openCodeMenuSource = runtimeSettings
? getConfiguredAgentRuntimeSource(
runtimeSettings,
"opencode",
configuredRuntimeLabels,
)
: undefined;
const continueMenuSource = runtimeSettings
? getConfiguredAgentRuntimeSource(
runtimeSettings,
"continue",
configuredRuntimeLabels,
)
: undefined;
const deepseekHarnessMenuSource = runtimeSettings
? getConfiguredAgentRuntimeSource(
runtimeSettings,
"deepseek-harness",
configuredRuntimeLabels,
)
: undefined;
// The model ID stays in the accessible name and tooltip; the visible label stays short.
const activeRuntimeModelDetail =
activeRuntimeResolution && runtimeSettings
? runtimeModelDetail(
activeRuntimeResolution.provider,
activeRuntimeResolution.model,
runtimeSettings,
t,
)
: undefined;
// Conversation refreshes replace the conversation object, so the runtime
// selection identity changes without the selection itself changing. Keying
@@ -4058,16 +3940,19 @@ function App(): React.JSX.Element {
}
}, [refreshKnowledge]);
/** Writes the conversation layer (undefined = follow the project) and refreshes status. */
const switchRuntime = useCallback(
async (selection: AgentRuntimeSelection): Promise<void> => {
if (
!runtimeSettings ||
!activeConversation ||
runtimeSwitching ||
(activeProjectUsesManagedSsh && selection.provider !== "opencode" && selection.provider !== "continue")
) {
async (layer: RuntimeSelectionLayer | undefined): Promise<void> => {
if (!runtimeSettings || !activeConversation || runtimeSwitching) {
return;
}
const nextLayer = compactRuntimeSelectionLayer(layer);
const resolved = resolveConversationRuntime(
activeProject,
nextLayer,
runtimeSettings,
);
const selection = resolved.selection;
runtimeMenuButtonRef.current?.focus();
setRuntimeSwitching(true);
setRuntimeMenuOpen(false);
@@ -4087,18 +3972,18 @@ function App(): React.JSX.Element {
key: selectionKey,
settings: runtimeSettings,
};
const label = getRuntimeSelectionLabel(
selection,
const label = runtimeChoiceLabel(
resolved,
runtimeSettings,
status,
runtimeLabels,
tRef.current,
);
setConversations((current) =>
current.map((conversation) =>
conversation.id === activeConversation.id
? {
...conversation,
runtimeSelection: selection,
runtimeSelection: nextLayer,
updatedAt: Date.now(),
}
: conversation,
@@ -4145,8 +4030,7 @@ function App(): React.JSX.Element {
},
[
activeConversation,
activeProjectUsesManagedSsh,
runtimeLabels,
activeProject,
runtimeSettings,
runtimeSwitching,
],
@@ -6211,7 +6095,7 @@ function App(): React.JSX.Element {
])
.then(([settings, status]) => {
const selectionKey = agentRuntimeSelectionKey(
getDefaultRuntimeSelection(settings),
resolveRuntimeChoice(settings).selection,
);
runtimeStatusCacheRef.current = {
key: selectionKey,
@@ -6344,25 +6228,8 @@ function App(): React.JSX.Element {
(selected.kind !== "channel" || candidate.remote !== undefined),
);
if (conversation) {
if (isManagedSshProject(selected)) {
const runtimeSelection = runtimeSettings
? getProjectDefaultRuntimeSelection(selected, runtimeSettings)
: ({ provider: "opencode" } as const);
setConversations((current) =>
current.map((candidate) =>
candidate.projectId === selected.id &&
candidate.runtimeSelection !== undefined &&
candidate.runtimeSelection?.provider !== "opencode" &&
candidate.runtimeSelection?.provider !== "continue"
? {
...candidate,
runtimeSelection,
updatedAt: Date.now(),
}
: candidate,
),
);
}
// Remote projects ignore incompatible conversation choices at resolution time
// and say so in the picker, instead of silently rewriting saved conversations.
setActiveId(conversation.id);
} else if (selected.kind === "channel") {
setActiveId("");
@@ -7095,8 +6962,9 @@ function App(): React.JSX.Element {
const project = projectsRef.current.find(
(candidate) => candidate.id === conversation?.projectId,
);
const selection = conversation?.runtimeSelection ??
(runtimeSettings ? getProjectDefaultRuntimeSelection(project, runtimeSettings) : undefined);
const selection = runtimeSettings && conversation
? resolveConversationRuntime(project, conversation.runtimeSelection, runtimeSettings).selection
: undefined;
if (!conversation || !selection) {
await window.goodbuddy.conversationQueue.releaseUser(queuedDispatch.item.id);
return;
@@ -7233,15 +7101,27 @@ function App(): React.JSX.Element {
queuedInput?.knowledgeRetrievalMode ??
conversationSnapshot.knowledgeRetrievalMode ??
"auto";
const runtimeSelectionSnapshot =
queuedInput?.runtimeSelection ?? activeRuntimeSelection;
// Queued messages follow the conversation's current choice at dispatch,
// so switching the model while messages wait applies to them too.
const runtimeSelectionSnapshot = queuedInput
? runtimeSettings
? resolveConversationRuntime(
projectsRef.current.find((project) => project.id === queuedInput!.projectId),
conversationSnapshot.runtimeSelection,
runtimeSettings,
).selection
: queuedInput.runtimeSelection
: activeRuntimeSelection;
if (!runtimeSelectionSnapshot) {
notify({ tone: "info", message: t("runtime.notSelected") });
await releaseQueuedItem();
return;
}
const runtimeControlSnapshot: RuntimeControl | undefined = queuedInput
? queuedInput.runtimeControl
? // A queued agent/command/preset only applies to the Runtime it was chosen for.
queuedInput.runtimeControl?.provider === runtimeSelectionSnapshot.provider
? queuedInput.runtimeControl
: undefined
: runtimeSelectionSnapshot.provider === "opencode" &&
(selectedRuntimeAgent || command)
? {
@@ -7388,14 +7268,7 @@ function App(): React.JSX.Element {
messageId: assistantMessage.id,
taskId: queuedDispatch?.scheduled ? queuedDispatch.item.taskId : undefined,
projectId: projectIdSnapshot,
runtimeSelectionKey: agentRuntimeSelectionKey(
runtimeSettings
? resolveContextMetricsRuntimeSelection(
runtimeSelectionSnapshot,
runtimeSettings,
)
: runtimeSelectionSnapshot,
),
runtimeSelectionKey: agentRuntimeSelectionKey(runtimeSelectionSnapshot),
});
preparingConversations.current.delete(conversationId);
const startedAt = new Date().toISOString();
@@ -7609,9 +7482,7 @@ function App(): React.JSX.Element {
...conversation,
contextCompressionState: state,
contextMetrics: {
runtimeSelectionKey: agentRuntimeSelectionKey(runtimeSettings
? resolveContextMetricsRuntimeSelection(activeRuntimeSelection, runtimeSettings)
: activeRuntimeSelection),
runtimeSelectionKey: agentRuntimeSelectionKey(activeRuntimeSelection),
contextTokens: estimatedAfterTokens,
source: "estimated",
basis: "conversation",
@@ -8533,7 +8404,7 @@ function App(): React.JSX.Element {
activeProject?.executionSpace.kind !== "ssh" &&
runtimeSettings?.modelProfiles.some(
(profile) =>
profile.id === getRuntimeSelectionProfileId(activeRuntimeSelection, runtimeSettings) &&
profile.id === activeRuntimeSelection.profileId &&
profile.protocol !== "openai-images-generations",
) === true
: (activeRuntimeSelection?.provider === "opencode" ||
@@ -8557,7 +8428,7 @@ function App(): React.JSX.Element {
setActiveId(conversation.id);
}
// Save the current selection before Main resolves the launch from its conversation ID.
const header = toLocalConversationHeader({ ...conversation, runtimeSelection: activeRuntimeSelection, workMode });
const header = toLocalConversationHeader({ ...conversation, workMode });
const operation = conversationPersistenceQueueRef.current.then(() =>
window.goodbuddy.conversations.saveLocal([{ header, messages: [] }]),
);
@@ -8575,10 +8446,7 @@ function App(): React.JSX.Element {
) {
return undefined;
}
const resolvedRuntimeSelection = resolveContextMetricsRuntimeSelection(
activeRuntimeSelection,
runtimeSettings,
);
const resolvedRuntimeSelection = activeRuntimeSelection;
const activeModelProfile =
"profileId" in resolvedRuntimeSelection &&
resolvedRuntimeSelection.profileId
@@ -10316,7 +10184,7 @@ function App(): React.JSX.Element {
<button
aria-expanded={runtimeMenuOpen}
aria-haspopup="menu"
className="model-button"
className={`model-button${activeConversation?.runtimeSelection ? " model-button--override" : ""}`}
disabled={isRunning || runtimeSwitching}
onClick={() => {
setComposerOptionsOpen(false);
@@ -10340,7 +10208,9 @@ function App(): React.JSX.Element {
}}
ref={runtimeMenuButtonRef}
title={t("runtime.pickerTitle", {
label: activeRuntimeLabel,
label: activeRuntimeModelDetail
? `${activeRuntimeLabel} (${activeRuntimeModelDetail})`
: activeRuntimeLabel,
})}
type="button"
>
@@ -10350,6 +10220,9 @@ function App(): React.JSX.Element {
? t("runtime.switching")
: activeRuntimeLabel}
</span>
{!runtimeSwitching && activeRuntimeModelDetail && (
<span className="sr-only">{` (${activeRuntimeModelDetail})`}</span>
)}
{runtime?.capability ===
"image-generation" && (
<span className="runtime-capability-badge">
@@ -10358,255 +10231,24 @@ function App(): React.JSX.Element {
)}
<ChevronDown aria-hidden="true" size={14} />
</button>
{runtimeMenuOpen && (
<div
aria-label={t("runtime.picker")}
className="runtime-picker__menu"
onKeyDown={(event) => {
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"], [role="menuitem"]',
),
).filter((item) => !item.disabled);
const currentIndex = items.indexOf(
document.activeElement as HTMLButtonElement,
);
let nextIndex: number | undefined;
if (event.key === "ArrowDown") {
nextIndex =
(currentIndex + 1) % items.length;
} else if (event.key === "ArrowUp") {
nextIndex =
(currentIndex - 1 + items.length) %
items.length;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = items.length - 1;
} else if (event.key === "Escape") {
event.preventDefault();
setRuntimeMenuOpen(false);
runtimeMenuButtonRef.current?.focus();
}
const nextItem =
nextIndex === undefined
? undefined
: items.at(nextIndex);
if (nextItem) {
event.preventDefault();
items.forEach((item) => {
item.tabIndex =
item === nextItem ? 0 : -1;
});
nextItem.focus();
}
{runtimeMenuOpen && runtimeSettings && (
<RuntimeModelPicker
conversationLayer={activeConversation?.runtimeSelection}
onClose={() => {
setRuntimeMenuOpen(false);
runtimeMenuButtonRef.current?.focus();
}}
onManage={() => {
setRuntimeMenuOpen(false);
setSettingsInitialCategory("model");
setView("settings");
}}
onSelect={(layer) => void switchRuntime(layer)}
projectLayer={activeProject?.runtimeSelection}
ref={runtimeMenuRef}
role="menu"
>
{!activeProjectUsesManagedSsh && (
<>
<strong role="presentation">
{t("runtime.directModels")}
</strong>
<button
aria-checked={activeRuntimeSelectionKey === "model:default"}
onClick={() => void switchRuntime({ provider: "model" })}
role="menuitemradio"
tabIndex={activeRuntimeSelectionKey === "model:default" ? 0 : -1}
type="button"
>
<span>{t("runtime.defaultDirect")}</span>
<small>{runtimeSettings?.modelProfiles.find((profile) => profile.id === runtimeSettings.defaultModelProfileId)?.name}</small>
</button>
{runtimeSettings?.modelProfiles.map(
(profile) => (
<button
aria-checked={
activeRuntimeSelectionKey ===
`model:${profile.id}`
}
key={profile.id}
onClick={() =>
void switchRuntime({
provider: "model",
profileId: profile.id,
})
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
`model:${profile.id}`
? 0
: -1
}
type="button"
>
<span>
{profile.name}
{profile.protocol ===
"openai-images-generations" && (
<span className="runtime-capability-badge">
{t(
"runtime.imageGeneration",
)}
</span>
)}
</span>
<small>
{profile.modelName}
</small>
</button>
),
)}
<div
className="runtime-picker__divider"
role="separator"
/>
</>
)}
<strong role="presentation">
OpenCode Runtime
</strong>
{openCodeMenuSelection &&
openCodeMenuSource && (
<button
aria-checked={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
openCodeMenuSelection,
)
}
onClick={() =>
void switchRuntime(
openCodeMenuSelection,
)
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
openCodeMenuSelection,
)
? 0
: -1
}
type="button"
>
<span>
{openCodeMenuSource.label}
</span>
<small>
{openCodeMenuSource.detail}
</small>
</button>
)}
<>
<div
className="runtime-picker__divider"
role="separator"
/>
<strong role="presentation">
Continue Runtime
</strong>
{continueMenuSelection &&
continueMenuSource && (
<button
aria-checked={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
continueMenuSelection,
)
}
onClick={() =>
void switchRuntime(
continueMenuSelection,
)
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
continueMenuSelection,
)
? 0
: -1
}
type="button"
>
<span>
{continueMenuSource.label}
</span>
<small>
{continueMenuSource.detail}
</small>
</button>
)}
</>
{!activeProjectUsesManagedSsh && (
<>
<div
className="runtime-picker__divider"
role="separator"
/>
<strong role="presentation">
{t("runtime.deepseekHarnessGroup")}
</strong>
{deepseekHarnessMenuSelection &&
deepseekHarnessMenuSource && (
<button
aria-checked={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
deepseekHarnessMenuSelection,
)
}
onClick={() =>
void switchRuntime(
deepseekHarnessMenuSelection,
)
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
deepseekHarnessMenuSelection,
)
? 0
: -1
}
type="button"
>
<span>
{
deepseekHarnessMenuSource.label
}
</span>
<small>
{
deepseekHarnessMenuSource.detail
}
</small>
</button>
)}
</>
)}
<div
className="runtime-picker__divider"
role="separator"
/>
<button
onClick={() => {
setRuntimeMenuOpen(false);
setView("settings");
}}
role="menuitem"
tabIndex={-1}
type="button"
>
<span>{t("runtime.manage")}</span>
</button>
</div>
remote={activeProjectUsesManagedSsh}
runtimeSettings={runtimeSettings}
/>
)}
</div>
<ComposerMenuSelect
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next'
import type { ProjectCreateInput } from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import {
repairChannelRuntimeSelection
compactRuntimeSelectionLayer,
resolveRuntimeChoice
} from '../../shared/runtime-selection-contracts'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import { SegmentedControl } from './WorkspacePrimitives'
@@ -13,15 +14,17 @@ export function channelProjectDraft(
project: ProjectCreateInput,
runtimeSettings: RuntimeSettings
): ProjectCreateInput {
const layer = compactRuntimeSelectionLayer(project.runtimeSelection)
return {
name: project.name,
description: project.description,
rootPath: project.rootPath,
defaultWorkMode: project.defaultWorkMode,
runtimeSelection: repairChannelRuntimeSelection(
project.runtimeSelection ?? { provider: 'auto' },
runtimeSettings
)
// Projects own their execution mode; fill in what older rows resolve to.
runtimeSelection: {
...layer,
provider: resolveRuntimeChoice(runtimeSettings, { project: layer }).provider
}
}
}
@@ -96,9 +99,7 @@ export function ChannelProjectSettingsFields({
<small>{t('channels.project.rootHelp')}</small>
</label>
<ProjectRuntimeSelector
ariaLabel={t('channels.project.backendAriaLabel', {
name: value.name
})}
ariaLabelPrefix={value.name}
disabled={disabled}
label={t('channels.project.backendLabel')}
onChange={(runtimeSelection) =>
@@ -106,7 +107,7 @@ export function ChannelProjectSettingsFields({
}
runtimeSettings={runtimeSettings}
selection={value.runtimeSelection}
selectionMode="channel"
textOnly
/>
<fieldset className="project-work-mode">
<legend><span className="inline-help-label">{t('channels.project.defaultMode')}<InlineHelp label={t('channels.project.defaultMode')}>{t('channels.project.overrideHelp')}</InlineHelp></span></legend>
@@ -18,7 +18,6 @@ import type {
AssistantProject,
ProjectCreateInput
} from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import { ChannelSettingsSection } from './ChannelSettingsSection'
import i18n from './i18n'
@@ -114,7 +113,7 @@ const projects: AssistantProject[] = [
rootPath: 'C:\\Users\\tester'
},
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'auto' },
runtimeSelection: { provider: 'model' },
kind: 'channel',
channel: channel as 'weixin' | 'wecom' | 'dingtalk',
status: 'active',
@@ -239,13 +238,8 @@ describe('ChannelSettingsSection', () => {
fireEvent.change(screen.getByLabelText('企业微信 默认工作目录'), {
target: { value: 'C:\\RemoteWorkspace' }
})
fireEvent.change(screen.getByLabelText('企业微信 消息处理后端'), {
target: {
value: agentRuntimeSelectionKey({
provider: 'model',
profileId: directProfileId
})
}
fireEvent.change(screen.getByLabelText('企业微信 模型'), {
target: { value: directProfileId }
})
fireEvent.click(
within(
@@ -265,7 +259,7 @@ describe('ChannelSettingsSection', () => {
defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'model',
profileId: directProfileId
model: { kind: 'profile', profileId: directProfileId }
}
})
)
@@ -495,56 +489,19 @@ describe('ChannelSettingsSection', () => {
})
renderChannelSettings()
const backend = await screen.findByLabelText(
'微信 ClawBot 消息处理后端'
)
expect(backend).toHaveValue(
agentRuntimeSelectionKey({
provider: 'model'
})
)
expect(
within(backend).queryByRole('option', {
name: /自动/u
})
).not.toBeInTheDocument()
expect(
within(backend).getByRole('option', {
name: '默认模型 · text-model'
})
).toBeInTheDocument()
expect(
within(backend).queryByRole('option', {
name: '图片模型 · image-model'
})
).not.toBeInTheDocument()
expect(
within(backend).queryByRole('option', {
name: '未配置模型 · missing-key-model'
})
).not.toBeInTheDocument()
expect(
within(backend).getByRole('option', { name: 'OpenCode' })
).toBeInTheDocument()
expect(
within(backend).getByRole('option', { name: 'Continue' })
).toBeInTheDocument()
expect(
within(backend).getByRole('option', {
name: 'DeepSeek Harness(预览 · OpenAI 兼容)'
})
).toBeInTheDocument()
const backend = await screen.findByLabelText('微信 ClawBot 执行方式')
// Channel projects start on the direct model; the model follows global settings.
expect(backend).toHaveValue('model')
expect(within(backend).queryByRole('option', { name: /自动/u })).not.toBeInTheDocument()
for (const name of ['直连模型', 'OpenCode', 'Continue', 'DeepSeek Harness']) {
expect(within(backend).getByRole('option', { name })).toBeInTheDocument()
}
const model = screen.getByLabelText('微信 ClawBot 模型')
expect(within(model).getByRole('option', { name: '默认模型 · text-model' })).toBeInTheDocument()
expect(within(model).queryByRole('option', { name: /image-model/u })).not.toBeInTheDocument()
expect(within(model).getByRole('option', { name: /missing-key-model.*缺少 API Key/u })).toBeInTheDocument()
fireEvent.change(backend, {
target: {
value: agentRuntimeSelectionKey({ provider: 'opencode' })
}
})
expect(
screen.getByText(
'通过 OpenCode Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 OpenCode 配置。'
)
).toBeInTheDocument()
fireEvent.change(backend, { target: { value: 'opencode' } })
fireEvent.click(
screen.getByRole('button', { name: '保存通道设置' })
)
@@ -1,51 +1,75 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultRuntimeSettings, type RuntimeSettings } from '../../shared/contracts'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import i18n from './i18n'
afterEach(cleanup)
beforeEach(async () => { await i18n.changeLanguage('zh-CN') })
describe('ProjectRuntimeSelector saved model references', () => {
it.each(['zh-CN', 'en-US'])('describes fixed selections accurately in %s without restoring duplicate menus', async (language) => {
await i18n.changeLanguage(language)
const profileId = '00000000-0000-4000-8000-000000000011'
const settings = {
...defaultRuntimeSettings,
defaultModelProfileId: profileId,
modelProfiles: [{
id: profileId, name: 'Model A', modelName: 'model-a',
baseUrl: 'https://example.com/v1', protocol: 'openai-chat-completions',
authentication: 'api-key', apiKeyConfigured: true
}]
} as RuntimeSettings
const firstId = '00000000-0000-4000-8000-000000000011'
const secondId = '00000000-0000-4000-8000-000000000012'
const settings = {
...defaultRuntimeSettings,
provider: 'opencode',
defaultModelProfileId: firstId,
opencodeModelSource: { kind: 'profile', profileId: firstId },
continueModelSource: { kind: 'platform' },
modelProfiles: [
{ id: firstId, name: 'Model A', modelName: 'model-a', baseUrl: 'https://example.com/v1',
protocol: 'openai-chat-completions', authentication: 'api-key', apiKeyConfigured: true },
{ id: secondId, name: 'Model B', modelName: 'model-b', baseUrl: 'https://example.com/v1',
protocol: 'anthropic-messages', authentication: 'none' }
]
} as RuntimeSettings
describe('ProjectRuntimeSelector', () => {
it('shows a concrete execution mode and follows that Runtime’s default model', () => {
render(<ProjectRuntimeSelector label="Runtime" onChange={vi.fn()} runtimeSettings={settings}
selection={{ provider: 'opencode' }} />)
const provider = screen.getByLabelText('执行方式') as HTMLSelectElement
expect(provider).toHaveValue('opencode')
// No "follow global" execution mode: projects own it.
expect(Array.from(provider.options).map((option) => option.value))
.toEqual(['model', 'opencode', 'continue', 'deepseek-harness'])
expect(screen.getByLabelText('模型')).toHaveValue('')
expect(screen.getByRole('option', { name: '跟随 OpenCode 默认(Model A)' })).toBeInTheDocument()
expect(screen.getByText('模型来自全局设置')).toBeInTheDocument()
})
it('switches mode and model and returns the model to the Runtime default', () => {
const onChange = vi.fn()
for (const provider of ['opencode', 'continue', 'deepseek-harness'] as const) {
const runtime = provider === 'opencode' ? 'OpenCode'
: provider === 'continue' ? 'Continue' : 'DeepSeek Harness'
const props = {
ariaLabel: 'Project Runtime', label: 'Runtime', onChange,
runtimeSettings: settings
}
const view = render(<ProjectRuntimeSelector {...props} selection={{ provider, profileId }} />)
const select = screen.getByRole('combobox')
expect(select).toHaveValue(`${provider}:${profileId}`)
expect(screen.getByText(language === 'zh-CN'
? `通过 ${runtime} 运行,固定使用 Model A。`
: `Run through ${runtime} with the fixed model Model A.`)).toBeInTheDocument()
expect(screen.getAllByRole('option').filter((option) =>
(option as HTMLOptionElement).value.endsWith(`:${profileId}`))).toHaveLength(2)
const view = render(<ProjectRuntimeSelector label="Runtime" onChange={onChange}
runtimeSettings={settings} selection={{ provider: 'opencode' }} />)
fireEvent.change(screen.getByLabelText('执行方式'), { target: { value: 'continue' } })
expect(onChange).toHaveBeenLastCalledWith({ provider: 'continue' })
view.rerender(<ProjectRuntimeSelector label="Runtime" onChange={onChange}
runtimeSettings={settings} selection={{ provider: 'continue' }} />)
expect(screen.getByRole('option', { name: '跟随 Continue 默认(自有配置)' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('模型'), { target: { value: secondId } })
expect(onChange).toHaveBeenLastCalledWith({
provider: 'continue', model: { kind: 'profile', profileId: secondId }
})
view.rerender(<ProjectRuntimeSelector label="Runtime" onChange={onChange} runtimeSettings={settings}
selection={{ provider: 'continue', model: { kind: 'profile', profileId: secondId } }} />)
fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } })
expect(onChange).toHaveBeenLastCalledWith({ provider: 'continue' })
})
fireEvent.change(select, { target: { value: `${provider}:default` } })
expect(onChange).toHaveBeenLastCalledWith({ provider })
view.rerender(<ProjectRuntimeSelector {...props} selection={{ provider }} />)
expect(select).toHaveValue(`${provider}:default`)
expect(screen.queryByText(language === 'zh-CN'
? `通过 ${runtime} 运行,固定使用 Model A。`
: `Run through ${runtime} with the fixed model Model A.`)).not.toBeInTheDocument()
expect(screen.getAllByRole('option').filter((option) =>
(option as HTMLOptionElement).value.endsWith(`:${profileId}`))).toHaveLength(1)
view.unmount()
}
it('explains and offers to clear a deleted model', () => {
const onChange = vi.fn()
render(<ProjectRuntimeSelector label="Runtime" onChange={onChange} runtimeSettings={settings}
selection={{ provider: 'opencode', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000099' } }} />)
expect(screen.getByText('所选模型连接已删除,已回退到 Model A')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '改为跟随默认模型' }))
expect(onChange).toHaveBeenLastCalledWith({ provider: 'opencode' })
})
it('limits remote projects to OpenCode and Continue without own configuration', () => {
render(<ProjectRuntimeSelector label="Runtime" onChange={vi.fn()} remote runtimeSettings={settings} />)
const providers = Array.from((screen.getByLabelText('执行方式') as HTMLSelectElement).options)
.map((option) => option.value)
expect(providers).toEqual(['opencode', 'continue'])
expect(screen.queryByRole('option', { name: /自有配置/u })).not.toBeInTheDocument()
})
})
+190 -185
View File
@@ -1,212 +1,217 @@
import type { TFunction } from 'i18next'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import type { RuntimeSettings } from '../../shared/contracts'
import {
agentRuntimeSelectionKey,
getRuntimeSelectionForProvider,
isChannelModelProfileUsable,
repairChannelRuntimeSelection,
type AgentRuntimeSelection
allowedRuntimeProviders,
compactRuntimeSelectionLayer,
globalDefaultModel,
isResolvableStatus,
resolveRuntimeChoice,
runtimeModelChoiceStatus,
selectableModelProfiles,
supportsRuntimeConfig,
type AgentRuntimeProvider,
type RuntimeModelChoice,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import {
ignoredChoiceMessage,
runtimeModelLabel,
runtimeProviderLabel,
sourceLabel
} from './runtime-selection'
type ProjectRuntimeSelectorProps = {
ariaLabel: string
disabled?: boolean
label: string
onChange: (selection: AgentRuntimeSelection) => void
help?: string
onChange: (selection: RuntimeSelectionLayer | undefined) => void
runtimeSettings: RuntimeSettings
selection?: AgentRuntimeSelection
selectionMode?: 'configured' | 'channel'
selection?: RuntimeSelectionLayer
/** Managed SSH projects accept only OpenCode and Continue with a GoodBuddy connection. */
remote?: boolean
/** Prefix for accessible names when several projects are edited on one page. */
ariaLabelPrefix?: string
/** Unattended channel projects cannot use image-generation connections. */
textOnly?: boolean
}
function runtimeSelectionDescription(
selection: AgentRuntimeSelection,
settings: RuntimeSettings,
t: TFunction<'integrations'>
): string {
if (selection.provider === 'model') {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === (selection.profileId ?? settings.defaultModelProfileId)
)
if (!profile) {
return t('channels.project.missingSelection')
}
if (profile.protocol === 'openai-images-generations') {
return t('channels.project.imageOnlySelection')
}
if (
profile.authentication === 'api-key' &&
!profile.apiKeyConfigured
) {
return t('channels.project.missingCredential')
}
return t('channels.project.directDescription', {
name: profile.name,
modelName: profile.modelName
})
}
if (selection.provider === 'auto') {
return t('channels.project.automaticDescription')
}
const runtimeLabel =
selection.provider === 'opencode'
? 'OpenCode'
: selection.provider === 'continue'
? 'Continue'
: 'DeepSeek Harness'
if (selection.profileId) {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId
)
return t('channels.project.fixedRuntimeDescription', {
runtime: runtimeLabel,
name: profile?.name ?? t('channels.project.missingProfile')
})
}
return t('channels.project.runtimeDescription', {
runtime: runtimeLabel
})
const INHERIT = ''
const RUNTIME_CONFIG = 'runtime-config'
function modelValue(model: RuntimeModelChoice | undefined): string {
if (!model) return INHERIT
return model.kind === 'profile' ? model.profileId : RUNTIME_CONFIG
}
/**
* Project layer editor: an execution mode and a model, each defaulting to
* "Follow global (current value)". Saving keeps inheritance instead of
* freezing today's global value.
*/
export function ProjectRuntimeSelector({
ariaLabel,
disabled = false,
label,
help,
onChange,
runtimeSettings,
selection,
selectionMode = 'configured'
remote = false,
ariaLabelPrefix,
textOnly = false
}: ProjectRuntimeSelectorProps): React.JSX.Element {
const { t } = useTranslation('integrations')
const initialSelection =
selection ?? { provider: 'auto' as const }
const runtimeSelection =
selectionMode === 'channel'
? repairChannelRuntimeSelection(initialSelection, runtimeSettings)
: initialSelection
const directProfiles = runtimeSettings.modelProfiles.filter(
isChannelModelProfileUsable
)
const selectedDirectProfileId =
runtimeSelection.provider === 'model'
? runtimeSelection.profileId
: undefined
const selectedDirectProfile = runtimeSettings.modelProfiles.find(
(profile) => profile.id === selectedDirectProfileId
)
const selectedDirectUnavailable =
selectedDirectProfileId !== undefined &&
!directProfiles.some(
(profile) => profile.id === selectedDirectProfileId
)
const runtimeProviders = [
'opencode',
'continue',
'deepseek-harness'
] as const
const runtimeSelections = runtimeProviders.map((provider) => {
return selectionMode === 'channel'
? ({ provider } as AgentRuntimeSelection)
: getRuntimeSelectionForProvider(provider, runtimeSettings)
})
const directSelections = directProfiles.map((profile) => ({
provider: 'model' as const,
profileId: profile.id
}))
const inheritedSelections: AgentRuntimeSelection[] = [
...(selectionMode === 'configured' ? [{ provider: 'auto' as const }] : []),
{ provider: 'model' }
]
const fixedRuntimeSelection = runtimeSelection.provider !== 'model' &&
'profileId' in runtimeSelection && runtimeSelection.profileId ? runtimeSelection : undefined
const selections = [...inheritedSelections, ...directSelections, ...runtimeSelections,
...(fixedRuntimeSelection ? [fixedRuntimeSelection] : [])]
const selectionByKey = new Map(
selections.map((candidate) => [
agentRuntimeSelectionKey(candidate),
candidate
])
const { t } = useTranslation('app')
const id = useId()
const options = { remote }
const layer = compactRuntimeSelectionLayer(selection)
const resolved = resolveRuntimeChoice(runtimeSettings, { project: layer }, options)
const provider = resolved.provider
const inheritedModel = globalDefaultModel(provider, runtimeSettings, options)
const profiles = selectableModelProfiles(provider, runtimeSettings).filter(
(profile) => !textOnly || profile.protocol !== 'openai-images-generations'
)
const storedModel = layer?.model
const storedModelStatus = storedModel
? runtimeModelChoiceStatus(provider, storedModel, runtimeSettings, options)
: 'ok'
const warning = ignoredChoiceMessage(resolved, runtimeSettings, t)
const accessible = (name: string): string =>
ariaLabelPrefix ? `${ariaLabelPrefix} ${name}` : name
const update = (next: RuntimeSelectionLayer): void => {
onChange(compactRuntimeSelectionLayer(next))
}
const changeProvider = (value: string): void => {
const nextProvider = value as AgentRuntimeProvider
const effective = nextProvider
// A model chosen for another execution mode rarely applies; keep it only when valid.
const keepModel =
storedModel &&
runtimeModelChoiceStatus(effective, storedModel, runtimeSettings, options) === 'ok'
update({
provider: nextProvider,
...(keepModel ? { model: storedModel } : {})
})
}
const changeModel = (value: string): void => {
update({
...(layer?.provider ? { provider: layer.provider } : {}),
...(value === INHERIT
? {}
: value === RUNTIME_CONFIG
? { model: { kind: 'runtime-config' as const } }
: { model: { kind: 'profile' as const, profileId: value } })
})
}
const providerLabel = t('runtimeSelection.providerLabel')
const modelLabel = t('runtimeSelection.modelLabel')
const effectiveProvider = runtimeProviderLabel(resolved.provider, t)
const effectiveModel = runtimeModelLabel(resolved.provider, resolved.model, runtimeSettings, t)
const overridden = resolved.modelSource !== 'global'
return (
<label className="field project-runtime-selector">
<span>{label}</span>
<select
aria-label={ariaLabel}
disabled={disabled}
onChange={(event) => {
const nextSelection = selectionByKey.get(event.target.value)
if (nextSelection) {
onChange(nextSelection)
}
}}
value={agentRuntimeSelectionKey(runtimeSelection)}
>
{inheritedSelections.map((candidate) => (
<option key={candidate.provider} value={agentRuntimeSelectionKey(candidate)}>
{t(candidate.provider === 'auto' ? 'channels.project.automaticDescription' : 'channels.project.defaultDirect')}
</option>
))}
{fixedRuntimeSelection && (
<option value={agentRuntimeSelectionKey(fixedRuntimeSelection)}>
{fixedRuntimeSelection.provider} · {runtimeSettings.modelProfiles.find((profile) => profile.id === fixedRuntimeSelection.profileId)?.name ?? t('channels.project.missingProfile')}
</option>
)}
<optgroup label={t('channels.project.directModels')}>
{selectedDirectUnavailable && (
<option
disabled
value={agentRuntimeSelectionKey(runtimeSelection)}
>
{selectedDirectProfile
? t('channels.project.unavailableProfile', {
name: selectedDirectProfile.name,
modelName: selectedDirectProfile.modelName
})
: t('channels.project.missingProfile')}
</option>
)}
{directProfiles.length === 0 && (
<option disabled value="model:unavailable">
{t('channels.project.noTextModels')}
</option>
)}
{directSelections.map((candidate) => {
const profile = directProfiles.find(
(item) => item.id === candidate.profileId
)!
return (
<option
key={profile.id}
value={agentRuntimeSelectionKey(candidate)}
>
{profile.name} · {profile.modelName}
<fieldset className="project-runtime-selector" disabled={disabled}>
<legend>{label}</legend>
<div className="project-runtime-selector__fields">
<label className="field" htmlFor={`${id}-provider`}>
<span>{providerLabel}</span>
<select
aria-label={accessible(providerLabel)}
id={`${id}-provider`}
onChange={(event) => changeProvider(event.target.value)}
// The project owns its execution mode; older projects show what they resolve to.
value={provider}
>
{allowedRuntimeProviders(options).map((candidate) => (
<option key={candidate} value={candidate}>
{runtimeProviderLabel(candidate, t)}
</option>
)
})}
</optgroup>
<optgroup label="Agent Runtime">
{runtimeSelections.map((candidate) => (
<option
key={candidate.provider}
value={agentRuntimeSelectionKey(candidate)}
>
{candidate.provider === 'opencode'
? 'OpenCode'
: candidate.provider === 'continue'
? 'Continue'
: t('channels.project.deepseekHarnessOption')}
))}
</select>
</label>
<label className="field" htmlFor={`${id}-model`}>
<span>{modelLabel}</span>
<select
aria-label={accessible(modelLabel)}
id={`${id}-model`}
onChange={(event) => changeModel(event.target.value)}
value={modelValue(storedModel)}
>
<option value={INHERIT}>
{t('runtimeSelection.followRuntimeDefault', {
runtime: runtimeProviderLabel(provider, t),
value: runtimeModelLabel(provider, inheritedModel, runtimeSettings, t)
})}
</option>
))}
</optgroup>
</select>
<small>
{runtimeSelectionDescription(
runtimeSelection,
runtimeSettings,
t
)}
</small>
</label>
{storedModel && !isResolvableStatus(storedModelStatus) && (
<option disabled value={modelValue(storedModel)}>
{storedModel.kind === 'profile'
? `⚠ ${runtimeModelLabel(provider, storedModel, runtimeSettings, t)}`
: `⚠ ${t('runtimeSelection.runtimeConfig', { runtime: runtimeProviderLabel(provider, t) })}`}
</option>
)}
{supportsRuntimeConfig(provider, options) && (
<option value={RUNTIME_CONFIG}>
{t('runtimeSelection.runtimeConfig', {
runtime: runtimeProviderLabel(provider, t)
})}
</option>
)}
{profiles.map((profile) => {
const status = runtimeModelChoiceStatus(
provider,
{ kind: 'profile', profileId: profile.id },
runtimeSettings,
options
)
return (
<option
disabled={!isResolvableStatus(status) && profile.id !== modelValue(storedModel)}
key={profile.id}
value={profile.id}
>
{profile.name}
{profile.modelName ? ` · ${profile.modelName}` : ''}
{status === 'unavailable' ? t('runtimeSelection.unavailableSuffix') : ''}
</option>
)
})}
</select>
</label>
</div>
<p
className={`project-runtime-selector__summary${overridden ? ' project-runtime-selector__summary--override' : ''}`}
>
<span>
{t('runtimeSelection.effective', {
provider: effectiveProvider,
model: effectiveModel
})}
</span>
<small>
{t('runtimeSelection.effectiveModelSource', {
model: sourceLabel(resolved.modelSource, t)
})}
</small>
</p>
{warning && (
<p className="project-runtime-selector__warning" role="status">
<span>{warning}</span>
{storedModel && !isResolvableStatus(storedModelStatus) && (
<button
className="secondary-button"
onClick={() => changeModel(INHERIT)}
type="button"
>
{t('runtimeSelection.followGlobalAction')}
</button>
)}
</p>
)}
{help && <small className="project-runtime-selector__scope-help">{help}</small>}
</fieldset>
)
}
+12 -38
View File
@@ -13,7 +13,6 @@ import {
defaultRuntimeSettings,
type RuntimeSettings
} from '../../shared/contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import type {
SshDirectoryBrowseResult,
SshHost,
@@ -66,7 +65,7 @@ const project: AssistantProject = {
rootPath: 'C:\\Workspace'
},
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'model', profileId },
runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId } },
kind: 'user',
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
@@ -413,21 +412,10 @@ describe('ProjectSwitcher runtime fields', () => {
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: 'Harness project' }
})
const runtime = within(dialog).getByLabelText(
'新对话默认 Runtime'
)
expect(
within(runtime).getByRole('option', {
name: 'DeepSeek Harness(预览 · OpenAI 兼容)'
})
).toBeInTheDocument()
fireEvent.change(runtime, {
target: {
value: agentRuntimeSelectionKey({
provider: 'deepseek-harness'
})
}
})
const runtime = within(dialog).getByLabelText('执行方式')
// New projects start from a concrete execution mode.
expect(runtime).not.toHaveValue('')
fireEvent.change(runtime, { target: { value: 'deepseek-harness' } })
fireEvent.click(
within(dialog).getByRole('button', { name: '创建' })
)
@@ -450,16 +438,9 @@ describe('ProjectSwitcher runtime fields', () => {
name: '管理项目 Local project'
}))
const dialog = screen.getByRole('dialog', { name: '项目设置' })
fireEvent.change(
within(dialog).getByLabelText('新对话默认 Runtime'),
{
target: {
value: agentRuntimeSelectionKey({
provider: 'deepseek-harness'
})
}
}
)
fireEvent.change(within(dialog).getByLabelText('执行方式'), {
target: { value: 'deepseek-harness' }
})
const modeGroup = within(dialog)
.getAllByRole('group', { name: '默认模式' })
.find((candidate) =>
@@ -494,11 +475,8 @@ describe('ProjectSwitcher runtime fields', () => {
fireEvent.click(screen.getByLabelText('New project'))
const dialog = screen.getByRole('dialog', { name: 'New project' })
expect(
within(dialog).getByLabelText(
'Default Runtime for new conversations'
)
).toBeInTheDocument()
expect(within(dialog).getByRole('group', { name: 'Default Runtime for new conversations' })).toBeInTheDocument()
expect(within(dialog).getByLabelText('Execution mode')).not.toHaveValue('')
expect(
within(dialog)
.getAllByRole('group', { name: 'Default mode' })
@@ -506,11 +484,7 @@ describe('ProjectSwitcher runtime fields', () => {
candidate.classList.contains('segmented-control')
)
).toBeInTheDocument()
expect(
within(dialog).getByRole('option', {
name: 'DeepSeek Harness (Preview · OpenAI-compatible)'
})
).toBeInTheDocument()
expect(within(dialog).getByRole('option', { name: 'DeepSeek Harness' })).toBeInTheDocument()
})
})
@@ -1291,7 +1265,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
fireEvent.change(within(dialog).getByRole('combobox', { name: '新对话默认 Runtime' }), {
fireEvent.change(within(dialog).getByRole('combobox', { name: '执行方式' }), {
target: { value: provider }
})
const modeGroup = within(dialog)
+42 -49
View File
@@ -43,8 +43,9 @@ import type {
} from '../../shared/remote-project-recovery-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import {
getDefaultRuntimeSelection
} from './runtime-selection'
resolveRuntimeChoice,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import {
channelProjectDraft,
ChannelProjectSettingsFields
@@ -889,6 +890,18 @@ export function ProjectSwitcher({
setSettingsProjectId(undefined)
}
// The project form always shows a concrete execution mode, so save exactly that mode.
const withProjectProvider = (
layer: RuntimeSelectionLayer | undefined,
remote: boolean
): RuntimeSelectionLayer | undefined =>
runtimeSettings
? {
...layer,
provider: resolveRuntimeChoice(runtimeSettings, { project: layer }, { remote }).provider
}
: layer
const openProjectSettings = (project: AssistantProject): void => {
if (
!remoteProjectsEnabled &&
@@ -917,11 +930,8 @@ export function ProjectSwitcher({
defaultWorkMode: normalizeInteractiveWorkMode(
project.defaultWorkMode
),
runtimeSelection:
project.runtimeSelection ??
(runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
: undefined)
// Keep "follow global" as absent so saving never freezes today's default.
runtimeSelection: project.runtimeSelection
}
setDraft(
project.kind === 'channel' && runtimeSettings
@@ -965,10 +975,7 @@ export function ProjectSwitcher({
const commonDraft = {
name: draft.name,
description: draft.description,
runtimeSelection:
draft.runtimeSelection?.provider === 'opencode' || draft.runtimeSelection?.provider === 'continue'
? draft.runtimeSelection
: ({ provider: 'opencode' } as const),
runtimeSelection: withProjectProvider(draft.runtimeSelection, true),
hostId: remoteHostId,
remoteRootPath
}
@@ -1010,13 +1017,10 @@ export function ProjectSwitcher({
setSaving(true)
setError(undefined)
try {
const input =
draft.runtimeSelection || !runtimeSettings
? draft
: {
...draft,
runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
}
const input = {
...draft,
runtimeSelection: withProjectProvider(draft.runtimeSelection, false)
}
if (dialogMode === 'settings' && settingsProject) {
await onUpdate(settingsProject.id, input)
} else {
@@ -1394,10 +1398,7 @@ export function ProjectSwitcher({
name: '',
description: '',
rootPath: '',
defaultWorkMode: 'ask',
runtimeSelection: runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
: undefined
defaultWorkMode: 'ask'
})
restoreFocusTarget.current = 'create'
setDialogMode('create')
@@ -1597,10 +1598,10 @@ export function ProjectSwitcher({
{runtimeSettings && (
<>
<ProjectRuntimeSelector
ariaLabel={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
disabled={busy}
help={t(
'projectSwitcher.dialog.defaultRuntimeHelp'
)}
label={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
@@ -1613,11 +1614,6 @@ export function ProjectSwitcher({
runtimeSettings={runtimeSettings}
selection={draft.runtimeSelection}
/>
<small className="project-runtime-selector__scope-help">
{t(
'projectSwitcher.dialog.defaultRuntimeHelp'
)}
</small>
</>
)}
</>
@@ -1748,27 +1744,24 @@ export function ProjectSwitcher({
{t('projectSwitcher.remote.rootHelp')}
</small>
</label>
<label className="remote-project-runtime">
<span>
{t(
{runtimeSettings && (
<ProjectRuntimeSelector
disabled={remoteFieldsDisabled}
help={t('projectSwitcher.remote.runtimeHelp')}
label={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
</span>
<select aria-label={t('projectSwitcher.dialog.fields.defaultRuntime')}
disabled={remoteFieldsDisabled} value={draft.runtimeSelection?.provider ?? 'opencode'}
onChange={event => {
const provider = event.target.value === 'continue' ? 'continue' : 'opencode'
setDraft(current => ({ ...current, runtimeSelection: { provider } }))
}}>
<option value="opencode">OpenCode</option>
<option value="continue">Continue</option>
</select>
<small>
{t(
'projectSwitcher.remote.runtimeHelp'
)}
</small>
</label>
onChange={(runtimeSelection) =>
setDraft((current) => ({
...current,
runtimeSelection
}))
}
remote
runtimeSettings={runtimeSettings}
selection={draft.runtimeSelection}
/>
)}
<ProjectWorkModeFields
ariaLabel={t(
'projectSwitcher.dialog.fields.defaultMode'
+367
View File
@@ -0,0 +1,367 @@
import { forwardRef, useCallback, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { RuntimeSettings } from '../../shared/contracts'
import {
allowedRuntimeProviders,
compactRuntimeSelectionLayer,
globalDefaultModel,
isResolvableStatus,
runtimeSelectionLayerSchema,
resolveRuntimeChoice,
runtimeModelChoiceStatus,
selectableModelProfiles,
supportsRuntimeConfig,
type AgentRuntimeProvider,
type RuntimeModelChoice,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import {
ignoredChoiceMessage,
runtimeModelDetail,
runtimeModelLabel,
runtimeProviderLabel,
sourceLabel
} from './runtime-selection'
type RuntimeModelPickerProps = {
runtimeSettings: RuntimeSettings
projectLayer?: RuntimeSelectionLayer
conversationLayer?: RuntimeSelectionLayer
remote: boolean
onSelect: (layer: RuntimeSelectionLayer | undefined) => void
onManage: () => void
onClose: () => void
}
const VIEWPORT_MARGIN = 8
/** The nearest ancestor that clips overflow, whose edges the menu must stay within. */
function clippingBounds(element: HTMLElement): { left: number; right: number } {
let bounds = { left: VIEWPORT_MARGIN, right: window.innerWidth - VIEWPORT_MARGIN }
for (let node = element.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node)
if (/(hidden|clip|auto|scroll)/u.test(style.overflowX + style.overflow)) {
const rect = node.getBoundingClientRect()
bounds = {
left: Math.max(bounds.left, rect.left + VIEWPORT_MARGIN),
right: Math.min(bounds.right, rect.right - VIEWPORT_MARGIN)
}
}
}
return bounds
}
function sameModel(
left: RuntimeModelChoice | undefined,
right: RuntimeModelChoice | undefined
): boolean {
if (!left || !right) return left === right
if (left.kind !== right.kind) return false
return left.kind === 'runtime-config' || left.profileId === (right as { profileId: string }).profileId
}
/**
* Two-column composer menu: execution modes on the left, models for the
* highlighted mode on the right. Choices write the conversation layer only.
*/
export const RuntimeModelPicker = forwardRef<HTMLDivElement, RuntimeModelPickerProps>(
function RuntimeModelPicker(
{
runtimeSettings,
projectLayer,
conversationLayer: rawConversationLayer,
remote,
onSelect,
onManage,
onClose
},
ref
) {
const { t } = useTranslation('app')
const menuRef = useRef<HTMLDivElement | null>(null)
const setMenuRef = useCallback(
(node: HTMLDivElement | null) => {
menuRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref]
)
// Keep the wide two-column menu inside the chat pane instead of under the sidebar.
useLayoutEffect(() => {
const menu = menuRef.current
if (!menu) return
const place = (): void => {
menu.style.transform = ''
menu.style.maxWidth = ''
const bounds = clippingBounds(menu)
const available = Math.max(0, bounds.right - bounds.left)
if (menu.offsetWidth > available) menu.style.maxWidth = `${available}px`
const rect = menu.getBoundingClientRect()
const shift =
rect.left < bounds.left
? bounds.left - rect.left
: rect.right > bounds.right
? bounds.right - rect.right
: 0
if (shift) menu.style.transform = `translateX(${Math.round(shift)}px)`
}
place()
window.addEventListener('resize', place)
return () => window.removeEventListener('resize', place)
}, [])
const options = { remote }
// Cached conversations may still hold pre-layer shapes.
const parsedLayer = runtimeSelectionLayerSchema.safeParse(rawConversationLayer)
const conversationLayer = rawConversationLayer === undefined || !parsedLayer.success
? undefined
: compactRuntimeSelectionLayer(parsedLayer.data)
const current = resolveRuntimeChoice(
runtimeSettings,
{ project: projectLayer, conversation: conversationLayer },
options
)
const [focusedProvider, setFocusedProvider] = useState<AgentRuntimeProvider>(
current.provider
)
// What this conversation gets for the focused mode if it does not pick a model itself.
const inheritedForFocused = resolveRuntimeChoice(
runtimeSettings,
{ project: projectLayer, conversation: { provider: focusedProvider } },
options
)
// A conversation model counts as chosen only while it is the one in effect.
const conversationModel =
focusedProvider === current.provider && current.modelSource === 'conversation'
? current.model
: undefined
const hasOverride = Boolean(conversationLayer?.provider || conversationLayer?.model)
const warning = ignoredChoiceMessage(current, runtimeSettings, t)
const providerIsInherited = (provider: AgentRuntimeProvider): boolean =>
resolveRuntimeChoice(runtimeSettings, { project: projectLayer }, options).provider === provider
const selectProvider = (provider: AgentRuntimeProvider): void => {
// Switching modes drops the conversation's model so the new mode's default applies.
onSelect(
providerIsInherited(provider) ? undefined : { provider }
)
}
const selectModel = (model: RuntimeModelChoice | undefined): void => {
const provider = providerIsInherited(focusedProvider) ? undefined : focusedProvider
onSelect({ ...(provider ? { provider } : {}), ...(model ? { model } : {}) })
}
const moveFocus = (event: React.KeyboardEvent<HTMLDivElement>): void => {
const container = event.currentTarget
const active = document.activeElement as HTMLElement | null
const column = active?.closest<HTMLElement>('[data-runtime-column]')
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
const columns = Array.from(
container.querySelectorAll<HTMLElement>('[data-runtime-column]')
)
const itemsOf = (element: HTMLElement | null | undefined): HTMLButtonElement[] =>
element
? Array.from(
element.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"], [role="menuitem"]'
)
).filter((item) => !item.disabled)
: []
let target: HTMLButtonElement | undefined
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const index = column ? columns.indexOf(column) : 0
const next = columns[index + (event.key === 'ArrowRight' ? 1 : -1)]
const items = itemsOf(next)
target =
items.find((item) => item.getAttribute('aria-checked') === 'true') ??
items[0]
} else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
const items = itemsOf(column ?? container)
const index = items.indexOf(active as HTMLButtonElement)
target =
event.key === 'Home'
? items[0]
: event.key === 'End'
? items.at(-1)
: items.at(
(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) %
items.length
)
}
if (target) {
event.preventDefault()
container
.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"], [role="menuitem"]')
.forEach((item) => {
item.tabIndex = item === target ? 0 : -1
})
target.focus()
const provider = target.dataset.provider as AgentRuntimeProvider | undefined
if (provider) setFocusedProvider(provider)
}
}
const profiles = selectableModelProfiles(focusedProvider, runtimeSettings)
// Reserve room for the longest model list so previewing another mode never
// resizes the menu; a bottom-anchored menu would otherwise jump under the pointer.
const modelRows = Math.max(
...allowedRuntimeProviders(options).map(
(provider) =>
1 +
selectableModelProfiles(provider, runtimeSettings).length +
(supportsRuntimeConfig(provider, options) ? 1 : 0)
)
)
const defaultModel = inheritedForFocused.model ??
globalDefaultModel(focusedProvider, runtimeSettings, options)
const defaultSource = inheritedForFocused.modelSource
const modelItem = (
key: string,
model: RuntimeModelChoice | undefined,
label: string,
detail: string | undefined,
badge?: string,
disabled = false
): React.JSX.Element => {
const checked =
focusedProvider === current.provider &&
(model === undefined ? !conversationModel : sameModel(conversationModel, model))
return (
<button
aria-checked={checked}
aria-label={[label, badge, detail].filter(Boolean).join(' · ')}
disabled={disabled}
key={key}
onClick={() => selectModel(model)}
role="menuitemradio"
tabIndex={-1}
title={detail ? `${label}\n${detail}` : label}
type="button"
>
<span>
<span className="runtime-picker__label">{label}</span>
{badge && <span className="runtime-picker__source">{badge}</span>}
</span>
{detail && <small>{detail}</small>}
</button>
)
}
return (
<div
aria-label={t('runtimeSelection.menuLabel')}
className="runtime-picker__menu runtime-picker__menu--columns"
onKeyDown={moveFocus}
ref={setMenuRef}
role="menu"
>
<div
aria-label={t('runtimeSelection.providerListLabel')}
className="runtime-picker__column"
data-runtime-column="provider"
role="group"
>
<strong role="presentation">{t('runtimeSelection.providerListLabel')}</strong>
{allowedRuntimeProviders(options).map((provider) => (
<button
aria-checked={provider === current.provider}
className={provider === focusedProvider ? 'runtime-picker__focused' : undefined}
data-provider={provider}
key={provider}
onClick={() => selectProvider(provider)}
onFocus={() => setFocusedProvider(provider)}
onMouseEnter={() => setFocusedProvider(provider)}
role="menuitemradio"
tabIndex={-1}
type="button"
>
<span>{runtimeProviderLabel(provider, t)}</span>
</button>
))}
</div>
<div
aria-label={t('runtimeSelection.modelListLabel')}
className="runtime-picker__column runtime-picker__column--models"
data-runtime-column="model"
role="group"
style={{ '--runtime-model-rows': modelRows } as React.CSSProperties}
>
<strong role="presentation">{t('runtimeSelection.modelListLabel')}</strong>
{modelItem(
'default',
undefined,
t('runtimeSelection.defaultModel', {
value: runtimeModelLabel(focusedProvider, defaultModel, runtimeSettings, t)
}),
runtimeModelDetail(focusedProvider, defaultModel, runtimeSettings, t),
sourceLabel(defaultSource, t)
)}
{profiles.map((profile) => {
const model = { kind: 'profile' as const, profileId: profile.id }
const status = runtimeModelChoiceStatus(focusedProvider, model, runtimeSettings, options)
return modelItem(
profile.id,
model,
profile.name ?? profile.id,
status === 'unavailable'
? `${profile.modelName ?? ''}${t('runtimeSelection.unavailableSuffix')}`
: profile.modelName,
undefined,
!isResolvableStatus(status)
)
})}
{supportsRuntimeConfig(focusedProvider, options) &&
modelItem(
'runtime-config',
{ kind: 'runtime-config' },
t('runtimeSelection.runtimeConfig', {
runtime: runtimeProviderLabel(focusedProvider, t)
}),
focusedProvider === 'deepseek-harness'
? runtimeModelLabel(focusedProvider, { kind: 'runtime-config' }, runtimeSettings, t)
: undefined
)}
</div>
<div className="runtime-picker__footer">
{warning && (
<p className="runtime-picker__warning" role="status">
{warning}
</p>
)}
<div className="runtime-picker__footer-row">
<small>
{hasOverride
? t('runtimeSelection.conversationOverride')
: t('runtimeSelection.conversationOnly')}
</small>
{hasOverride && (
<button
onClick={() => onSelect(undefined)}
role="menuitem"
tabIndex={-1}
type="button"
>
{t('runtimeSelection.resetToProject')}
</button>
)}
</div>
<button
className="runtime-picker__manage"
onClick={onManage}
role="menuitem"
tabIndex={-1}
type="button"
>
{t('runtimeSelection.manage')}
</button>
</div>
</div>
)
}
)
+11 -3
View File
@@ -57,6 +57,12 @@ import type { BrandingPreferences } from './branding'
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const nativeImage = globalThis.Image
function harnessModelSourceRadios(): HTMLElement[] {
return screen
.queryAllByRole('radio')
.filter((radio) => radio.getAttribute('name') !== 'runtime-default-provider')
}
function openCapabilitySettingsTab(
tab: 'Skills' | 'MCP' | '工具执行环境'
): void {
@@ -3064,7 +3070,8 @@ describe('SettingsPanel runtime files', () => {
const source = screen.getByLabelText(
'DeepSeek Harness OpenAI 兼容模型连接'
)
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
// Only the global execution-mode choice uses radios; Harness has no model-source radios.
expect(harnessModelSourceRadios()).toHaveLength(0)
expect(
screen.queryByText('使用管理员预置模型连接')
).not.toBeInTheDocument()
@@ -3147,7 +3154,7 @@ describe('SettingsPanel runtime files', () => {
name: 'DeepSeek Harness(预览)'
})
)
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
expect(harnessModelSourceRadios()).toHaveLength(0)
if (sourceKind === 'environment') {
expect(screen.getByText('此选项实际使用管理员预置:actual-administrator-model。')).toBeInTheDocument()
expect(screen.queryByText(/已保存配置中的实际回退/)).not.toBeInTheDocument()
@@ -4930,7 +4937,8 @@ describe('SettingsPanel runtime files', () => {
)
await waitFor(() =>
expect(testRuntime).toHaveBeenLastCalledWith({
provider: 'continue'
provider: 'continue',
runtimeConfig: true
})
)
expect(testModelConnection).not.toHaveBeenCalled()
+5 -3
View File
@@ -1645,7 +1645,9 @@ export function SettingsPanel({
provider: agentRuntimeType,
profileId: runtimeSource.profileId
}
: { provider: agentRuntimeType }
: runtimeSource.kind === 'platform'
? { provider: agentRuntimeType, runtimeConfig: true as const }
: { provider: agentRuntimeType }
const status = testingModel
? await window.goodbuddy.settings.testModelConnection(profileId)
: await window.goodbuddy.settings.testRuntime(runtimeSelection)
@@ -3563,10 +3565,10 @@ export function SettingsPanel({
'openai-images-generations'
? t('model.profile.imageGeneration')
: t('model.profile.textChat'),
continueCompatibility: isContinueCompatible(profile)
continueCompatibility: isAgentRuntimeModelProtocol(profile.protocol)
? t('model.profile.compatible')
: t('model.profile.incompatible'),
openCodeCompatibility: isOpenCodeCompatible(profile)
openCodeCompatibility: isAgentRuntimeModelProtocol(profile.protocol)
? t('model.profile.compatible')
: t('model.profile.incompatibleImageProtocol'),
deepseekHarnessCompatibility:
@@ -259,6 +259,47 @@ export const app = {
unavailable: 'Unavailable'
}
},
runtimeSelection: {
providers: {
model: 'Direct model',
opencode: 'OpenCode',
continue: 'Continue',
'deepseek-harness': 'DeepSeek Harness'
},
providerLabel: 'Execution mode',
followRuntimeDefault: 'Follow {{runtime}} default ({{value}})',
effectiveModelSource: 'Model from {{model}} settings',
modelLabel: 'Model',
runtimeConfig: 'Use {{runtime}}’s own configuration',
runtimeConfigShort: 'Own configuration',
followGlobal: 'Follow global ({{value}})',
followProject: 'Follow project ({{value}})',
defaultModel: 'Default ({{value}})',
noModel: 'No model available',
sources: {
global: 'Global',
project: 'Project',
conversation: 'This conversation'
},
effective: 'In effect: {{provider}} · {{model}}',
effectiveSources: 'Execution mode from {{provider}} settings · model from {{model}} settings',
ignored: {
missing: 'The selected model connection was deleted. Using {{fallback}} instead.',
incompatible: 'The selected model does not work with this execution mode. Using {{fallback}} instead.',
unavailable: 'The selected model has no API key. Using {{fallback}} instead.',
provider: 'Remote projects support only OpenCode and Continue. Using {{fallback}} instead.'
},
followGlobalAction: 'Use the default model',
missingProfile: 'Deleted model',
unavailableSuffix: ' (no API key)',
conversationOnly: 'Applies to this conversation only',
resetToProject: 'Reset to project default',
manage: 'Manage model connections…',
menuLabel: 'Execution mode and model',
providerListLabel: 'Execution mode',
modelListLabel: 'Model',
conversationOverride: 'Chosen for this conversation'
},
chat: {
heading: 'Conversation',
user: 'You',
@@ -246,6 +246,47 @@ export const app = {
unavailable: '不可用'
}
},
runtimeSelection: {
providers: {
model: '直连模型',
opencode: 'OpenCode',
continue: 'Continue',
'deepseek-harness': 'DeepSeek Harness'
},
providerLabel: '执行方式',
followRuntimeDefault: '跟随 {{runtime}} 默认({{value}})',
effectiveModelSource: '模型来自{{model}}设置',
modelLabel: '模型',
runtimeConfig: '使用 {{runtime}} 自有配置',
runtimeConfigShort: '自有配置',
followGlobal: '跟随全局({{value}})',
followProject: '跟随项目({{value}})',
defaultModel: '默认({{value}})',
noModel: '无可用模型',
sources: {
global: '全局',
project: '项目',
conversation: '本会话'
},
effective: '实际生效:{{provider}} · {{model}}',
effectiveSources: '执行方式来自{{provider}}设置 · 模型来自{{model}}设置',
ignored: {
missing: '所选模型连接已删除,已回退到 {{fallback}}',
incompatible: '所选模型与当前执行方式不兼容,已回退到 {{fallback}}',
unavailable: '所选模型缺少 API Key,已回退到 {{fallback}}',
provider: '远程项目仅支持 OpenCode 和 Continue,已回退到 {{fallback}}'
},
followGlobalAction: '改为跟随默认模型',
missingProfile: '已删除的模型',
unavailableSuffix: '(缺少 API Key)',
conversationOnly: '仅对本会话生效',
resetToProject: '恢复为项目默认',
manage: '管理模型连接…',
menuLabel: '执行方式和模型',
providerListLabel: '执行方式',
modelListLabel: '模型',
conversationOverride: '本会话单独选择'
},
chat: {
heading: '对话内容',
user: '用户',
+43 -81
View File
@@ -1,93 +1,55 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeSettings } from '../../shared/contracts'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionProfileId,
repairChannelRuntimeSelection,
getRuntimeSelectionForProvider
} from '../../shared/runtime-selection-contracts'
import { resolveRuntimeChoice } from '../../shared/runtime-selection-contracts'
import i18n from './i18n'
import { ignoredChoiceMessage, runtimeModelLabel } from './runtime-selection'
const harnessProfileId = '00000000-0000-4000-8000-000000000071'
const imageId = '00000000-0000-4000-8000-000000000072'
const anthropicId = '00000000-0000-4000-8000-000000000073'
const openAiId = '00000000-0000-4000-8000-000000000074'
function harnessSettings(
source: { kind: 'platform' } | { kind: 'profile'; profileId: string }
): RuntimeSettings {
return {
provider: 'deepseek-harness',
deepseekHarnessModelSource: source
} as RuntimeSettings
}
describe('DeepSeek Harness runtime selection', () => {
it.each(['opencode', 'continue', 'deepseek-harness'] as const)(
'preserves an explicit %s channel profile even when it equals the old default', (provider) => {
const selection = { provider, profileId: harnessProfileId }
const settings = {
defaultModelProfileId: '00000000-0000-4000-8000-000000000072',
modelProfiles: [{
id: harnessProfileId,
baseUrl: 'https://gateway.example/v1',
protocol: 'openai-chat-completions',
authentication: 'api-key' as const,
apiKeyConfigured: true
}],
opencodeModelSource: { kind: 'default' as const },
continueModelSource: { kind: 'platform' as const }
}
expect(repairChannelRuntimeSelection(selection, settings)).toEqual(selection)
expect(repairChannelRuntimeSelection({ provider }, settings)).toEqual({ provider })
const settings = {
provider: 'model',
defaultModelProfileId: imageId,
opencodeBaseUrl: '',
opencodeModelSource: { kind: 'default' },
continueModelSource: { kind: 'default' },
deepseekHarnessModelSource: { kind: 'platform' },
deepseekHarnessPlatformModel: { source: 'environment', name: '管理员预置模型', modelName: 'deepseek-chat' },
modelProfiles: [
{ id: imageId, name: 'Image', modelName: 'image', protocol: 'openai-images-generations', authentication: 'none' },
{ id: anthropicId, name: 'Claude', modelName: 'claude', protocol: 'anthropic-messages', authentication: 'none' },
{
id: openAiId, name: 'Gateway', modelName: 'gateway', baseUrl: 'https://gateway.example/v1',
protocol: 'openai-chat-completions', authentication: 'api-key', apiKeyConfigured: true
}
)
]
} as unknown as RuntimeSettings
it('references the Runtime configuration without copying its profile', () => {
const selection = getRuntimeSelectionForProvider(
'deepseek-harness',
harnessSettings({
kind: 'profile',
profileId: harnessProfileId
})
)
expect(selection).toEqual({
provider: 'deepseek-harness'
} satisfies Record<string, string>)
describe('renderer runtime labels', () => {
it('resolves "follow recommended" to the first compatible connection per mode', () => {
expect(resolveRuntimeChoice(settings, { project: { provider: 'opencode' } }).selection)
.toEqual({ provider: 'opencode', profileId: anthropicId })
expect(resolveRuntimeChoice(settings, { project: { provider: 'continue' } }).selection)
.toEqual({ provider: 'continue', profileId: anthropicId })
// An image-only global default is skipped for the direct model too.
expect(resolveRuntimeChoice(settings).selection)
.toEqual({ provider: 'model', profileId: anthropicId })
})
it('uses platform settings without a profile id', () => {
const settings = harnessSettings({ kind: 'platform' })
it('names the model DeepSeek Harness actually uses for its own configuration', async () => {
await i18n.changeLanguage('zh-CN')
const t = i18n.getFixedT('zh-CN', 'app')
expect(runtimeModelLabel('deepseek-harness', { kind: 'runtime-config' }, settings, t))
.toBe('自有配置 · deepseek-chat')
})
expect(getDefaultRuntimeSelection(settings)).toEqual({
provider: 'deepseek-harness'
it('explains why a saved choice was not used', async () => {
const t = i18n.getFixedT('en-US', 'app')
const resolved = resolveRuntimeChoice(settings, {
conversation: { model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000099' } }
})
})
it('resolves default references to the first Runtime-compatible profile', () => {
const imageId = '00000000-0000-4000-8000-000000000072'
const anthropicId = '00000000-0000-4000-8000-000000000073'
const openAiId = '00000000-0000-4000-8000-000000000074'
const settings = {
provider: 'model' as const,
defaultModelProfileId: imageId,
opencodeBaseUrl: '',
opencodeEmbedded: true,
opencodeModelSource: { kind: 'default' as const },
continueModelSource: { kind: 'default' as const },
deepseekHarnessModelSource: { kind: 'default' as const },
modelProfiles: [
{ id: imageId, protocol: 'openai-images-generations' },
{ id: anthropicId, protocol: 'anthropic-messages' },
{
id: openAiId,
baseUrl: 'https://gateway.example/v1',
protocol: 'openai-chat-completions',
authentication: 'api-key' as const,
apiKeyConfigured: true
}
]
}
expect(getRuntimeSelectionProfileId({ provider: 'opencode' }, settings)).toBe(anthropicId)
expect(getRuntimeSelectionProfileId({ provider: 'continue' }, settings)).toBe(anthropicId)
expect(getRuntimeSelectionProfileId({ provider: 'deepseek-harness' }, settings)).toBe(openAiId)
expect(ignoredChoiceMessage(resolved, settings, t))
.toBe('The selected model connection was deleted. Using Claude instead.')
})
})
+81 -3
View File
@@ -1,4 +1,82 @@
export {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
import type { TFunction } from 'i18next'
import type { RuntimeSettings } from '../../shared/contracts'
import type {
AgentRuntimeProvider,
ResolvedRuntimeChoice,
RuntimeModelChoice,
RuntimeSelectionLayerName
} from '../../shared/runtime-selection-contracts'
export type AppTranslate = TFunction<'app'>
export function runtimeProviderLabel(
provider: AgentRuntimeProvider,
t: AppTranslate
): string {
return t(`runtimeSelection.providers.${provider}`)
}
function harnessPlatformModelName(settings: RuntimeSettings): string | undefined {
const platform = settings.deepseekHarnessPlatformModel
return platform && platform.source !== 'unavailable'
? platform.modelName
: undefined
}
/** A short model label: connection name, or "own configuration" with the resolved model when known. */
export function runtimeModelLabel(
provider: AgentRuntimeProvider,
model: RuntimeModelChoice | undefined,
settings: RuntimeSettings,
t: AppTranslate
): string {
if (!model) return t('runtimeSelection.noModel')
if (model.kind === 'runtime-config') {
const detail =
provider === 'deepseek-harness' ? harnessPlatformModelName(settings) : undefined
const label = t('runtimeSelection.runtimeConfigShort')
return detail ? `${label} · ${detail}` : label
}
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === model.profileId
)
return profile?.name ?? t('runtimeSelection.missingProfile')
}
export function runtimeModelDetail(
provider: AgentRuntimeProvider,
model: RuntimeModelChoice | undefined,
settings: RuntimeSettings,
t: AppTranslate
): string | undefined {
if (model?.kind === 'runtime-config') {
return t('runtimeSelection.runtimeConfig', {
runtime: runtimeProviderLabel(provider, t)
})
}
if (model?.kind !== 'profile') return undefined
return settings.modelProfiles.find((profile) => profile.id === model.profileId)
?.modelName
}
export function sourceLabel(
source: RuntimeSelectionLayerName,
t: AppTranslate
): string {
return t(`runtimeSelection.sources.${source}`)
}
export function ignoredChoiceMessage(
resolved: ResolvedRuntimeChoice,
settings: RuntimeSettings,
t: AppTranslate
): string | undefined {
if (!resolved.ignored) return undefined
const fallback =
resolved.ignored.kind === 'provider'
? runtimeProviderLabel(resolved.provider, t)
: runtimeModelLabel(resolved.provider, resolved.model, settings, t)
return resolved.ignored.kind === 'provider'
? t('runtimeSelection.ignored.provider', { fallback })
: t(`runtimeSelection.ignored.${resolved.ignored.reason}`, { fallback })
}
+245
View File
@@ -7911,6 +7911,190 @@ button > svg {
background: var(--border-subtle);
}
.runtime-picker__menu--columns {
display: grid;
width: min(480px, calc(100vw - 32px));
grid-template-columns: minmax(140px, 0.8fr) minmax(0, 1.2fr);
/* Columns size themselves; the footer sits below instead of overlapping them. */
max-height: none;
grid-template-rows: auto auto;
overflow: hidden;
}
.runtime-picker__menu--columns[style*="max-width"] {
grid-template-columns: minmax(110px, 0.7fr) minmax(0, 1.3fr);
}
.runtime-picker__column {
display: flex;
min-width: 0;
max-height: min(340px, 45vh);
flex-direction: column;
overflow-y: auto;
overscroll-behavior: contain;
gap: var(--space-1);
}
/* Fixed row height so the reserved space matches every mode's list exactly. */
.runtime-picker__column--models > button {
height: 52px;
min-height: 52px;
flex: 0 0 52px;
}
.runtime-picker__column--models {
/* Header (~30px) + rows × (52px + 4px gap); capped and scrollable when long. */
height: min(
calc(30px + var(--runtime-model-rows, 1) * 56px),
min(340px, 45vh)
);
}
/* Rows have a fixed height, so long names truncate instead of wrapping into the detail line. */
.runtime-picker__column--models > button > span {
overflow: hidden;
white-space: nowrap;
}
.runtime-picker__column--models > button > span > .runtime-picker__label {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
}
.runtime-picker__column--models > button > span > .runtime-picker__source {
flex: none;
}
.runtime-picker__column + .runtime-picker__column {
padding-left: var(--space-2);
border-left: 1px solid var(--border-subtle);
}
.runtime-picker__column > strong {
padding: var(--space-2) var(--space-2) var(--space-1);
color: var(--text-muted);
font-size: var(--font-caption);
}
.runtime-picker__column > button,
.runtime-picker__footer button {
display: grid;
width: 100%;
height: auto;
min-height: 38px;
align-items: center;
padding: var(--space-2);
border-radius: var(--radius-control);
background: transparent;
color: var(--text-primary);
cursor: pointer;
gap: 2px var(--space-2);
grid-template-columns: minmax(0, 1fr) auto;
text-align: left;
}
.runtime-picker__column > button:hover,
.runtime-picker__column > button.runtime-picker__focused,
.runtime-picker__footer button:hover {
background: var(--surface-muted);
}
.runtime-picker__column > button[aria-checked="true"] {
background: var(--accent-subtle);
color: var(--accent);
}
.runtime-picker__column > button[aria-checked="true"]::after {
content: "✓";
font-weight: 700;
grid-column: 2;
grid-row: 1 / span 2;
}
.runtime-picker__column > button:disabled {
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.58;
}
.runtime-picker__column > button > span {
display: flex;
min-width: 0;
align-items: center;
font-size: var(--font-body);
font-weight: 600;
gap: var(--space-1);
}
.runtime-picker__column > button > small {
grid-column: 1;
grid-row: 2;
overflow: hidden;
color: var(--text-muted);
font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-picker__source {
padding: 0 6px;
border-radius: 999px;
background: var(--surface-muted);
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 500;
}
.runtime-picker__footer {
display: flex;
flex-direction: column;
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
margin-top: var(--space-1);
gap: var(--space-1);
grid-column: 1 / -1;
}
.runtime-picker__footer-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-2);
gap: var(--space-2);
}
.runtime-picker__footer-row small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.runtime-picker__footer-row button {
width: auto;
min-height: 30px;
color: var(--accent);
}
.runtime-picker__warning {
margin: 0;
padding: var(--space-2);
border-radius: var(--radius-control);
background: color-mix(in srgb, var(--warning, #d97706) 12%, transparent);
color: var(--text-primary);
font-size: var(--font-caption);
}
.model-button--override .model-button__label::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
border-radius: 50%;
background: var(--accent);
vertical-align: middle;
}
.send-button {
display: grid;
width: 36px;
@@ -10277,6 +10461,67 @@ details.settings-section > :not(summary) + :not(summary) {
line-height: 1.45;
}
.project-runtime-selector {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.project-runtime-selector > legend {
margin-bottom: var(--space-2);
padding: 0;
color: var(--text-primary);
font-size: var(--font-caption);
font-weight: 650;
}
.project-runtime-selector__fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-3);
}
.project-runtime-selector__summary {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: var(--space-2) var(--space-3);
border-radius: 8px;
background: var(--surface-muted);
color: var(--text-muted);
font-size: var(--font-caption);
}
.project-runtime-selector__summary--override {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--text-primary);
}
.project-runtime-selector__summary small {
color: var(--text-muted);
}
.project-runtime-selector__warning {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin: 0;
color: var(--warning, var(--text-primary));
font-size: var(--font-caption);
}
@media (max-width: 719px) {
.project-runtime-selector__fields {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 719px) {
.project-create-card .segmented-control {
width: 100%;