feat: unify execution and add workspace file imports
Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content. Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation. Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit. Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
This commit is contained in:
@@ -557,7 +557,6 @@ describe('ActivityPanel', () => {
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Workspace'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'user',
|
||||
builtInDefault: true,
|
||||
status: 'active',
|
||||
@@ -610,7 +609,6 @@ describe('ActivityPanel', () => {
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Renamed'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'user',
|
||||
status: 'active',
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
@@ -1083,4 +1081,4 @@ describe('ActivityPanel paged by Main', () => {
|
||||
expect(onFilterChange).toHaveBeenCalledWith('failed')
|
||||
expect(container.textContent).toContain('1,200')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+178
-187
@@ -240,7 +240,6 @@ const project = {
|
||||
kind: "local" as const,
|
||||
rootPath: "C:\\Users\\test",
|
||||
},
|
||||
defaultWorkMode: "ask" as const,
|
||||
kind: "user" as const,
|
||||
builtInDefault: true,
|
||||
status: "active" as const,
|
||||
@@ -337,7 +336,6 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
})),
|
||||
run,
|
||||
cancel: vi.fn(async () => {}),
|
||||
respondApproval: vi.fn(async () => {}),
|
||||
respondQuestion: vi.fn(async () => {}),
|
||||
compactConversation: vi.fn(async () => ({
|
||||
provider: "continue" as const,
|
||||
@@ -449,7 +447,6 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
profileId: modelProfileId,
|
||||
},
|
||||
secureStorageAvailable: true,
|
||||
toolApproval: "always",
|
||||
})),
|
||||
updateRuntime: vi.fn<DesktopApi["settings"]["updateRuntime"]>(
|
||||
async (input) => ({
|
||||
@@ -505,7 +502,6 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
opencodeModelSource: input.opencodeModelSource ?? { kind: "platform" },
|
||||
continueModelSource: input.continueModelSource ?? { kind: "platform" },
|
||||
secureStorageAvailable: true,
|
||||
toolApproval: input.toolApproval,
|
||||
}),
|
||||
),
|
||||
selectWorkspace: vi.fn(async () => undefined),
|
||||
@@ -644,6 +640,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
}),
|
||||
},
|
||||
workspace: {
|
||||
importFiles: vi.fn(async () => ({ imported: [], failed: [] })),
|
||||
manage: vi.fn(async () => ({ kind: 'branches' as const, current: 'main', branches: [] })),
|
||||
getFileDiff: vi.fn(),
|
||||
getChanges: vi.fn(async () => ({
|
||||
@@ -1145,7 +1142,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
};
|
||||
|
||||
function composerMenuTrigger(
|
||||
label: "专家角色" | "工作模式",
|
||||
label: "专家角色",
|
||||
): HTMLButtonElement {
|
||||
if (label === "专家角色") openComposerOptions();
|
||||
return screen.getByRole("button", {
|
||||
@@ -1158,13 +1155,13 @@ function openComposerOptions(): void {
|
||||
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
|
||||
}
|
||||
|
||||
function openComposerMenu(label: "专家角色" | "工作模式"): HTMLElement {
|
||||
function openComposerMenu(label: "专家角色"): HTMLElement {
|
||||
fireEvent.click(composerMenuTrigger(label));
|
||||
return screen.getByRole("menu", { name: label });
|
||||
}
|
||||
|
||||
function selectComposerOption(
|
||||
label: "专家角色" | "工作模式",
|
||||
label: "专家角色",
|
||||
optionLabel: string,
|
||||
): void {
|
||||
const menu = openComposerMenu(label);
|
||||
@@ -1296,7 +1293,6 @@ describe("App", () => {
|
||||
vi.mocked(api.knowledge.getSnapshot).mockReset();
|
||||
vi.mocked(api.knowledge.externalInstancesList).mockReset();
|
||||
vi.mocked(api.agent.respondQuestion).mockReset().mockResolvedValue();
|
||||
vi.mocked(api.agent.respondApproval).mockReset().mockResolvedValue();
|
||||
magicTodoStatusChangedListener = undefined;
|
||||
vi.mocked(api.magicNotes.getTodoStatus)
|
||||
.mockReset()
|
||||
@@ -1599,7 +1595,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
expect(within(screen.getByRole("menu")).getByRole("menuitemradio", { name: /Background project/u }))
|
||||
.not.toHaveTextContent("个运行中");
|
||||
expect(within(screen.getByRole('list', { name: '会话' })).getByRole('button', { name: 'Exact background discussion' })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole('list', { name: '会话' })).getByRole('button', { name: /^Exact background discussion /u })).not.toHaveTextContent('已完成');
|
||||
});
|
||||
|
||||
it.each((["foreground", "background"] as const).flatMap((location) =>
|
||||
@@ -1784,6 +1780,9 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' });
|
||||
fireEvent.change(within(dialog).getByLabelText('名称'), { target: { value: createdProject.name } });
|
||||
vi.mocked(api.settings.selectWorkspace).mockResolvedValueOnce('C:\\Created');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '选择项目根目录' }));
|
||||
await waitFor(() => expect(within(dialog).getByRole('button', { name: '创建' })).toBeEnabled());
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
}
|
||||
const discard = await screen.findByRole('button', { name: '放弃更改并关闭' });
|
||||
@@ -1920,7 +1919,8 @@ describe("App", () => {
|
||||
act(() => conversationQueueChangeListener?.(request.conversationId!));
|
||||
await screen.findAllByText(refreshed.title);
|
||||
if (kind === "question") expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).toBeInTheDocument();
|
||||
if (kind === "question") expect(screen.getByRole("button", { name: "提交回答" })).toBeInTheDocument();
|
||||
else expect(screen.queryByRole("button", { name: "仅此次" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
}
|
||||
let resolveReply: (() => void) | undefined;
|
||||
@@ -1948,12 +1948,12 @@ describe("App", () => {
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it.each((["question", "approval"] as const).flatMap((kind) =>
|
||||
it.each((["question"] as const).flatMap((kind) =>
|
||||
(["success", "question", "approval", "done", "failure"] as const).map((outcome) => ({ kind, outcome })),
|
||||
))("settles $kind response activity safely on $outcome", async ({ kind, outcome }) => {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
vi.mocked(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval)
|
||||
vi.mocked(api.agent.respondQuestion)
|
||||
.mockImplementationOnce(() => new Promise<void>((yes, no) => { resolve = yes; reject = no; }));
|
||||
render(<App />);
|
||||
await screen.findByLabelText("更多会话操作 Current discussion");
|
||||
@@ -1973,7 +1973,7 @@ describe("App", () => {
|
||||
act(() => agentListener?.(kind === "question" ? questionEvent : approvalEvent));
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理 1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" }));
|
||||
await waitFor(() => expect(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval).toHaveBeenCalled());
|
||||
await waitFor(() => expect(api.agent.respondQuestion).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
if (outcome === "failure") reject(new Error("Response failed"));
|
||||
else {
|
||||
@@ -2128,7 +2128,6 @@ describe("App", () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValue(snapshots);
|
||||
vi.mocked(api.schedules.create).mockReset().mockImplementation(async (input) => ({
|
||||
...input,
|
||||
workMode: "execute",
|
||||
id: "00000000-0000-4000-8000-000000000815",
|
||||
taskId: "00000000-0000-4000-8000-000000000816",
|
||||
conversationId: input.conversationId ?? "00000000-0000-4000-8000-000000000817",
|
||||
@@ -2271,7 +2270,7 @@ describe("App", () => {
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Hi, I’m GoodBuddy\. Ask me a question, add local files/u,
|
||||
/Hi, I’m GoodBuddy\. Ask a question or give me a task/u,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
|
||||
@@ -2312,7 +2311,6 @@ describe("App", () => {
|
||||
instructions: "Verify the project label",
|
||||
origin: "schedule",
|
||||
status: "queued",
|
||||
workMode: "ask",
|
||||
createdAt: "2026-07-31T01:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
@@ -2436,7 +2434,7 @@ describe("App", () => {
|
||||
expect(document.getElementById("topbar-runtime-detail")).toBeNull();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件/u,
|
||||
/你好,我是 GoodBuddy。可以直接提问或交给我任务/u,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("智能工作台")).toBeInTheDocument();
|
||||
@@ -2729,7 +2727,6 @@ describe("App", () => {
|
||||
conversationId,
|
||||
title: task.title,
|
||||
prompt: task.instructions,
|
||||
workMode: "execute",
|
||||
recurrence: "weekly",
|
||||
nextRunAt: "2026-08-21T09:00:00.000Z",
|
||||
enabled: true,
|
||||
@@ -2772,7 +2769,7 @@ describe("App", () => {
|
||||
const taskRegion = await screen.findByRole("region", {
|
||||
name: "当前会话的任务",
|
||||
});
|
||||
expect(within(taskRegion).getByText("Ask")).toBeInTheDocument();
|
||||
expect(within(taskRegion).queryByText(/^(Ask|Execute)$/u)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes scheduled Task state on queue-only events and guards manual runs through completion", async () => {
|
||||
@@ -2795,7 +2792,6 @@ describe("App", () => {
|
||||
conversationId,
|
||||
title: task.title,
|
||||
prompt: task.instructions,
|
||||
workMode: "execute",
|
||||
recurrence: "weekly",
|
||||
nextRunAt: "2026-08-21T09:00:00.000Z",
|
||||
enabled: true,
|
||||
@@ -2931,7 +2927,6 @@ describe("App", () => {
|
||||
conversationId,
|
||||
title: tasks[0]!.title,
|
||||
prompt: tasks[0]!.instructions,
|
||||
workMode: "ask",
|
||||
recurrence: "once",
|
||||
nextRunAt: "2026-08-19T03:00:00.000Z",
|
||||
enabled: false,
|
||||
@@ -3028,7 +3023,7 @@ describe("App", () => {
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!).getByText(target.title));
|
||||
expect(await screen.findByText("Retained scheduled history")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "仅此次" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "仅此次" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes scheduled Task approvals to the associated Conversation", async () => {
|
||||
@@ -3074,7 +3069,7 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText("请求写入工作区")).not.toHaveLength(0);
|
||||
expect(screen.getByText("仅此次")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "仅此次" })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
|
||||
@@ -3083,11 +3078,8 @@ describe("App", () => {
|
||||
const taskRow = screen.getByRole("button", { name: /发布任务发布审批会话/u }).closest("article")!;
|
||||
expect(within(taskRow).getByLabelText("等待审批: write_file")).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "等待审批" })).not.toBeInTheDocument();
|
||||
fireEvent.click(within(taskRow).getByRole("button", { name: "仅此次允许" }));
|
||||
await waitFor(() => expect(api.agent.respondApproval).toHaveBeenCalledWith("00000000-0000-4000-8000-000000000834", "once"));
|
||||
await waitFor(() => expect(within(taskRow).queryByLabelText("等待审批: write_file")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个运行中"));
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
expect(within(taskRow).queryByRole("button", { name: "仅此次允许" })).not.toBeInTheDocument();
|
||||
expect(within(taskRow).queryByRole("button", { name: "拒绝" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("idle-preloads only the small Heartbeat route at startup", async () => {
|
||||
@@ -7208,12 +7200,10 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
const expertButton = composerMenuTrigger("专家角色");
|
||||
const modeButton = composerMenuTrigger("工作模式");
|
||||
const runtimeButton = await screen.findByRole("button", {
|
||||
name: /sonnet-5/u,
|
||||
});
|
||||
expect(expertButton).toBeEnabled();
|
||||
expect(modeButton).toBeEnabled();
|
||||
expect(runtimeButton).toBeEnabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
@@ -7227,7 +7217,6 @@ describe("App", () => {
|
||||
screen.queryByRole("menu", { name: "专家角色" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(expertButton).toBeDisabled();
|
||||
expect(modeButton).toBeDisabled();
|
||||
expect(runtimeButton).toBeDisabled();
|
||||
|
||||
const request = run.mock.calls[0]?.[0];
|
||||
@@ -7242,7 +7231,6 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(expertButton).toBeEnabled());
|
||||
expect(modeButton).toBeEnabled();
|
||||
expect(runtimeButton).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -7302,9 +7290,7 @@ describe("App", () => {
|
||||
const otherRow = screen.getByRole("button", { name: /Another taskScheduled approval target/u }).closest("article")!;
|
||||
expect(within(otherRow).queryByLabelText("等待审批: write_file")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "等待审批" })).not.toBeInTheDocument();
|
||||
fireEvent.click(within(row).getByRole("button", { name: "拒绝" }));
|
||||
await waitFor(() => expect(api.agent.respondApproval).toHaveBeenCalledWith(approvalId, "deny"));
|
||||
await waitFor(() => expect(within(row).queryByLabelText("等待审批: write_file")).not.toBeInTheDocument());
|
||||
expect(within(row).queryByRole("button", { name: "拒绝" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends scheduled instructions with the target conversation's current settings and history", async () => {
|
||||
@@ -7322,7 +7308,7 @@ describe("App", () => {
|
||||
});
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([{
|
||||
id: conversationId, projectId, title: "Scheduled target", updatedAt: Date.now(),
|
||||
runtimeSelection: { provider: "continue" }, workMode: "execute",
|
||||
runtimeSelection: { provider: "continue" },
|
||||
knowledgeLibraryIds: [knowledgeId], knowledgeRetrievalMode: "always",
|
||||
messages: [{ id: historyId, role: "user", content: "Existing context", state: "complete", createdAt: Date.now() }],
|
||||
}]);
|
||||
@@ -7336,7 +7322,7 @@ describe("App", () => {
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
expect(run.mock.calls[0]![0]).toMatchObject({
|
||||
requestId: runId, queueItemId: runId, conversationId,
|
||||
runtimeSelection: { provider: "continue" }, workMode: "execute",
|
||||
runtimeSelection: { provider: "continue" },
|
||||
knowledgeLibraryIds: [knowledgeId], knowledgeRetrievalMode: "always",
|
||||
history: [{ role: "user", content: "Existing context" }], historyMessageIds: [historyId],
|
||||
contextIds: [],
|
||||
@@ -7368,9 +7354,9 @@ describe("App", () => {
|
||||
it("uses normal project defaults for a new scheduled conversation without another conversation's context", async () => {
|
||||
const targetId = crypto.randomUUID();
|
||||
const runId = crypto.randomUUID();
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([{ ...project, defaultWorkMode: "execute", runtimeSelection: { provider: "opencode" } }]);
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([{ ...project, runtimeSelection: { provider: "opencode" } }]);
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([
|
||||
{ id: crypto.randomUUID(), projectId, title: "Other conversation", updatedAt: Date.now(), workMode: "ask", runtimeSelection: { provider: "model" }, messages: [{ id: crypto.randomUUID(), role: "user", content: "Do not inherit this source history", state: "complete", createdAt: Date.now() }] },
|
||||
{ id: crypto.randomUUID(), projectId, title: "Other conversation", updatedAt: Date.now(), runtimeSelection: { provider: "model" }, messages: [{ id: crypto.randomUUID(), role: "user", content: "Do not inherit this source history", state: "complete", createdAt: Date.now() }] },
|
||||
{ id: targetId, projectId, title: "New scheduled conversation", updatedAt: Date.now(), messages: [] },
|
||||
]);
|
||||
render(<App />);
|
||||
@@ -7382,7 +7368,7 @@ describe("App", () => {
|
||||
}));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
expect(run.mock.calls[0]![0]).toMatchObject({
|
||||
conversationId: targetId, runtimeSelection: { provider: "opencode" }, workMode: "execute",
|
||||
conversationId: targetId, runtimeSelection: { provider: "opencode" },
|
||||
history: [], knowledgeLibraryIds: [], contextIds: [],
|
||||
});
|
||||
});
|
||||
@@ -8653,34 +8639,20 @@ describe("App", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("offers only Ask and Execute in visible work mode controls", async () => {
|
||||
it("omits work mode controls from the composer and project form", async () => {
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
});
|
||||
const modeMenu = openComposerMenu("工作模式");
|
||||
expect(
|
||||
within(modeMenu)
|
||||
.getAllByRole("menuitemradio")
|
||||
.map((option) => option.querySelector("span")?.textContent),
|
||||
).toEqual(["Ask · 只读问答", "Execute · 完全权限"]);
|
||||
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
expect(screen.queryByRole("button", { name: /工作模式/u })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
const dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
const defaultMode = within(dialog)
|
||||
.getAllByRole("group", { name: "默认模式" })
|
||||
.find((candidate) => candidate.classList.contains("segmented-control"))!;
|
||||
expect(
|
||||
within(defaultMode)
|
||||
.getAllByRole("button")
|
||||
.map((button) => button.textContent),
|
||||
).toEqual(["Ask · 只读问答", "Execute · 完全权限"]);
|
||||
expect(within(dialog).queryByRole("group", { name: "默认模式" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /Plan/u })).toBeNull();
|
||||
});
|
||||
|
||||
it("matches expert and work mode keyboard menus to the model picker", async () => {
|
||||
it("matches the expert keyboard menu to the model picker", async () => {
|
||||
render(<App />);
|
||||
openComposerOptions();
|
||||
|
||||
@@ -8714,13 +8686,8 @@ describe("App", () => {
|
||||
expect(optionsTrigger).toHaveFocus();
|
||||
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument();
|
||||
const modeTrigger = composerMenuTrigger("工作模式");
|
||||
fireEvent.click(modeTrigger);
|
||||
const modeMenu = screen.getByRole("menu", { name: "工作模式" });
|
||||
expect(modeMenu).toHaveClass("runtime-picker__menu");
|
||||
fireEvent.click(optionsTrigger);
|
||||
expect(screen.queryByRole("menu", { name: "工作模式" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(expertTrigger).toHaveFocus());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "专家角色:通用助手" })).toHaveFocus());
|
||||
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
||||
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(
|
||||
@@ -8785,15 +8752,10 @@ describe("App", () => {
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(conversationSettings).getByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
within(conversationSettings).queryByRole("button", {
|
||||
name: /工作模式/u,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(conversationSettings).getByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
}),
|
||||
).toHaveTextContent(/^Ask$/u);
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起",
|
||||
@@ -8815,7 +8777,6 @@ describe("App", () => {
|
||||
kind: "local" as const,
|
||||
rootPath: "C:\\Second",
|
||||
},
|
||||
defaultWorkMode: "execute" as const,
|
||||
};
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
project,
|
||||
@@ -8829,10 +8790,10 @@ describe("App", () => {
|
||||
await screen.findByRole("button", { name: "当前项目" }),
|
||||
).toHaveTextContent(project.name);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
screen.queryByRole("button", {
|
||||
name: /工作模式/u,
|
||||
}),
|
||||
).toBeEnabled();
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
selectProjectOption(secondProject.name);
|
||||
await waitFor(() =>
|
||||
@@ -9837,6 +9798,48 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['local', 'ssh'] as const)('restores archived %s projects through the menu and refreshes summaries before deliberate entry', async (kind) => {
|
||||
installRemoteProjectsSetting(kind === 'ssh');
|
||||
const restored = { ...project, id: '00000000-0000-4000-8000-000000000608', name: 'Restored workspace', builtInDefault: false,
|
||||
executionSpace: kind === 'ssh' ? { kind: 'ssh' as const, hostId: '00000000-0000-4000-8000-000000000610', remoteRootPath: '/srv/restored' } : project.executionSpace };
|
||||
let archived = true;
|
||||
vi.mocked(api.projects.list).mockImplementation(async (includeArchived) => [project, ...(includeArchived || !archived ? [{ ...restored, status: archived ? 'archived' as const : 'active' as const }] : [])]);
|
||||
vi.mocked(api.projects.setArchived).mockImplementation(async (_id, value) => { archived = value; });
|
||||
const saved = { id: '00000000-0000-4000-8000-000000000609', projectId: restored.id, title: 'Restored history', updatedAt: 10, messages: [] };
|
||||
render(<App />);
|
||||
const trigger = await screen.findByRole('button', { name: '当前项目' });
|
||||
await waitFor(() => expect(trigger).toHaveTextContent(project.name));
|
||||
fireEvent.click(trigger);
|
||||
expect(api.projects.list).not.toHaveBeenCalledWith(true);
|
||||
fireEvent.click(screen.getByRole('button', { name: '已归档' }));
|
||||
const restore = await screen.findByRole('menuitem', { name: '恢复项目 Restored workspace' });
|
||||
vi.mocked(api.conversations.listSummaries).mockResolvedValue([saved]);
|
||||
if (kind === 'local') {
|
||||
vi.mocked(api.conversations.listSummaries).mockRejectedValueOnce(new Error('Restore summaries unavailable'));
|
||||
fireEvent.click(restore);
|
||||
await screen.findByText('Restore summaries unavailable');
|
||||
expect(trigger).toHaveTextContent(project.name);
|
||||
await waitFor(() => expect(restore).toBeEnabled());
|
||||
expect(screen.queryByRole('menuitemradio', { name: /Restored workspace/ })).not.toBeInTheDocument();
|
||||
}
|
||||
fireEvent.click(restore);
|
||||
await waitFor(() => {
|
||||
expect(api.projects.setArchived).toHaveBeenCalledWith(restored.id, false);
|
||||
expect(screen.queryByRole('menuitem', { name: '恢复项目 Restored workspace' })).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveTextContent(project.name);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: kind === 'ssh' ? '远程' : '本地' }));
|
||||
fireEvent.pointerOver(screen.getByRole('menuitemradio', { name: /Restored workspace/ }), { pointerType: 'mouse' });
|
||||
expect(screen.getByRole('button', { name: /Restored history/ })).toBeVisible();
|
||||
expect(trigger).toHaveTextContent(project.name);
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入项目 Restored workspace' }));
|
||||
await waitFor(() => {
|
||||
expect(trigger).toHaveTextContent('Restored workspace');
|
||||
expect(document.querySelector('.conversation-title')).toHaveTextContent('Restored history');
|
||||
});
|
||||
});
|
||||
|
||||
it("shows grouped project details and keeps the rich menu keyboard accessible", async () => {
|
||||
const channelProject = {
|
||||
...project,
|
||||
@@ -10072,13 +10075,7 @@ describe("App", () => {
|
||||
value: "opencode",
|
||||
},
|
||||
});
|
||||
fireEvent.click(
|
||||
within(
|
||||
within(dialog).getByRole("group", {
|
||||
name: "微信 ClawBot 默认模式",
|
||||
}),
|
||||
).getByRole("button", { name: "执行" }),
|
||||
);
|
||||
expect(within(dialog).queryByRole("group", { name: "微信 ClawBot 默认模式" })).not.toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "保存项目" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -10104,13 +10101,6 @@ describe("App", () => {
|
||||
value: "continue",
|
||||
},
|
||||
});
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole("group", {
|
||||
name: "微信 ClawBot 默认模式",
|
||||
}),
|
||||
).getByRole("button", { name: "对话" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存通道设置" }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -10119,7 +10109,6 @@ describe("App", () => {
|
||||
expect.objectContaining({
|
||||
description: "从消息通道更新",
|
||||
rootPath: "C:\\FromChannels",
|
||||
defaultWorkMode: "ask",
|
||||
runtimeSelection: { provider: "continue" },
|
||||
}),
|
||||
),
|
||||
@@ -10139,13 +10128,7 @@ describe("App", () => {
|
||||
expect(
|
||||
within(dialog).getByLabelText("微信 ClawBot 执行方式"),
|
||||
).toHaveValue("continue");
|
||||
expect(
|
||||
within(
|
||||
within(dialog).getByRole("group", {
|
||||
name: "微信 ClawBot 默认模式",
|
||||
}),
|
||||
).getByRole("button", { name: "对话" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(within(dialog).queryByRole("group", { name: "微信 ClawBot 默认模式" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows client-created remote conversations without obsolete approval copy", async () => {
|
||||
@@ -10218,7 +10201,7 @@ describe("App", () => {
|
||||
it.each([
|
||||
["opencode", "OpenCode"],
|
||||
["continue", "Continue CLI"],
|
||||
] as const)("lets %s select Ask or Execute", async (runtimeId, label) => {
|
||||
] as const)("lets %s run without work mode selection", async (runtimeId, label) => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: runtimeId,
|
||||
label,
|
||||
@@ -10228,16 +10211,10 @@ describe("App", () => {
|
||||
});
|
||||
render(<App />);
|
||||
|
||||
const mode = await screen.findByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
});
|
||||
expect(mode).toBeEnabled();
|
||||
expect(mode.closest(".composer")).not.toBeNull();
|
||||
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
expect(screen.queryByRole("button", { name: /工作模式/u })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问"))
|
||||
.toHaveAttribute("placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起")));
|
||||
selectComposerOption("工作模式", "Execute · 完全权限");
|
||||
expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限");
|
||||
expect(mode).toHaveTextContent(/^Execute$/u);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "执行任务" },
|
||||
@@ -10248,7 +10225,6 @@ describe("App", () => {
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "执行任务",
|
||||
workMode: "execute",
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -10811,7 +10787,7 @@ describe("App", () => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000731";
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
||||
id: conversationId, projectId, title: "Native client", updatedAt: Date.now(),
|
||||
runtimeSelection: { provider }, workMode: "execute", messages: [],
|
||||
runtimeSelection: { provider }, messages: [],
|
||||
}]);
|
||||
render(<App />);
|
||||
const open = await screen.findByRole("button", { name: "在终端中打开" });
|
||||
@@ -10825,7 +10801,7 @@ describe("App", () => {
|
||||
expect(screen.getByRole("region", { name: "用户终端:终端 1" })).toBeInTheDocument();
|
||||
});
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledWith([{
|
||||
header: expect.objectContaining({ id: conversationId, runtimeSelection: { provider }, workMode: "execute" }), messages: [],
|
||||
header: expect.objectContaining({ id: conversationId, runtimeSelection: { provider } }), messages: [],
|
||||
}]);
|
||||
expect(screen.getByRole("textbox", { name: "向 GoodBuddy 提问" })).toHaveValue("Keep my draft");
|
||||
expect(api.terminal.create).not.toHaveBeenCalled();
|
||||
@@ -11059,7 +11035,7 @@ describe("App", () => {
|
||||
expect(api.agent.compactConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the direct-model mode after leaving an Agent Runtime", async () => {
|
||||
it("switches from an Agent Runtime to a direct model without mode controls", async () => {
|
||||
const settings = await api.settings.getRuntime();
|
||||
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||
...settings,
|
||||
@@ -11084,23 +11060,16 @@ describe("App", () => {
|
||||
});
|
||||
render(<App />);
|
||||
|
||||
const mode = await screen.findByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
});
|
||||
expect(mode).toBeEnabled();
|
||||
selectComposerOption("工作模式", "Execute · 完全权限");
|
||||
expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限");
|
||||
|
||||
await screen.findByRole("button", { name: /^OpenCode ·/u });
|
||||
pickRuntime("直连模型");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mode).toHaveAccessibleName("工作模式:Ask · 只读问答");
|
||||
expect(mode).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: /sonnet-5/u })).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: /工作模式/u })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Execute for a runtime without tool support", async () => {
|
||||
it("shows capability guidance without mode controls for a runtime without tools", async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: "model",
|
||||
label: "legacy-model",
|
||||
@@ -11110,16 +11079,8 @@ describe("App", () => {
|
||||
});
|
||||
render(<App />);
|
||||
|
||||
const mode = await screen.findByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
});
|
||||
const modeMenu = openComposerMenu("工作模式");
|
||||
expect(
|
||||
within(modeMenu).getByRole("menuitemradio", {
|
||||
name: /^Execute · 完全权限/u,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
expect(mode).toHaveAccessibleName("工作模式:Ask · 只读问答");
|
||||
expect(await screen.findByText('当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。')).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: /工作模式/u })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens model settings from no-tool image guidance without losing the request draft', async () => {
|
||||
@@ -11143,7 +11104,7 @@ describe("App", () => {
|
||||
expect(api.conversations.imageOperations.regenerate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a direct model to submit Execute with GoodBuddy approvals", async () => {
|
||||
it("submits direct model tool requests without mode fields", async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: "model",
|
||||
label: "sonnet-5",
|
||||
@@ -11153,24 +11114,20 @@ describe("App", () => {
|
||||
});
|
||||
render(<App />);
|
||||
|
||||
const mode = await screen.findByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
});
|
||||
selectComposerOption("工作模式", "Execute · 完全权限");
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "读取项目文件" },
|
||||
});
|
||||
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "读取项目文件",
|
||||
workMode: "execute",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mode).toBeDisabled();
|
||||
expect(run.mock.calls[0]![0]).not.toHaveProperty("workMode");
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -11348,10 +11305,10 @@ describe("App", () => {
|
||||
"当前对话已切换到 OpenCode · 默认模型",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
screen.queryByRole("button", {
|
||||
name: /工作模式/u,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
pickRuntime("Continue");
|
||||
await act(async () => {
|
||||
@@ -11371,10 +11328,10 @@ describe("App", () => {
|
||||
screen.queryByText("当前对话已切换到 Continue · 默认模型"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "工作模式:Ask · 只读问答",
|
||||
screen.queryByRole("button", {
|
||||
name: /工作模式/u,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -11707,7 +11664,19 @@ describe("App", () => {
|
||||
};
|
||||
localStorage.setItem(
|
||||
"goodbuddy.conversations.v1",
|
||||
JSON.stringify([legacyConversation]),
|
||||
JSON.stringify([{
|
||||
...legacyConversation,
|
||||
workMode: 'plan',
|
||||
messages: [{
|
||||
...legacyConversation.messages[0],
|
||||
subagents: [{
|
||||
childTaskId: '00000000-0000-4000-8000-000000000463',
|
||||
actor: { kind: 'direct-model', label: '编程 Subagent' },
|
||||
routingMode: 'native', state: 'completed', workMode: 'execute',
|
||||
output: '/ask Keep Execute and workMode in user content',
|
||||
}],
|
||||
}],
|
||||
}]),
|
||||
);
|
||||
render(<App />);
|
||||
|
||||
@@ -11719,11 +11688,21 @@ describe("App", () => {
|
||||
expect(api.conversations.replace).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
...legacyConversation,
|
||||
messages: [expect.objectContaining({
|
||||
...legacyConversation.messages[0],
|
||||
subagents: [{
|
||||
childTaskId: '00000000-0000-4000-8000-000000000463',
|
||||
actor: { kind: 'direct-model', label: '编程 Subagent' },
|
||||
routingMode: 'native', state: 'completed',
|
||||
output: '/ask Keep Execute and workMode in user content',
|
||||
}],
|
||||
})],
|
||||
contextMetrics: normalizedContextMetrics,
|
||||
projectId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(vi.mocked(api.conversations.replace).mock.calls[0]![0][0]).not.toHaveProperty('workMode');
|
||||
expect(api.conversations.saveLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -12197,13 +12176,16 @@ describe("App", () => {
|
||||
fireEvent.change(within(dialog).getByLabelText("名称"), {
|
||||
target: { value: "新项目" },
|
||||
});
|
||||
vi.mocked(api.settings.selectWorkspace).mockResolvedValueOnce('C:\\NewProject');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '选择项目根目录' }));
|
||||
await waitFor(() => expect(within(dialog).getByRole('button', { name: '创建' })).toBeEnabled());
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "创建" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.projects.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "新项目",
|
||||
rootPath: "",
|
||||
rootPath: "C:\\NewProject",
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -12222,6 +12204,9 @@ describe("App", () => {
|
||||
fireEvent.change(nameInput, {
|
||||
target: { value: "保留的项目名称" },
|
||||
});
|
||||
vi.mocked(api.settings.selectWorkspace).mockResolvedValueOnce('C:\\Unavailable');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '选择项目根目录' }));
|
||||
await waitFor(() => expect(within(dialog).getByRole('button', { name: '创建' })).toBeEnabled());
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "创建" }));
|
||||
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
@@ -12495,7 +12480,7 @@ describe("App", () => {
|
||||
let imageListener: ((value: typeof operation) => void) | undefined;
|
||||
vi.mocked(api.conversations.imageOperations.onChanged).mockImplementationOnce(listener => { imageListener = listener; return () => { imageListener = undefined; }; });
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{ id: conversationId, projectId, title: '图片原会话', updatedAt: 2, workMode: 'execute', messages: [
|
||||
{ id: conversationId, projectId, title: '图片原会话', updatedAt: 2, messages: [
|
||||
{ id: messageId, role: 'assistant', content: '图片稍后保存', createdAt: 1, state: 'complete', imageOperations: [operation, secondOperation] },
|
||||
] },
|
||||
{ id: otherId, projectId, title: '另一会话', updatedAt: 1, messages: [
|
||||
@@ -12539,7 +12524,7 @@ describe("App", () => {
|
||||
input: { intent: 'edit', prompt: 'Change the background', sourceArtifactIds: [crypto.randomUUID()] },
|
||||
state: 'failed', error: 'Original source unavailable', artifactIds: [], createdAt: 1, updatedAt: 1,
|
||||
};
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: conversationId, projectId, title: 'Failed image editing', updatedAt: 2, workMode: 'execute', messages: [
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: conversationId, projectId, title: 'Failed image editing', updatedAt: 2, messages: [
|
||||
{ id: operation.messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete', imageOperations: [operation] },
|
||||
] }, { id: otherId, projectId, title: 'Other image draft', updatedAt: 1, messages: [] }]);
|
||||
const source = { id: crypto.randomUUID(), name: 'replacement.png', size: 64, preview: 'Replacement image', kind: 'image' as const, contentUrl: 'data:image/png;base64,iVBORw0KGgo=' };
|
||||
@@ -12618,7 +12603,6 @@ describe("App", () => {
|
||||
smartRouting: true,
|
||||
expertId: undefined,
|
||||
teamMode: false,
|
||||
workMode: "ask",
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -12986,7 +12970,6 @@ describe("App", () => {
|
||||
label: "编程 Subagent",
|
||||
},
|
||||
routingMode: "native",
|
||||
workMode: "execute",
|
||||
state: "completed",
|
||||
reason: "修复并验证聚焦变更",
|
||||
output: "验证通过",
|
||||
@@ -12999,7 +12982,7 @@ describe("App", () => {
|
||||
selector: "strong",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(within(region).getByText("直连模型 · Execute")).toBeInTheDocument();
|
||||
expect(within(region).getByText("直连模型")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
const persistedActivities = vi
|
||||
.mocked(api.conversations.saveLocal)
|
||||
@@ -13015,7 +12998,6 @@ describe("App", () => {
|
||||
kind: "direct-model",
|
||||
label: "编程 Subagent",
|
||||
},
|
||||
workMode: "execute",
|
||||
output: "验证通过",
|
||||
}),
|
||||
);
|
||||
@@ -13086,7 +13068,7 @@ describe("App", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("offers once, session, permanent, and deny for a tool call", async () => {
|
||||
it("preserves approval event details without tool authorization buttons", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
@@ -13112,17 +13094,11 @@ describe("App", () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText("仅此次")).toBeInTheDocument();
|
||||
expect(screen.getByText("此会话")).toBeInTheDocument();
|
||||
expect(screen.getByText("永久允许")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("拒绝")).toHaveLength(2);
|
||||
fireEvent.click(screen.getByText("此会话"));
|
||||
await waitFor(() =>
|
||||
expect(api.agent.respondApproval).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"session",
|
||||
),
|
||||
);
|
||||
expect(await screen.findAllByText("Continue 请求调用 Bash")).not.toHaveLength(0);
|
||||
expect(screen.getByText("echo safe")).toBeVisible();
|
||||
for (const name of ["仅此次", "此会话", "永久允许", "拒绝"]) {
|
||||
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps interactive questions in event order after a late answer response and history reload", async () => {
|
||||
@@ -13482,7 +13458,7 @@ describe("App", () => {
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Agent Runtime" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "安全与数据" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "安全与数据" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "模型连接" }));
|
||||
|
||||
const apiKeyInput = screen.getByLabelText("API Key");
|
||||
@@ -13614,15 +13590,9 @@ describe("App", () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "打开工作栏应用" }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByText("成果", { selector: "strong" }).closest("button")!,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "导入 PDF、图片或网页" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "生成与导入成果" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("成果", { selector: "strong" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "工作区" }));
|
||||
expect(await screen.findByRole("button", { name: "导入文件" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "预览" })).not.toBeInTheDocument();
|
||||
fireEvent.click(assistantTrigger);
|
||||
expect(sidebar).not.toHaveClass("assistant-sidebar--open");
|
||||
@@ -13634,7 +13604,7 @@ describe("App", () => {
|
||||
await waitFor(() => expect(assistantTrigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it("keeps completed chat replies out of the results sidebar", async () => {
|
||||
it("keeps completed chat replies in conversation without a results application", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
@@ -13663,18 +13633,12 @@ describe("App", () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "打开工作栏应用" }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByText("成果", { selector: "strong" }).closest("button")!,
|
||||
);
|
||||
expect(screen.queryByText("成果", { selector: "strong" })).not.toBeInTheDocument();
|
||||
const sidebar = screen.getByLabelText("助手工作栏");
|
||||
expect(
|
||||
within(sidebar).queryByText("这是一条普通聊天回复"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(sidebar).getByText(
|
||||
"生成的文件、图片、报告和手动导入内容会显示在这里。",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(api.artifacts.importFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the browser toolbar and starts an empty session by address", async () => {
|
||||
@@ -14255,6 +14219,33 @@ describe("App", () => {
|
||||
expect(screen.getByRole("button", { name: "回顾" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes Supervisor review feedback through dismissible application notifications across tabs', async () => {
|
||||
let reject!: (reason: Error) => void;
|
||||
vi.mocked(api.supervision.execution).mockResolvedValue({ active: false });
|
||||
vi.mocked(api.supervision.run).mockImplementation(() => new Promise((_, fail) => { reject = fail; }));
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '监督者' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '回顾' }));
|
||||
const started = await screen.findByText('回顾已开始,可到活动记录查看进度。');
|
||||
expect(started.closest('.app-notification')).toHaveAttribute('role', 'status');
|
||||
fireEvent.click(within(started.closest('.app-notification') as HTMLElement).getByRole('button', { name: '关闭通知' }));
|
||||
expect(screen.queryByText('回顾已开始,可到活动记录查看进度。')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }));
|
||||
expect(await screen.findByText('已有回顾正在处理,请到活动记录查看进度。')).toBeVisible();
|
||||
expect(api.supervision.run).toHaveBeenCalledOnce();
|
||||
fireEvent.click(screen.getByRole('tab', { name: '故事线图谱' }));
|
||||
await act(async () => reject(new TypeError('terminated')));
|
||||
const failed = await screen.findByText('回顾未能完成。请到活动记录查看原因,并继续已保存的进度;也可重新发起回顾。');
|
||||
expect(failed.closest('.app-notification')).toHaveAttribute('role', 'alert');
|
||||
expect(document.querySelectorAll('.app-notification')).toHaveLength(1);
|
||||
expect(document.querySelector('.supervisor-workspace__run-status')).toBeNull();
|
||||
expect(document.querySelector('.supervisor-workspace__inline-error')).toBeNull();
|
||||
expect(screen.queryByText(/TypeError|terminated/)).not.toBeInTheDocument();
|
||||
fireEvent.click(within(failed.closest('.app-notification') as HTMLElement).getByRole('button', { name: '关闭通知' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }));
|
||||
expect(document.querySelector('.app-notification')).toBeNull();
|
||||
});
|
||||
|
||||
it("graph navigation opens the pinned sidebar result and reenters the keepalive heartbeat route", async () => {
|
||||
const makeResult = (id: string) => ({ id, storyLineId: `story-${id}`, sourceId: null, summary: `${id} recap`, changeDigest: '', openItems: [], scope: { kind: 'global' as const }, createdAt: '2026-09-23T00:00:00Z', timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-23T00:00:00Z' } });
|
||||
const a = makeResult('A');
|
||||
@@ -15361,7 +15352,7 @@ describe("App", () => {
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['sidebar', 'project', 'new conversation'] as const)('opens chat after a single guarded Magic Notes discard from %s', async (entry) => {
|
||||
it.each(['sidebar', 'project', 'enter project', 'new conversation'] as const)('opens chat after a single guarded Magic Notes discard from %s', async (entry) => {
|
||||
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
||||
const note = {
|
||||
id: '00000000-0000-4000-8000-000000000602', title: 'Focus note', preview: '',
|
||||
@@ -15384,7 +15375,7 @@ describe("App", () => {
|
||||
if (entry === 'project') fireEvent.click(target)
|
||||
else {
|
||||
fireEvent.pointerOver(target, { pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建会话' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: entry === 'enter project' ? '进入项目 Guarded workspace' : '新建会话' }))
|
||||
}
|
||||
}
|
||||
const discard = await screen.findByRole('button', { name: '放弃草稿并切换' })
|
||||
@@ -15397,7 +15388,7 @@ describe("App", () => {
|
||||
await waitFor(() => {
|
||||
const composer = screen.getByRole('textbox', { name: '向 GoodBuddy 提问' })
|
||||
expect(composer).toBeVisible()
|
||||
if (entry !== 'project') expect(composer).toHaveFocus()
|
||||
if (entry !== 'project' && entry !== 'enter project') expect(composer).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '对话' })).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
expect(title).not.toBeVisible()
|
||||
|
||||
+14
-140
@@ -62,7 +62,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { TFunction } from "i18next";
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
AgentEvent,
|
||||
AgentQuestionAnswer,
|
||||
AgentRuntimeStatus,
|
||||
@@ -115,7 +114,6 @@ import type {
|
||||
ConversationSnapshot,
|
||||
ConversationAttachment,
|
||||
ProjectCreateInput,
|
||||
InteractiveWorkMode,
|
||||
ProjectChannel,
|
||||
WorkspaceChanges,
|
||||
} from "../../shared/assistant-contracts";
|
||||
@@ -127,8 +125,6 @@ import {
|
||||
conversationContextMetricsSchema,
|
||||
conversationMessageBlocksSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
interactiveWorkModes,
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannelLabels,
|
||||
} from "../../shared/assistant-contracts";
|
||||
import type {
|
||||
@@ -171,12 +167,10 @@ import {
|
||||
useAllTasks,
|
||||
useConversationHasBusyTask,
|
||||
useProductTasks,
|
||||
useSidebarArtifacts,
|
||||
useTaskActivityRows,
|
||||
useTaskStatusRows,
|
||||
} from "./task-selectors";
|
||||
import {
|
||||
loadArtifact,
|
||||
refreshArtifacts,
|
||||
refreshTasks,
|
||||
useArtifactHydration,
|
||||
@@ -191,6 +185,7 @@ import {
|
||||
toLocalConversationHeader,
|
||||
withRecoveredQuestions,
|
||||
} from "./conversation-persistence";
|
||||
import { stripLegacyConversationModes } from "./legacy-conversation-import";
|
||||
import { startConversationRefresh } from "./conversation-refresh";
|
||||
import {
|
||||
handleAgentEvent as applyAgentEvent,
|
||||
@@ -214,6 +209,7 @@ import {
|
||||
ScopeBadge,
|
||||
} from "./WorkspacePrimitives";
|
||||
import { ProjectSwitcher } from "./ProjectSwitcher";
|
||||
import { restoreProject } from "./restore-project";
|
||||
import { useStableHandlers } from "./stable-derived-value";
|
||||
import { useUnviewedCompletions } from "./use-unviewed-completions";
|
||||
import { useExecutionStats } from "./use-execution-stats";
|
||||
@@ -514,10 +510,6 @@ export function AppNotificationViewport({
|
||||
);
|
||||
}
|
||||
|
||||
function supportsSubagentSmartRouting(workMode: string): boolean {
|
||||
return workMode === "ask";
|
||||
}
|
||||
|
||||
type WorkspaceView =
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings";
|
||||
|
||||
@@ -588,7 +580,7 @@ const emptyAttachments: ContextAttachment[] = [];
|
||||
type RightSidebarHandlers = Required<Pick<RightAssistantSidebarProps,
|
||||
| "onBeforeCloseNotes" | "onOpenSupervisionGraph" | "onContinueSupervision" | "onCreateCustomTask"
|
||||
| "onBackBrowser" | "onNavigateBrowser" | "onReloadBrowser" | "onStopLoadingBrowser"
|
||||
| "onImportArtifacts" | "onLoadArtifact" | "onOpenTask" | "onRespondApproval"
|
||||
| "onOpenTask"
|
||||
>>;
|
||||
const emptyImageReferences: AssistantArtifact[] = [];
|
||||
|
||||
@@ -650,7 +642,7 @@ function loadPrimarySidebarWidth(): number {
|
||||
function createConversation(
|
||||
projectId?: string,
|
||||
runtimeSelection?: RuntimeSelectionLayer,
|
||||
greeting = "你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。",
|
||||
greeting = "你好,我是 GoodBuddy。可以直接提问或交给我任务。可用工具由所选 Runtime 和设置决定。",
|
||||
): Conversation {
|
||||
const now = Date.now();
|
||||
return {
|
||||
@@ -712,6 +704,7 @@ function loadConversations(
|
||||
return [createConversation(undefined, undefined, greeting)];
|
||||
}
|
||||
const conversations = parsed
|
||||
.map(stripLegacyConversationModes)
|
||||
.filter(isConversation)
|
||||
.slice(0, 100)
|
||||
.map((conversation) => ({
|
||||
@@ -847,7 +840,6 @@ function toConversationSnapshots(
|
||||
id: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
workMode: conversation.workMode,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
@@ -1269,19 +1261,6 @@ function App(): React.JSX.Element {
|
||||
],
|
||||
[assistantExperts, t],
|
||||
);
|
||||
const workModeOptions = useMemo<ComposerMenuOption<InteractiveWorkMode>[]>(
|
||||
() =>
|
||||
interactiveWorkModes.map((value) => ({
|
||||
value,
|
||||
label: t(`composer.modes.${value}.label`),
|
||||
description:
|
||||
value === "execute"
|
||||
? t("composer.modes.execute.description")
|
||||
: t("composer.modes.ask.description"),
|
||||
disabled: value === "execute" && !runtime?.supportsToolExecution,
|
||||
})),
|
||||
[runtime?.supportsToolExecution, t],
|
||||
);
|
||||
const quickActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -2334,20 +2313,6 @@ function App(): React.JSX.Element {
|
||||
[activeProjectId, projects],
|
||||
);
|
||||
const activeProjectUsesManagedSsh = isManagedSshProject(activeProject);
|
||||
const workMode = normalizeInteractiveWorkMode(
|
||||
activeConversation?.workMode ?? activeProject?.defaultWorkMode,
|
||||
);
|
||||
const effectiveWorkMode =
|
||||
workMode === "execute" && runtime?.supportsToolExecution === false
|
||||
? "ask"
|
||||
: workMode;
|
||||
const setWorkMode = (mode: InteractiveWorkMode): void => {
|
||||
setConversations((current) => current.map((conversation) =>
|
||||
conversation.id === activeId
|
||||
? { ...conversation, workMode: mode, updatedAt: Date.now() }
|
||||
: conversation,
|
||||
));
|
||||
};
|
||||
const activeProjectRecovery =
|
||||
activeProjectUsesManagedSsh && activeProject
|
||||
? projectRecoveryByProjectId[activeProject.id]
|
||||
@@ -2871,7 +2836,6 @@ function App(): React.JSX.Element {
|
||||
busyConversationIdsRef.current = new Set([...activityByConversationId.keys(), ...queuedConversationIds]);
|
||||
}, [activityByConversationId, queuedConversationIds]);
|
||||
const pendingSidebarApprovals = usePendingSidebarApprovals(conversationStore);
|
||||
const sidebarArtifacts = useSidebarArtifacts(taskStore, activeProjectId);
|
||||
// Pending supervisor suggestions; earlier report proposals are migrated into them.
|
||||
const [pendingHeartbeatSuggestionCount, setPendingHeartbeatSuggestionCount] = useState(0);
|
||||
|
||||
@@ -4475,7 +4439,6 @@ function App(): React.JSX.Element {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setWorkMode("ask");
|
||||
setInput(
|
||||
[t("notices.heartbeatTaskPrompt"), task.title, task.instructions].join(
|
||||
"\n\n",
|
||||
@@ -4975,19 +4938,16 @@ function App(): React.JSX.Element {
|
||||
await window.goodbuddy.conversationQueue.releaseUser(queuedDispatch.item.id);
|
||||
return;
|
||||
}
|
||||
const mode = normalizeInteractiveWorkMode(conversation.workMode ?? project?.defaultWorkMode);
|
||||
queuedInput = {
|
||||
conversationId: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
prompt: queuedDispatch.input.prompt,
|
||||
runtimeSelection: selection,
|
||||
workMode: mode,
|
||||
includeMemoryContext: true,
|
||||
attachments: [],
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds ?? [],
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode ?? "auto",
|
||||
smartRouting: runtimeSettings?.subagentSmartRoutingEnabled === true &&
|
||||
supportsSubagentSmartRouting(mode) ? true : undefined,
|
||||
smartRouting: runtimeSettings?.subagentSmartRoutingEnabled === true ? true : undefined,
|
||||
};
|
||||
}
|
||||
const releaseQueuedItem = async (): Promise<void> => {
|
||||
@@ -5154,9 +5114,6 @@ function App(): React.JSX.Element {
|
||||
: runtime?.capability === "image-generation"
|
||||
? ""
|
||||
: selectedExpertId;
|
||||
const workModeSnapshot = normalizeInteractiveWorkMode(
|
||||
queuedInput?.workMode ?? effectiveWorkMode,
|
||||
);
|
||||
const knowledgeLibraryIdsSnapshot =
|
||||
queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds;
|
||||
const smartRoutingSnapshot =
|
||||
@@ -5164,8 +5121,7 @@ function App(): React.JSX.Element {
|
||||
(!queuedInput &&
|
||||
runtime?.capability !== "image-generation" &&
|
||||
runtimeSettings?.subagentSmartRoutingEnabled === true &&
|
||||
!selectedExpertSnapshot &&
|
||||
supportsSubagentSmartRouting(workModeSnapshot)
|
||||
!selectedExpertSnapshot
|
||||
? true
|
||||
: undefined);
|
||||
|
||||
@@ -5182,7 +5138,6 @@ function App(): React.JSX.Element {
|
||||
: {}),
|
||||
...(selectedExpertSnapshot === "team" ? { teamMode: true } : {}),
|
||||
...(smartRoutingSnapshot ? { smartRouting: true } : {}),
|
||||
workMode: workModeSnapshot,
|
||||
includeMemoryContext: !command,
|
||||
prompt,
|
||||
attachments: attachmentSnapshot,
|
||||
@@ -5321,7 +5276,6 @@ function App(): React.JSX.Element {
|
||||
...conversationSnapshot,
|
||||
title: conversationSnapshot.title === "新对话" ? prompt.slice(0, 24) : conversationSnapshot.title,
|
||||
updatedAt: assistantMessage.createdAt,
|
||||
workMode: workModeSnapshot,
|
||||
messages: [...historySnapshot, userMessage, assistantMessage],
|
||||
};
|
||||
await window.goodbuddy.conversations.saveLocal([{
|
||||
@@ -5344,7 +5298,6 @@ function App(): React.JSX.Element {
|
||||
: undefined,
|
||||
teamMode: selectedExpertSnapshot === "team",
|
||||
smartRouting: smartRoutingSnapshot,
|
||||
workMode: workModeSnapshot,
|
||||
prompt: executionPrompt,
|
||||
knowledgeLibraryIds: knowledgeLibraryIdsSnapshot,
|
||||
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot,
|
||||
@@ -5524,64 +5477,6 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const respondToApproval = useCallback(
|
||||
async (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision,
|
||||
): Promise<void> => {
|
||||
const pendingMessage = conversationStore.getState()
|
||||
.find((conversation) => conversation.id === conversationId)
|
||||
?.messages.find((message) => message.id === messageId);
|
||||
if (pendingMessage?.approval?.id !== approvalId) return;
|
||||
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
|
||||
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
|
||||
try {
|
||||
await window.goodbuddy.agent.respondApproval(approvalId, decision);
|
||||
const approved = decision !== "deny";
|
||||
const decisionLabel = {
|
||||
deny: tRef.current("chat.approval.decisionDeny"),
|
||||
once: tRef.current("chat.approval.decisionOnce"),
|
||||
session: tRef.current("chat.approval.decisionSession"),
|
||||
permanent: tRef.current("chat.approval.decisionPermanent"),
|
||||
}[decision];
|
||||
activityStore.updateApproval(
|
||||
conversationId,
|
||||
approved ? "completed" : "denied",
|
||||
tRef.current("notices.userDecision", { decision: decisionLabel }),
|
||||
);
|
||||
updateMessage(conversationId, messageId, (message) => {
|
||||
if (message.approval?.id !== approvalId) return message;
|
||||
if (!message.pendingQuestions?.length) {
|
||||
setAssistantTasks((current) => current.map((task) =>
|
||||
task.id === taskId && task.status === "waiting_approval"
|
||||
? { ...task, status: "running" }
|
||||
: task,
|
||||
));
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
approval: undefined,
|
||||
status: message.pendingQuestions?.length
|
||||
? message.status
|
||||
: approved && message.task
|
||||
? undefined
|
||||
: approved
|
||||
? tRef.current("chat.approval.executing", { decision: decisionLabel })
|
||||
: tRef.current("chat.approval.denied"),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
updateMessage(conversationId, messageId, (message) => message.approval?.id !== approvalId ? message : ({
|
||||
...message,
|
||||
status: tRef.current("chat.approval.responseFailed"),
|
||||
}));
|
||||
}
|
||||
},
|
||||
[activityStore, updateMessage, conversationStore, setAssistantTasks],
|
||||
);
|
||||
|
||||
const respondToQuestion = useCallback(
|
||||
async (
|
||||
conversationId: string,
|
||||
@@ -6452,8 +6347,8 @@ function App(): React.JSX.Element {
|
||||
activeRuntimeSelection?.provider === "opencode" ||
|
||||
(activeRuntimeSelection?.provider === "deepseek-harness" && activeProject?.executionSpace.kind === "local");
|
||||
const nativeClientContextKey = useMemo(() => JSON.stringify([
|
||||
activeId, activeProjectId, activeRuntimeSelection, workMode, runtimeSettings,
|
||||
]), [activeId, activeProjectId, activeRuntimeSelection, workMode, runtimeSettings]);
|
||||
activeId, activeProjectId, activeRuntimeSelection, runtimeSettings,
|
||||
]), [activeId, activeProjectId, activeRuntimeSelection, runtimeSettings]);
|
||||
const prepareNativeClientConversation = async (): Promise<string> => {
|
||||
let conversation = conversationStore.getConversation(activeId);
|
||||
if (!conversation) {
|
||||
@@ -6465,7 +6360,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, workMode });
|
||||
const header = toLocalConversationHeader(conversation);
|
||||
await conversationPersistence.enqueue(() =>
|
||||
window.goodbuddy.conversations.saveLocal([{ header, messages: [] }]),
|
||||
);
|
||||
@@ -6524,7 +6419,6 @@ function App(): React.JSX.Element {
|
||||
selectRuntimeAgent: setSelectedRuntimeAgent,
|
||||
selectContinuePreset: setSelectedContinuePreset,
|
||||
selectRuntimeAction,
|
||||
setWorkMode,
|
||||
switchRuntime: (layer) => void switchRuntime(layer),
|
||||
prepareNativeClientConversation,
|
||||
openNativeTerminal: (terminal, origin) => {
|
||||
@@ -6562,6 +6456,8 @@ function App(): React.JSX.Element {
|
||||
// chat updates, and fresh inline closures would re-render all of them.
|
||||
const projectSwitcherActions = useStableHandlers({
|
||||
onArchive: archiveProject,
|
||||
onRestore: async (projectId: string) => setProjects(await restoreProject(projectId, conversationStore, conversationPersistence, retainedConversationDetailIds)),
|
||||
notify,
|
||||
onCreate: createProject,
|
||||
onDelete: deleteProject,
|
||||
onRemoteCommitted: loadCommittedRemoteProject,
|
||||
@@ -6670,25 +6566,7 @@ function App(): React.JSX.Element {
|
||||
runBrowserCommand((browserApi) =>
|
||||
browserApi.stopLoading({ conversationId, tabId }),
|
||||
),
|
||||
onImportArtifacts: async () => {
|
||||
const imported = await window.goodbuddy.artifacts.importFiles(
|
||||
activeProjectId || undefined,
|
||||
);
|
||||
if (imported.length > 0) {
|
||||
setAssistantArtifacts((current) => [...imported, ...current]);
|
||||
setAssistantSidebarTab("results");
|
||||
}
|
||||
},
|
||||
onLoadArtifact: (artifactId) => loadArtifact(taskStore, artifactId),
|
||||
onOpenTask: openAssistantTask,
|
||||
onRespondApproval: (approval, decision) => {
|
||||
void respondToApproval(
|
||||
approval.conversationId,
|
||||
approval.messageId,
|
||||
approval.approvalId,
|
||||
decision,
|
||||
);
|
||||
},
|
||||
});
|
||||
const notesPanelActive = assistantSidebarOpen && magicNotesEnabled;
|
||||
const notesPanel = useMemo(
|
||||
@@ -7195,7 +7073,6 @@ function App(): React.JSX.Element {
|
||||
onOpenCitationSource={openCitationSource}
|
||||
onOpenImage={openImageViewer}
|
||||
onRemoveSchedule={removeAssistantSchedule}
|
||||
onRespondApproval={respondToApproval}
|
||||
onRespondQuestion={respondToQuestion}
|
||||
onRetry={retryMessage}
|
||||
onRunSchedule={runAssistantSchedule}
|
||||
@@ -7206,7 +7083,6 @@ function App(): React.JSX.Element {
|
||||
onVisibleMessageCountChange={
|
||||
handleVisibleMessageCountChange
|
||||
}
|
||||
projects={projects}
|
||||
quickActions={quickActions}
|
||||
schedules={assistantSchedules}
|
||||
scrollSnapshot={chatScrollSnapshots[conversationId]}
|
||||
@@ -7217,7 +7093,6 @@ function App(): React.JSX.Element {
|
||||
visibleMessageCounts[conversationId] ??
|
||||
messageRenderBatchSize
|
||||
}
|
||||
workModeOverride={conversationId === activeId ? effectiveWorkMode : undefined}
|
||||
/>
|
||||
))}
|
||||
{activeProject?.kind === "channel" &&
|
||||
@@ -7283,7 +7158,6 @@ function App(): React.JSX.Element {
|
||||
conversationHint={composerConversationHint}
|
||||
conversationId={activeId}
|
||||
conversationStore={conversationStore}
|
||||
effectiveWorkMode={effectiveWorkMode}
|
||||
executionRunning={conversationExecutionRunning}
|
||||
externalInstances={externalInstances}
|
||||
fileSelectionProgress={fileSelectionProgress}
|
||||
@@ -7320,7 +7194,6 @@ function App(): React.JSX.Element {
|
||||
updateAttachmentBusy={updateAttachmentBusy}
|
||||
voiceListening={voiceListening}
|
||||
voiceRecording={voiceRecording}
|
||||
workModeOptions={workModeOptions}
|
||||
workspaceView={view}
|
||||
/>
|
||||
</>
|
||||
@@ -7423,6 +7296,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
>
|
||||
{isApplicationEnabled(applicationSettings, 'heartbeat') ? <HeartbeatCenter
|
||||
onNotify={notify}
|
||||
runtimeSettings={runtimeSettings}
|
||||
applicationSettings={applicationSettings}
|
||||
applicationSettingsPending={applicationSettingsPending}
|
||||
@@ -7699,6 +7573,7 @@ function App(): React.JSX.Element {
|
||||
)}</ConversationListView>
|
||||
)}
|
||||
<RightAssistantSidebar
|
||||
notify={notify}
|
||||
{...rightSidebarActions}
|
||||
notesEnabled={magicNotesEnabled}
|
||||
notesSettingsReady={Boolean(applicationSettings)}
|
||||
@@ -7709,7 +7584,6 @@ function App(): React.JSX.Element {
|
||||
conversationStats={conversationStats}
|
||||
taskDurations={taskDurations}
|
||||
approvals={pendingSidebarApprovals}
|
||||
artifacts={sidebarArtifacts}
|
||||
browserStates={browserStates}
|
||||
conversationTitles={conversationTitles}
|
||||
currentProject={activeProject}
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
resolveRuntimeChoice
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
|
||||
export function channelProjectDraft(
|
||||
project: ProjectCreateInput,
|
||||
@@ -19,8 +17,6 @@ export function channelProjectDraft(
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
rootPath: project.rootPath,
|
||||
defaultWorkMode: project.defaultWorkMode,
|
||||
// Projects own their execution mode; fill in what older rows resolve to.
|
||||
runtimeSelection: {
|
||||
...layer,
|
||||
provider: resolveRuntimeChoice(runtimeSettings, { project: layer }).provider
|
||||
@@ -109,27 +105,7 @@ export function ChannelProjectSettingsFields({
|
||||
selection={value.runtimeSelection}
|
||||
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>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('channels.project.defaultModeAriaLabel', {
|
||||
name: value.name
|
||||
})}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: 'ask', label: t('channels.project.modes.ask') },
|
||||
{ value: 'execute', label: t('channels.project.modes.execute') }
|
||||
]}
|
||||
onChange={(defaultWorkMode) =>
|
||||
onChange({ ...value, defaultWorkMode })
|
||||
}
|
||||
value={value.defaultWorkMode}
|
||||
/>
|
||||
</fieldset>
|
||||
<p className="channel-project-settings__risk">
|
||||
{value.defaultWorkMode === 'execute'
|
||||
? t('channels.project.executeRisk')
|
||||
: t('channels.project.askRisk')}{' '}
|
||||
{t('channels.project.riskSuffix')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -112,7 +112,6 @@ const projects: AssistantProject[] = [
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Users\\tester'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: { provider: 'model' },
|
||||
kind: 'channel',
|
||||
channel: channel as 'weixin' | 'wecom' | 'dingtalk',
|
||||
@@ -241,13 +240,7 @@ describe('ChannelSettingsSection', () => {
|
||||
fireEvent.change(screen.getByLabelText('企业微信 模型'), {
|
||||
target: { value: directProfileId }
|
||||
})
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', {
|
||||
name: '企业微信 默认模式'
|
||||
})
|
||||
).getByRole('button', { name: '执行' })
|
||||
)
|
||||
expect(screen.queryByRole('group', { name: '企业微信 默认模式' })).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存通道设置' })
|
||||
)
|
||||
@@ -256,7 +249,6 @@ describe('ChannelSettingsSection', () => {
|
||||
expect.objectContaining({
|
||||
description: '企业微信同步项目',
|
||||
rootPath: 'C:\\RemoteWorkspace',
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
model: { kind: 'profile', profileId: directProfileId }
|
||||
@@ -675,7 +667,7 @@ describe('ChannelSettingsSection', () => {
|
||||
expect(screen.queryByText('Channel project')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Remote Execute operations can run only within this project directory.'
|
||||
'The working directory for this channel project.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
} from '../../shared/channel-settings-contracts'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import {
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannels,
|
||||
type AssistantProject,
|
||||
type ProjectCreateInput,
|
||||
@@ -176,9 +175,6 @@ function projectDraftsFrom(
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
rootPath: project.rootPath,
|
||||
defaultWorkMode: normalizeInteractiveWorkMode(
|
||||
project.defaultWorkMode
|
||||
),
|
||||
runtimeSelection: project.runtimeSelection
|
||||
},
|
||||
runtimeSettings
|
||||
@@ -192,7 +188,6 @@ function projectDraftKey(project: ChannelProjectDraft): string {
|
||||
return JSON.stringify({
|
||||
description: project.description,
|
||||
rootPath: project.rootPath,
|
||||
defaultWorkMode: project.defaultWorkMode,
|
||||
runtimeSelection: project.runtimeSelection
|
||||
})
|
||||
}
|
||||
@@ -960,7 +955,6 @@ export function ChannelSettingsSection({
|
||||
name: project!.name,
|
||||
description: project!.description,
|
||||
rootPath: project!.rootPath,
|
||||
defaultWorkMode: project!.defaultWorkMode,
|
||||
runtimeSelection: project!.runtimeSelection
|
||||
})
|
||||
)
|
||||
|
||||
@@ -133,7 +133,6 @@ function baseProps(messages: Message[], overrides: Partial<PaneProps> = {}): Pan
|
||||
onOpenImage: vi.fn(),
|
||||
onOpenImageModelSettings: vi.fn(),
|
||||
onReselectImageSources: vi.fn(),
|
||||
onRespondApproval: vi.fn(async () => undefined),
|
||||
onRespondQuestion: vi.fn(async () => undefined),
|
||||
onRetry: vi.fn(),
|
||||
onScrollSnapshotChange: vi.fn(),
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useTranslation } from "react-i18next";
|
||||
import type { AssistantArtifact } from "../../shared/assistant-contracts";
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
ApprovalDecision,
|
||||
KnowledgeSearchReference,
|
||||
} from "../../shared/contracts";
|
||||
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
||||
@@ -158,7 +157,6 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
onOpenCitationContext,
|
||||
onOpenCitationSource,
|
||||
onOpenImage,
|
||||
onRespondApproval,
|
||||
onRespondQuestion,
|
||||
onRetry,
|
||||
onScrollSnapshotChange,
|
||||
@@ -184,12 +182,6 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise<void>;
|
||||
onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise<void>;
|
||||
onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void;
|
||||
onRespondApproval: (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision,
|
||||
) => Promise<void>;
|
||||
onRespondQuestion: (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
@@ -1134,7 +1126,6 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
onOpenCitationContext={onOpenCitationContext}
|
||||
onOpenCitationSource={onOpenCitationSource}
|
||||
onOpenImage={onOpenImage}
|
||||
onRespondApproval={onRespondApproval}
|
||||
onRespondQuestion={onRespondQuestion}
|
||||
onRetry={onRetry}
|
||||
onRevealEarlier={revealEarlierMessages}
|
||||
|
||||
@@ -62,7 +62,6 @@ const callbacks = {
|
||||
onOpenCitationContext: vi.fn(async () => undefined),
|
||||
onOpenCitationSource: vi.fn(async () => undefined),
|
||||
onOpenImage: vi.fn(),
|
||||
onRespondApproval: vi.fn(async () => undefined),
|
||||
onRespondQuestion: vi.fn(async () => undefined),
|
||||
onRetry: vi.fn(),
|
||||
onRevealEarlier: vi.fn()
|
||||
@@ -1150,7 +1149,7 @@ describe('ChatTimeline', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('labels direct-model subagents with their inherited work mode', () => {
|
||||
it('labels direct-model subagents without work mode metadata', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: 'assistant-message',
|
||||
@@ -1166,7 +1165,6 @@ describe('ChatTimeline', () => {
|
||||
label: '编程 Subagent'
|
||||
},
|
||||
routingMode: 'native',
|
||||
workMode: 'execute',
|
||||
state: 'running',
|
||||
reason: '修复并验证聚焦变更'
|
||||
}
|
||||
@@ -1195,7 +1193,7 @@ describe('ChatTimeline', () => {
|
||||
selector: 'strong'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(within(region).getByText('直连模型 · Execute'))
|
||||
expect(within(region).getByText('直连模型'))
|
||||
.toBeInTheDocument()
|
||||
expect(
|
||||
within(region).getByText('修复并验证聚焦变更')
|
||||
|
||||
@@ -23,7 +23,6 @@ import { ImageOperationStatus } from './ImageOperationStatus'
|
||||
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
||||
import type { RuntimeChecklist } from '../../shared/runtime-checklist'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
AgentEvent,
|
||||
AgentQuestionAnswer,
|
||||
KnowledgeSearchReference
|
||||
@@ -378,10 +377,6 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
: subagent.routingMode === 'native'
|
||||
? t('chat.subagents.native')
|
||||
: t('chat.subagents.manual')
|
||||
const sourceAndMode =
|
||||
actor.kind === 'direct-model' && subagent.workMode
|
||||
? `${source} · ${subagent.workMode === 'execute' ? 'Execute' : 'Ask'}`
|
||||
: source
|
||||
const progress = subagent.progress?.filter((block, index, blocks) =>
|
||||
!(
|
||||
subagent.state === 'completed' &&
|
||||
@@ -412,7 +407,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</span>
|
||||
)}
|
||||
<small className="subagent-status-card__source">
|
||||
{sourceAndMode}
|
||||
{source}
|
||||
</small>
|
||||
</span>
|
||||
<span
|
||||
@@ -552,12 +547,6 @@ type ChatMessageRowProps = {
|
||||
reference: KnowledgeSearchReference
|
||||
) => Promise<void>
|
||||
onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void
|
||||
onRespondApproval: (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision
|
||||
) => Promise<void>
|
||||
onRespondQuestion: (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
@@ -586,7 +575,6 @@ function ChatMessageRowView({
|
||||
onOpenCitationContext,
|
||||
onOpenCitationSource,
|
||||
onOpenImage,
|
||||
onRespondApproval,
|
||||
onRespondQuestion,
|
||||
onRetry,
|
||||
retryContent
|
||||
@@ -1164,64 +1152,6 @@ function ChatMessageRowView({
|
||||
<code>{message.approval.argumentSummary}</code>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="approval-card__deny"
|
||||
onClick={() =>
|
||||
void onRespondApproval(
|
||||
conversationId,
|
||||
message.id,
|
||||
message.approval!.id,
|
||||
'deny'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.approval.deny')}
|
||||
</button>
|
||||
<button
|
||||
className="approval-card__allow"
|
||||
onClick={() =>
|
||||
void onRespondApproval(
|
||||
conversationId,
|
||||
message.id,
|
||||
message.approval!.id,
|
||||
'once'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.approval.once')}
|
||||
</button>
|
||||
<button
|
||||
className="approval-card__allow"
|
||||
onClick={() =>
|
||||
void onRespondApproval(
|
||||
conversationId,
|
||||
message.id,
|
||||
message.approval!.id,
|
||||
'session'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.approval.session')}
|
||||
</button>
|
||||
{message.approval.allowPermanent && (
|
||||
<button
|
||||
className="approval-card__allow"
|
||||
onClick={() =>
|
||||
void onRespondApproval(
|
||||
conversationId,
|
||||
message.id,
|
||||
message.approval!.id,
|
||||
'permanent'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.approval.permanent')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{question && !orderedQuestionIds.has(question.questionId) && renderQuestion(question.questionId)}
|
||||
@@ -1400,12 +1330,6 @@ type ChatTimelineProps = {
|
||||
reference: KnowledgeSearchReference
|
||||
) => Promise<void>
|
||||
onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void
|
||||
onRespondApproval: (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision
|
||||
) => Promise<void>
|
||||
onRespondQuestion: (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
@@ -1445,7 +1369,6 @@ export const ChatTimeline = memo(function ChatTimeline({
|
||||
onOpenCitationContext,
|
||||
onOpenCitationSource,
|
||||
onOpenImage,
|
||||
onRespondApproval,
|
||||
onRespondQuestion,
|
||||
onRetry,
|
||||
onRevealEarlier,
|
||||
@@ -1472,7 +1395,6 @@ export const ChatTimeline = memo(function ChatTimeline({
|
||||
onOpenCitationContext,
|
||||
onOpenCitationSource,
|
||||
onOpenImage,
|
||||
onRespondApproval,
|
||||
onRespondQuestion,
|
||||
onRetry,
|
||||
renderAssistantHtml,
|
||||
@@ -1566,7 +1488,6 @@ export const ChatTimeline = memo(function ChatTimeline({
|
||||
onOpenCitationContext={onOpenCitationContext}
|
||||
onOpenCitationSource={onOpenCitationSource}
|
||||
onOpenImage={onOpenImage}
|
||||
onRespondApproval={onRespondApproval}
|
||||
onRespondQuestion={onRespondQuestion}
|
||||
onRetry={onRetry}
|
||||
renderAssistantHtml={renderAssistantHtml}
|
||||
|
||||
@@ -37,7 +37,6 @@ function setup() {
|
||||
conversationHint: "",
|
||||
conversationId: "a",
|
||||
conversationStore: stores.conversations,
|
||||
effectiveWorkMode: "ask",
|
||||
executionRunning: false,
|
||||
externalInstances: [],
|
||||
fileSelectionProgress: undefined,
|
||||
@@ -74,7 +73,6 @@ function setup() {
|
||||
voiceListening: false,
|
||||
voiceRecording: false,
|
||||
workspaceView: "chat",
|
||||
workModeOptions: [],
|
||||
};
|
||||
const renders = vi.fn();
|
||||
const view = render(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
FileText,
|
||||
Library,
|
||||
LoaderCircle,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
Paperclip,
|
||||
RefreshCw,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Square,
|
||||
@@ -42,7 +40,6 @@ import {
|
||||
import type {
|
||||
AssistantArtifact,
|
||||
ConversationQueueItem,
|
||||
InteractiveWorkMode,
|
||||
} from "../../shared/assistant-contracts";
|
||||
import type { ExternalKnowledgeInstanceSummary } from "../../shared/external-knowledge-contracts";
|
||||
import { maximumAttachmentsPerMessage } from "../../shared/attachment-limits";
|
||||
@@ -96,7 +93,6 @@ export type ComposerProps = {
|
||||
conversationHint: string;
|
||||
conversationId: string;
|
||||
conversationStore: ConversationStore;
|
||||
effectiveWorkMode: InteractiveWorkMode;
|
||||
executionRunning: boolean;
|
||||
externalInstances: readonly ExternalKnowledgeInstanceSummary[];
|
||||
fileSelectionProgress: ContextFileSelectionProgress | undefined;
|
||||
@@ -135,7 +131,6 @@ export type ComposerProps = {
|
||||
voiceRecording: boolean;
|
||||
/** The workspace view; switching views closes the composer popups. */
|
||||
workspaceView: string;
|
||||
workModeOptions: readonly ComposerMenuOption<InteractiveWorkMode>[];
|
||||
};
|
||||
|
||||
const composerContextErrorId = "composer-context-error";
|
||||
@@ -164,7 +159,6 @@ export const Composer = memo(function Composer({
|
||||
conversationHint,
|
||||
conversationId,
|
||||
conversationStore,
|
||||
effectiveWorkMode,
|
||||
executionRunning,
|
||||
externalInstances,
|
||||
fileSelectionProgress,
|
||||
@@ -202,7 +196,6 @@ export const Composer = memo(function Composer({
|
||||
voiceListening,
|
||||
voiceRecording,
|
||||
workspaceView,
|
||||
workModeOptions,
|
||||
}: ComposerProps): React.JSX.Element {
|
||||
const { t } = useTranslation("app");
|
||||
const view = useComposerConversationView(conversationStore, conversationId);
|
||||
@@ -243,11 +236,6 @@ export const Composer = memo(function Composer({
|
||||
? { menu: "expert", runtimeMenuOpen: false, knowledgeScopeOpen: false }
|
||||
: { menu: undefined });
|
||||
}, [setMenus]);
|
||||
const setModeMenuOpen = useCallback((open: boolean): void => {
|
||||
setMenus(open
|
||||
? { menu: "mode", runtimeMenuOpen: false, optionsOpen: false, knowledgeScopeOpen: false }
|
||||
: { menu: undefined });
|
||||
}, [setMenus]);
|
||||
const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => {
|
||||
setMenus(open
|
||||
? { menu: "runtime-agent", runtimeMenuOpen: false, knowledgeScopeOpen: false }
|
||||
@@ -335,7 +323,7 @@ export const Composer = memo(function Composer({
|
||||
};
|
||||
const frame = requestAnimationFrame(() => {
|
||||
// Keyboard users land on the same first control as the model picker
|
||||
// (expert / work mode menus); optional switches above stay Tab-reachable.
|
||||
// and expert menu; optional switches above stay Tab-reachable.
|
||||
composerOptionsRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
|
||||
});
|
||||
document.addEventListener("pointerdown", dismissOutside);
|
||||
@@ -530,10 +518,6 @@ export const Composer = memo(function Composer({
|
||||
<div className="composer">
|
||||
<ImageCapabilityNotice
|
||||
runtime={runtime}
|
||||
workMode={effectiveWorkMode}
|
||||
hasCallableImageModels={runtimeSettings?.modelProfiles.some(profile =>
|
||||
profile.protocol === "openai-images-generations" && profile.allowConversationInvocation === true
|
||||
) ?? false}
|
||||
onOpenModelSettings={actions.openModelSettings}
|
||||
/>
|
||||
{composerOptionSummary && (
|
||||
@@ -1159,24 +1143,6 @@ export const Composer = memo(function Composer({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.modeLabel")}
|
||||
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
|
||||
disabled={isRunning}
|
||||
icon={
|
||||
effectiveWorkMode === "execute" ? (
|
||||
<ShieldCheck aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<CircleHelp aria-hidden="true" size={15} />
|
||||
)
|
||||
}
|
||||
menuOpen={composerMenuOpen === "mode"}
|
||||
onChange={actions.setWorkMode}
|
||||
onOpenChange={setModeMenuOpen}
|
||||
options={workModeOptions}
|
||||
triggerLabel={effectiveWorkMode === "execute" ? "Execute" : "Ask"}
|
||||
value={effectiveWorkMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="composer__submit-actions">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChevronDown } from "lucide-react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// A composer picker (expert, work mode, runtime agent/preset/action) with a
|
||||
// A composer picker (expert, runtime agent/preset/action) with a
|
||||
// roving-focus menu. Moved out of App.tsx unchanged.
|
||||
|
||||
export type ComposerMenuOption<T extends string> = {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { memo, useEffect, useMemo, useState, type ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
AssistantProject,
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
InteractiveWorkMode,
|
||||
} from "../../shared/assistant-contracts";
|
||||
import { normalizeInteractiveWorkMode } from "../../shared/assistant-contracts";
|
||||
import { ChatHistoryPane } from "./ChatHistoryPane";
|
||||
import type { Conversation } from "./chat-conversation";
|
||||
import { useConversation, type ConversationStore } from "./conversation-store";
|
||||
@@ -48,9 +45,6 @@ export type ConversationHistorySlotProps = PaneProps & {
|
||||
/** Artifacts are read here, so a new result re-renders the panes, not App. */
|
||||
taskStore: TaskStore;
|
||||
loadHistory: (conversationId: string) => Promise<Conversation>;
|
||||
/** The active conversation's effective mode; others use their own or the project's. */
|
||||
workModeOverride?: InteractiveWorkMode;
|
||||
projects: readonly AssistantProject[];
|
||||
tasks: AssistantTask[];
|
||||
schedules: AssistantSchedule[];
|
||||
selectedAssistantTaskId?: string;
|
||||
@@ -71,8 +65,6 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
|
||||
store,
|
||||
taskStore,
|
||||
loadHistory,
|
||||
workModeOverride,
|
||||
projects,
|
||||
tasks,
|
||||
schedules,
|
||||
selectedAssistantTaskId,
|
||||
@@ -85,10 +77,6 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
|
||||
const conversation = useConversation(store, conversationId);
|
||||
const artifactById = useArtifactById(taskStore);
|
||||
const remote = Boolean(conversation?.remote);
|
||||
const conversationMode = workModeOverride ?? normalizeInteractiveWorkMode(
|
||||
conversation?.workMode ??
|
||||
projects.find(project => project.id === conversation?.projectId)?.defaultWorkMode,
|
||||
);
|
||||
const selectedTaskId = tasks.some(task => task.id === selectedAssistantTaskId)
|
||||
? selectedAssistantTaskId : undefined;
|
||||
const messages = conversation?.messages;
|
||||
@@ -98,7 +86,6 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
|
||||
<div className="conversation-context-strips">
|
||||
{!remote && (
|
||||
<ConversationTaskStrip
|
||||
conversationMode={conversationMode}
|
||||
locale={locale}
|
||||
onRemoveSchedule={onRemoveSchedule}
|
||||
onRunSchedule={onRunSchedule}
|
||||
@@ -111,7 +98,7 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
|
||||
)}
|
||||
<RuntimeChecklistStrip messages={messages} activeMessageId={activeMessageId} />
|
||||
</div>
|
||||
), [remote, conversationMode, locale, onRemoveSchedule, onRunSchedule, onSelectTask,
|
||||
), [remote, locale, onRemoveSchedule, onRunSchedule, onSelectTask,
|
||||
onSetScheduleEnabled, schedules, selectedTaskId, tasks, messages, activeMessageId]);
|
||||
if (!conversation) return null;
|
||||
if (conversation.messageSummary) {
|
||||
|
||||
@@ -24,7 +24,6 @@ describe('ConversationTaskStrip', () => {
|
||||
|
||||
render(
|
||||
<ConversationTaskStrip
|
||||
conversationMode="execute"
|
||||
locale="zh-CN"
|
||||
onRemoveSchedule={onRemoveSchedule}
|
||||
onRunSchedule={onRunSchedule}
|
||||
@@ -37,7 +36,6 @@ describe('ConversationTaskStrip', () => {
|
||||
conversationId,
|
||||
title: '每日状态',
|
||||
prompt: '汇总状态',
|
||||
workMode: 'execute',
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2026-08-20T09:00:00.000Z',
|
||||
enabled: true,
|
||||
@@ -66,7 +64,7 @@ describe('ConversationTaskStrip', () => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '新建任务' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Execute')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^(Ask|Execute|模式)$/u)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '立即运行' }))
|
||||
await waitFor(() =>
|
||||
expect(onRunSchedule).toHaveBeenCalledWith(scheduleId)
|
||||
@@ -111,47 +109,24 @@ describe('ConversationTaskStrip', () => {
|
||||
})
|
||||
|
||||
it.each(['running', 'waiting_approval', 'completed', 'failed', 'interrupted'] as const)(
|
||||
'shows the actual %s Task mode, not the schedule or current conversation mode',
|
||||
'shows %s Task details without mode metadata',
|
||||
(status) => {
|
||||
const { task, schedule, props } = modeFixtures()
|
||||
const { task, schedule, props } = taskFixtures()
|
||||
render(
|
||||
<ConversationTaskStrip
|
||||
{...props}
|
||||
conversationMode="execute"
|
||||
schedules={[schedule]}
|
||||
tasks={[{ ...task, status, workMode: 'ask' }]}
|
||||
tasks={[{ ...task, status }]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Ask')).toBeInTheDocument()
|
||||
expect(screen.getByText(task.title, { selector: 'strong' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Ask')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('tracks current conversation mode for an unrun schedule instead of its legacy mode', () => {
|
||||
const { task, schedule, props } = modeFixtures()
|
||||
const { rerender } = render(
|
||||
<ConversationTaskStrip
|
||||
{...props}
|
||||
conversationMode="ask"
|
||||
schedules={[schedule]}
|
||||
tasks={[task]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Ask')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
|
||||
rerender(
|
||||
<ConversationTaskStrip
|
||||
{...props}
|
||||
conversationMode="execute"
|
||||
schedules={[{ ...schedule, workMode: 'ask' }]}
|
||||
tasks={[task]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Execute')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not invent a mode when the conversation or historical Task mode is unavailable', () => {
|
||||
const { task, schedule, props } = modeFixtures()
|
||||
it('omits mode metadata for unrun and completed scheduled tasks', () => {
|
||||
const { task, schedule, props } = taskFixtures()
|
||||
const { rerender } = render(
|
||||
<ConversationTaskStrip {...props} schedules={[schedule]} tasks={[task]} />
|
||||
)
|
||||
@@ -160,9 +135,8 @@ describe('ConversationTaskStrip', () => {
|
||||
rerender(
|
||||
<ConversationTaskStrip
|
||||
{...props}
|
||||
conversationMode="execute"
|
||||
schedules={[schedule]}
|
||||
tasks={[{ ...task, status: 'completed', workMode: undefined }]}
|
||||
tasks={[{ ...task, status: 'completed' }]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
|
||||
@@ -170,15 +144,15 @@ describe('ConversationTaskStrip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function modeFixtures() {
|
||||
function taskFixtures() {
|
||||
const task: AssistantTask = {
|
||||
id: 'task', conversationId: 'conversation', scheduleId: 'schedule',
|
||||
title: '模式回归', instructions: '', origin: 'schedule', status: 'idle',
|
||||
workMode: 'execute', createdAt: '2026-08-19T00:00:00.000Z'
|
||||
createdAt: '2026-08-19T00:00:00.000Z'
|
||||
}
|
||||
const schedule: AssistantSchedule = {
|
||||
id: 'schedule', taskId: task.id, conversationId: 'conversation',
|
||||
title: task.title, prompt: '', workMode: 'execute', recurrence: 'daily',
|
||||
title: task.title, prompt: '', recurrence: 'daily',
|
||||
nextRunAt: '2026-08-20T09:00:00.000Z', enabled: true,
|
||||
createdAt: task.createdAt, updatedAt: task.createdAt
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
WorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
findTaskSchedule,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
} from './TaskScheduleActions'
|
||||
|
||||
type ConversationTaskStripProps = {
|
||||
conversationMode?: WorkMode
|
||||
locale: string
|
||||
onRemoveSchedule: (scheduleId: string) => Promise<void>
|
||||
onRunSchedule: (scheduleId: string) => Promise<void>
|
||||
@@ -32,7 +30,6 @@ type ConversationTaskStripProps = {
|
||||
}
|
||||
|
||||
export const ConversationTaskStrip = memo(function ConversationTaskStrip({
|
||||
conversationMode,
|
||||
locale,
|
||||
onRemoveSchedule,
|
||||
onRunSchedule,
|
||||
@@ -54,17 +51,6 @@ export const ConversationTaskStrip = memo(function ConversationTaskStrip({
|
||||
const selectedSchedule = selectedTask
|
||||
? findTaskSchedule(selectedTask, schedules)
|
||||
: undefined
|
||||
const unrunSchedule =
|
||||
selectedSchedule &&
|
||||
!selectedSchedule.lastRunAt &&
|
||||
!selectedTask?.startedAt &&
|
||||
!selectedTask?.completedAt &&
|
||||
(selectedTask?.status === 'idle' ||
|
||||
selectedTask?.status === 'queued' ||
|
||||
selectedTask?.status === 'paused')
|
||||
const selectedMode = unrunSchedule
|
||||
? conversationMode
|
||||
: selectedTask?.workMode
|
||||
const expanded =
|
||||
manuallyExpanded ||
|
||||
(Boolean(selectedTaskId) && collapsedTaskId !== selectedTaskId)
|
||||
@@ -147,14 +133,6 @@ export const ConversationTaskStrip = memo(function ConversationTaskStrip({
|
||||
)}
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{t('task.fields.mode')}</dt>
|
||||
<dd>
|
||||
{selectedMode
|
||||
? t(`task.mode.${selectedMode}`)
|
||||
: t('task.mode.unavailable')}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('task.fields.schedule')}</dt>
|
||||
<dd>
|
||||
|
||||
@@ -128,7 +128,6 @@ function createProps(
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Workspace'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'user',
|
||||
builtInDefault: true,
|
||||
status: 'active',
|
||||
|
||||
@@ -23,6 +23,7 @@ import { defaultSupervisionTimeoutSeconds, supervisionTimeoutSecondsSchema, defa
|
||||
import { SupervisionReviewSettings, type SupervisionSettingsSection } from './SupervisionReviewSettings'
|
||||
import { SupervisorWorkspace, type SupervisionGraphNavigation } from './SupervisorWorkspace'
|
||||
import { SupervisorActivity } from './SupervisorActivity'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import './supervisor-workspace.css'
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { useWorkspaceUnsavedChanges } from './workspace-unsaved-changes'
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
} from './WorkspacePrimitives'
|
||||
|
||||
export type HeartbeatCenterProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
runtimeSettings?: RuntimeSettings
|
||||
applicationSettings?: ApplicationSettings
|
||||
applicationSettingsPending?: boolean
|
||||
@@ -139,11 +141,7 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element
|
||||
onTabChange={setPageTab}
|
||||
projects={props.projects}
|
||||
onOpenConversation={props.onOpenConversation}
|
||||
onOpenActivity={() => {
|
||||
setActivityPlanId('')
|
||||
setPageTab('activity')
|
||||
window.requestAnimationFrame(() => centerRef.current?.querySelector<HTMLElement>('#supervisor-tab-activity')?.focus())
|
||||
}}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</div>
|
||||
{pageTab === 'activity' && <SupervisorActivity
|
||||
|
||||
@@ -9,13 +9,13 @@ const runtime: AgentRuntimeStatus = { id: 'model', label: 'Chat', available: tru
|
||||
describe('conversation image capability guidance', () => {
|
||||
it('explains the existing direct image workflow and links to configuration without selecting a model', () => {
|
||||
const onOpenModelSettings = vi.fn()
|
||||
const view = render(<ImageCapabilityNotice runtime={runtime} workMode="ask" hasCallableImageModels={false} onOpenModelSettings={onOpenModelSettings} />)
|
||||
const view = render(<ImageCapabilityNotice runtime={runtime} onOpenModelSettings={onOpenModelSettings} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('在 Runtime 选择器中选择图片模型')
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }))
|
||||
expect(onOpenModelSettings).toHaveBeenCalledOnce()
|
||||
view.rerender(<ImageCapabilityNotice runtime={{ ...runtime, supportsToolExecution: true }} workMode="ask" hasCallableImageModels onOpenModelSettings={onOpenModelSettings} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('生成或编辑图片需要 Execute 模式')
|
||||
view.rerender(<ImageCapabilityNotice runtime={{ ...runtime, supportsToolExecution: true }} onOpenModelSettings={onOpenModelSettings} />)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('conversation image capability guidance', () => {
|
||||
{ ...runtime, capability: 'image-generation' as const },
|
||||
{ ...runtime, supportsToolExecution: true },
|
||||
])('does not claim missing tool capability for loading, unavailable, image, or tool-capable runtimes: %j', current => {
|
||||
const { container } = render(<ImageCapabilityNotice runtime={current} workMode="execute" hasCallableImageModels onOpenModelSettings={vi.fn()} />)
|
||||
const { container } = render(<ImageCapabilityNotice runtime={current} onOpenModelSettings={vi.fn()} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AgentRuntimeStatus } from '../../shared/contracts'
|
||||
|
||||
export function ImageCapabilityNotice({ runtime, workMode, hasCallableImageModels, onOpenModelSettings }: {
|
||||
export function ImageCapabilityNotice({ runtime, onOpenModelSettings }: {
|
||||
runtime?: AgentRuntimeStatus
|
||||
workMode: 'ask' | 'execute'
|
||||
hasCallableImageModels: boolean
|
||||
onOpenModelSettings: () => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t } = useTranslation('app')
|
||||
if (!runtime?.available || runtime.capability === 'image-generation') return null
|
||||
const noTools = !runtime.supportsToolExecution
|
||||
if (!noTools && (!hasCallableImageModels || workMode !== 'ask')) return null
|
||||
if (runtime.supportsToolExecution) return null
|
||||
return (
|
||||
<div className="composer__option-summary" role="status">
|
||||
<span>{t(noTools ? 'chat.images.noToolsNotice' : 'chat.images.askNotice')}</span>
|
||||
{noTools && (
|
||||
<>
|
||||
{' '}
|
||||
<span>{t('chat.images.directWorkflowNotice')}</span>
|
||||
<div className="message-image-actions">
|
||||
<button type="button" onClick={onOpenModelSettings}>{t('chat.images.openModelSettings')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span>{t('chat.images.noToolsNotice')}</span>
|
||||
{' '}
|
||||
<span>{t('chat.images.directWorkflowNotice')}</span>
|
||||
<div className="message-image-actions">
|
||||
<button type="button" onClick={onOpenModelSettings}>{t('chat.images.openModelSettings')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -428,7 +428,6 @@ export function McpSettingsSection({
|
||||
const webSearch = snapshot?.webSearch ?? {
|
||||
provider: 'exa' as const,
|
||||
enabled: true,
|
||||
availableIn: ['ask', 'execute'] as const,
|
||||
tools: ['web_search', 'web_fetch'] as const
|
||||
}
|
||||
|
||||
@@ -755,8 +754,6 @@ export function McpSettingsSection({
|
||||
? t('mcp.builtin.serverSummaryDisabled')
|
||||
: server.id === 'obsidian'
|
||||
? t('mcp.obsidian.access')
|
||||
: server.id === 'builtin-browser'
|
||||
? t('mcp.builtin.serverSummaryExecuteOnly')
|
||||
: server.access === 'mixed'
|
||||
? t('mcp.builtin.serverSummaryMixed')
|
||||
: t('mcp.builtin.serverSummaryReadOnly')}
|
||||
|
||||
@@ -30,6 +30,8 @@ import { createConversationStores } from './conversation-store'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
|
||||
const menuProps = {
|
||||
onRestore: vi.fn(async () => undefined),
|
||||
notify: vi.fn(),
|
||||
conversationStore: createConversationStores([], { flushIntervalMs: 250 }).conversations,
|
||||
activities: [] as ConversationActivity[],
|
||||
onOpenConversation: vi.fn(),
|
||||
@@ -78,7 +80,6 @@ const project: AssistantProject = {
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Workspace'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId } },
|
||||
kind: 'user',
|
||||
status: 'active',
|
||||
@@ -503,7 +504,7 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('edits an ordinary project to use DeepSeek Harness and switches work mode by keyboard', async () => {
|
||||
it('edits an ordinary project to use DeepSeek Harness without mode controls', async () => {
|
||||
const { onUpdate } = renderSwitcher({
|
||||
...project, rootPath: '', executionSpace: { kind: 'local', rootPath: '' }
|
||||
})
|
||||
@@ -516,20 +517,7 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
fireEvent.change(within(dialog).getByLabelText('执行方式'), {
|
||||
target: { value: 'deepseek-harness' }
|
||||
})
|
||||
const modeGroup = within(dialog)
|
||||
.getAllByRole('group', { name: '默认模式' })
|
||||
.find((candidate) =>
|
||||
candidate.classList.contains('segmented-control')
|
||||
)!
|
||||
const ask = within(modeGroup).getByRole('button', {
|
||||
name: 'Ask · 只读问答'
|
||||
})
|
||||
const execute = within(modeGroup).getByRole('button', {
|
||||
name: 'Execute · 完全权限'
|
||||
})
|
||||
fireEvent.keyDown(ask, { key: 'ArrowRight' })
|
||||
expect(execute).toHaveFocus()
|
||||
expect(execute).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(within(dialog).queryByRole('group', { name: '默认模式' })).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '保存项目' })
|
||||
)
|
||||
@@ -538,14 +526,13 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
project.id,
|
||||
expect.objectContaining({
|
||||
rootPath: '',
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: { provider: 'deepseek-harness' }
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('localizes shared runtime and work mode fields in English', async () => {
|
||||
it('localizes shared runtime fields and omits work mode in English', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
renderSwitcher()
|
||||
|
||||
@@ -556,12 +543,8 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
expect(within(dialog).getByLabelText('Root folder')).toHaveAttribute('aria-required', 'true')
|
||||
expect(within(dialog).getByLabelText('Root folder')).toHaveAccessibleDescription('Required: select a root folder to create the project.')
|
||||
expect(
|
||||
within(dialog)
|
||||
.getAllByRole('group', { name: 'Default mode' })
|
||||
.find((candidate) =>
|
||||
candidate.classList.contains('segmented-control')
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
within(dialog).queryByRole('group', { name: 'Default mode' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('option', { name: 'DeepSeek Harness' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1338,7 +1321,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
expect(input).toHaveValue('/typed/path')
|
||||
})
|
||||
|
||||
it.each(['opencode', 'continue'])('saves %s Execute in one request without extra confirmation checklists', async (provider) => {
|
||||
it.each(['opencode', 'continue'])('saves %s in one request without mode controls', async (provider) => {
|
||||
const api = installRemoteApi()
|
||||
const { onRemoteCommitted } = renderSwitcher()
|
||||
openCreate('新建项目')
|
||||
@@ -1360,16 +1343,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
fireEvent.change(within(dialog).getByRole('combobox', { name: '执行方式' }), {
|
||||
target: { value: provider }
|
||||
})
|
||||
const modeGroup = within(dialog)
|
||||
.getAllByRole('group', { name: '默认模式' })
|
||||
.find((candidate) =>
|
||||
candidate.classList.contains('segmented-control')
|
||||
)!
|
||||
fireEvent.click(
|
||||
within(modeGroup).getByRole('button', {
|
||||
name: 'Execute · 完全权限'
|
||||
})
|
||||
)
|
||||
expect(within(dialog).queryByRole('group', { name: '默认模式' })).not.toBeInTheDocument()
|
||||
const save = within(dialog).getByRole('button', {
|
||||
name: '保存远程项目'
|
||||
})
|
||||
@@ -1380,7 +1354,6 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
expect(api.save).toHaveBeenCalledWith({
|
||||
intent: 'create',
|
||||
draft: expect.objectContaining({
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: { provider },
|
||||
hostId,
|
||||
remoteRootPath: '/srv/project'
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
ProjectCreateInput
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannelLabels
|
||||
} from '../../shared/assistant-contracts'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
@@ -51,7 +50,6 @@ import { getProjectDisplayText } from './project-display'
|
||||
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
|
||||
import { ProjectActivityCounts } from './ProjectActivity'
|
||||
import type { ProjectActivityCountsProps } from './ProjectActivity'
|
||||
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
|
||||
import { ChannelIcon } from './ChannelIcon'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
@@ -59,6 +57,7 @@ import { WorkspaceMenu } from './WorkspaceMenu'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import type { ConversationStore } from './conversation-store'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
|
||||
type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
@@ -72,6 +71,8 @@ type ProjectSwitcherProps = {
|
||||
remoteProjectsEnabled?: boolean
|
||||
runtimeSettings?: RuntimeSettings
|
||||
onArchive: (projectId: string) => Promise<void>
|
||||
onRestore: (projectId: string) => Promise<void>
|
||||
notify: (input: AppNotificationInput) => void
|
||||
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
|
||||
onDelete: (projectId: string, confirmation: string) => Promise<void>
|
||||
onSelect: (projectId: string) => void
|
||||
@@ -230,6 +231,8 @@ function ProjectSwitcherView({
|
||||
remoteProjectsEnabled = false,
|
||||
runtimeSettings,
|
||||
onArchive,
|
||||
onRestore,
|
||||
notify,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onRemoteCommitted,
|
||||
@@ -431,7 +434,6 @@ function ProjectSwitcherView({
|
||||
name: '',
|
||||
description: '',
|
||||
rootPath: '',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
const activeProject = projects.find(
|
||||
(project) => project.id === activeProjectId
|
||||
@@ -847,9 +849,6 @@ function ProjectSwitcherView({
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
rootPath: project.rootPath,
|
||||
defaultWorkMode: normalizeInteractiveWorkMode(
|
||||
project.defaultWorkMode
|
||||
),
|
||||
// Keep "follow global" as absent so saving never freezes today's default.
|
||||
runtimeSelection: project.runtimeSelection
|
||||
}
|
||||
@@ -899,23 +898,19 @@ function ProjectSwitcherView({
|
||||
hostId: remoteHostId,
|
||||
remoteRootPath
|
||||
}
|
||||
const modeDraft = {
|
||||
...commonDraft,
|
||||
defaultWorkMode: draft.defaultWorkMode
|
||||
}
|
||||
const result =
|
||||
await window.goodbuddy.projects.remote.save(
|
||||
dialogMode === 'settings' && settingsProject
|
||||
? {
|
||||
intent: 'update',
|
||||
draft: {
|
||||
...modeDraft,
|
||||
...commonDraft,
|
||||
projectId: settingsProject.id
|
||||
}
|
||||
}
|
||||
: {
|
||||
intent: 'create',
|
||||
draft: modeDraft
|
||||
draft: commonDraft
|
||||
}
|
||||
)
|
||||
await onRemoteCommitted(result)
|
||||
@@ -1175,6 +1170,7 @@ function ProjectSwitcherView({
|
||||
hosts={sshHosts?.hosts ?? emptyHosts} connectionStates={agentConnectionStatusByHostId}
|
||||
anchorRef={projectPickerButtonRef} controlsRef={projectPickerRef}
|
||||
renderProject={renderProjectMenuItem} onClose={closeProjectMenu}
|
||||
onEnterProject={onSelect} onRestore={onRestore} notify={notify}
|
||||
onNewConversation={onNewConversation} onOpenConversation={onOpenConversation}
|
||||
onCreateProject={() => {
|
||||
setProjectMenuOpen(false)
|
||||
@@ -1188,7 +1184,6 @@ function ProjectSwitcherView({
|
||||
name: '',
|
||||
description: '',
|
||||
rootPath: '',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
restoreFocusTarget.current = 'create'
|
||||
setDialogMode('create')
|
||||
@@ -1371,26 +1366,6 @@ function ProjectSwitcherView({
|
||||
{t('projectSwitcher.dialog.rootRequired')}
|
||||
</small>
|
||||
)}
|
||||
<ProjectWorkModeFields
|
||||
ariaLabel={t(
|
||||
'projectSwitcher.dialog.fields.defaultMode'
|
||||
)}
|
||||
disabled={busy}
|
||||
labels={{
|
||||
ask: t('projectSwitcher.workModes.ask'),
|
||||
execute: t('projectSwitcher.workModes.execute')
|
||||
}}
|
||||
legend={t(
|
||||
'projectSwitcher.dialog.fields.defaultMode'
|
||||
)}
|
||||
onChange={(defaultWorkMode) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
defaultWorkMode
|
||||
}))
|
||||
}
|
||||
value={draft.defaultWorkMode}
|
||||
/>
|
||||
{runtimeSettings && (
|
||||
<>
|
||||
<ProjectRuntimeSelector
|
||||
@@ -1558,26 +1533,6 @@ function ProjectSwitcherView({
|
||||
selection={draft.runtimeSelection}
|
||||
/>
|
||||
)}
|
||||
<ProjectWorkModeFields
|
||||
ariaLabel={t(
|
||||
'projectSwitcher.dialog.fields.defaultMode'
|
||||
)}
|
||||
disabled={remoteFieldsDisabled}
|
||||
labels={{
|
||||
ask: t('projectSwitcher.workModes.ask'),
|
||||
execute: t('projectSwitcher.workModes.execute')
|
||||
}}
|
||||
legend={t(
|
||||
'projectSwitcher.dialog.fields.defaultMode'
|
||||
)}
|
||||
onChange={(defaultWorkMode) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
defaultWorkMode
|
||||
}))
|
||||
}
|
||||
value={draft.defaultWorkMode}
|
||||
/>
|
||||
{remoteSaving && remoteSavePhase && (
|
||||
<RemoteProjectProgress
|
||||
phase={remoteSavePhase}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { WorkMode } from '../../shared/assistant-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
|
||||
export function ProjectWorkModeFields({
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
help,
|
||||
labels,
|
||||
legend,
|
||||
onChange,
|
||||
value
|
||||
}: {
|
||||
ariaLabel: string
|
||||
disabled?: boolean
|
||||
help?: string
|
||||
labels: { ask: string; execute: string }
|
||||
legend: string
|
||||
onChange: (value: WorkMode) => void
|
||||
value: WorkMode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<fieldset className="project-work-mode">
|
||||
<legend>{legend}</legend>
|
||||
<SegmentedControl
|
||||
ariaLabel={ariaLabel}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: 'ask', label: labels.ask },
|
||||
{ value: 'execute', label: labels.execute }
|
||||
]}
|
||||
value={value}
|
||||
/>
|
||||
{help && <small>{help}</small>}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -126,7 +126,6 @@ const currentProject: AssistantProject = {
|
||||
name: '当前项目',
|
||||
description: '',
|
||||
rootPath: 'D:\\project',
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'user',
|
||||
executionSpace: { kind: 'local', rootPath: 'D:\\project' },
|
||||
status: 'active',
|
||||
@@ -138,13 +137,11 @@ function sidebarElement({
|
||||
open = true,
|
||||
tab = 'tasks',
|
||||
approvals = [],
|
||||
artifacts = [],
|
||||
onListWorkspaceDirectory = vi.fn(async (path: string) => ({
|
||||
path,
|
||||
entries: [],
|
||||
truncated: false
|
||||
})),
|
||||
onLoadArtifact = vi.fn(async () => undefined),
|
||||
onLoadWorkspaceFile = vi.fn(),
|
||||
tasks = [],
|
||||
activeProject = currentProject,
|
||||
@@ -165,7 +162,6 @@ function sidebarElement({
|
||||
onStopLoadingBrowser,
|
||||
onOpenTask = vi.fn(),
|
||||
onCreateCustomTask = vi.fn(),
|
||||
onRespondApproval = vi.fn(),
|
||||
onTabChange = vi.fn()
|
||||
}: {
|
||||
open?: boolean
|
||||
@@ -207,7 +203,6 @@ function sidebarElement({
|
||||
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
|
||||
onOpenTask?: (task: AssistantTask) => void
|
||||
onCreateCustomTask?: () => void
|
||||
onRespondApproval?: React.ComponentProps<typeof RightAssistantSidebar>['onRespondApproval']
|
||||
onTabChange?: (tab: AssistantSidebarTab) => void
|
||||
} = {}): React.JSX.Element {
|
||||
return (
|
||||
@@ -222,7 +217,6 @@ function sidebarElement({
|
||||
conversationStats={conversationStats}
|
||||
taskDurations={taskDurations}
|
||||
selectedTaskId={selectedTaskId}
|
||||
artifacts={artifacts}
|
||||
browserStates={browserStates}
|
||||
schedules={[]}
|
||||
tasks={tasks}
|
||||
@@ -231,9 +225,7 @@ function sidebarElement({
|
||||
projectNames={new Map()}
|
||||
onCreateCustomTask={onCreateCustomTask}
|
||||
onBackBrowser={onBackBrowser}
|
||||
onImportArtifacts={vi.fn(async () => undefined)}
|
||||
onListWorkspaceDirectory={onListWorkspaceDirectory}
|
||||
onLoadArtifact={onLoadArtifact}
|
||||
onLoadWorkspaceFile={onLoadWorkspaceFile}
|
||||
onNavigateBrowser={onNavigateBrowser}
|
||||
onLoadWorkspaceDiff={vi.fn()}
|
||||
@@ -241,7 +233,6 @@ function sidebarElement({
|
||||
onRefreshChanges={vi.fn(async () => undefined)}
|
||||
onReloadBrowser={onReloadBrowser}
|
||||
onRemoveSchedule={vi.fn(async () => undefined)}
|
||||
onRespondApproval={onRespondApproval}
|
||||
onRunSchedule={vi.fn(async () => undefined)}
|
||||
onSetScheduleEnabled={vi.fn(async () => undefined)}
|
||||
onStopLoadingBrowser={onStopLoadingBrowser}
|
||||
@@ -351,12 +342,12 @@ describe('RightAssistantSidebar tab titles', () => {
|
||||
const initialTabs = screen.getAllByRole('tab')
|
||||
const savedLayout = localStorage.getItem('goodbuddy.workbar-layout.v1')
|
||||
expect(initialTabs.map((tab) => tab.textContent)).toEqual([
|
||||
'任务中心', '工作区', '浏览器 · 未绑定会话', '成果'
|
||||
'任务中心', '工作区', '浏览器 · 未绑定会话'
|
||||
])
|
||||
|
||||
await act(async () => { await i18n.changeLanguage('en-US') })
|
||||
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
|
||||
'Task center', 'Files', '浏览器 · 未绑定会话', 'Results'
|
||||
'Task center', 'Files', '浏览器 · 未绑定会话'
|
||||
])
|
||||
expect(screen.getAllByRole('tab')).toEqual(initialTabs)
|
||||
expect(screen.getByRole('tab', { name: 'Task center' })).toHaveAttribute('aria-selected', 'true')
|
||||
@@ -364,7 +355,7 @@ describe('RightAssistantSidebar tab titles', () => {
|
||||
|
||||
await act(async () => { await i18n.changeLanguage('zh-CN') })
|
||||
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
|
||||
'任务中心', '工作区', '浏览器 · 未绑定会话', '成果'
|
||||
'任务中心', '工作区', '浏览器 · 未绑定会话'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -389,7 +380,7 @@ describe('RightAssistantSidebar tab titles', () => {
|
||||
expect(screen.getByRole('tab', { name: 'Terminal 1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Terminal 2' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(await screen.findByRole('textbox', { name: 'Test terminal name' })).toHaveValue('Terminal 2')
|
||||
for (const name of ['Task center', 'Files', 'Results']) {
|
||||
for (const name of ['Task center', 'Files']) {
|
||||
expect(screen.getByRole('tab', { name })).toBeInTheDocument()
|
||||
}
|
||||
await act(async () => { await i18n.changeLanguage('zh-CN') })
|
||||
@@ -674,7 +665,7 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('tab').map((tab) => tab.textContent)
|
||||
).toEqual(['任务中心', '工作区', '浏览器 · 未绑定会话', '成果'])
|
||||
).toEqual(['任务中心', '工作区', '浏览器 · 未绑定会话'])
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '预览' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -812,10 +803,9 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
title: '写入工作区', description: '更新 release.md'
|
||||
}
|
||||
const onOpenTask = vi.fn()
|
||||
const onRespondApproval = vi.fn()
|
||||
renderSidebar({
|
||||
tasks: [...tasks, { ...tasks[4]!, id: 'child', title: 'Child task', parentTaskId: 'running' }],
|
||||
approvals: [approval], onOpenTask, onRespondApproval
|
||||
approvals: [approval], onOpenTask
|
||||
})
|
||||
|
||||
const filters = within(screen.getByRole('group', { name: '筛选任务' }))
|
||||
@@ -845,13 +835,11 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(screen.getByLabelText('等待审批: 1')).toBeVisible()
|
||||
expect(screen.getByLabelText('等待审批: 写入工作区')).toBeVisible()
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: '仅此次允许' }))
|
||||
expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'once')
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }))
|
||||
expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'deny')
|
||||
expect(screen.queryByRole('button', { name: '仅此次允许' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '拒绝' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('embeds matched approvals only in active task cards and preserves their actions', () => {
|
||||
it('preserves matched approval records without response actions', () => {
|
||||
const task: AssistantTask = {
|
||||
id: 'scheduled-task', conversationId: 'conversation-1', projectId: currentProject.id,
|
||||
title: 'Scheduled task', instructions: '', origin: 'schedule', status: 'paused',
|
||||
@@ -861,15 +849,12 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
taskId: task.id, conversationId: task.conversationId!, projectId: task.projectId,
|
||||
messageId: 'message-1', approvalId: 'approval-1', title: 'Confirm write', description: 'write file'
|
||||
}
|
||||
const onRespondApproval = vi.fn()
|
||||
renderSidebar({ tasks: [task], approvals: [approval], onRespondApproval })
|
||||
renderSidebar({ tasks: [task], approvals: [approval] })
|
||||
const row = screen.getByText(task.title).closest('article')!
|
||||
expect(within(row).getByLabelText('等待审批: Confirm write')).toBeVisible()
|
||||
expect(screen.queryByRole('heading', { name: '等待审批' })).not.toBeInTheDocument()
|
||||
fireEvent.click(within(row).getByRole('button', { name: '仅此次允许' }))
|
||||
expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'once')
|
||||
fireEvent.click(within(row).getByRole('button', { name: '拒绝' }))
|
||||
expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'deny')
|
||||
expect(within(row).queryByRole('button', { name: '仅此次允许' })).not.toBeInTheDocument()
|
||||
expect(within(row).queryByRole('button', { name: '拒绝' })).not.toBeInTheDocument()
|
||||
for (const filter of ['暂停', '已结束']) {
|
||||
fireEvent.click(screen.getByRole('button', { name: filter }))
|
||||
expect(screen.queryByText(task.title)).not.toBeInTheDocument()
|
||||
@@ -1162,7 +1147,7 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
onCreateCustomTask: vi.fn(), onImportArtifacts: vi.fn(), onLoadArtifact: vi.fn(),
|
||||
onLoadWorkspaceFile: vi.fn(() => new Promise<WorkspaceFilePreview>((resolve) => { resolveRead = resolve })),
|
||||
onLoadWorkspaceDiff: vi.fn(), onOpenWorkspaceEntry: vi.fn(),
|
||||
onRefreshChanges: vi.fn(), onRemoveSchedule: vi.fn(), onRespondApproval: vi.fn(),
|
||||
onRefreshChanges: vi.fn(), onRemoveSchedule: vi.fn(),
|
||||
onRunSchedule: vi.fn(), onSetScheduleEnabled: vi.fn(), onOpenTask: vi.fn(), onTabChange: vi.fn(),
|
||||
onListWorkspaceDirectory: vi.fn(async (path: string) => ({
|
||||
path, entries: [{ name: 'file.txt', path: 'file.txt', type: 'file' as const }], truncated: false
|
||||
@@ -1239,34 +1224,12 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('previews a result without switching to a separate tab', () => {
|
||||
const onLoadArtifact = vi.fn(async () => undefined)
|
||||
renderSidebar({
|
||||
tab: 'results',
|
||||
artifacts: [
|
||||
{
|
||||
id: 'artifact-1',
|
||||
title: '发布说明',
|
||||
content: '# 发布说明',
|
||||
createdAt: Date.now(),
|
||||
mimeType: 'text/markdown'
|
||||
}
|
||||
],
|
||||
onLoadArtifact
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布说明/u }))
|
||||
|
||||
expect(onLoadArtifact).toHaveBeenCalledWith('artifact-1')
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '成果' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回成果列表' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '预览' })
|
||||
).not.toBeInTheDocument()
|
||||
it('removes results from both the tabs and application catalog', () => {
|
||||
renderSidebar()
|
||||
expect(screen.queryByRole('tab', { name: '成果' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
|
||||
expect(screen.queryByText('成果')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '导入成果' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires a committed URL to reload a fresh active browser tab, but still allows Stop', async () => {
|
||||
|
||||
@@ -6,15 +6,13 @@ import {
|
||||
CircleAlert,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Pin,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
Square,
|
||||
Upload
|
||||
Square
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
lazy,
|
||||
@@ -40,7 +38,6 @@ import type { KnowledgeLibrary } from '../../shared/contracts'
|
||||
import type { SupervisionResultView, SupervisionTarget } from '../../shared/supervision-contracts'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
BrowserLiveState,
|
||||
BrowserTabId
|
||||
} from '../../shared/contracts'
|
||||
@@ -83,7 +80,6 @@ export type AssistantSidebarTab =
|
||||
| 'tasks'
|
||||
| 'workspace'
|
||||
| 'browser'
|
||||
| 'results'
|
||||
| 'notes'
|
||||
|
||||
export type SidebarArtifact = {
|
||||
@@ -121,6 +117,7 @@ export type SidebarTaskDuration = {
|
||||
}
|
||||
|
||||
export type RightAssistantSidebarProps = {
|
||||
notify?: (input: import('./notifications').AppNotificationInput) => void
|
||||
notesEnabled?: boolean
|
||||
notesSettingsReady?: boolean
|
||||
notesOpenRequest?: number
|
||||
@@ -130,7 +127,6 @@ export type RightAssistantSidebarProps = {
|
||||
open: boolean
|
||||
tab: AssistantSidebarTab
|
||||
approvals: PendingSidebarApproval[]
|
||||
artifacts: SidebarArtifact[]
|
||||
schedules: AssistantSchedule[]
|
||||
tasks: AssistantTask[]
|
||||
conversationTitles: ReadonlyMap<string, string>
|
||||
@@ -154,8 +150,6 @@ export type RightAssistantSidebarProps = {
|
||||
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
|
||||
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
|
||||
onCreateCustomTask: () => void
|
||||
onImportArtifacts: () => Promise<void>
|
||||
onLoadArtifact: (artifactId: string) => Promise<void>
|
||||
onRefreshChanges: () => Promise<void>
|
||||
onLoadWorkspaceDiff: (path: string) => Promise<WorkspaceChanges>
|
||||
onListWorkspaceDirectory: (
|
||||
@@ -170,10 +164,6 @@ export type RightAssistantSidebarProps = {
|
||||
type: 'file' | 'directory'
|
||||
) => Promise<void>
|
||||
onRemoveSchedule: (scheduleId: string) => Promise<void>
|
||||
onRespondApproval: (
|
||||
approval: PendingSidebarApproval,
|
||||
decision: ApprovalDecision
|
||||
) => void
|
||||
onRunSchedule: (scheduleId: string) => Promise<void>
|
||||
onSetScheduleEnabled: (
|
||||
scheduleId: string,
|
||||
@@ -187,7 +177,6 @@ const tabIds: AssistantSidebarTab[] = [
|
||||
'tasks',
|
||||
'workspace',
|
||||
'browser',
|
||||
'results',
|
||||
'notes'
|
||||
]
|
||||
const emptyChangedFiles: WorkspaceChanges['files'] = []
|
||||
@@ -196,9 +185,8 @@ const minimumPaneWidth = 160
|
||||
const keyboardResizeStep = 16
|
||||
const workbarStorageKey = 'goodbuddy.workbar-layout.v1'
|
||||
|
||||
function SidebarApproval({ approval, onRespondApproval }: {
|
||||
function SidebarApproval({ approval }: {
|
||||
approval: PendingSidebarApproval
|
||||
onRespondApproval: RightAssistantSidebarProps['onRespondApproval']
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
return (
|
||||
@@ -209,14 +197,6 @@ function SidebarApproval({ approval, onRespondApproval }: {
|
||||
<strong>{approval.title}</strong>
|
||||
<p>{approval.description}</p>
|
||||
{approval.toolName && <code>{approval.toolName}</code>}
|
||||
<div className="assistant-sidebar__approval-actions">
|
||||
<button className="secondary-button" onClick={() => onRespondApproval(approval, 'deny')} type="button">
|
||||
{t('sidebar.tasks.deny')}
|
||||
</button>
|
||||
<button className="primary-button" onClick={() => onRespondApproval(approval, 'once')} type="button">
|
||||
{t('sidebar.tasks.allowOnce')}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -818,7 +798,6 @@ function RightAssistantSidebarView({
|
||||
open,
|
||||
tab,
|
||||
approvals,
|
||||
artifacts,
|
||||
schedules,
|
||||
tasks,
|
||||
conversationTitles,
|
||||
@@ -842,15 +821,13 @@ function RightAssistantSidebarView({
|
||||
onReloadBrowser = async () => {},
|
||||
onStopLoadingBrowser = async () => {},
|
||||
onCreateCustomTask,
|
||||
onImportArtifacts,
|
||||
onLoadArtifact,
|
||||
onRefreshChanges,
|
||||
notify,
|
||||
onLoadWorkspaceDiff,
|
||||
onListWorkspaceDirectory,
|
||||
onLoadWorkspaceFile,
|
||||
onOpenWorkspaceEntry,
|
||||
onRemoveSchedule,
|
||||
onRespondApproval,
|
||||
onRunSchedule,
|
||||
onSetScheduleEnabled,
|
||||
onOpenTask,
|
||||
@@ -865,14 +842,6 @@ function RightAssistantSidebarView({
|
||||
const seconds = Math.floor(Math.max(0, durationMs) / 1000)
|
||||
return `${Math.floor(seconds / 3600).toString().padStart(2, '0')}:${Math.floor(seconds / 60 % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
const sidebarTimeFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}),
|
||||
[locale]
|
||||
)
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
tabIds.map((id) => ({
|
||||
@@ -998,7 +967,6 @@ function RightAssistantSidebarView({
|
||||
)
|
||||
const liveSidebarWidth = useRef(sidebarWidth)
|
||||
const resizePointerId = useRef<number | undefined>(undefined)
|
||||
const [selectedArtifactId, setSelectedArtifactId] = useState<string>()
|
||||
const [workspacePreview, setWorkspacePreview] = useState<
|
||||
| {
|
||||
projectId?: string
|
||||
@@ -1081,8 +1049,6 @@ function RightAssistantSidebarView({
|
||||
}>()
|
||||
const terminalCloseCancelRef = useRef<HTMLButtonElement>(null)
|
||||
const lastExternalTabRef = useRef(tab)
|
||||
const artifactPreview =
|
||||
artifacts.find((artifact) => artifact.id === selectedArtifactId)
|
||||
const currentWorkspacePreview =
|
||||
workspacePreview?.projectId === workspaceProjectId
|
||||
? workspacePreview
|
||||
@@ -1857,7 +1823,6 @@ function RightAssistantSidebarView({
|
||||
state: 'ready',
|
||||
file
|
||||
})
|
||||
setSelectedArtifactId(undefined)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -2224,7 +2189,7 @@ function RightAssistantSidebarView({
|
||||
{unassociatedApprovals.length > 0 && <>
|
||||
<h3><ShieldAlert size={15} />{t('sidebar.tasks.approvalsTitle')}</h3>
|
||||
{unassociatedApprovals.map((approval) => (
|
||||
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
|
||||
<SidebarApproval key={approval.approvalId} approval={approval} />
|
||||
))}
|
||||
</>}
|
||||
<div className="task-center__list" id={`project-tasks-${instance.id}`} hidden={!taskListExpanded}>
|
||||
@@ -2286,13 +2251,6 @@ function RightAssistantSidebarView({
|
||||
})
|
||||
: t('sidebar.tasks.unboundProject')}
|
||||
</span>
|
||||
<span>
|
||||
{schedule
|
||||
? t('task.mode.conversation')
|
||||
: task.workMode
|
||||
? t(`task.mode.${task.workMode}`)
|
||||
: t('task.mode.unavailable')}
|
||||
</span>
|
||||
<span>{t(`task.status.${task.status}`)}</span>
|
||||
{duration && <span title={t('sidebar.tasks.stats.taskDuration')}>
|
||||
{t('sidebar.tasks.stats.taskDuration')}: {formatDuration(duration.durationMs)}
|
||||
@@ -2322,7 +2280,7 @@ function RightAssistantSidebarView({
|
||||
: t('sidebar.tasks.notStarted'))}
|
||||
</p>
|
||||
{approvalsByTask.get(task.id)?.map((approval) => (
|
||||
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
|
||||
<SidebarApproval key={approval.approvalId} approval={approval} />
|
||||
))}
|
||||
{schedule && (
|
||||
<div className="task-center__actions">
|
||||
@@ -2482,6 +2440,7 @@ function RightAssistantSidebarView({
|
||||
if (event.target instanceof Element) workspaceTrigger.current = event.target.closest('button') ?? undefined
|
||||
}}>
|
||||
<WorkspaceFilesPanel
|
||||
notify={notify}
|
||||
rootPath={currentProject?.rootPath}
|
||||
onRefresh={onRefreshChanges}
|
||||
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
|
||||
@@ -2499,115 +2458,6 @@ function RightAssistantSidebarView({
|
||||
</>
|
||||
)}
|
||||
|
||||
{instance.appId === 'results' && (
|
||||
artifactPreview ? (
|
||||
<section className="assistant-sidebar__preview">
|
||||
<header>
|
||||
<button
|
||||
aria-label={t('sidebar.results.back')}
|
||||
className="assistant-sidebar__back"
|
||||
onClick={() => {
|
||||
setSelectedArtifactId(undefined)
|
||||
setActionError('')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
{t('sidebar.results.title')}
|
||||
</button>
|
||||
<span>
|
||||
<strong>{artifactPreview.title}</strong>
|
||||
<small>
|
||||
{sidebarTimeFormatter.format(
|
||||
new Date(artifactPreview.createdAt)
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<div className="markdown-body markdown-content">
|
||||
{artifactPreview.mimeType.startsWith('image/') ? (
|
||||
artifactPreview.content ? (
|
||||
<img
|
||||
alt={artifactPreview.title}
|
||||
className="assistant-sidebar__image-preview"
|
||||
src={artifactPreview.content}
|
||||
/>
|
||||
) : (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{t('sidebar.results.loadingImage')}
|
||||
</p>
|
||||
)
|
||||
) : artifactPreview.mimeType === 'text/html' ? (
|
||||
<iframe
|
||||
className="assistant-sidebar__web-preview"
|
||||
sandbox=""
|
||||
srcDoc={artifactPreview.content}
|
||||
title={artifactPreview.title}
|
||||
/>
|
||||
) : artifactPreview.mimeType === 'application/json' ? (
|
||||
<pre>{artifactPreview.content}</pre>
|
||||
) : (
|
||||
<MarkdownRenderer>
|
||||
{artifactPreview.content}
|
||||
</MarkdownRenderer>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="assistant-sidebar__section">
|
||||
<h3>
|
||||
<FileText size={15} />
|
||||
{t('sidebar.results.sectionTitle')}
|
||||
</h3>
|
||||
<button
|
||||
className="secondary-button assistant-sidebar__import"
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
onImportArtifacts,
|
||||
t('sidebar.errors.importResult')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Upload size={13} />
|
||||
{t('sidebar.results.import')}
|
||||
</button>
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{t('sidebar.results.empty')}
|
||||
</p>
|
||||
) : (
|
||||
artifacts.map((artifact) => (
|
||||
<button
|
||||
className="assistant-sidebar__row"
|
||||
key={artifact.id}
|
||||
onClick={() => {
|
||||
setSelectedArtifactId(artifact.id)
|
||||
setActionError('')
|
||||
void runAction(
|
||||
() => onLoadArtifact(artifact.id),
|
||||
t('sidebar.errors.loadResult')
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={15} />
|
||||
<span>
|
||||
<strong>{artifact.title}</strong>
|
||||
<small>
|
||||
{sidebarTimeFormatter.format(
|
||||
new Date(artifact.createdAt)
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
|
||||
{instance.appId === 'browser' && (
|
||||
<section
|
||||
className="assistant-sidebar__browser"
|
||||
|
||||
@@ -214,17 +214,7 @@ const NativeInventory = memo(function NativeInventory({
|
||||
t(
|
||||
`runtime.customization.inventory.toolSource.${tool.source}`,
|
||||
tool.source
|
||||
),
|
||||
t('runtime.customization.inventory.toolModes', {
|
||||
ask: t(
|
||||
`runtime.customization.inventory.toolAccess.${tool.ask}`,
|
||||
tool.ask
|
||||
),
|
||||
execute: t(
|
||||
`runtime.customization.inventory.toolAccess.${tool.execute}`,
|
||||
tool.execute
|
||||
)
|
||||
})
|
||||
)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
@@ -160,7 +160,6 @@ const runtimeSettings: RuntimeSettings = {
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
deepseekHarnessPlatformModel: { source: 'unavailable' },
|
||||
secureStorageAvailable: true,
|
||||
toolApproval: 'always'
|
||||
}
|
||||
|
||||
const getRuntime = vi.fn(async () => runtimeSettings)
|
||||
@@ -274,7 +273,6 @@ const capabilitySnapshot = {
|
||||
webSearch: {
|
||||
provider: 'exa' as const,
|
||||
enabled: true,
|
||||
availableIn: ['ask', 'execute'] as const,
|
||||
tools: ['web_search', 'web_fetch'] as const
|
||||
},
|
||||
computerCapabilities: [
|
||||
@@ -286,7 +284,7 @@ const capabilitySnapshot = {
|
||||
supported: true,
|
||||
browserProfileId: null,
|
||||
riskSummary:
|
||||
'总开关关闭时不会向任何 Runtime 提供浏览器工具;开启后,已分配的 Runtime 可在 Execute 模式直接读取网页并操作网站,不再逐次询问。'
|
||||
'总开关关闭时不会向任何 Runtime 提供浏览器工具;开启后,已分配的 Runtime 可直接读取网页并操作网站。'
|
||||
},
|
||||
{
|
||||
id: 'linux-desktop-control' as const,
|
||||
@@ -1973,7 +1971,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
).not.toHaveClass('capability-card--disabled')
|
||||
)
|
||||
expect(
|
||||
screen.getAllByText('按模式读写')
|
||||
screen.getAllByText('读写')
|
||||
).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -2011,7 +2009,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime',
|
||||
'settings-tab-ssh-hosts',
|
||||
'settings-tab-document-parsing', 'settings-tab-channels', 'settings-tab-roles',
|
||||
'settings-tab-capabilities', 'settings-tab-security', 'settings-tab-about'
|
||||
'settings-tab-capabilities', 'settings-tab-about'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -2920,7 +2918,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '清除本地数据' })
|
||||
)
|
||||
@@ -2979,46 +2977,30 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toHaveFocus()
|
||||
})
|
||||
|
||||
it('explains automatic Execute authorization and the deny-all policy', async () => {
|
||||
it('removes security settings and keeps confirmed data clearing under general settings', async () => {
|
||||
const onClearLocalData = vi.fn(async () => {})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClearLocalData={onClearLocalData}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
|
||||
const policy = await screen.findByLabelText(
|
||||
'直连模型工具安全策略'
|
||||
)
|
||||
expect(policy).toHaveValue('always')
|
||||
expect(
|
||||
within(policy).getByRole('option', {
|
||||
name: 'Execute 自动授权已启用的工具'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(policy).getByRole('option', {
|
||||
name: '禁止所有工具执行'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/选择 Execute 即授权当前交互运行自动调用这些工具/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/禁止策略会拒绝所有工具调用/)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(policy, { target: { value: 'policy' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolApproval: 'policy' })
|
||||
)
|
||||
)
|
||||
expect(screen.queryByRole('tab', { name: '安全与数据' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('直连模型工具安全策略')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
const panel = screen.getByRole('tabpanel', { name: '平台功能' })
|
||||
expect(within(panel).getByText('本地数据与隐私')).toBeVisible()
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '清除本地数据' }))
|
||||
expect(onClearLocalData).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '取消' }))
|
||||
expect(onClearLocalData).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '清除本地数据' }))
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '清除本地数据' }))
|
||||
await waitFor(() => expect(onClearLocalData).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('saves the accessible Subagent smart routing switch', async () => {
|
||||
@@ -3032,7 +3014,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
expect(
|
||||
screen.queryByRole('switch', {
|
||||
name: '启用 Subagent 智能路由'
|
||||
@@ -3043,17 +3025,17 @@ describe('SettingsPanel runtime files', () => {
|
||||
name: '启用 Subagent 智能路由'
|
||||
})
|
||||
expect(smartRouting).not.toBeChecked()
|
||||
expect(screen.getByText(/仅在 Ask 模式/)).not.toBeVisible()
|
||||
expect(smartRouting).toHaveAccessibleDescription(/仅在 Ask 模式/)
|
||||
expect(screen.getByText(/未显式选择专家或团队时/)).not.toBeVisible()
|
||||
expect(smartRouting).toHaveAccessibleDescription(/未显式选择专家或团队时/)
|
||||
const routingHelp = screen.getByRole('button', { name: 'Subagent 智能路由' })
|
||||
expect(routingHelp.closest('label, summary')).toBeNull()
|
||||
fireEvent.click(routingHelp)
|
||||
expect(smartRouting).not.toBeChecked()
|
||||
expect(screen.getByText(/仅在 Ask 模式/)).toHaveTextContent(
|
||||
expect(screen.getByText(/未显式选择专家或团队时/)).toHaveTextContent(
|
||||
'自动选择 1 位专家'
|
||||
)
|
||||
expect(screen.getByText(/仅在 Ask 模式/)).toHaveTextContent(
|
||||
'继承当前模式和已启用工具'
|
||||
expect(screen.getByText(/未显式选择专家或团队时/)).toHaveTextContent(
|
||||
'子专家使用默认文本模型和已启用工具'
|
||||
)
|
||||
|
||||
fireEvent.click(smartRouting)
|
||||
@@ -3065,6 +3047,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(updateRuntime.mock.calls[0]![0]).not.toHaveProperty('toolApproval')
|
||||
})
|
||||
|
||||
it('offers roles only model connections that have been saved', async () => {
|
||||
@@ -3549,7 +3532,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.getByText(/自定义 Continue 可执行文件将以当前用户权限运行/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/Ask 仅可调用当前 Runtime 允许的只读能力/)
|
||||
screen.getByText(/可用工具由所选 Runtime 和设置决定/)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
|
||||
expect(input).toHaveValue('')
|
||||
@@ -3685,8 +3668,6 @@ describe('SettingsPanel runtime files', () => {
|
||||
description: 'Edit a file',
|
||||
kind: 'write',
|
||||
source: 'runtime',
|
||||
ask: 'blocked',
|
||||
execute: 'allowed'
|
||||
}
|
||||
],
|
||||
toolsSupported: true,
|
||||
@@ -3759,7 +3740,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(screen.getByText('edit')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Edit a file · 文件修改 · Runtime 内置 · Ask:不可用 · Execute:可用'
|
||||
'Edit a file · 文件修改 · Runtime 内置'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
const commandsTab = within(inventoryTabs).getByRole('tab', {
|
||||
@@ -5295,7 +5276,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: '启用向量检索' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -5880,7 +5861,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
)
|
||||
expect(
|
||||
within(browserBuiltinCard!).getByText('仅 Execute')
|
||||
within(browserBuiltinCard!).getByText('读写')
|
||||
).toBeInTheDocument()
|
||||
const builtinBrowserToggle = screen.getByRole('button', {
|
||||
name: '展开服务器 内置浏览器'
|
||||
@@ -5895,7 +5876,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/关闭时不会向任何 Runtime 提供浏览器工具/)
|
||||
).toHaveTextContent('不再逐次询问')
|
||||
).toHaveTextContent('已分配的 Runtime 可直接读取网页并操作网站')
|
||||
expect(
|
||||
screen.getByRole('region', { name: '内置浏览器 工具' })
|
||||
).toContainElement(screen.getByText('browser_navigate'))
|
||||
@@ -6088,7 +6069,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
expect(
|
||||
screen.getByText(/远程访问令牌由系统安全存储加密/)
|
||||
).toHaveTextContent('工具调用前仍需 GoodBuddy 审批')
|
||||
).toHaveTextContent('启用并分配后,该服务提供的工具可供对应 Runtime 使用')
|
||||
expect(
|
||||
await screen.findByText('尚未配置 MCP Server')
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -391,7 +391,6 @@ function hydrateRuntimeSettings(
|
||||
knowledgeRerankApiKey: (value: string) => void
|
||||
clearKnowledgeRerankApiKey: (value: boolean) => void
|
||||
workspacePath: (value: string) => void
|
||||
toolApproval: (value: RuntimeSettingsInput['toolApproval']) => void
|
||||
subagentSmartRoutingEnabled: (value: boolean) => void
|
||||
contextCompression: (value: ContextCompressionSettings) => void
|
||||
},
|
||||
@@ -453,9 +452,6 @@ function hydrateRuntimeSettings(
|
||||
setters.knowledgeRerankApiKey('')
|
||||
setters.clearKnowledgeRerankApiKey(false)
|
||||
setters.workspacePath(configured.workspacePath)
|
||||
setters.toolApproval(
|
||||
value.toolApproval === 'policy' ? 'policy' : 'always'
|
||||
)
|
||||
setters.subagentSmartRoutingEnabled(
|
||||
value.subagentSmartRoutingEnabled
|
||||
)
|
||||
@@ -751,10 +747,6 @@ function SettingsPanelView({
|
||||
const [workspacePath, setWorkspacePath] = useState<string>(
|
||||
defaultRuntimeSettings.workspacePath
|
||||
)
|
||||
const [toolApproval, setToolApproval] =
|
||||
useState<RuntimeSettingsInput['toolApproval']>(
|
||||
defaultRuntimeSettings.toolApproval
|
||||
)
|
||||
const [
|
||||
subagentSmartRoutingEnabled,
|
||||
setSubagentSmartRoutingEnabled
|
||||
@@ -897,7 +889,6 @@ function SettingsPanelView({
|
||||
knowledgeRerankApiKey: setKnowledgeRerankApiKey,
|
||||
clearKnowledgeRerankApiKey: setClearKnowledgeRerankApiKey,
|
||||
workspacePath: setWorkspacePath,
|
||||
toolApproval: setToolApproval,
|
||||
subagentSmartRoutingEnabled: setSubagentSmartRoutingEnabled,
|
||||
contextCompression: setContextCompression
|
||||
},
|
||||
@@ -916,7 +907,6 @@ function SettingsPanelView({
|
||||
activeTab === 'model' ||
|
||||
activeTab === 'context-control' ||
|
||||
activeTab === 'runtime' ||
|
||||
activeTab === 'security' ||
|
||||
activeTab === 'roles'
|
||||
const categoryRendersOwnHeader =
|
||||
activeTab === 'platform-features' ||
|
||||
@@ -958,7 +948,6 @@ function SettingsPanelView({
|
||||
knowledgeRerankApiKey,
|
||||
clearKnowledgeRerankApiKey,
|
||||
workspacePath,
|
||||
toolApproval,
|
||||
subagentSmartRoutingEnabled,
|
||||
contextCompression,
|
||||
contextCompressionTokenInput
|
||||
@@ -1009,8 +998,6 @@ function SettingsPanelView({
|
||||
knowledgeRerankApiKey: '',
|
||||
clearKnowledgeRerankApiKey: false,
|
||||
workspacePath: savedConfiguredSettings?.workspacePath,
|
||||
toolApproval:
|
||||
settings.toolApproval === 'policy' ? 'policy' : 'always',
|
||||
subagentSmartRoutingEnabled:
|
||||
settings.subagentSmartRoutingEnabled,
|
||||
contextCompression:
|
||||
@@ -1591,7 +1578,6 @@ function SettingsPanelView({
|
||||
continueModelSource,
|
||||
deepseekHarnessModelSource,
|
||||
contextCompression: contextCompressionInput,
|
||||
toolApproval,
|
||||
subagentSmartRoutingEnabled
|
||||
})
|
||||
let selectedSpeechModelId = speechModelDraftId
|
||||
@@ -4160,38 +4146,13 @@ function SettingsPanelView({
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
{activeTab === 'platform-features' && (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<label className="field">
|
||||
<span>{t('security.toolPolicy.label')}</span>
|
||||
<select
|
||||
aria-label={t('security.toolPolicy.label')}
|
||||
value={toolApproval}
|
||||
onChange={(event) =>
|
||||
setToolApproval(
|
||||
event.target.value as RuntimeSettingsInput['toolApproval']
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="always">
|
||||
{t('security.toolPolicy.always')}
|
||||
</option>
|
||||
<option value="policy">
|
||||
{t('security.toolPolicy.deny')}
|
||||
</option>
|
||||
</select>
|
||||
<small>
|
||||
{t('security.toolPolicy.description')}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="settings-section settings-section--danger">
|
||||
<div>
|
||||
<strong>{t('security.localData.title')}</strong>
|
||||
<strong>{t('localData.title')}</strong>
|
||||
<p>
|
||||
{t('security.localData.description')}
|
||||
{t('localData.description')}
|
||||
</p>
|
||||
</div>
|
||||
{confirmingClear ? (
|
||||
|
||||
@@ -664,7 +664,6 @@ describe('SshHostsSettingsSection', () => {
|
||||
hostId,
|
||||
remoteRootPath: '/srv/missing'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: { provider: 'opencode' },
|
||||
kind: 'user',
|
||||
status: 'active',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor, wi
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { SupervisorWorkspace } from './SupervisorWorkspace'
|
||||
import { changeUiLocale, i18nResources } from './i18n'
|
||||
|
||||
const result = { id: 'result-1', storyLineId: 'story', sourceId: null, summary: 'Recap', changeDigest: '', createdAt: '2026-09-22T00:00:00.000Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-20T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' }, openItems: [] }
|
||||
const render = (ui: React.ReactNode) => {
|
||||
@@ -10,6 +11,22 @@ const render = (ui: React.ReactNode) => {
|
||||
}
|
||||
|
||||
describe('SupervisorWorkspace', () => {
|
||||
it('keeps source lookup failures retryable through notifications', async () => {
|
||||
const onNotify = vi.fn()
|
||||
const source = vi.fn().mockRejectedValueOnce(new TypeError('terminated')).mockResolvedValueOnce({ title: 'Source', content: 'Saved source', occurredAt: result.createdAt })
|
||||
window.goodbuddy = { supervision: {
|
||||
overview: async () => [result], source,
|
||||
graph: async () => ({ storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
|
||||
entities: [], relations: [], eventEntities: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }],
|
||||
eventSources: [{ event_id: 'event', source_id: 'source' }] })
|
||||
} } as never
|
||||
render(<SupervisorWorkspace tab="graph" onNotify={onNotify} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Source/ }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalledWith({ tone: 'error', message: i18nResources['zh-CN'].heartbeat.supervisor.sourceLoadFailed }))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Source/ }))
|
||||
expect(await screen.findByText('Saved source')).toBeVisible()
|
||||
})
|
||||
it.each(['conversation', 'knowledge'])('opens only conversation sources without replacing the saved %s snapshot', async (sourceType) => {
|
||||
const onOpenConversation = vi.fn()
|
||||
const source = vi.fn(async () => ({ sourceType, sourceId: 'original-conversation', title: 'Original source', content: 'Saved snapshot', occurredAt: result.createdAt }))
|
||||
@@ -40,9 +57,9 @@ describe('SupervisorWorkspace', () => {
|
||||
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result, old], graph, run } } as never
|
||||
const onTabChange = vi.fn()
|
||||
const onOpenActivity = vi.fn()
|
||||
const project = { id: 'project', name: 'New project', description: '', status: 'active', kind: 'user', rootPath: 'C:\\project', executionSpace: { kind: 'local', rootPath: 'C:\\project' }, defaultWorkMode: 'ask', createdAt: result.createdAt, updatedAt: result.createdAt } as const
|
||||
render(<SupervisorWorkspace projects={[project]} onTabChange={onTabChange} onOpenActivity={onOpenActivity} />)
|
||||
const onNotify = vi.fn()
|
||||
const project = { id: 'project', name: 'New project', description: '', status: 'active', kind: 'user', rootPath: 'C:\\project', executionSpace: { kind: 'local', rootPath: 'C:\\project' }, createdAt: result.createdAt, updatedAt: result.createdAt } as const
|
||||
render(<SupervisorWorkspace projects={[project]} onTabChange={onTabChange} onNotify={onNotify} />)
|
||||
const history = await screen.findByLabelText('历史结果')
|
||||
await waitFor(() => expect(history).toBeEnabled())
|
||||
fireEvent.change(history, { target: { value: 'old' } })
|
||||
@@ -64,41 +81,40 @@ describe('SupervisorWorkspace', () => {
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(recap.textContent).toBe(frozenText)
|
||||
expect(screen.getByText('Important final conclusion.')).toBeVisible()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理')
|
||||
expect(onNotify).toHaveBeenLastCalledWith({ tone: 'info', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewStarted, dedupeKey: 'supervisor-review' })
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '回顾' })).toBeEnabled()
|
||||
expect(screen.getByLabelText('关注范围')).toBeEnabled()
|
||||
expect(screen.getByLabelText('时间范围')).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
|
||||
expect(screen.queryByText('关注范围')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('时间范围')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }))
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
expect(screen.getByRole('status')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '活动记录' }))
|
||||
expect(onOpenActivity).toHaveBeenCalledOnce()
|
||||
expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ message: i18nResources['zh-CN'].heartbeat.supervisor.reviewBusy }))
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
expect(run).toHaveBeenCalledWith(expect.objectContaining({ scope: { kind: 'projects', projectIds: [project.id] } }))
|
||||
await act(async () => finish())
|
||||
expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ tone: 'success' }))
|
||||
expect(history).toHaveValue('old')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
it('discovers automatic execution on mount, preserves dismissal through polling, and guards start until cleanup', async () => {
|
||||
it('loads silently and checks automatic execution before starting, including stopping cleanup', async () => {
|
||||
vi.useFakeTimers()
|
||||
const execution = vi.fn().mockResolvedValue({ active: true, runId: 'automatic', stopping: 'cancelled' })
|
||||
const run = vi.fn().mockResolvedValue(undefined)
|
||||
const cancel = vi.fn()
|
||||
window.goodbuddy = { supervision: { execution, overview: async () => [], run, cancel } } as never
|
||||
const view = render(<SupervisorWorkspace onOpenActivity={vi.fn()} />)
|
||||
const onNotify = vi.fn()
|
||||
const view = render(<SupervisorWorkspace onNotify={onNotify} />)
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }))
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
await act(() => vi.advanceTimersByTimeAsync(2000))
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾' })))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
|
||||
expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ message: i18nResources['zh-CN'].heartbeat.reviewSettings.cancelling }))
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
execution.mockResolvedValue({ active: false })
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾' })))
|
||||
@@ -114,10 +130,11 @@ describe('SupervisorWorkspace', () => {
|
||||
it('turns a backend busy race into a dismissible ongoing notice', async () => {
|
||||
const run = vi.fn().mockRejectedValue(new Error('SUPERVISION_REVIEW_BUSY'))
|
||||
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
|
||||
render(<SupervisorWorkspace onOpenActivity={vi.fn()} />)
|
||||
const onNotify = vi.fn()
|
||||
render(<SupervisorWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText('还没有成功回顾')
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理'))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith({ tone: 'info', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewBusy, dedupeKey: 'supervisor-review' }))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '回顾' })).toBeEnabled()
|
||||
})
|
||||
@@ -178,7 +195,8 @@ describe('SupervisorWorkspace', () => {
|
||||
const graph = vi.fn().mockRejectedValue(new Error('Missing requested result'))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
|
||||
render(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'older' }} />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Missing requested result')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(i18nResources['zh-CN'].heartbeat.supervisor.loadFailed)
|
||||
expect(screen.queryByText('Missing requested result')).not.toBeInTheDocument()
|
||||
expect(graph).toHaveBeenCalledWith({ resultId: 'older', storyLineId: undefined })
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
|
||||
@@ -215,7 +233,7 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(tokens, token).toContain(`${token}:`)
|
||||
}
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); window.goodbuddy = {} as never })
|
||||
afterEach(async () => { cleanup(); vi.useRealTimers(); window.goodbuddy = {} as never; await changeUiLocale('zh-CN') })
|
||||
it('shows unavailable immediately and does not call supervision APIs when the bridge is absent', () => {
|
||||
window.goodbuddy = {} as never
|
||||
|
||||
@@ -274,22 +292,96 @@ describe('SupervisorWorkspace', () => {
|
||||
await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'manual', reanalyze: true })))
|
||||
})
|
||||
|
||||
it('keeps review failures retryable even with a selected graph event', async () => {
|
||||
it('notifies review failures and allows a new review without a workspace error banner', async () => {
|
||||
const run = vi.fn().mockRejectedValueOnce(new Error('Review provider unavailable')).mockResolvedValueOnce(undefined)
|
||||
window.goodbuddy = { supervision: {
|
||||
overview: async () => [],
|
||||
graph: async () => ({ storyLine: null, events: [{ id: 'event', title: 'Decision', description: '', occurred_at: '2026-09-21T00:00:00.000Z' }], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }),
|
||||
run
|
||||
} } as never
|
||||
render(<SupervisorWorkspace />)
|
||||
const onNotify = vi.fn()
|
||||
render(<SupervisorWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText('还没有成功回顾')
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Review provider unavailable')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试回顾' }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ tone: 'error', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewFailed })))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'] as const)('localizes failures after refresh and preserves request controls in %s', async locale => {
|
||||
await changeUiLocale(locale)
|
||||
const copy = i18nResources[locale].heartbeat
|
||||
let reject!: (reason: Error) => void
|
||||
const run = vi.fn(() => new Promise((_, fail) => { reject = fail }))
|
||||
const onNotify = vi.fn()
|
||||
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
|
||||
render(<SupervisorWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText(copy.supervisor.empty)
|
||||
fireEvent.change(screen.getByLabelText(copy.supervisor.period), { target: { value: '30' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.run }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: copy.center.actions.refresh }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy.center.actions.refresh })).toBeEnabled())
|
||||
await act(async () => reject(new Error("Error invoking remote method 'supervision:run': TypeError: terminated")))
|
||||
expect(onNotify).toHaveBeenLastCalledWith({ tone: 'error', message: copy.supervisor.reviewFailed, dedupeKey: 'supervisor-review' })
|
||||
expect(screen.getByLabelText(copy.supervisor.period)).toHaveValue('30')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['completed', 'paused', 'cancelled', 'no_change', 'failed'] as const)('notifies %s once without an operation banner', async status => {
|
||||
const copy = i18nResources['zh-CN'].heartbeat
|
||||
const onNotify = vi.fn()
|
||||
window.goodbuddy = { supervision: { overview: async () => [], run: vi.fn().mockResolvedValue({ status }) } } as never
|
||||
render(<SupervisorWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText(copy.supervisor.empty)
|
||||
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.run }))
|
||||
const message = { completed: copy.supervisor.reviewCompleted, paused: copy.reviewSettings.pausedHint,
|
||||
cancelled: copy.supervisor.reviewCancelled, no_change: copy.supervisor.reviewNoChange, failed: copy.supervisor.reviewFailed }[status]
|
||||
await waitFor(() => {
|
||||
expect(onNotify).toHaveBeenCalledTimes(2)
|
||||
expect(onNotify).toHaveBeenLastCalledWith({ message, tone: status === 'completed' ? 'success' : status === 'failed' ? 'error' : 'info', dedupeKey: 'supervisor-review' })
|
||||
expect(screen.getByRole('button', { name: copy.center.actions.refresh })).toBeEnabled()
|
||||
})
|
||||
expect(document.querySelector('.supervisor-workspace__run-status')).toBeNull()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports execution lookup failure without starting a review', async () => {
|
||||
const onNotify = vi.fn()
|
||||
const run = vi.fn()
|
||||
window.goodbuddy = { supervision: { overview: async () => [], execution: vi.fn().mockRejectedValue(new TypeError('terminated')), run } } as never
|
||||
render(<SupervisorWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText('还没有成功回顾')
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ tone: 'error', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewStartFailed })))
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['preflight', 'running'] as const)('ignores late %s responses after unmount without cancelling background work', async phase => {
|
||||
let resolve!: (value: unknown) => void
|
||||
const deferred = new Promise(done => { resolve = done })
|
||||
const execution = vi.fn().mockImplementation(() => phase === 'preflight' ? deferred : Promise.resolve({ active: false }))
|
||||
const run = vi.fn(() => deferred)
|
||||
const cancel = vi.fn()
|
||||
const overview = vi.fn().mockResolvedValue([])
|
||||
const onNotify = vi.fn()
|
||||
window.goodbuddy = { supervision: { overview, execution, run, cancel } } as never
|
||||
const view = render(<SupervisorWorkspace onNotify={onNotify} />)
|
||||
await screen.findByText('还没有成功回顾')
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(phase === 'preflight' ? execution : run).toHaveBeenCalledOnce())
|
||||
view.unmount()
|
||||
onNotify.mockClear()
|
||||
await act(async () => resolve(phase === 'preflight' ? { active: false } : { status: 'completed' }))
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
expect(overview).toHaveBeenCalledOnce()
|
||||
expect(run).toHaveBeenCalledTimes(phase === 'preflight' ? 0 : 1)
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws only actual event to entity links and filters sources for the selected event', async () => {
|
||||
window.goodbuddy = { supervision: {
|
||||
overview: vi.fn(async () => [result]),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
import { BookOpen, ChevronLeft, ChevronRight, Ellipsis, Info, Network, RefreshCw, X } from 'lucide-react'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { EmptyState, PageTabs } from './WorkspacePrimitives'
|
||||
import { SupervisionDiscussion } from './SupervisionDiscussion'
|
||||
import { SupervisionStoryDigest } from './SupervisionStoryDigest'
|
||||
@@ -38,7 +38,7 @@ type Props = {
|
||||
graphNavigation?: SupervisionGraphNavigation
|
||||
tab?: 'overview' | 'graph' | 'plans' | 'activity' | 'settings'
|
||||
projects?: AssistantProject[]
|
||||
onOpenActivity?: () => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onOpenConversation?: (conversationId: string) => void
|
||||
onTabChange?: (tab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings') => void
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function SupervisorWorkspace({
|
||||
tab = 'overview',
|
||||
projects = [],
|
||||
onTabChange,
|
||||
onOpenActivity,
|
||||
onNotify,
|
||||
onOpenConversation
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
@@ -82,8 +82,6 @@ export function SupervisorWorkspace({
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string>()
|
||||
const [pending, setPending] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [errorAction, setErrorAction] = useState<'run' | 'source' | 'action'>()
|
||||
const [selection, setSelection] = useState<Selection | undefined>(graphNavigation?.focus)
|
||||
const [listTab, setListTab] = useState<Selection['kind']>(graphNavigation?.focus?.kind ?? 'event')
|
||||
const [graphMode, setGraphMode] = useState<'flat' | 'spiral'>('flat')
|
||||
@@ -97,12 +95,12 @@ export function SupervisorWorkspace({
|
||||
}>()
|
||||
const [projectId, setProjectId] = useState('global')
|
||||
const [days, setDays] = useState(7)
|
||||
const [lastRequest, setLastRequest] = useState<SupervisionRunRequest>()
|
||||
const [pausedReview, setPausedReview] = useState(false)
|
||||
const [execution, setExecution] = useState<SupervisionReviewExecution>({ active: false })
|
||||
const [running, setRunning] = useState(false)
|
||||
const runPending = useRef(false)
|
||||
const [dismissedNotice, setDismissedNotice] = useState(false)
|
||||
const mounted = useRef(false)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => { mounted.current = false }
|
||||
}, [])
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [confirmReanalyze, setConfirmReanalyze] = useState(false)
|
||||
const moreRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -118,7 +116,6 @@ export function SupervisorWorkspace({
|
||||
setSelection(graphNavigation?.focus)
|
||||
if (graphNavigation?.focus) setListTab(graphNavigation.focus.kind)
|
||||
setPending(undefined)
|
||||
setError(undefined)
|
||||
setLoadError(undefined)
|
||||
}
|
||||
const selectedResult = useRef<string | undefined>(undefined)
|
||||
@@ -126,8 +123,6 @@ export function SupervisorWorkspace({
|
||||
const loadGeneration = useRef(0)
|
||||
const [confirmRemoval, setConfirmRemoval] = useState(false)
|
||||
const [revision, setRevision] = useState<string>()
|
||||
const errorText = (reason: unknown) =>
|
||||
reason instanceof Error ? reason.message : t('common.operationFailed')
|
||||
const date = (value: string) =>
|
||||
new Date(value).toLocaleString(i18n.resolvedLanguage)
|
||||
const shortDate = (value: string) =>
|
||||
@@ -193,11 +188,9 @@ export function SupervisorWorkspace({
|
||||
)[0]
|
||||
return latestEvent ? { kind: 'event', id: latestEvent.id } : undefined
|
||||
})
|
||||
} catch (reason) {
|
||||
} catch {
|
||||
if (generation !== loadGeneration.current) return
|
||||
setLoadError(
|
||||
reason instanceof Error ? reason.message : t('common.operationFailed')
|
||||
)
|
||||
setLoadError(t('supervisor.loadFailed'))
|
||||
} finally {
|
||||
if (generation === loadGeneration.current) setLoading(false)
|
||||
}
|
||||
@@ -213,35 +206,17 @@ export function SupervisorWorkspace({
|
||||
return () => { window.clearTimeout(task); invalidate() }
|
||||
}, [refresh, graphNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return
|
||||
let disposed = false
|
||||
let wasActive = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.execution()
|
||||
if (!disposed) {
|
||||
setExecution(next)
|
||||
if (next.active && !wasActive && !runPending.current) setDismissedNotice(false)
|
||||
wasActive = next.active
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!disposed) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
|
||||
} finally {
|
||||
if (!disposed) timer = setTimeout(() => void poll(), 2000)
|
||||
}
|
||||
}
|
||||
void poll()
|
||||
return () => { disposed = true; clearTimeout(timer) }
|
||||
}, [api, t])
|
||||
|
||||
// A review is incremental by default. Only an explicit re-analysis reads the whole interval again.
|
||||
const run = async (reanalyze = false) => {
|
||||
if (!api) return
|
||||
setConfirmReanalyze(false)
|
||||
setDismissedNotice(false)
|
||||
if (runPending.current) return
|
||||
const notice = (message: string, tone: AppNotificationInput['tone'] = 'info') => {
|
||||
if (mounted.current) onNotify?.({ tone, message, dedupeKey: 'supervisor-review' })
|
||||
}
|
||||
if (runPending.current) {
|
||||
notice(t('supervisor.reviewBusy'))
|
||||
return
|
||||
}
|
||||
runPending.current = true
|
||||
const generation = loadGeneration.current
|
||||
const to = new Date()
|
||||
@@ -257,31 +232,37 @@ export function SupervisorWorkspace({
|
||||
to: to.toISOString()
|
||||
}
|
||||
}
|
||||
setErrorAction('run')
|
||||
setError(undefined)
|
||||
let submitted = false
|
||||
try {
|
||||
const current = await api.execution()
|
||||
setExecution(current)
|
||||
if (current.active) return
|
||||
setLastRequest(request)
|
||||
setPausedReview(false)
|
||||
setRunning(true)
|
||||
if (!mounted.current) return
|
||||
if (current.active) {
|
||||
notice(t(current.stopping === 'cancelled' ? 'reviewSettings.cancelling' : current.stopping === 'paused' ? 'reviewSettings.pausing' : 'supervisor.reviewBusy'))
|
||||
return
|
||||
}
|
||||
submitted = true
|
||||
notice(t('supervisor.reviewStarted'))
|
||||
const outcome = await api.run(request) as { status?: string } | undefined
|
||||
setExecution(await api.execution())
|
||||
setPausedReview(outcome?.status === 'paused')
|
||||
if (!mounted.current) return
|
||||
switch (outcome?.status) {
|
||||
case 'paused': notice(t('reviewSettings.pausedHint')); break
|
||||
case 'cancelled': notice(t('supervisor.reviewCancelled')); break
|
||||
case 'no_change': notice(t('supervisor.reviewNoChange')); break
|
||||
case 'failed': notice(t('supervisor.reviewFailed'), 'error'); break
|
||||
default: notice(t('supervisor.reviewCompleted'), 'success')
|
||||
}
|
||||
// Refresh/navigation can invalidate result selection, but not operation feedback.
|
||||
if (generation !== loadGeneration.current) return
|
||||
selectedResult.current = undefined
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
if (/SUPERVISION_REVIEW_BUSY/.test(String(reason))) {
|
||||
setExecution({ active: true })
|
||||
notice(t('supervisor.reviewBusy'))
|
||||
return
|
||||
}
|
||||
if (generation !== loadGeneration.current) return
|
||||
setError(errorText(reason))
|
||||
notice(t(submitted ? 'supervisor.reviewFailed' : 'supervisor.reviewStartFailed'), 'error')
|
||||
} finally {
|
||||
runPending.current = false
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,8 +419,6 @@ export function SupervisorWorkspace({
|
||||
if (!api || !selection || pending) return
|
||||
const generation = loadGeneration.current
|
||||
setPending('action')
|
||||
setError(undefined)
|
||||
setErrorAction('action')
|
||||
try {
|
||||
if (selection.kind === 'entity')
|
||||
await api.entityAction({
|
||||
@@ -456,9 +435,9 @@ export function SupervisorWorkspace({
|
||||
setRevision(undefined)
|
||||
if (kind === 'revoke') setSelection(undefined)
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
} catch {
|
||||
if (generation !== loadGeneration.current) return
|
||||
setError(errorText(reason))
|
||||
onNotify?.({ tone: 'error', message: t('common.operationFailed') })
|
||||
} finally {
|
||||
if (generation === loadGeneration.current) setPending(undefined)
|
||||
}
|
||||
@@ -467,13 +446,14 @@ export function SupervisorWorkspace({
|
||||
if (!api || pending) return
|
||||
const generation = loadGeneration.current
|
||||
setPending('source')
|
||||
setError(undefined)
|
||||
setSource(undefined)
|
||||
setErrorAction('source')
|
||||
try {
|
||||
const item = await api.source(id)
|
||||
if (generation !== loadGeneration.current) return
|
||||
if (!item) throw new Error(t('supervisor.sourceMissing'))
|
||||
if (!item) {
|
||||
onNotify?.({ tone: 'error', message: t('supervisor.sourceMissing') })
|
||||
return
|
||||
}
|
||||
setSource({
|
||||
id,
|
||||
title: String(item.title),
|
||||
@@ -482,9 +462,9 @@ export function SupervisorWorkspace({
|
||||
conversationId: item.sourceType === 'conversation' && typeof item.sourceId === 'string'
|
||||
? item.sourceId : undefined
|
||||
})
|
||||
} catch (reason) {
|
||||
} catch {
|
||||
if (generation !== loadGeneration.current) return
|
||||
setError(errorText(reason))
|
||||
onNotify?.({ tone: 'error', message: t('supervisor.sourceLoadFailed') })
|
||||
} finally {
|
||||
if (generation === loadGeneration.current) setPending(undefined)
|
||||
}
|
||||
@@ -506,7 +486,6 @@ export function SupervisorWorkspace({
|
||||
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
|
||||
const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined
|
||||
const busy = !!api && (loading || pending !== undefined)
|
||||
const showRunNotice = (execution.active || running || pausedReview) && !dismissedNotice
|
||||
|
||||
return (
|
||||
<div className="supervisor-workspace" data-view={tab} aria-busy={busy}>
|
||||
@@ -541,35 +520,6 @@ export function SupervisorWorkspace({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showRunNotice && <div className="supervisor-workspace__run-status" role="status">
|
||||
<span>{t(execution.stopping === 'cancelled' ? 'reviewSettings.cancelling' : execution.stopping === 'paused' ? 'reviewSettings.pausing' : pausedReview && !running && !execution.active ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span>
|
||||
{onOpenActivity && <button className="link-button" onClick={onOpenActivity}>{t('activity.title')}</button>}
|
||||
<button type="button" className="icon-button" aria-label={t('supervisor.dismiss')} title={t('supervisor.dismiss')} onClick={() => setDismissedNotice(true)}><X size={16} aria-hidden="true" /></button>
|
||||
</div>}
|
||||
{error && (
|
||||
<div className="supervisor-workspace__inline-error" role="alert">
|
||||
<strong>{t('common.operationFailed')}</strong>
|
||||
<p>{error}</p>
|
||||
{errorAction === 'run' && lastRequest && (
|
||||
<p>
|
||||
{scopeText(lastRequest.scope)} ·{' '}
|
||||
{date(lastRequest.timeRange.from)} –{' '}
|
||||
{date(lastRequest.timeRange.to)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
errorAction === 'run' ? void run(lastRequest?.reanalyze === true) : setError(undefined)
|
||||
}
|
||||
>
|
||||
{errorAction === 'run'
|
||||
? t('supervisor.retryRun')
|
||||
: t('supervisor.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'overview' && (
|
||||
<>
|
||||
<div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
|
||||
|
||||
@@ -10,7 +10,7 @@ const schedule: AssistantSchedule = {
|
||||
id: '00000000-0000-4000-8000-000000000801',
|
||||
taskId: '00000000-0000-4000-8000-000000000802',
|
||||
conversationId: '00000000-0000-4000-8000-000000000803',
|
||||
title: 'Immediate task', prompt: 'Run once', workMode: 'ask',
|
||||
title: 'Immediate task', prompt: 'Run once',
|
||||
recurrence: 'once', enabled: false,
|
||||
nextRunAt: triggeredAt, lastRunAt: triggeredAt,
|
||||
createdAt: triggeredAt, updatedAt: triggeredAt
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('WorkbarShell', () => {
|
||||
const tablist = screen.getByRole('tablist', {
|
||||
name: '已打开的工作栏应用'
|
||||
})
|
||||
expect(within(tablist).getAllByRole('tab')).toHaveLength(4)
|
||||
expect(within(tablist).getAllByRole('tab')).toHaveLength(3)
|
||||
expect(within(tablist).getAllByRole('tab').map((tab) => tab.textContent))
|
||||
.toEqual(DEFAULT_WORKBAR_INSTANCES.map((instance) => instance.title))
|
||||
expect(
|
||||
@@ -224,16 +224,16 @@ describe('WorkbarShell', () => {
|
||||
})
|
||||
|
||||
it('pins tasks and workspace in supplied layouts and navigates in displayed order', () => {
|
||||
const [tasks, workspace, browser, results] = DEFAULT_WORKBAR_INSTANCES
|
||||
const [tasks, workspace, browser] = DEFAULT_WORKBAR_INSTANCES
|
||||
render(
|
||||
<ControlledShell
|
||||
initialInstances={[terminalOne, workspace!, results!, tasks!, browser!]}
|
||||
initialInstances={[terminalOne, workspace!, tasks!, browser!]}
|
||||
/>
|
||||
)
|
||||
|
||||
const tabs = screen.getAllByRole('tab')
|
||||
expect(tabs.map((tab) => tab.textContent)).toEqual(
|
||||
[tasks!, workspace!, terminalOne, results!, browser!].map((instance) => instance.title)
|
||||
[tasks!, workspace!, terminalOne, browser!].map((instance) => instance.title)
|
||||
)
|
||||
expect(tabs[2]).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.keyDown(tabs[2]!, { key: 'Home' })
|
||||
@@ -368,10 +368,10 @@ describe('WorkbarShell', () => {
|
||||
expect(workspace).toHaveAttribute('tabindex', '0')
|
||||
|
||||
fireEvent.keyDown(workspace, { key: 'End' })
|
||||
const results = screen.getByRole('tab', { name: '成果' })
|
||||
expect(results).toHaveFocus()
|
||||
const browser = screen.getByRole('tab', { name: '浏览器' })
|
||||
expect(browser).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(results, { key: 'Home' })
|
||||
fireEvent.keyDown(browser, { key: 'Home' })
|
||||
expect(tasks).toHaveFocus()
|
||||
})
|
||||
|
||||
@@ -387,9 +387,9 @@ describe('WorkbarShell', () => {
|
||||
screen.getByRole('button', { name: '关闭浏览器' })
|
||||
)
|
||||
|
||||
const results = screen.getByRole('tab', { name: '成果' })
|
||||
await waitFor(() => expect(results).toHaveFocus())
|
||||
expect(results).toHaveAttribute('aria-selected', 'true')
|
||||
const terminal = screen.getByRole('tab', { name: terminalOne.title })
|
||||
await waitFor(() => expect(terminal).toHaveFocus())
|
||||
expect(terminal).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '浏览器' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
FileOutput,
|
||||
FolderTree,
|
||||
Globe2,
|
||||
NotebookPen,
|
||||
@@ -35,7 +34,6 @@ const WORKBAR_APP_ICONS: Record<WorkbarAppId, LucideIcon> = {
|
||||
tasks: ClipboardCheck,
|
||||
workspace: FolderTree,
|
||||
browser: Globe2,
|
||||
results: FileOutput,
|
||||
notes: NotebookPen,
|
||||
terminal: SquareTerminal
|
||||
}
|
||||
@@ -43,8 +41,7 @@ const WORKBAR_APP_ICONS: Record<WorkbarAppId, LucideIcon> = {
|
||||
const DEFAULT_INSTANCE_IDS = {
|
||||
tasks: '10000000-0000-4000-8000-000000000001',
|
||||
workspace: '10000000-0000-4000-8000-000000000002',
|
||||
browser: '10000000-0000-4000-8000-000000000003',
|
||||
results: '10000000-0000-4000-8000-000000000004'
|
||||
browser: '10000000-0000-4000-8000-000000000003'
|
||||
} as const
|
||||
|
||||
export const DEFAULT_WORKBAR_INSTANCES: readonly WorkbarTabInstance[] =
|
||||
|
||||
@@ -15,6 +15,42 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('WorkspaceFilesPanel', () => {
|
||||
it('imports into the selected directory, reports partial failure, refreshes and prevents duplicate submission', async () => {
|
||||
let complete!: (result: { imported: string[]; failed: { name: string; error: string }[] }) => void
|
||||
const importFiles = vi.fn(() => new Promise<{ imported: string[]; failed: { name: string; error: string }[] }>(resolve => { complete = resolve }))
|
||||
Object.assign(window.goodbuddy.workspace, { importFiles })
|
||||
const notify = vi.fn()
|
||||
const list = vi.fn(async (path: string) => ({ path, truncated: false, entries: path ? [] : [{ name: 'docs', path: 'docs', type: 'directory' as const }] }))
|
||||
render(<WorkspaceFilesPanel projectId="project" changedFiles={[]} notify={notify} onListDirectory={list} onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs' }))
|
||||
expect(screen.getByText('目标目录:docs')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '导入文件' }))
|
||||
expect(screen.getByRole('button', { name: '正在导入…' })).toBeDisabled()
|
||||
expect(importFiles).toHaveBeenCalledExactlyOnceWith('project', 'docs')
|
||||
await act(async () => { complete({ imported: ['docs/new.txt'], failed: [{ name: 'existing.txt', error: 'Destination already exists' }] }) })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '导入文件' })).toBeEnabled()
|
||||
expect(notify).toHaveBeenCalledWith({ tone: 'error', message: expect.stringContaining('existing.txt: Destination already exists') })
|
||||
expect(list.mock.calls.filter(([path]) => path === 'docs').length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts siblings by real modification times, keeps directories first and omits unavailable creation times', async () => {
|
||||
const { container } = render(<WorkspaceFilesPanel projectId="project" changedFiles={[]} onLoadDiff={vi.fn()} onOpenFile={vi.fn()}
|
||||
onListDirectory={async path => ({ path, truncated: false, entries: [
|
||||
{ name: 'old', path: 'old', type: 'file', modifiedAt: '2026-01-01T00:00:00.000Z' },
|
||||
{ name: 'new', path: 'new', type: 'file', modifiedAt: '2026-10-01T00:00:00.000Z' },
|
||||
{ name: 'unknown', path: 'unknown', type: 'file' },
|
||||
{ name: 'docs', path: 'docs', type: 'directory' }
|
||||
] })} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '修改时间' }))
|
||||
const names = () => [...container.querySelectorAll('.workspace-files__row')].map(row => row.textContent)
|
||||
expect(names()).toEqual(['docs', 'new', 'old', 'unknown'])
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改时间 ↓' }))
|
||||
expect(names()).toEqual(['docs', 'old', 'new', 'unknown'])
|
||||
expect(screen.queryByRole('button', { name: '创建时间' })).not.toBeInTheDocument()
|
||||
expect(container.querySelector('time[datetime="2026-01-01T00:00:00.000Z"]')).toBeInTheDocument()
|
||||
})
|
||||
it('groups view switching and refresh, and only shows actions for the active view', async () => {
|
||||
const onRefresh = vi.fn(async () => undefined)
|
||||
const { container } = render(<WorkspaceFilesPanel projectId="project" isRepository rootPath="D:\\workspace\\demo"
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
Trash2,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen
|
||||
FolderOpen,
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useCallback,
|
||||
@@ -31,6 +32,7 @@ import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { WorkspaceActionDialog } from './WorkspaceActionDialog'
|
||||
import { WorkspaceGitTools } from './WorkspaceGitTools'
|
||||
import { useListWindow } from './use-list-window'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
@@ -39,6 +41,7 @@ import type {
|
||||
} from '../../shared/assistant-contracts'
|
||||
|
||||
type WorkspaceFilesPanelProps = {
|
||||
notify?: (input: AppNotificationInput) => void
|
||||
projectId?: string
|
||||
rootPath?: string
|
||||
onRefresh?: () => Promise<void>
|
||||
@@ -90,11 +93,18 @@ export function flattenFileTree(
|
||||
listings: Readonly<Record<string, WorkspaceDirectoryListing>>,
|
||||
expandedPaths: ReadonlySet<string>,
|
||||
loadingPaths: ReadonlySet<string>,
|
||||
errors: Readonly<Record<string, string | undefined>>
|
||||
errors: Readonly<Record<string, string | undefined>>,
|
||||
sort: { field: 'name' | 'modifiedAt' | 'createdAt'; descending: boolean } = { field: 'name', descending: false }
|
||||
): FileTreeRow[] {
|
||||
const rows: FileTreeRow[] = []
|
||||
const visit = (items: readonly WorkspaceDirectoryEntry[], depth: number): void => {
|
||||
for (const entry of items) {
|
||||
const ordered = [...items].sort((left, right) => {
|
||||
if (left.type !== right.type) return left.type === 'directory' ? -1 : 1
|
||||
const a = left[sort.field], b = right[sort.field]
|
||||
if (!a || !b) return a ? -1 : b ? 1 : left.name.localeCompare(right.name)
|
||||
return (a.localeCompare(b) * (sort.descending ? -1 : 1)) || left.name.localeCompare(right.name)
|
||||
})
|
||||
for (const entry of ordered) {
|
||||
rows.push({ kind: 'entry', key: fileRowKey(entry.path), depth, entry })
|
||||
if (entry.type !== 'directory' || !expandedPaths.has(entry.path)) continue
|
||||
const listing = listings[entry.path]
|
||||
@@ -110,6 +120,7 @@ export function flattenFileTree(
|
||||
}
|
||||
|
||||
export function WorkspaceFilesPanel({
|
||||
notify,
|
||||
projectId,
|
||||
rootPath,
|
||||
onRefresh,
|
||||
@@ -181,6 +192,9 @@ export function WorkspaceFilesPanel({
|
||||
const [dialog, setDialog] = useState<{ kind: 'rename' | 'move' | 'delete' | 'properties' | 'createFile' | 'createDirectory'; path: string }>()
|
||||
const [changeView, setChangeView] = useState<'list' | 'tree'>('list')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const importingRef = useRef(false)
|
||||
const [sort, setSort] = useState<{ field: 'name' | 'modifiedAt' | 'createdAt'; descending: boolean }>({ field: 'name', descending: false })
|
||||
const repositoryKnown = useRef<{ projectId?: string; available: boolean }>({ available: false })
|
||||
useEffect(() => { if (!gitError && isRepository !== undefined) repositoryKnown.current = { projectId, available: isRepository } }, [gitError, isRepository, projectId])
|
||||
const showGit = isRepository === true || (Boolean(gitError) && repositoryKnown.current.projectId === projectId && repositoryKnown.current.available)
|
||||
@@ -407,6 +421,23 @@ export function WorkspaceFilesPanel({
|
||||
}}><MoreHorizontal size={14} aria-hidden="true" /></button>
|
||||
)
|
||||
|
||||
const importFiles = async (): Promise<void> => {
|
||||
if (!projectId || importingRef.current) return
|
||||
importingRef.current = true
|
||||
setImporting(true)
|
||||
const directory = createTarget
|
||||
try {
|
||||
const result = await window.goodbuddy.workspace.importFiles(projectId, directory)
|
||||
if (result.imported.length || result.failed.length) {
|
||||
notify?.({ tone: result.failed.length ? 'error' : 'success', message:
|
||||
`${t('management.imported', { count: result.imported.length, path: directory || '/' })}${result.failed.length ? ` ${result.failed.map(file => `${file.name}: ${file.error}`).join('; ')}` : ''}` })
|
||||
await refresh()
|
||||
}
|
||||
} catch (reason) {
|
||||
notify?.({ tone: 'error', message: String(reason) })
|
||||
} finally { importingRef.current = false; setImporting(false) }
|
||||
}
|
||||
|
||||
const renderTreeRow = (
|
||||
row: FileTreeRow,
|
||||
rowProps: Record<string, unknown>
|
||||
@@ -430,10 +461,11 @@ export function WorkspaceFilesPanel({
|
||||
const changed = changedByPath.get(entry.path)
|
||||
if (entry.type === 'directory') {
|
||||
return (
|
||||
<div className="workspace-files__entry" key={row.key} style={indent} {...rowProps}>
|
||||
<div className="workspace-files__entry" key={row.key} {...rowProps}>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="workspace-files__row"
|
||||
style={row.depth ? { paddingLeft: `calc(${row.depth} * var(--space-4) + var(--space-2))` } : undefined}
|
||||
onClick={() => toggleDirectory(entry.path)}
|
||||
type="button"
|
||||
>
|
||||
@@ -445,14 +477,16 @@ export function WorkspaceFilesPanel({
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
{renderTimes(entry)}
|
||||
{entryMenu(entry)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="workspace-files__entry" key={row.key} style={indent} {...rowProps}>
|
||||
<div className="workspace-files__entry" key={row.key} {...rowProps}>
|
||||
<button
|
||||
className="workspace-files__row"
|
||||
style={row.depth ? { paddingLeft: `calc(${row.depth} * var(--space-4) + var(--space-2))` } : undefined}
|
||||
aria-current={selected?.projectId === projectId && selected?.path === entry.path ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
setSelected({ projectId, path: entry.path })
|
||||
@@ -471,15 +505,23 @@ export function WorkspaceFilesPanel({
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
{renderTimes(entry)}
|
||||
{entryMenu(entry)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const root = listings[browsedPath]
|
||||
const showCreated = useMemo(() => Object.values(listings).some(listing => listing.entries.some(entry => entry.createdAt)), [listings])
|
||||
const renderTimes = (entry: WorkspaceDirectoryEntry): React.JSX.Element => <>
|
||||
{(['modifiedAt', ...(showCreated ? ['createdAt' as const] : [])] as const).map(field =>
|
||||
<time className="workspace-files__time" key={field} dateTime={entry[field]} title={entry[field] ? new Date(entry[field]).toLocaleString() : undefined}>
|
||||
{entry[field] ? new Date(entry[field]).toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}
|
||||
</time>)}
|
||||
</>
|
||||
const treeRows = useMemo(
|
||||
() => flattenFileTree(root?.entries ?? [], listings, expandedPaths, loadingPaths, errors),
|
||||
[errors, expandedPaths, listings, loadingPaths, root]
|
||||
() => flattenFileTree(root?.entries ?? [], listings, expandedPaths, loadingPaths, errors, sort),
|
||||
[errors, expandedPaths, listings, loadingPaths, root, sort]
|
||||
)
|
||||
const treeRowKeysKey = treeRows.map((row) => row.key).join('\n')
|
||||
const treeRowKeys = useMemo(() => (treeRowKeysKey ? treeRowKeysKey.split('\n') : []), [treeRowKeysKey])
|
||||
@@ -524,7 +566,7 @@ export function WorkspaceFilesPanel({
|
||||
</pre>
|
||||
)
|
||||
return (
|
||||
<div ref={panelRef} className="workspace-files">
|
||||
<div ref={panelRef} className="workspace-files" data-created-time={showCreated}>
|
||||
{diff && <section className="assistant-sidebar__preview" aria-busy={!diff.value && !diff.error}>
|
||||
<header>
|
||||
<button ref={backRef} className="assistant-sidebar__back" type="button" onClick={() => {
|
||||
@@ -558,6 +600,8 @@ export function WorkspaceFilesPanel({
|
||||
<button ref={refreshRef} className="icon-button" type="button" disabled={refreshing} aria-label={t('sidebar.workspace.refreshAriaLabel')} title={t('sidebar.workspace.refresh')} onClick={() => void refresh()}><RefreshCw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
{activeView === 'files' && root && <div className="workspace-files__toolbar workspace-files__actions">
|
||||
<button className="secondary-button" type="button" disabled={importing} onClick={() => void importFiles()}><Upload size={14} aria-hidden="true" />{t(importing ? 'management.importing' : 'management.importFiles')}</button>
|
||||
<span className="workspace-files__destination" title={createTarget || '/'}>{t('management.importTarget', { path: createTarget || '/' })}</span>
|
||||
<button className="icon-button" type="button" aria-label={t('management.createFile')} title={`${t('management.createFile')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createFile', path: createTarget })}><FilePlus size={14} aria-hidden="true" /></button>
|
||||
<button className="icon-button" type="button" aria-label={t('management.createDirectory')} title={`${t('management.createDirectory')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createDirectory', path: createTarget })}><FolderPlus size={14} aria-hidden="true" /></button>
|
||||
</div>}
|
||||
@@ -622,6 +666,11 @@ export function WorkspaceFilesPanel({
|
||||
) : errors[browsedPath] && !root ? null : root?.entries.length ? (
|
||||
<>
|
||||
<div ref={treeRef} className="workspace-files__tree" onBlur={treeWindow.onBlur} onFocus={treeWindow.onFocus}>
|
||||
<div className="workspace-files__columns">
|
||||
{(['name', 'modifiedAt', ...(showCreated ? ['createdAt' as const] : [])] as const).map(field => <button type="button" key={field} aria-pressed={sort.field === field} onClick={() => setSort(current => ({ field, descending: current.field === field ? !current.descending : field !== 'name' }))}>
|
||||
{t(field === 'name' ? 'management.name' : field === 'modifiedAt' ? 'management.modified' : 'management.created')}{sort.field === field ? (sort.descending ? ' ↓' : ' ↑') : ''}
|
||||
</button>)}
|
||||
</div>
|
||||
{treeWindow.segments.map((segment) => {
|
||||
if (segment.kind === 'spacer') {
|
||||
return <div aria-hidden="true" className="workspace-files__spacer" key={`spacer:${segment.key}`} style={{ height: `${segment.height}px` }} />
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('WorkspaceFilesPanel tree windowing', () => {
|
||||
expect(fileRow('a', 0)).toBeNull()
|
||||
expect(rows().length).toBeLessThanOrEqual(defaultMaxRenderedRows)
|
||||
// Indentation still shows the nesting.
|
||||
expect(fileRow('c', 1)!.parentElement!.style.marginLeft).toContain('1 *')
|
||||
expect(fileRow('c', 1)!.style.paddingLeft).toContain('1 *')
|
||||
}, 20_000)
|
||||
|
||||
it('keeps the selected file and the open menu mounted, and scrolls back to the selection', async () => {
|
||||
@@ -135,4 +135,3 @@ describe('WorkspaceFilesPanel tree windowing', () => {
|
||||
expect(fileRow('a', 0)).toBeInTheDocument()
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { createRef, Profiler } from 'react'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type { Conversation } from './chat-conversation'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { createConversationStores } from './conversation-store'
|
||||
import { ProjectSwitcher } from './ProjectSwitcher'
|
||||
import i18n from './i18n'
|
||||
import { WorkspaceMenu } from './WorkspaceMenu'
|
||||
import { formatConversationListTime } from './time-format'
|
||||
import { formatMediumDateTime } from './locale-formatters'
|
||||
|
||||
const local: AssistantProject = {
|
||||
id: 'local', name: 'Local workspace', description: '', rootPath: 'C:/workspace',
|
||||
executionSpace: { kind: 'local', rootPath: 'C:/workspace' }, defaultWorkMode: 'ask',
|
||||
executionSpace: { kind: 'local', rootPath: 'C:/workspace' },
|
||||
kind: 'user', status: 'active', createdAt: '2026-10-01T00:00:00Z', updatedAt: '2026-10-01T00:00:00Z'
|
||||
}
|
||||
const other = { ...local, id: 'other', name: 'Other workspace', rootPath: 'C:/other' }
|
||||
@@ -20,6 +23,7 @@ function setup({ projects = [local, other], conversations = [conversation('Local
|
||||
activities = [] as ConversationActivity[], remoteProjectsEnabled = false } = {}) {
|
||||
const store = createConversationStores(conversations, { flushIntervalMs: 250 }).conversations
|
||||
const actions = { onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(), onRemoteCommitted: vi.fn(),
|
||||
onRestore: vi.fn<(id: string) => Promise<void>>().mockResolvedValue(undefined), notify: vi.fn(),
|
||||
onSelect: vi.fn(), onSelectRoot: vi.fn(), onUpdate: vi.fn(), onOpenConversation: vi.fn(), onNewConversation: vi.fn() }
|
||||
const props = { ...actions, projects, conversationStore: store, activeProjectId: local.id, activities, remoteProjectsEnabled }
|
||||
const renders = vi.fn()
|
||||
@@ -50,6 +54,88 @@ describe('production workspace menu', () => {
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enters the previewed project explicitly and uses the current project in all-project scope', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const { actions } = setup()
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerOver(screen.getByRole('menuitemradio', { name: /Other workspace/ }), { pointerType: 'mouse' })
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
const enter = screen.getByRole('button', { name: 'Enter project Other workspace' })
|
||||
expect(enter).toHaveTextContent('Enter project')
|
||||
expect(enter).toHaveAttribute('title', 'Enter project Other workspace')
|
||||
fireEvent.click(enter)
|
||||
expect(actions.onSelect).toHaveBeenCalledExactlyOnceWith(other.id)
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'All projects' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter project Local workspace' }))
|
||||
expect(actions.onSelect).toHaveBeenLastCalledWith(local.id)
|
||||
expect(actions.onNewConversation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads archives only on demand, searches them, respects remote gating and restores without navigation', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const archived = { ...other, status: 'archived' as const }
|
||||
const remote: AssistantProject = { ...archived, id: 'remote', name: 'Archived remote', executionSpace: { kind: 'ssh', hostId: 'host', remoteRootPath: '/srv' } }
|
||||
const list = vi.fn(async () => [local, archived, remote, { ...archived, id: 'channel', kind: 'channel' as const }])
|
||||
const original = window.goodbuddy
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { projects: { list } } })
|
||||
try {
|
||||
const { actions, props, rerender, renders } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
fireEvent.pointerOver(screen.getByRole('menuitemradio', { name: /Other workspace/ }), { pointerType: 'mouse' })
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Archived' }))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading archived projects')
|
||||
await screen.findByRole('menuitem', { name: 'Restore project Other workspace' })
|
||||
expect(list).toHaveBeenCalledExactlyOnceWith(true)
|
||||
expect(screen.queryByRole('menuitem', { name: 'Restore project Archived remote' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('tablist', { name: 'Project categories' })).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'missing' } })
|
||||
expect(screen.getByText('No matching projects. Try another search.')).toBeVisible()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'C:/other' } })
|
||||
let finishRestore!: () => void
|
||||
actions.onRestore.mockImplementationOnce(() => new Promise<void>(resolve => { finishRestore = resolve }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Restore project Other workspace' }))
|
||||
expect(screen.getByRole('menuitem', { name: 'Restore project Other workspace' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Restore project Other workspace' }))
|
||||
await act(async () => finishRestore())
|
||||
await waitFor(() => expect(actions.notify).toHaveBeenCalledWith({ tone: 'success', message: 'Other workspace restored to its project category.' }))
|
||||
expect(actions.onRestore).toHaveBeenCalledExactlyOnceWith(other.id)
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
expect(actions.onOpenConversation).not.toHaveBeenCalled()
|
||||
rerender(<Profiler id="workspace" onRender={renders}><ProjectSwitcher {...props} remoteProjectsEnabled /></Profiler>)
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
|
||||
expect(screen.getByRole('menuitem', { name: 'Restore project Archived remote' })).toBeVisible()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Restore project Other workspace' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Archived' }))
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
} finally { Object.defineProperty(window, 'goodbuddy', { configurable: true, value: original }) }
|
||||
})
|
||||
|
||||
it('keeps failed archive operations retryable and reports errors through notifications', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const list = vi.fn().mockRejectedValueOnce(new Error('Archive read failed')).mockResolvedValue([{ ...other, status: 'archived' }])
|
||||
const original = window.goodbuddy
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { projects: { list } } })
|
||||
try {
|
||||
const { actions } = setup()
|
||||
actions.onRestore.mockRejectedValueOnce(new Error('Restore write failed'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Archived' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Retry loading archives' }))
|
||||
expect(actions.notify).toHaveBeenCalledWith({ tone: 'error', message: 'Archive read failed' })
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Restore project Other workspace' }))
|
||||
await waitFor(() => {
|
||||
expect(actions.notify).toHaveBeenCalledWith({ tone: 'error', message: 'Restore write failed' })
|
||||
expect(screen.getByRole('menuitem', { name: 'Restore project Other workspace' })).toBeEnabled()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Restore project Other workspace' }))
|
||||
await screen.findByText('No archived projects. Archived projects can be restored here.')
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
} finally { Object.defineProperty(window, 'goodbuddy', { configurable: true, value: original }) }
|
||||
})
|
||||
it('puts All Projects first in a fixed tab row and filters categories only when activated', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const remote: AssistantProject = { ...other, id: 'remote', name: 'Remote workspace', rootPath: '/srv/workspace',
|
||||
@@ -88,7 +174,7 @@ describe('production workspace menu', () => {
|
||||
const remoteRow = within(list).getByRole('menuitemradio', { name: /Remote workspace/ })
|
||||
fireEvent.pointerOver(remoteRow, { pointerType: 'mouse' })
|
||||
const preview = screen.getByRole('region', { name: remote.name })
|
||||
expect(within(preview).getByRole('button', { name: 'Remote chat' })).toBeVisible()
|
||||
expect(within(preview).getByRole('button', { name: /^Remote chat/ })).toBeVisible()
|
||||
expect(within(preview).queryByText(remote.name)).not.toBeInTheDocument()
|
||||
expect(within(tabs).getByRole('tab', { name: 'Channels' })).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
|
||||
@@ -139,7 +225,7 @@ describe('production workspace menu', () => {
|
||||
const target = screen.getByRole('menuitemradio', { name: /Other workspace/ })
|
||||
fireEvent.pointerOver(target, { pointerType: 'mouse' })
|
||||
const list = screen.getByRole('list', { name: 'Conversations' })
|
||||
expect(within(list).getByRole('button', { name: 'Other chat' })).toBeVisible()
|
||||
expect(within(list).getByRole('button', { name: /^Other chat/ })).toBeVisible()
|
||||
expect(within(list).queryByText('Other workspace')).not.toBeInTheDocument()
|
||||
const scopedPane = screen.getByRole('region', { name: 'Other workspace' })
|
||||
expect(scopedPane).toHaveClass('workspace-menu__activity')
|
||||
@@ -229,7 +315,9 @@ describe('production workspace menu', () => {
|
||||
|
||||
it('does not rerender the menu for message content deltas with unchanged summaries', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const initial = { ...conversation('Streaming chat'), messages: [{ id: 'm', role: 'assistant' as const, content: 'first', createdAt: 1, state: 'streaming' as const }] }
|
||||
const now = new Date(2026, 9, 4, 14, 30, 10).getTime()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now + 1000)
|
||||
const initial = { ...conversation('Streaming chat', local.id, now), messages: [{ id: 'm', role: 'assistant' as const, content: 'first', createdAt: 1, state: 'streaming' as const }] }
|
||||
const { store, renders } = setup({ conversations: [initial] })
|
||||
act(() => store.rememberOpened(initial.id))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
@@ -240,6 +328,67 @@ describe('production workspace menu', () => {
|
||||
expect(store.getActivityTimes()).toBe(activityTimes)
|
||||
})
|
||||
|
||||
it('updates only the subscribed time when updatedAt crosses displayed precision', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const now = new Date(2026, 9, 4, 14, 30, 10).getTime()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const initial = { ...conversation('Streaming chat', local.id, now), messages: [
|
||||
{ id: 'm', role: 'assistant' as const, content: 'first', createdAt: 1, state: 'streaming' as const }
|
||||
] }
|
||||
const store = createConversationStores([initial], { flushIntervalMs: 250 }).conversations
|
||||
const renderProject = vi.fn((project: AssistantProject) => <button>{project.name}</button>)
|
||||
const renders = vi.fn()
|
||||
render(<Profiler id="menu" onRender={renders}><WorkspaceMenu projects={[local]} activeProjectId={local.id}
|
||||
activities={[]} conversationStore={store} remoteProjectsEnabled={false} hosts={[]} connectionStates={{}}
|
||||
anchorRef={createRef()} controlsRef={createRef()} id="menu" renderProject={renderProject}
|
||||
onEnterProject={vi.fn()} onRestore={vi.fn()} notify={vi.fn()}
|
||||
onClose={vi.fn()} onCreateProject={vi.fn()} onNewConversation={vi.fn()} onOpenConversation={vi.fn()} /></Profiler>)
|
||||
renderProject.mockClear()
|
||||
renders.mockClear()
|
||||
const updatedAt = now + 60_000
|
||||
act(() => store.set([{ ...initial, updatedAt }]))
|
||||
const time = screen.getByRole('button', { name: /^Streaming chat/ }).querySelector('time')!
|
||||
expect(time).toHaveTextContent(formatConversationListTime(updatedAt, 'en-US'))
|
||||
expect(time).toHaveAttribute('title', formatMediumDateTime(updatedAt, 'en-US'))
|
||||
expect(time).toHaveAttribute('datetime', new Date(updatedAt).toISOString())
|
||||
expect(renders).toHaveBeenCalledTimes(1)
|
||||
expect(renderProject).not.toHaveBeenCalled()
|
||||
renders.mockClear()
|
||||
act(() => store.set([{ ...initial, updatedAt: updatedAt + 1000 }]))
|
||||
expect(renders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['en-US', 'zh-CN'] as const)('shows actual update times in %s without using visits or hydrating task-only rows', async (locale) => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const now = new Date(2026, 9, 4, 14, 30).getTime()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const times = [now - 60_000, new Date(2026, 8, 2, 10).getTime(), new Date(2025, 8, 2, 10).getTime()]
|
||||
const conversations = times.map((time, i) => conversation(`Chat ${i}`, local.id, time))
|
||||
const activity: ConversationActivity = { conversationId: 'task-only', projectId: local.id,
|
||||
projectName: local.name, title: 'Task without summary', status: 'running' }
|
||||
const original = window.goodbuddy
|
||||
const get = vi.fn(), listSummaries = vi.fn()
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { conversations: { get, listSummaries } } })
|
||||
try {
|
||||
const { store } = setup({ conversations, activities: [activity] })
|
||||
act(() => { store.rememberOpened('Chat 2'); store.recordActivityStatuses([{ ...activity, status: 'completed' }]) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
await act(() => i18n.changeLanguage(locale))
|
||||
for (const [i, updatedAt] of times.entries()) {
|
||||
const time = screen.getByRole('button', { name: new RegExp(`^Chat ${i} `) }).querySelector('time')!
|
||||
expect(time).toHaveTextContent(formatConversationListTime(updatedAt, locale))
|
||||
expect(time).toHaveAttribute('title', formatMediumDateTime(updatedAt, locale))
|
||||
expect(time).toHaveAttribute('datetime', new Date(updatedAt).toISOString())
|
||||
}
|
||||
expect(screen.getByRole('button', { name: /^Task without summary/ }).querySelector('time')).toBeNull()
|
||||
expect(store.getActivityTimes().get('Chat 2')).toBeGreaterThan(times[2]!)
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(listSummaries).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
it('records activity transitions while closed and promotes task-only completions when opened', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const conversations = Array.from({ length: 12 }, (_, i) => conversation(`Recent ${i}`, local.id, 1000 - i))
|
||||
@@ -265,7 +414,7 @@ describe('production workspace menu', () => {
|
||||
const list = screen.getByRole('list', { name: 'Conversations' })
|
||||
expect(within(list).getAllByRole('button').length).toBeLessThan(120)
|
||||
fireEvent.keyDown(list, { key: 'End' })
|
||||
const last = within(list).getByRole('button', { name: 'Result 299 Completed' })
|
||||
const last = within(list).getByRole('button', { name: /^Result 299 .* Completed$/ })
|
||||
expect(last).toHaveFocus()
|
||||
expect(last.closest('[role="listitem"]')).toHaveAttribute('aria-setsize', '300')
|
||||
fireEvent.click(last)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, Plus, Server, X } from 'lucide-react'
|
||||
import { Archive, ArrowRight, ChevronDown, Plus, Server, X } from 'lucide-react'
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -7,10 +7,12 @@ import type { SshHost, SshHostAgentConnectionState } from '../../shared/ssh-host
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import type { ConversationStore } from './conversation-store'
|
||||
import { FloatingPortal } from './FloatingPortal'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { useListWindow } from './use-list-window'
|
||||
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
|
||||
import { useWorkspaceConversations, workspaceConversationRows, type WorkspaceFilter } from './workspace-menu-selectors'
|
||||
import { useWorkspaceConversations, useWorkspaceConversationTime, workspaceConversationRows, type WorkspaceFilter } from './workspace-menu-selectors'
|
||||
import './workspace-menu.css'
|
||||
|
||||
type ProjectCategory = 'allProjects' | 'local' | 'remote' | 'channels'
|
||||
@@ -18,6 +20,12 @@ type ProjectRow =
|
||||
| { id: string; kind: 'host'; hostId: string; label: string; state: SshHostAgentConnectionState }
|
||||
| { id: string; kind: 'project'; project: AssistantProject }
|
||||
|
||||
function WorkspaceConversationTime({ store, id }: { store: ConversationStore; id: string }): React.JSX.Element | null {
|
||||
const { i18n } = useTranslation()
|
||||
const time = useWorkspaceConversationTime(store, id, i18n.resolvedLanguage === 'en-US' ? 'en-US' : 'zh-CN')
|
||||
return time ? <time dateTime={time.dateTime} title={time.title}>{time.label}</time> : null
|
||||
}
|
||||
|
||||
export type WorkspaceMenuProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
@@ -32,6 +40,9 @@ export type WorkspaceMenuProps = {
|
||||
renderProject: (project: AssistantProject) => ReactNode
|
||||
onClose: () => void
|
||||
onCreateProject: () => void
|
||||
onEnterProject: (projectId: string) => void
|
||||
onRestore: (projectId: string) => Promise<void>
|
||||
notify: (input: AppNotificationInput) => void
|
||||
onNewConversation: (projectId: string) => void
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
}
|
||||
@@ -39,7 +50,7 @@ export type WorkspaceMenuProps = {
|
||||
export function WorkspaceMenu({
|
||||
projects, activeProjectId, activities, conversationStore, remoteProjectsEnabled,
|
||||
hosts, connectionStates, anchorRef, controlsRef, id, renderProject,
|
||||
onClose, onCreateProject, onNewConversation, onOpenConversation
|
||||
onClose, onCreateProject, onEnterProject, onRestore, notify, onNewConversation, onOpenConversation
|
||||
}: WorkspaceMenuProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const { t: tApp } = useTranslation('app')
|
||||
@@ -48,18 +59,25 @@ export function WorkspaceMenu({
|
||||
const projectListRef = useRef<HTMLDivElement>(null)
|
||||
const conversationListRef = useRef<HTMLDivElement>(null)
|
||||
const categoriesRef = useRef<HTMLDivElement>(null)
|
||||
const archiveButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [category, setCategory] = useState<ProjectCategory>('allProjects')
|
||||
if (category === 'remote' && !remoteProjectsEnabled) setCategory('allProjects')
|
||||
const [scope, setScope] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<WorkspaceFilter>('all')
|
||||
const [query, setQuery] = useState('')
|
||||
const [archivedView, setArchivedView] = useState(false)
|
||||
const [archivedProjects, setArchivedProjects] = useState<AssistantProject[]>()
|
||||
const [loadingArchives, setLoadingArchives] = useState(false)
|
||||
const [restoringId, setRestoringId] = useState<string>()
|
||||
const archivePending = useRef(false)
|
||||
const restorePending = useRef(false)
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const [keyboardProject, setKeyboardProject] = useState<string>()
|
||||
const [keyboardConversation, setKeyboardConversation] = useState<string>()
|
||||
const activityTimes = useSyncExternalStore(conversationStore.subscribeActivityTimes, conversationStore.getActivityTimes)
|
||||
const conversations = useWorkspaceConversations(conversationStore, tApp('conversation.defaultTitle'), activityTimes)
|
||||
const visibleProjects = useMemo(() => projects.filter((project) =>
|
||||
project.kind === 'channel' || project.executionSpace.kind !== 'ssh' || remoteProjectsEnabled
|
||||
const visibleProjects = useMemo(() => projects.filter((project) => project.status === 'active' && (
|
||||
project.kind === 'channel' || project.executionSpace.kind !== 'ssh' || remoteProjectsEnabled)
|
||||
), [projects, remoteProjectsEnabled])
|
||||
const projectById = useMemo(() => new Map(visibleProjects.map((project) => [project.id, project])), [visibleProjects])
|
||||
const availableActivities = useMemo(() => activities.filter((activity) => !activity.projectId || projectById.has(activity.projectId)), [activities, projectById])
|
||||
@@ -74,11 +92,13 @@ export function WorkspaceMenu({
|
||||
const projectRows = useMemo(() => {
|
||||
const hostById = new Map(hosts.map((host) => [host.id, host]))
|
||||
const search = query.trim().toLocaleLowerCase()
|
||||
const matching = visibleProjects.filter((project) => {
|
||||
const candidates = archivedView ? (archivedProjects ?? []).filter(project => project.kind === 'user' &&
|
||||
(project.executionSpace.kind === 'local' || remoteProjectsEnabled)) : visibleProjects
|
||||
const matching = candidates.filter((project) => {
|
||||
const host = project.executionSpace.kind === 'ssh' ? hostById.get(project.executionSpace.hostId) : undefined
|
||||
if (search) return `${getProjectDisplayText(project, t).name} ${project.rootPath} ${host?.name ?? ''} ${host?.hostname ?? ''} ${project.channel ?? ''}`.toLocaleLowerCase().includes(search)
|
||||
const kind = project.kind === 'channel' ? 'channels' : project.executionSpace.kind === 'ssh' ? 'remote' : 'local'
|
||||
return category === 'allProjects' || category === kind
|
||||
return archivedView || category === 'allProjects' || category === kind
|
||||
})
|
||||
const rows: ProjectRow[] = []
|
||||
const addProjects = (items: AssistantProject[]): void => {
|
||||
@@ -103,11 +123,11 @@ export function WorkspaceMenu({
|
||||
}
|
||||
addProjects(matching.filter((project) => project.kind === 'channel'))
|
||||
return rows
|
||||
}, [visibleProjects, hosts, query, category, t, remoteProjectsEnabled, connectionStates, collapsed])
|
||||
}, [visibleProjects, archivedView, archivedProjects, hosts, query, category, t, remoteProjectsEnabled, connectionStates, collapsed])
|
||||
const rows = useMemo(() => workspaceConversationRows(availableConversations, availableActivities, scope, filter, activityTimes), [availableConversations, availableActivities, scope, filter, activityTimes])
|
||||
const projectIds = useMemo(() => projectRows.map((row) => row.id), [projectRows])
|
||||
const conversationIds = useMemo(() => rows.map((row) => row.id), [rows])
|
||||
const projectWindow = useListWindow({ ids: projectIds, scope: `${category}:${query}`, scrollRef: projectListRef,
|
||||
const projectWindow = useListWindow({ ids: projectIds, scope: `${archivedView}:${category}:${query}`, scrollRef: projectListRef,
|
||||
keepIds: [keyboardProject], enabled: true, resetScrollOnScopeChange: true, estimatedRowHeight: 64 })
|
||||
const conversationWindow = useListWindow({ ids: conversationIds, scope: `${scope}:${filter}`, scrollRef: conversationListRef,
|
||||
keepIds: [keyboardConversation], enabled: true, resetScrollOnScopeChange: true, estimatedRowHeight: 56 })
|
||||
@@ -115,6 +135,37 @@ export function WorkspaceMenu({
|
||||
const scopeName = scope === null ? t('workspaceMenu.allProjects')
|
||||
: projectById.has(scope) ? getProjectDisplayText(projectById.get(scope)!, t).name : t('projectActivity.unassigned')
|
||||
|
||||
async function loadArchives(): Promise<void> {
|
||||
if (archivePending.current) return
|
||||
archivePending.current = true
|
||||
setLoadingArchives(true)
|
||||
try {
|
||||
setArchivedProjects((await window.goodbuddy.projects.list(true)).filter(project => project.status === 'archived'))
|
||||
} catch (reason) {
|
||||
notify({ tone: 'error', message: displayErrorMessage(reason, t('workspaceMenu.loadArchivesFailed')) })
|
||||
} finally {
|
||||
archivePending.current = false
|
||||
setLoadingArchives(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(project: AssistantProject): Promise<void> {
|
||||
if (restorePending.current) return
|
||||
restorePending.current = true
|
||||
setRestoringId(project.id)
|
||||
try {
|
||||
await onRestore(project.id)
|
||||
if (document.activeElement?.closest('[data-list-window-row]')?.getAttribute('data-list-window-row') === project.id) archiveButtonRef.current?.focus()
|
||||
setArchivedProjects(current => current?.filter(item => item.id !== project.id))
|
||||
notify({ tone: 'success', message: t('workspaceMenu.restored', { name: getProjectDisplayText(project, t).name }) })
|
||||
} catch (reason) {
|
||||
notify({ tone: 'error', message: displayErrorMessage(reason, t('workspaceMenu.restoreFailed')) })
|
||||
} finally {
|
||||
restorePending.current = false
|
||||
setRestoringId(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const menu = menuRef.current
|
||||
const anchor = anchorRef.current
|
||||
@@ -124,12 +175,13 @@ export function WorkspaceMenu({
|
||||
const width = Math.min(920, window.innerWidth - 32)
|
||||
menu.style.width = `${width}px`
|
||||
menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - width - 16))}px`
|
||||
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - menu.offsetHeight - 16))}px`
|
||||
const top = Math.max(16, rect.bottom + 8)
|
||||
menu.style.top = `${top}px`
|
||||
menu.style.maxHeight = `${Math.max(0, window.innerHeight - top - 16)}px`
|
||||
}
|
||||
position()
|
||||
const observer = new ResizeObserver(position)
|
||||
observer.observe(anchor)
|
||||
observer.observe(menu)
|
||||
window.addEventListener('resize', position)
|
||||
window.addEventListener('scroll', position, true)
|
||||
return () => {
|
||||
@@ -190,7 +242,7 @@ export function WorkspaceMenu({
|
||||
flushSync(() => setKeyboardProject(scope ?? undefined))
|
||||
const row = Array.from(projectListRef.current?.querySelectorAll<HTMLElement>('[data-list-window-row]') ?? [])
|
||||
.find((item) => item.dataset.listWindowRow === scope)
|
||||
const target = row?.querySelector<HTMLButtonElement>('button') ?? categoriesRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]')
|
||||
const target = row?.querySelector<HTMLButtonElement>('button') ?? (archivedView ? archiveButtonRef.current : categoriesRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]'))
|
||||
target?.focus()
|
||||
}
|
||||
}}>
|
||||
@@ -202,16 +254,23 @@ export function WorkspaceMenu({
|
||||
<section className="workspace-menu__projects">
|
||||
<input className="field-control workspace-menu__search" type="search" aria-label={t('workspaceMenu.search')}
|
||||
placeholder={t('workspaceMenu.search')} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<div className="workspace-menu__categories" ref={categoriesRef}>
|
||||
<div className="workspace-menu__categories" ref={categoriesRef} hidden={archivedView}>
|
||||
<PageTabs ariaLabel={t('workspaceMenu.categories')} idPrefix={`${id}-categories`} variant="segmented"
|
||||
tabs={categoryTabs} value={category} onChange={(next) => {
|
||||
setCategory(next)
|
||||
if (next === 'allProjects') setScope(null)
|
||||
}} />
|
||||
</div>
|
||||
<div className="workspace-menu__project-panel" role="tabpanel" id={`${id}-categories-panel-${category}`}
|
||||
aria-labelledby={query.trim() ? undefined : `${id}-categories-tab-${category}`}
|
||||
aria-label={query.trim() ? t('workspaceMenu.search') : undefined}>
|
||||
{archivedView && <div className="workspace-menu__archive-heading">
|
||||
<strong>{t('workspaceMenu.archived')}</strong>
|
||||
<button type="button" className="secondary-button" onClick={() => {
|
||||
flushSync(() => setArchivedView(false))
|
||||
categoriesRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]')?.focus()
|
||||
}}>{t('workspaceMenu.back')}</button>
|
||||
</div>}
|
||||
<div className="workspace-menu__project-panel" role={archivedView ? 'region' : 'tabpanel'} id={`${id}-categories-panel-${category}`}
|
||||
aria-labelledby={archivedView || query.trim() ? undefined : `${id}-categories-tab-${category}`}
|
||||
aria-label={archivedView ? t('workspaceMenu.archived') : query.trim() ? t('workspaceMenu.search') : undefined}>
|
||||
<div className="workspace-menu__project-list" ref={projectListRef} role="menu" aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
onScroll={projectWindow.onScroll} onFocus={projectWindow.onFocus} onBlur={projectWindow.onBlur}
|
||||
onKeyDown={(event) => moveFocus(event, 'projects')}>
|
||||
@@ -228,9 +287,14 @@ export function WorkspaceMenu({
|
||||
}} onFocus={(event) => {
|
||||
if (row.kind === 'project' && (event.target as HTMLElement).matches('[role="menuitemradio"]')) setScope(row.project.id)
|
||||
}}>
|
||||
{(category === 'allProjects' || query.trim()) && rowCategory !== previousCategory &&
|
||||
{(archivedView || category === 'allProjects' || query.trim()) && rowCategory !== previousCategory &&
|
||||
<h3 className="workspace-menu__project-category">{t(`workspaceMenu.${rowCategory}`)}</h3>}
|
||||
{row.kind === 'project' ? renderProject(row.project)
|
||||
{row.kind === 'project' ? archivedView ? <div className="workspace-menu__archived-project">
|
||||
<span><strong>{getProjectDisplayText(row.project, t).name}</strong><small>{row.project.rootPath}</small></span>
|
||||
<button type="button" role="menuitem" className="secondary-button" disabled={Boolean(restoringId)}
|
||||
aria-label={t('workspaceMenu.restoreNamed', { name: getProjectDisplayText(row.project, t).name })}
|
||||
onClick={() => void restore(row.project)}>{t(restoringId === row.project.id ? 'workspaceMenu.restoring' : 'workspaceMenu.restore')}</button>
|
||||
</div> : renderProject(row.project)
|
||||
: <button type="button" role="menuitem" className="project-switcher__host-heading workspace-menu__host"
|
||||
aria-expanded={Boolean(query.trim()) || !collapsed.has(row.hostId)} onClick={() => setCollapsed((current) => {
|
||||
const next = new Set(current)
|
||||
@@ -244,11 +308,16 @@ export function WorkspaceMenu({
|
||||
</button>}
|
||||
</div>
|
||||
})}
|
||||
{!projectRows.length && <p className="workspace-menu__empty">{t('workspaceMenu.noProjects')}</p>}
|
||||
{archivedView && loadingArchives ? <p className="workspace-menu__empty" role="status">{t('workspaceMenu.loadingArchives')}</p>
|
||||
: archivedView && !archivedProjects ? <div className="workspace-menu__empty"><button type="button" className="secondary-button" onClick={() => void loadArchives()}>{t('workspaceMenu.retryArchives')}</button></div>
|
||||
: !projectRows.length && <p className="workspace-menu__empty">{t(archivedView && !query.trim() ? 'workspaceMenu.noArchives' : 'workspaceMenu.noProjects')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="workspace-menu__footer"><button type="button" className="secondary-button" onClick={onCreateProject}>
|
||||
<Plus size={15} aria-hidden="true" />{t('projectSwitcher.selector.create')}
|
||||
</button><button type="button" className="workspace-menu__archived" ref={archiveButtonRef} aria-pressed={archivedView}
|
||||
onClick={() => { setArchivedView(!archivedView); if (!archivedView && !archivedProjects) void loadArchives() }}>
|
||||
<Archive size={14} aria-hidden="true" />{t('workspaceMenu.archived')}
|
||||
</button></footer>
|
||||
</section>
|
||||
<section className="workspace-menu__activity" aria-label={scopeName}>
|
||||
@@ -275,12 +344,23 @@ export function WorkspaceMenu({
|
||||
<h3 className="workspace-menu__category">{t(`workspaceMenu.groups.${row.group}`)}</h3>}
|
||||
<button type="button" className="workspace-menu__conversation" onClick={() => { onClose(); onOpenConversation(row.id) }}>
|
||||
<span><strong>{row.title}</strong>{scope === null && <>{' '}<small>{project ? getProjectDisplayText(project, t).name : t('projectActivity.unassigned')}</small></>}</span>{' '}
|
||||
{row.status && <small className={`project-activity__${row.status === 'running' || row.status === 'completed' ? row.status : 'attention'}`}>{t(`projectActivity.status.${row.status}`)}</small>}
|
||||
<span className="workspace-menu__metadata">
|
||||
<WorkspaceConversationTime store={conversationStore} id={row.id} />{' '}
|
||||
{row.status && <small className={`project-activity__${row.status === 'running' || row.status === 'completed' ? row.status : 'attention'}`}>{t(`projectActivity.status.${row.status}`)}</small>}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
})}
|
||||
{!rows.length && <p className="workspace-menu__empty">{t('workspaceMenu.noConversations')}</p>}
|
||||
</div>
|
||||
<footer className="workspace-menu__footer workspace-menu__enter">
|
||||
<button type="button" className="primary-button" disabled={!newProject}
|
||||
title={t('workspaceMenu.enterNamed', { name: newProject ? getProjectDisplayText(newProject, t).name : '' })}
|
||||
aria-label={t('workspaceMenu.enterNamed', { name: newProject ? getProjectDisplayText(newProject, t).name : '' })}
|
||||
onClick={() => { if (newProject) { onEnterProject(newProject.id); onClose() } }}>
|
||||
{t('workspaceMenu.enter')}<ArrowRight size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -431,10 +431,6 @@ export function createActivityStore(initial: readonly ActivityRecord[] = []) {
|
||||
updateRequest(requestId: string, status: ActivityRecord['status'], detail?: string): void {
|
||||
apply({ type: 'update-request', requestId, status, ...(detail === undefined ? {} : { detail }) })
|
||||
},
|
||||
/** Resolves the newest pending approval of a conversation. */
|
||||
updateApproval(conversationId: string, status: ActivityRecord['status'], detailLine: string): void {
|
||||
apply({ type: 'resolve-approval', conversationId, status, detailLine })
|
||||
},
|
||||
/** Drops every record of a request that never started. */
|
||||
removeByRequest(requestId: string): void {
|
||||
apply({ type: 'remove-request', requestId })
|
||||
@@ -597,4 +593,4 @@ function sameRecords(left: readonly ActivityRecord[], right: readonly ActivityRe
|
||||
return record === other || (recordKeys.every((key) => record[key] === other[key]) &&
|
||||
JSON.stringify(record.scope) === JSON.stringify(other.scope))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ type AssistantDatabase = {
|
||||
getActivityHistoryPage: (input: unknown) => ActivityHistoryPage
|
||||
getActivityHistorySummary: (input: unknown) => ActivityHistorySummary
|
||||
reconcileActivityHistory: (input: unknown) => number
|
||||
createTask: (input: { id: string; title: string; instructions: string; workMode: 'ask' }) => AssistantTask
|
||||
createTask: (input: { id: string; title: string; instructions: string }) => AssistantTask
|
||||
updateTaskStatus: (taskId: string, status: AssistantTask['status']) => void
|
||||
}
|
||||
let AssistantDatabase: new (path: string) => AssistantDatabase
|
||||
@@ -161,14 +161,6 @@ function oracle(initial: readonly ActivityRecord[]) {
|
||||
list = list.map((item) => item.requestId === requestId && item.kind === 'request'
|
||||
? { ...item, status, detail: detail ?? item.detail } : item)
|
||||
},
|
||||
updateApproval: (conversationId: string, status: ActivityRecord['status'], line: string) => {
|
||||
let updated = false
|
||||
list = list.map((item) => {
|
||||
if (updated || item.conversationId !== conversationId || item.kind !== 'approval' || item.status !== 'pending') return item
|
||||
updated = true
|
||||
return { ...item, status, detail: `${item.detail}\n${line}` }
|
||||
})
|
||||
},
|
||||
removeByRequest: (requestId: string) => { list = list.filter((item) => item.requestId !== requestId) },
|
||||
removeToolByCallId: (requestId: string, callId: string) => {
|
||||
list = list.filter((item) => !(item.requestId === requestId && item.kind === 'tool' && item.callId === callId))
|
||||
@@ -205,7 +197,7 @@ describe('activity history sync (paged)', () => {
|
||||
database.replaceActivityHistory({ records: stored, legacyHistoryMayBeIncomplete: false })
|
||||
const tasks: AssistantTask[] = []
|
||||
for (const [index, status] of (['completed', 'failed', 'running', 'cancelled'] as const).entries()) {
|
||||
const created = database.createTask({ id: `request-${index}`, title: 't', instructions: 'i', workMode: 'ask' })
|
||||
const created = database.createTask({ id: `request-${index}`, title: 't', instructions: 'i' })
|
||||
database.updateTaskStatus(created.id, status)
|
||||
tasks.push({ ...created, status })
|
||||
}
|
||||
@@ -238,9 +230,6 @@ describe('activity history sync (paged)', () => {
|
||||
const detail = random() < 0.5 ? 'done' : undefined
|
||||
store.updateRequest(requestId, status, detail)
|
||||
expected.updateRequest(requestId, status, detail)
|
||||
} else if (roll < 0.42) {
|
||||
store.updateApproval(conversationId, 'denied', 'decision')
|
||||
expected.updateApproval(conversationId, 'denied', 'decision')
|
||||
} else if (roll < 0.46) {
|
||||
store.removeByRequest(requestId)
|
||||
expected.removeByRequest(requestId)
|
||||
@@ -346,7 +335,7 @@ describe('activity history sync (paged)', () => {
|
||||
const done = record({ status: 'running', requestId: 'finished', kind: 'request', detail: 'started' })
|
||||
const live = record({ status: 'running', requestId: 'live' })
|
||||
database.replaceActivityHistory({ records: [leftover, done, live], legacyHistoryMayBeIncomplete: false })
|
||||
const task = database.createTask({ id: 'finished', title: 't', instructions: 'i', workMode: 'ask' })
|
||||
const task = database.createTask({ id: 'finished', title: 't', instructions: 'i' })
|
||||
database.updateTaskStatus(task.id, 'completed')
|
||||
const api = databaseApi(database)
|
||||
const store = createActivityStore()
|
||||
|
||||
@@ -582,11 +582,6 @@ export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies
|
||||
: event.routingMode === "native"
|
||||
? tRef.current("chat.subagents.native")
|
||||
: tRef.current("chat.subagents.manual"),
|
||||
actor.kind === "direct-model" && event.workMode
|
||||
? event.workMode === "execute"
|
||||
? "Execute"
|
||||
: "Ask"
|
||||
: undefined,
|
||||
event.reason,
|
||||
event.error,
|
||||
]
|
||||
@@ -603,7 +598,6 @@ export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies
|
||||
childTaskId: event.childTaskId,
|
||||
routingMode: event.routingMode,
|
||||
runtimeCallId: event.runtimeCallId,
|
||||
workMode: event.workMode,
|
||||
state: event.state,
|
||||
reason: event.reason,
|
||||
progress: event.progress,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
||||
*/
|
||||
export type ComposerMenuId =
|
||||
| "expert"
|
||||
| "mode"
|
||||
| "runtime-agent"
|
||||
| "runtime-action"
|
||||
| "runtime-preset";
|
||||
|
||||
@@ -58,7 +58,6 @@ export function toLocalConversationHeader(
|
||||
id: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
workMode: conversation.workMode,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
|
||||
@@ -150,7 +150,7 @@ export function useConversationActivitySummary(
|
||||
*/
|
||||
export type ActiveConversationView = Pick<
|
||||
Conversation,
|
||||
| "id" | "title" | "projectId" | "workMode" | "runtimeSelection" | "knowledgeLibraryIds"
|
||||
| "id" | "title" | "projectId" | "runtimeSelection" | "knowledgeLibraryIds"
|
||||
| "knowledgeRetrievalMode" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
|
||||
> & {
|
||||
/** Untouched greeting-only conversation: shown with the default title. */
|
||||
@@ -185,7 +185,6 @@ export function selectActiveConversationView(
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
projectId: conversation.projectId,
|
||||
workMode: conversation.workMode,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
@@ -213,7 +212,7 @@ export function sameActiveConversationView(
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
return left.id === right.id && left.title === right.title &&
|
||||
left.projectId === right.projectId && left.workMode === right.workMode &&
|
||||
left.projectId === right.projectId &&
|
||||
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
|
||||
left.unused === right.unused && left.historyLoaded === right.historyLoaded &&
|
||||
left.running === right.running && left.messageCount === right.messageCount &&
|
||||
|
||||
@@ -119,7 +119,7 @@ export const app = {
|
||||
defaultTitle: 'New conversation',
|
||||
remoteTitle: 'Remote conversation',
|
||||
greeting:
|
||||
'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.',
|
||||
'Hi, I’m GoodBuddy. Ask a question or give me a task. Available tools depend on the selected Runtime and settings.',
|
||||
interrupted: 'The previous run stopped unexpectedly. You can resend your question.',
|
||||
active: 'Conversation is active',
|
||||
unread: 'Unread',
|
||||
@@ -187,7 +187,6 @@ export const app = {
|
||||
instructions: 'Task content',
|
||||
timing: 'Execution time',
|
||||
destination: 'Conversation',
|
||||
mode: 'Work mode',
|
||||
recurrence: 'Frequency',
|
||||
time: 'First run'
|
||||
},
|
||||
@@ -195,34 +194,24 @@ export const app = {
|
||||
current: 'Current conversation',
|
||||
new: 'New conversation',
|
||||
currentHelp:
|
||||
'Use the selected conversation\'s history, Runtime, work mode, and enabled capabilities when the message is sent.',
|
||||
'Use the selected conversation\'s history, Runtime, and enabled capabilities when the message is sent.',
|
||||
newHelp:
|
||||
'Create an independent ordinary conversation with project defaults and the task name as its title.',
|
||||
currentUnavailable:
|
||||
'The current conversation cannot host this task. Choose another conversation in this project or a new conversation.'
|
||||
},
|
||||
mode: {
|
||||
execute: 'Execute',
|
||||
ask: 'Ask',
|
||||
executeUnavailable:
|
||||
'The current Runtime cannot use tools, so read-only Ask is selected.'
|
||||
},
|
||||
recurrence: {
|
||||
once: 'Once',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly'
|
||||
},
|
||||
scope: {
|
||||
conversationSettings: 'Use the target conversation\'s Runtime, work mode, and enabled tools at send time. Adjust them in the conversation.',
|
||||
conversationSettings: 'Use the target conversation\'s Runtime and enabled tools at send time. Adjust them in the conversation.',
|
||||
title: 'Execution scope',
|
||||
project: 'Project',
|
||||
runtime: 'Runtime',
|
||||
workspace: 'Workspace',
|
||||
tools: 'Tools and approval',
|
||||
executeApproval:
|
||||
'Use all tools, processes, network access, and writable paths available to the current account; calls are recorded in Activity',
|
||||
askReadOnly:
|
||||
'Stay within the read-only boundary for answers and retrieval; make no changes',
|
||||
noWorkspace: 'No workspace configured'
|
||||
},
|
||||
errors: {
|
||||
@@ -369,7 +358,6 @@ export const app = {
|
||||
recoveryPrompt: 'Use image model "{{model}}": {{prompt}}',
|
||||
noToolsNotice: 'This chat model cannot call image tools automatically. You can use the existing direct image workflow.',
|
||||
directWorkflowNotice: 'Choose an image model in the Runtime selector and send your request. If none is configured, add an image connection in model settings.',
|
||||
askNotice: 'Generating or editing images requires Execute mode. Change it using the work mode selector.',
|
||||
openModelSettings: 'Open image model settings',
|
||||
cancelNotice: 'Waiting was stopped. The provider may still generate and charge for the image; a late result will be saved here.',
|
||||
cancellingNotice: 'Cancellation was requested. The provider may still generate and charge for the image.',
|
||||
@@ -527,18 +515,7 @@ export const app = {
|
||||
skipped: 'Skipped'
|
||||
},
|
||||
approval: {
|
||||
waiting: 'Awaiting approval: {{tool}}',
|
||||
deny: 'Deny',
|
||||
once: 'Allow once',
|
||||
session: 'Allow for conversation',
|
||||
permanent: 'Always allow',
|
||||
decisionDeny: 'Denied',
|
||||
decisionOnce: 'Allowed once',
|
||||
decisionSession: 'Allowed for this conversation',
|
||||
decisionPermanent: 'Always allowed',
|
||||
executing: '{{decision}}; Agent is running',
|
||||
denied: 'Tool execution denied',
|
||||
responseFailed: 'Failed to respond to approval. Try again.'
|
||||
waiting: 'Awaiting approval: {{tool}}'
|
||||
}
|
||||
},
|
||||
composer: {
|
||||
@@ -566,7 +543,6 @@ export const app = {
|
||||
storyGraph: { label: 'Use story graph' },
|
||||
options: 'Options',
|
||||
expertLabel: 'Expert role',
|
||||
modeLabel: 'Work mode',
|
||||
runtimeControls: {
|
||||
groupLabel: '{{runtime}} controls',
|
||||
agentLabel: 'OpenCode Runtime Agent',
|
||||
@@ -644,18 +620,6 @@ export const app = {
|
||||
teamDescription: 'Multiple experts collaborate in parallel',
|
||||
customDescription: 'Custom expert role'
|
||||
},
|
||||
modes: {
|
||||
ask: {
|
||||
label: 'Ask · Read only',
|
||||
description:
|
||||
'Stay within the read-only boundary for answers and retrieval; make no changes'
|
||||
},
|
||||
execute: {
|
||||
label: 'Execute · Full access',
|
||||
description:
|
||||
'Use all tools, processes, network access, and writable paths available to the current account; calls are recorded in Activity'
|
||||
}
|
||||
},
|
||||
voice: {
|
||||
stopRecording: 'Stop recording',
|
||||
cancel: 'Cancel speech recognition',
|
||||
@@ -728,14 +692,6 @@ export const app = {
|
||||
configureRuntime: 'Configure an available model or Agent Runtime first.',
|
||||
imageGeneration:
|
||||
'Image model: describe a scene to generate an image that will appear here and be saved to Results.',
|
||||
agentAsk:
|
||||
'{{runtime}} Ask mode: stays within the read-only boundary for answers and retrieval and makes no changes.',
|
||||
agentExecute:
|
||||
'{{runtime}} Execute mode: uses all tools, processes, network access, and writable paths available to the current account; calls are recorded in Activity.',
|
||||
ask:
|
||||
'Ask mode: stays within the read-only boundary for answers and retrieval and makes no changes.',
|
||||
execute:
|
||||
'Execute mode: uses all tools, processes, network access, and writable paths available to the current account; calls are recorded in Activity.'
|
||||
},
|
||||
errors: {
|
||||
pasteImageType: 'Only JPEG, PNG, or WebP images can be pasted',
|
||||
@@ -819,7 +775,6 @@ export const app = {
|
||||
heartbeatTaskAdded:
|
||||
'Added “{{title}}” to the conversation. Review it before sending.',
|
||||
userStartedTask: 'User started a conversation task',
|
||||
userDecision: 'User selected {{decision}}',
|
||||
conversationDeleted: 'The related conversation has been deleted',
|
||||
localDataCleared:
|
||||
'Local conversations, tasks, memory, automatic supervision data, automations, and knowledge indexes were cleared',
|
||||
|
||||
@@ -96,6 +96,15 @@ export const heartbeat = {
|
||||
unknownTime: 'Unknown time'
|
||||
},
|
||||
supervisor: {
|
||||
loadFailed: 'Review content could not be loaded. Please retry.',
|
||||
sourceLoadFailed: 'The source could not be loaded. Open it again to retry.',
|
||||
reviewStarted: 'Review started. Open Activity records to check progress.',
|
||||
reviewBusy: 'A review is already in progress. Open Activity records to check it.',
|
||||
reviewStartFailed: 'Could not check whether a review is running. Please try again shortly.',
|
||||
reviewFailed: 'The review could not finish. Open Activity records for details and to continue saved progress, or start a new review.',
|
||||
reviewCompleted: 'Review completed. You can view the result or Activity records.',
|
||||
reviewCancelled: 'Review cancelled. Existing results remain available.',
|
||||
reviewNoChange: 'There is no new content to review. Existing results are unchanged.',
|
||||
automatic: 'Smart heartbeat',
|
||||
projectScope: 'Project: {{names}}',
|
||||
canvasTitle: 'Events and knowledge', generatedAt: 'Generated {{time}}', canvasNote: 'Read counter-clockwise · Select a node to explore knowledge and sources', readingGuide: 'Reading the graph',
|
||||
|
||||
@@ -6,11 +6,11 @@ export const integrations = {
|
||||
title: 'Image generation and editing',
|
||||
managed: 'Builtin · Automatically managed',
|
||||
description: 'Automatically managed by the image models’ “Allow AI to invoke in conversations” setting.',
|
||||
runtimeScope: 'Assignments include applicable remote Runtimes through GoodBuddy Agent. Generation requires Execute and a chat model with tool calling; assignments do not indicate runtime readiness.',
|
||||
runtimeScope: 'Assignments include applicable remote Runtimes through GoodBuddy Agent. Generation requires a chat model with tool calling; assignments do not indicate runtime readiness.',
|
||||
openSettings: 'Go to image model settings',
|
||||
tools: {
|
||||
generate_image: 'Generate a new image with an image model allowed in conversations, or edit images from this conversation. Results are saved in the conversation.',
|
||||
save_image: 'Save a generated or uploaded image from this conversation to a PNG, JPEG or WebP file. Requires Execute mode; in remote projects the file is written on the remote host.'
|
||||
save_image: 'Save a generated or uploaded image from this conversation to a PNG, JPEG or WebP file. In remote projects the file is written on the remote host.'
|
||||
}
|
||||
},
|
||||
channels: {
|
||||
@@ -39,7 +39,7 @@ export const integrations = {
|
||||
'Choose the default working directory for {{name}}',
|
||||
select: 'Choose',
|
||||
rootHelp:
|
||||
'Remote Execute operations can run only within this project directory.',
|
||||
'The working directory for this channel project.',
|
||||
backendLabel: 'Message processing backend',
|
||||
backendAriaLabel: '{{name}} message processing backend',
|
||||
directModels: 'Direct models',
|
||||
@@ -62,18 +62,6 @@ export const integrations = {
|
||||
'Follow the global default Runtime setting.',
|
||||
runtimeDescription:
|
||||
'Run through the {{runtime}} Agent Runtime using the global {{runtime}} configuration under Agent Runtime settings.',
|
||||
defaultMode: 'Default mode',
|
||||
defaultModeAriaLabel: '{{name}} default mode',
|
||||
modes: {
|
||||
ask: 'Ask',
|
||||
execute: 'Execute'
|
||||
},
|
||||
overrideHelp:
|
||||
'Prefix a message with /ask, /execute, Ask:, or Execute: to override the mode temporarily.',
|
||||
executeRisk:
|
||||
'Execute messages are sent to the selected backend immediately without per-request confirmation.',
|
||||
askRisk:
|
||||
'In Ask mode, allowlisted senders can still use /execute to start an operation without confirmation.',
|
||||
riskSuffix:
|
||||
'Connect only trusted accounts and limit the working directory to the required scope.'
|
||||
},
|
||||
@@ -211,7 +199,7 @@ export const integrations = {
|
||||
custom: 'Custom MCP'
|
||||
},
|
||||
customNotice:
|
||||
'Custom MCP loads only in Execute mode and can be assigned to direct models, GoodBuddy-managed OpenCode, Continue Agent, or DeepSeek Harness. New servers target direct models by default. Servers and their tools run with the current user’s permissions, so add only trusted services. GoodBuddy keeps server addresses, commands, and credentials in the main process, encrypts remote access tokens in secure system storage, and still requires approval for tool calls. stdio servers start in a restricted environment without desktop session variables; use diagnosed built-in capabilities for computer control.',
|
||||
'Once enabled and assigned, this server’s tools are available to the selected Runtime. Custom MCP can be assigned to direct models, GoodBuddy-managed OpenCode, Continue Agent, or DeepSeek Harness. New servers target direct models by default. Servers and their tools run with the current user’s permissions, so add only trusted services. GoodBuddy keeps server addresses, commands, and credentials in the main process and encrypts remote access tokens in secure system storage. stdio servers start in a restricted environment without desktop session variables; use diagnosed built-in capabilities for computer control.',
|
||||
computer: {
|
||||
title: 'Computer control capabilities',
|
||||
supported: 'Supported on this device',
|
||||
@@ -240,8 +228,7 @@ export const integrations = {
|
||||
runtimeAssignmentUnsupportedAriaLabel:
|
||||
'{{name}} cannot be assigned to {{runtime}} because this runtime does not support built-in MCP',
|
||||
unsupportedSuffix: ' (not supported yet)',
|
||||
serverSummaryExecuteOnly: 'Execute only',
|
||||
serverSummaryMixed: 'Read and write by mode',
|
||||
serverSummaryMixed: 'Read and write',
|
||||
serverSummaryReadOnly: 'Read-only',
|
||||
serverSummaryDisabled: 'Disabled · Enable Magic Notes first',
|
||||
featureDisabled:
|
||||
@@ -263,11 +250,11 @@ export const integrations = {
|
||||
},
|
||||
browser: {
|
||||
title: 'Built-in browser',
|
||||
subtitle: 'Built-in GoodBuddy capability · Isolated browser · Execute',
|
||||
subtitle: 'Built-in GoodBuddy capability · Isolated browser',
|
||||
description:
|
||||
'Opens and operates webpages in GoodBuddy’s temporary isolated browser. It does not control Chrome, Edge, or another browser installed on the client computer.',
|
||||
control:
|
||||
'This switch controls Agent access only. When off, browser tools are unavailable to every runtime, but you can still operate the browser manually from the workbar. When on, assigned runtimes can use them directly in Execute mode without per-action prompts.',
|
||||
'This switch controls Agent access only. When off, browser tools are unavailable to every runtime, but you can still operate the browser manually from the workbar. When on, assigned runtimes can use them directly.',
|
||||
enableAriaLabel: 'Enable the built-in GoodBuddy browser',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
@@ -277,9 +264,9 @@ export const integrations = {
|
||||
},
|
||||
webSearch: {
|
||||
title: 'Web search',
|
||||
subtitle: 'Direct-model tool · Exa MCP · Ask / Execute',
|
||||
subtitle: 'Direct-model tool · Exa MCP',
|
||||
description:
|
||||
'Provides web_search and web_fetch for public web search and reading only. The tools are available in Ask and Execute.',
|
||||
'Provides web_search and web_fetch for public web search and reading only.',
|
||||
privacy:
|
||||
'Queries and public webpage addresses are sent to the third-party Exa service. Model API keys, local files, and knowledge content are not sent.',
|
||||
enableAriaLabel: 'Enable direct-model web search',
|
||||
|
||||
@@ -71,16 +71,11 @@ export const settings = {
|
||||
description:
|
||||
'Manage SSH hosts, pin host keys, and verify remote execution environments. Locally configured Skills and MCP servers do not apply to remote hosts.'
|
||||
},
|
||||
security: {
|
||||
label: 'Security and data',
|
||||
navigationDescription: 'Tool policies and local privacy',
|
||||
description: 'Tool policies and local privacy'
|
||||
},
|
||||
channels: {
|
||||
label: 'Message channels',
|
||||
navigationDescription: 'WeChat, WeCom, and DingTalk',
|
||||
description:
|
||||
'Configure connections, workspaces, message backends, and default modes'
|
||||
'Configure connections, workspaces, and message backends'
|
||||
},
|
||||
roles: {
|
||||
label: 'Roles and prompts',
|
||||
@@ -234,7 +229,7 @@ export const settings = {
|
||||
followGoodBuddy: 'Follow GoodBuddy · {{name}} ({{model}})',
|
||||
noCompatibleModel: 'No compatible text model is configured',
|
||||
permissions:
|
||||
'Choose Ask or Execute in a conversation. Ask can use only read-only capabilities allowed by the current Runtime. Execute can use enabled tools, and records tool calls in Activity.',
|
||||
'Available tools depend on the selected Runtime and settings. Tool calls are recorded in Activity.',
|
||||
customization: {
|
||||
title: 'Capabilities and defaults',
|
||||
description:
|
||||
@@ -271,7 +266,6 @@ export const settings = {
|
||||
unsupported: 'Not supported by this Runtime',
|
||||
toolsUnsupported:
|
||||
'This Runtime does not support static discovery of Tools',
|
||||
toolModes: 'Ask: {{ask}} · Execute: {{execute}}',
|
||||
toolKind: {
|
||||
read: 'Read',
|
||||
write: 'File modification',
|
||||
@@ -288,11 +282,6 @@ export const settings = {
|
||||
skill: 'Skill',
|
||||
unknown: 'Unknown source'
|
||||
},
|
||||
toolAccess: {
|
||||
allowed: 'Available',
|
||||
blocked: 'Unavailable',
|
||||
conditional: 'Request-dependent'
|
||||
}
|
||||
},
|
||||
agentMode: {
|
||||
primary: 'Primary Agent',
|
||||
@@ -413,7 +402,7 @@ export const settings = {
|
||||
title: 'DeepSeek Harness',
|
||||
previewDescription: 'Developer preview · OpenAI-compatible',
|
||||
description:
|
||||
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Ask can call native read/skill plus enabled Web Search/Fetch, while Execute can use every enabled tool and DSH plugin capability. Cancellation and workspace boundaries remain in place.',
|
||||
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. The Runtime can use enabled tools and DSH plugin capabilities. Cancellation and workspace boundaries remain in place.',
|
||||
platformSource: 'Administrator environment, then GoodBuddy fallback',
|
||||
environmentSource:
|
||||
'This option resolves to the administrator preset: {{model}}.',
|
||||
@@ -445,7 +434,7 @@ export const settings = {
|
||||
disabledDescription:
|
||||
'The plugin marketplace is off by default. Turn it on to connect to the public npm catalog and show its management interface. Turning off the marketplace does not disable or uninstall existing plugins.',
|
||||
permissionNotice:
|
||||
'Third-party install scripts, initialization code, and tools run with your user permissions. Ask cannot call third-party plugin tools, but it cannot limit plugin initialization code. Execute can call every tool from enabled plugins. Install only packages you trust.',
|
||||
'Third-party install scripts, initialization code, and tools run with your user permissions. Enabled plugins provide their tools to the Runtime. Install only packages you trust.',
|
||||
refresh: 'Refresh',
|
||||
refreshAria: 'Refresh the DSH plugin marketplace',
|
||||
searchLabel: 'Search plugins',
|
||||
@@ -866,19 +855,10 @@ export const settings = {
|
||||
'This prompt is sent as a trusted summary instruction. Conversation content is always treated as untrusted historical data.',
|
||||
restoreDefaultPrompt: 'Restore default prompt'
|
||||
},
|
||||
security: {
|
||||
toolPolicy: {
|
||||
label: 'Direct model tool security policy',
|
||||
always: 'Automatically authorize enabled tools in Execute',
|
||||
deny: 'Block all tool execution',
|
||||
description:
|
||||
'Direct models in Execute mode can use built-in workspace tools and assigned MCP tools. Choosing Execute authorizes automatic tool calls for the current interaction without individual prompts. The blocking policy denies every tool call. OpenCode and Continue continue to use their own tool systems.'
|
||||
},
|
||||
localData: {
|
||||
title: 'Local data and privacy',
|
||||
description:
|
||||
'Clear local conversations, activity records, and knowledge base indexes. Saved Runtime credentials and source files are not deleted.'
|
||||
}
|
||||
localData: {
|
||||
title: 'Local data and privacy',
|
||||
description:
|
||||
'Clear local conversations, activity records, and knowledge base indexes. Saved Runtime credentials and source files are not deleted.'
|
||||
},
|
||||
roles: {
|
||||
smartRouting: {
|
||||
@@ -887,7 +867,7 @@ export const settings = {
|
||||
'Automatically choose the expert role that best matches the question',
|
||||
enabled: 'Enable Smart Subagent routing',
|
||||
help:
|
||||
'Off by default. In Ask mode, when no expert or team is explicitly selected, GoodBuddy chooses one expert. The Subagent uses the default text model and inherits the current mode and enabled tools.'
|
||||
'Off by default. When no expert or team is explicitly selected, GoodBuddy chooses one expert. The Subagent uses the default text model and enabled tools.'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
|
||||
|
||||
export const workspace = {
|
||||
workspaceMenu: {
|
||||
enter: 'Enter project', enterNamed: 'Enter project {{name}}',
|
||||
archived: 'Archived', back: 'Back', restore: 'Restore', restoreNamed: 'Restore project {{name}}', restoring: 'Restoring…',
|
||||
restored: '{{name}} restored to its project category.', restoreFailed: 'Could not restore the project.',
|
||||
loadingArchives: 'Loading archived projects…', loadArchivesFailed: 'Could not load archived projects.',
|
||||
retryArchives: 'Retry loading archives', noArchives: 'No archived projects. Archived projects can be restored here.',
|
||||
title: 'Projects and activity', close: 'Close projects and activity', search: 'Search projects',
|
||||
allProjects: 'All projects', local: 'Local', remote: 'Remote', channels: 'Channels', categories: 'Project categories',
|
||||
filter: 'Filter conversation status', conversations: 'Conversations', newConversation: 'New conversation',
|
||||
@@ -31,6 +36,7 @@ export const workspace = {
|
||||
}
|
||||
},
|
||||
management: {
|
||||
importFiles: 'Import files', importing: 'Importing…', importTarget: 'Destination: {{path}}', imported: 'Imported {{count}} files into {{path}}.', created: 'Created',
|
||||
gitWorkspace: 'Git Workspace', more: 'More actions for {{name}}', browse: 'Browse directory', parent: 'Parent directory',
|
||||
createFile: 'New file', createDirectory: 'New directory', rename: 'Rename', move: 'Move', delete: 'Delete', properties: 'Properties',
|
||||
defaultOpen: 'Open with default application', name: 'Name', nameOnly: 'Name cannot contain path separators', destination: 'Destination directory',
|
||||
@@ -47,10 +53,6 @@ export const workspace = {
|
||||
description: 'GoodBuddy default workspace'
|
||||
},
|
||||
projectSwitcher: {
|
||||
workModes: {
|
||||
ask: 'Ask · Read-only',
|
||||
execute: 'Execute · Full access'
|
||||
},
|
||||
selector: {
|
||||
ariaLabel: 'Current project',
|
||||
userProjects: 'Local projects',
|
||||
@@ -95,7 +97,6 @@ export const workspace = {
|
||||
description: 'Description',
|
||||
rootPath: 'Root folder',
|
||||
executionSpace: 'Execution space',
|
||||
defaultMode: 'Default mode',
|
||||
defaultRuntime: 'Default Runtime for new conversations'
|
||||
},
|
||||
runtimeOptions: {
|
||||
@@ -182,7 +183,7 @@ export const workspace = {
|
||||
select: 'Select this directory'
|
||||
},
|
||||
runtimeHelp:
|
||||
'Managed SSH projects can use an installed OpenCode or Continue Runtime. Ask is read-only; Execute can use every permission available to the selected SSH account. Saving checks the Host, Agent, workspace, and selected Runtime.',
|
||||
'Managed SSH projects can use an installed OpenCode or Continue Runtime. Saving checks the Host, Agent, workspace, and selected Runtime.',
|
||||
actions: {
|
||||
save: 'Save remote project',
|
||||
saving: 'Saving remote project…'
|
||||
@@ -220,7 +221,7 @@ export const workspace = {
|
||||
notes: { label: 'Notes', description: 'Search notes, capture conversations, and append text.' },
|
||||
tasks: {
|
||||
label: 'Task center',
|
||||
description: 'Review approvals and manage automations'
|
||||
description: 'Review task status and manage automations'
|
||||
},
|
||||
workspace: {
|
||||
label: 'Files',
|
||||
@@ -331,8 +332,6 @@ export const workspace = {
|
||||
tasks: {
|
||||
approvalsTitle: 'Awaiting approval',
|
||||
noApprovals: 'There are no operations awaiting approval.',
|
||||
deny: 'Deny',
|
||||
allowOnce: 'Allow once',
|
||||
taskIndexTitle: 'Project tasks',
|
||||
runningCount: '{{count}} running',
|
||||
stats: {
|
||||
@@ -345,7 +344,7 @@ export const workspace = {
|
||||
incomplete: 'Some replies have no timing records. Only known durations are included.',
|
||||
partial: 'Partial records',
|
||||
timeHelp: 'About timing',
|
||||
timeDescription: 'Adds the actual duration of each Agent reply, excluding idle time between replies. Missing durations are not estimated from conversation or task start and end times.',
|
||||
timeDescription: 'Adds time while each Agent reply is running, excluding pauses, approval waits and idle time between replies. Old history and interrupted intervals are not reconstructed. Remote requests include only running time observed by the desktop.',
|
||||
taskDuration: 'Cumulative task time'
|
||||
},
|
||||
empty: 'Explicitly created tasks will appear here.',
|
||||
@@ -410,11 +409,14 @@ export const workspace = {
|
||||
truncatedDiff: '\n\n[Output exceeded the safety limit and was truncated]'
|
||||
},
|
||||
results: {
|
||||
back: 'Back to results',
|
||||
title: 'Results',
|
||||
sectionTitle: 'Generated and imported results',
|
||||
loadingImage: 'Loading image…',
|
||||
import: 'Import PDF, image, or web page',
|
||||
retryImage: 'Retry loading image',
|
||||
import: 'Import files',
|
||||
importProjectHelp: 'Add local files to this project’s results, not a knowledge base.',
|
||||
importGlobalHelp: 'Add local files to global results, not a knowledge base.',
|
||||
importTooltip: 'Supports PDF, images, text, JSON, and local HTML files. PDFs display extracted text.',
|
||||
empty:
|
||||
'Generated files, images, reports, and manually imported content will appear here.'
|
||||
},
|
||||
@@ -479,14 +481,7 @@ export const workspace = {
|
||||
cancelled: 'Cancelled',
|
||||
interrupted: 'Interrupted'
|
||||
},
|
||||
mode: {
|
||||
conversation: 'Conversation settings',
|
||||
ask: 'Ask',
|
||||
execute: 'Execute',
|
||||
unavailable: 'Mode unavailable'
|
||||
},
|
||||
fields: {
|
||||
mode: 'Mode',
|
||||
schedule: 'Schedule',
|
||||
nextRun: 'Next run',
|
||||
outcome: 'Latest result'
|
||||
|
||||
@@ -116,7 +116,7 @@ export const app = {
|
||||
defaultTitle: '新对话',
|
||||
remoteTitle: '远程会话',
|
||||
greeting:
|
||||
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。',
|
||||
'你好,我是 GoodBuddy。可以直接提问或交给我任务。可用工具由所选 Runtime 和设置决定。',
|
||||
interrupted: '上次运行意外中断,可以重新发送问题',
|
||||
active: '会话正在活动',
|
||||
unread: '未读',
|
||||
@@ -179,37 +179,28 @@ export const app = {
|
||||
instructions: '任务内容',
|
||||
timing: '执行时间',
|
||||
destination: '关联会话',
|
||||
mode: '执行模式',
|
||||
recurrence: '运行频率',
|
||||
time: '首次运行'
|
||||
},
|
||||
destination: {
|
||||
current: '当前会话',
|
||||
new: '新建会话',
|
||||
currentHelp: '发送时使用所选会话的历史、Runtime、工作模式和已启用能力。',
|
||||
currentHelp: '发送时使用所选会话的历史、Runtime 和已启用能力。',
|
||||
newHelp: '创建独立的普通会话,使用项目默认设置,以任务名称作为标题。',
|
||||
currentUnavailable: '当前会话不能关联此任务,请选择本项目其他会话或新建会话。'
|
||||
},
|
||||
mode: {
|
||||
execute: 'Execute',
|
||||
ask: 'Ask',
|
||||
executeUnavailable: '当前 Runtime 不支持工具执行,已使用只读 Ask。'
|
||||
},
|
||||
recurrence: {
|
||||
once: '单次',
|
||||
daily: '每日',
|
||||
weekly: '每周'
|
||||
},
|
||||
scope: {
|
||||
conversationSettings: '发送时使用目标会话的 Runtime、工作模式和已启用工具,可在会话中调整。',
|
||||
conversationSettings: '发送时使用目标会话的 Runtime 和已启用工具,可在会话中调整。',
|
||||
title: '执行范围',
|
||||
project: '项目',
|
||||
runtime: 'Runtime',
|
||||
workspace: '工作目录',
|
||||
tools: '工具与审批',
|
||||
executeApproval:
|
||||
'使用当前账号可用的全部工具、进程、网络和可写路径;调用记录到活动',
|
||||
askReadOnly: '保持只读边界,仅问答和检索,不执行任何变更',
|
||||
noWorkspace: '未设置工作目录'
|
||||
},
|
||||
errors: {
|
||||
@@ -354,7 +345,6 @@ export const app = {
|
||||
recoveryPrompt: '使用图片模型“{{model}}”:{{prompt}}',
|
||||
noToolsNotice: '当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。',
|
||||
directWorkflowNotice: '在 Runtime 选择器中选择图片模型后发送请求;尚未配置时,请前往模型设置添加图片连接。',
|
||||
askNotice: '生成或编辑图片需要 Execute 模式,请通过工作模式选择器切换。',
|
||||
openModelSettings: '前往图片模型设置',
|
||||
cancelNotice: '已停止等待。提供商可能继续生成并计费,晚到的结果会保存在这里。',
|
||||
cancellingNotice: '已请求取消。提供商可能继续生成并计费。',
|
||||
@@ -508,18 +498,7 @@ export const app = {
|
||||
skipped: '已跳过'
|
||||
},
|
||||
approval: {
|
||||
waiting: '等待审批:{{tool}}',
|
||||
deny: '拒绝',
|
||||
once: '仅此次',
|
||||
session: '此会话',
|
||||
permanent: '永久允许',
|
||||
decisionDeny: '拒绝',
|
||||
decisionOnce: '仅此次允许',
|
||||
decisionSession: '此会话允许',
|
||||
decisionPermanent: '永久允许',
|
||||
executing: '{{decision}},Agent 正在执行',
|
||||
denied: '已拒绝工具执行',
|
||||
responseFailed: '审批响应失败,请重试'
|
||||
waiting: '等待审批:{{tool}}'
|
||||
}
|
||||
},
|
||||
composer: {
|
||||
@@ -546,7 +525,6 @@ export const app = {
|
||||
storyGraph: { label: '使用故事图谱' },
|
||||
options: '选项',
|
||||
expertLabel: '专家角色',
|
||||
modeLabel: '工作模式',
|
||||
runtimeControls: {
|
||||
groupLabel: '{{runtime}} 专属功能',
|
||||
agentLabel: 'OpenCode Runtime Agent',
|
||||
@@ -620,17 +598,6 @@ export const app = {
|
||||
teamDescription: '多个专家并行协作',
|
||||
customDescription: '自定义专家角色'
|
||||
},
|
||||
modes: {
|
||||
ask: {
|
||||
label: 'Ask · 只读问答',
|
||||
description: '保持只读边界,仅问答和检索,不执行任何变更'
|
||||
},
|
||||
execute: {
|
||||
label: 'Execute · 完全权限',
|
||||
description:
|
||||
'使用当前账号可用的全部工具、进程、网络和可写路径;调用记录到活动'
|
||||
}
|
||||
},
|
||||
voice: {
|
||||
stopRecording: '停止录音',
|
||||
cancel: '取消语音识别',
|
||||
@@ -689,13 +656,6 @@ export const app = {
|
||||
configureRuntime: '请先配置可用的模型或 Agent Runtime。',
|
||||
imageGeneration:
|
||||
'图像生成模型:输入画面描述后,生成结果会直接显示并保存到成果。',
|
||||
agentAsk:
|
||||
'{{runtime}} Ask 模式:保持只读边界,仅问答和检索,不执行任何变更。',
|
||||
agentExecute:
|
||||
'{{runtime}} Execute 模式:使用当前账号可用的全部工具、进程、网络和可写路径;调用记录到活动。',
|
||||
ask: 'Ask 模式:保持只读边界,仅问答和检索,不执行任何变更。',
|
||||
execute:
|
||||
'Execute 模式:使用当前账号可用的全部工具、进程、网络和可写路径;调用记录到活动。'
|
||||
},
|
||||
errors: {
|
||||
pasteImageType: '仅支持粘贴 JPEG、PNG 或 WebP 图片',
|
||||
@@ -767,7 +727,6 @@ export const app = {
|
||||
heartbeatTaskPrompt: '请根据以下监督反馈制定可执行方案:',
|
||||
heartbeatTaskAdded: '已将“{{title}}”带入对话,请确认后发送',
|
||||
userStartedTask: '用户发起对话任务',
|
||||
userDecision: '用户选择了{{decision}}',
|
||||
conversationDeleted: '对应对话已被删除',
|
||||
localDataCleared:
|
||||
'本地对话、任务、记忆、自动监督、自动化和知识库索引已清除',
|
||||
|
||||
@@ -93,6 +93,15 @@ export const heartbeat = {
|
||||
unknownTime: '时间未知'
|
||||
},
|
||||
supervisor: {
|
||||
loadFailed: '无法读取回顾内容,请重试。',
|
||||
sourceLoadFailed: '无法读取来源,请再次打开来源重试。',
|
||||
reviewStarted: '回顾已开始,可到活动记录查看进度。',
|
||||
reviewBusy: '已有回顾正在处理,请到活动记录查看进度。',
|
||||
reviewStartFailed: '暂时无法确认回顾运行状态,请稍后重试。',
|
||||
reviewFailed: '回顾未能完成。请到活动记录查看原因,并继续已保存的进度;也可重新发起回顾。',
|
||||
reviewCompleted: '回顾已完成。可查看回顾结果或活动记录。',
|
||||
reviewCancelled: '回顾已取消,已有结果仍可查看。',
|
||||
reviewNoChange: '没有需要整理的新内容,已有回顾保持不变。',
|
||||
automatic: '智能心跳',
|
||||
projectScope: '项目:{{names}}',
|
||||
canvasTitle: '事件与知识', generatedAt: '生成于 {{time}}', canvasNote: '沿外圈逆时针阅读 · 选择节点,查看知识与来源', readingGuide: '如何读图',
|
||||
|
||||
@@ -3,11 +3,11 @@ export const integrations = {
|
||||
title: '图片生成与编辑',
|
||||
managed: '内置 · 自动管理',
|
||||
description: '由图片模型的「允许 AI 在会话中调用」设置自动管理。',
|
||||
runtimeScope: '分配包含 GoodBuddy Agent 远程路径上的适用 Runtime。生成需要 Execute 模式及支持工具调用的聊天模型;勾选不代表 Runtime 已就绪。',
|
||||
runtimeScope: '分配包含 GoodBuddy Agent 远程路径上的适用 Runtime。生成需要支持工具调用的聊天模型;勾选不代表 Runtime 已就绪。',
|
||||
openSettings: '前往图片模型设置',
|
||||
tools: {
|
||||
generate_image: '使用已允许会话调用的图片模型生成新图片,或基于会话中的图片进行编辑,结果保存在当前会话。',
|
||||
save_image: '将当前会话中已生成或已上传的图片保存为 PNG、JPEG 或 WebP 文件;需要 Execute 模式,远程项目中文件写入远程主机。'
|
||||
save_image: '将当前会话中已生成或已上传的图片保存为 PNG、JPEG 或 WebP 文件;远程项目中文件写入远程主机。'
|
||||
}
|
||||
},
|
||||
channels: {
|
||||
@@ -33,7 +33,7 @@ export const integrations = {
|
||||
rootAriaLabel: '{{name}} 默认工作目录',
|
||||
selectRootAriaLabel: '选择 {{name}} 默认工作目录',
|
||||
select: '选择',
|
||||
rootHelp: '远程 Execute 只能在此项目目录范围内运行。',
|
||||
rootHelp: '此消息通道项目使用的工作目录。',
|
||||
backendLabel: '消息处理后端',
|
||||
backendAriaLabel: '{{name}} 消息处理后端',
|
||||
directModels: '直连模型',
|
||||
@@ -54,18 +54,6 @@ export const integrations = {
|
||||
fixedRuntimeDescription: '通过 {{runtime}} 运行,固定使用 {{name}}。',
|
||||
runtimeDescription:
|
||||
'通过 {{runtime}} Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 {{runtime}} 配置。',
|
||||
defaultMode: '默认模式',
|
||||
defaultModeAriaLabel: '{{name}} 默认模式',
|
||||
modes: {
|
||||
ask: '对话',
|
||||
execute: '执行'
|
||||
},
|
||||
overrideHelp:
|
||||
'可在消息前加 /ask、/execute、对话:或执行:临时覆盖。',
|
||||
executeRisk:
|
||||
'执行消息会立即交给所选后端,不再逐次弹窗确认。',
|
||||
askRisk:
|
||||
'默认对话时,白名单发送者仍可用 /execute 临时发起执行,且不会弹窗确认。',
|
||||
riskSuffix: '请只连接可信账号,并将工作目录限制在必要范围。'
|
||||
},
|
||||
credential: {
|
||||
@@ -196,7 +184,7 @@ export const integrations = {
|
||||
custom: '自定义 MCP'
|
||||
},
|
||||
customNotice:
|
||||
'自定义 MCP 仅在 Execute 模式加载,可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness,新建时默认分配给直连模型。Server 及其工具具有当前用户权限,请仅添加可信服务;服务地址、命令和凭据由 GoodBuddy 主进程保管,远程访问令牌由系统安全存储加密,工具调用前仍需 GoodBuddy 审批。stdio Server 会以不含桌面会话变量的受限环境启动;需要电脑控制时请使用经过诊断的内置能力。',
|
||||
'启用并分配后,该服务提供的工具可供对应 Runtime 使用。自定义 MCP 可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness,新建时默认分配给直连模型。Server 及其工具具有当前用户权限,请仅添加可信服务;服务地址、命令和凭据由 GoodBuddy 主进程保管,远程访问令牌由系统安全存储加密。stdio Server 会以不含桌面会话变量的受限环境启动;需要电脑控制时请使用经过诊断的内置能力。',
|
||||
computer: {
|
||||
title: '电脑控制能力',
|
||||
supported: '当前设备支持',
|
||||
@@ -224,8 +212,7 @@ export const integrations = {
|
||||
runtimeAssignmentUnsupportedAriaLabel:
|
||||
'{{name}} 无法分配给 {{runtime}},当前 Runtime 不支持内置 MCP',
|
||||
unsupportedSuffix: '(暂不支持)',
|
||||
serverSummaryExecuteOnly: '仅 Execute',
|
||||
serverSummaryMixed: '按模式读写',
|
||||
serverSummaryMixed: '读写',
|
||||
serverSummaryReadOnly: '只读',
|
||||
serverSummaryDisabled: '未启用 · 需要开启魔法笔记',
|
||||
featureDisabled:
|
||||
@@ -247,11 +234,11 @@ export const integrations = {
|
||||
},
|
||||
browser: {
|
||||
title: '内置浏览器',
|
||||
subtitle: 'GoodBuddy 内置能力 · 隔离浏览器 · Execute',
|
||||
subtitle: 'GoodBuddy 内置能力 · 隔离浏览器',
|
||||
description:
|
||||
'在 GoodBuddy 内置的临时隔离浏览器中打开并操作网页,不会控制客户端已安装的 Chrome、Edge 或其他浏览器。',
|
||||
control:
|
||||
'此开关只控制 Agent:关闭时不向任何 Runtime 提供浏览器工具,但不影响你在浏览器工作栏中手动操作;开启后,已分配的 Runtime 可在 Execute 模式直接使用,不再逐次询问。',
|
||||
'此开关只控制 Agent:关闭时不向任何 Runtime 提供浏览器工具,但不影响你在浏览器工作栏中手动操作;开启后,已分配的 Runtime 可直接使用。',
|
||||
enableAriaLabel: '启用 GoodBuddy 内置浏览器',
|
||||
enabled: '已启用',
|
||||
disabled: '已停用',
|
||||
@@ -261,9 +248,9 @@ export const integrations = {
|
||||
},
|
||||
webSearch: {
|
||||
title: '联网搜索',
|
||||
subtitle: '直连模型工具 · Exa MCP · Ask / Execute',
|
||||
subtitle: '直连模型工具 · Exa MCP',
|
||||
description:
|
||||
'提供 web_search 和 web_fetch,只允许搜索及读取公开网页;Ask 和 Execute 均可使用。',
|
||||
'提供 web_search 和 web_fetch,只允许搜索及读取公开网页。',
|
||||
privacy:
|
||||
'查询词和公开网页地址会发送给第三方 Exa 服务,不会发送模型 API Key、本地文件或知识库内容。',
|
||||
enableAriaLabel: '启用直连模型联网搜索',
|
||||
|
||||
@@ -61,15 +61,10 @@ export const settings = {
|
||||
description:
|
||||
'管理 SSH 主机、固定主机密钥并验证远端执行环境;本机配置的 Skills 和 MCP 不会应用于远程主机'
|
||||
},
|
||||
security: {
|
||||
label: '安全与数据',
|
||||
navigationDescription: '工具策略与本地隐私',
|
||||
description: '工具策略与本地隐私'
|
||||
},
|
||||
channels: {
|
||||
label: '消息通道',
|
||||
navigationDescription: '微信、企业微信与钉钉',
|
||||
description: '配置连接、工作目录、消息处理后端与默认模式'
|
||||
description: '配置连接、工作目录与消息处理后端'
|
||||
},
|
||||
roles: {
|
||||
label: '角色与提示词',
|
||||
@@ -211,7 +206,7 @@ export const settings = {
|
||||
followGoodBuddy: '跟随 GoodBuddy · {{name}}({{model}})',
|
||||
noCompatibleModel: '尚未配置兼容的文本模型',
|
||||
permissions:
|
||||
'对话时可选择 Ask 或 Execute。Ask 仅可调用当前 Runtime 允许的只读能力;Execute 可调用已启用工具,调用过程会记录到活动。',
|
||||
'可用工具由所选 Runtime 和设置决定,调用过程会记录到活动。',
|
||||
customization: {
|
||||
title: '能力与默认配置',
|
||||
description:
|
||||
@@ -246,7 +241,6 @@ export const settings = {
|
||||
emptyDescription: '当前 Runtime 未报告此类别中的可用能力。',
|
||||
unsupported: '当前 Runtime 不支持',
|
||||
toolsUnsupported: '当前 Runtime 不支持静态发现 Tools',
|
||||
toolModes: 'Ask:{{ask}} · Execute:{{execute}}',
|
||||
toolKind: {
|
||||
read: '读取',
|
||||
write: '文件修改',
|
||||
@@ -263,11 +257,6 @@ export const settings = {
|
||||
skill: 'Skill',
|
||||
unknown: '来源未知'
|
||||
},
|
||||
toolAccess: {
|
||||
allowed: '可用',
|
||||
blocked: '不可用',
|
||||
conditional: '按当前请求可用'
|
||||
}
|
||||
},
|
||||
agentMode: {
|
||||
primary: '主 Agent',
|
||||
@@ -381,7 +370,7 @@ export const settings = {
|
||||
title: 'DeepSeek Harness',
|
||||
previewDescription: '开发者预览 · OpenAI 兼容',
|
||||
description:
|
||||
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Ask 可调用 Harness 原生 read/skill 与已启用的网页搜索/抓取,Execute 可调用全部已启用工具及 DSH 插件能力,并保留取消和工作区边界。',
|
||||
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;可调用已启用工具及 DSH 插件能力,并保留取消和工作区边界。',
|
||||
platformSource: '管理员环境优先,回退 GoodBuddy 兼容连接',
|
||||
environmentSource:
|
||||
'此选项实际使用管理员预置:{{model}}。',
|
||||
@@ -412,7 +401,7 @@ export const settings = {
|
||||
disabledDescription:
|
||||
'插件市场默认关闭。开启后才会连接公共 npm 目录并显示管理界面;关闭市场不会停用或卸载已有插件。',
|
||||
permissionNotice:
|
||||
'第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行。Ask 不允许模型调用第三方插件工具,但无法限制插件初始化代码;Execute 可调用已启用插件提供的全部工具。请仅安装可信包。',
|
||||
'第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行。已启用插件会向 Runtime 提供工具。请仅安装可信包。',
|
||||
refresh: '刷新',
|
||||
refreshAria: '刷新 DSH 插件市场',
|
||||
searchLabel: '搜索插件',
|
||||
@@ -802,19 +791,10 @@ export const settings = {
|
||||
'提示词作为受信任的摘要指令发送;对话内容始终按不可信历史数据处理。',
|
||||
restoreDefaultPrompt: '恢复默认提示词'
|
||||
},
|
||||
security: {
|
||||
toolPolicy: {
|
||||
label: '直连模型工具安全策略',
|
||||
always: 'Execute 自动授权已启用的工具',
|
||||
deny: '禁止所有工具执行',
|
||||
description:
|
||||
'直连模型的 Execute 模式可使用内置工作区工具及已分配的 MCP 工具;选择 Execute 即授权当前交互运行自动调用这些工具,不再逐次询问。禁止策略会拒绝所有工具调用。OpenCode 与 Continue 继续使用各自的工具系统。'
|
||||
},
|
||||
localData: {
|
||||
title: '本地数据与隐私',
|
||||
description:
|
||||
'清除本机对话、活动记录和知识库索引。已保存的 Runtime 凭据和原目录文件不会被删除。'
|
||||
}
|
||||
localData: {
|
||||
title: '本地数据与隐私',
|
||||
description:
|
||||
'清除本机对话、活动记录和知识库索引。已保存的 Runtime 凭据和原目录文件不会被删除。'
|
||||
},
|
||||
roles: {
|
||||
smartRouting: {
|
||||
@@ -822,7 +802,7 @@ export const settings = {
|
||||
description: '按问题内容自动选择最匹配的专家角色',
|
||||
enabled: '启用 Subagent 智能路由',
|
||||
help:
|
||||
'默认关闭。仅在 Ask 模式且未显式选择专家或团队时,自动选择 1 位专家;子专家使用默认文本模型,并继承当前模式和已启用工具。'
|
||||
'默认关闭。未显式选择专家或团队时,自动选择 1 位专家;子专家使用默认文本模型和已启用工具。'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export const workspace = {
|
||||
workspaceMenu: {
|
||||
enter: '进入项目', enterNamed: '进入项目 {{name}}',
|
||||
archived: '已归档', back: '返回', restore: '恢复', restoreNamed: '恢复项目 {{name}}', restoring: '恢复中…',
|
||||
restored: '{{name}} 已恢复到原项目分类。', restoreFailed: '恢复项目失败。',
|
||||
loadingArchives: '正在加载已归档项目…', loadArchivesFailed: '加载已归档项目失败。',
|
||||
retryArchives: '重新加载已归档项目', noArchives: '暂无已归档项目。归档后可在此恢复。',
|
||||
title: '项目与活动', close: '关闭项目与活动', search: '搜索项目',
|
||||
allProjects: '全部项目', local: '本地', remote: '远程', channels: '消息通道', categories: '项目类型',
|
||||
filter: '筛选会话状态', conversations: '会话', newConversation: '新建会话',
|
||||
@@ -28,6 +33,7 @@ export const workspace = {
|
||||
}
|
||||
},
|
||||
management: {
|
||||
importFiles: '导入文件', importing: '正在导入…', importTarget: '目标目录:{{path}}', imported: '已导入 {{count}} 个文件到 {{path}}。', created: '创建时间',
|
||||
gitWorkspace: 'Git 工作区', more: '{{name}} 的更多操作', browse: '浏览此目录', parent: '返回父目录',
|
||||
createFile: '新建文件', createDirectory: '新建目录', rename: '重命名', move: '移动', delete: '删除', properties: '属性',
|
||||
defaultOpen: '使用默认应用打开', name: '名称', nameOnly: '名称不能包含路径分隔符', destination: '目标目录',
|
||||
@@ -44,10 +50,6 @@ export const workspace = {
|
||||
description: 'GoodBuddy 默认工作区'
|
||||
},
|
||||
projectSwitcher: {
|
||||
workModes: {
|
||||
ask: 'Ask · 只读问答',
|
||||
execute: 'Execute · 完全权限'
|
||||
},
|
||||
selector: {
|
||||
ariaLabel: '当前项目',
|
||||
userProjects: '本地项目',
|
||||
@@ -92,7 +94,6 @@ export const workspace = {
|
||||
description: '说明',
|
||||
rootPath: '根目录',
|
||||
executionSpace: '执行空间',
|
||||
defaultMode: '默认模式',
|
||||
defaultRuntime: '新对话默认 Runtime'
|
||||
},
|
||||
runtimeOptions: {
|
||||
@@ -177,7 +178,7 @@ export const workspace = {
|
||||
select: '选择此目录'
|
||||
},
|
||||
runtimeHelp:
|
||||
'托管 SSH 项目可使用已安装的 OpenCode 或 Continue Runtime。Ask 为只读;Execute 可使用所选 SSH 账户拥有的全部权限。保存时会检查主机、远端 Agent、工作区和所选 Runtime。',
|
||||
'托管 SSH 项目可使用已安装的 OpenCode 或 Continue Runtime。保存时会检查主机、远端 Agent、工作区和所选 Runtime。',
|
||||
actions: {
|
||||
save: '保存远程项目',
|
||||
saving: '正在保存远程项目…'
|
||||
@@ -214,7 +215,7 @@ export const workspace = {
|
||||
notes: { label: '笔记', description: '搜索笔记、采集会话和快速追加文字。' },
|
||||
tasks: {
|
||||
label: '任务中心',
|
||||
description: '处理待审批操作并管理自动化'
|
||||
description: '查看任务状态并管理自动化'
|
||||
},
|
||||
workspace: {
|
||||
label: '工作区',
|
||||
@@ -323,8 +324,6 @@ export const workspace = {
|
||||
tasks: {
|
||||
approvalsTitle: '等待审批',
|
||||
noApprovals: '当前没有等待审批的操作。',
|
||||
deny: '拒绝',
|
||||
allowOnce: '仅此次允许',
|
||||
taskIndexTitle: '项目任务',
|
||||
runningCount: '{{count}} 运行中',
|
||||
stats: {
|
||||
@@ -337,7 +336,7 @@ export const workspace = {
|
||||
incomplete: '部分回复缺少计时记录,时长仅包含已知记录。',
|
||||
partial: '部分记录',
|
||||
timeHelp: '时间说明',
|
||||
timeDescription: '累计每次 Agent 回复的实际用时,不包含回复之间的空闲时间。缺少计时记录的回复不按会话或任务的起止时间估算。',
|
||||
timeDescription: '累计每次 Agent 回复处于运行状态的时间,不包含暂停、等待审批或回复之间的空闲时间。旧历史和中断区间不重建;远程请求只统计桌面观察到的运行时间。',
|
||||
taskDuration: '任务累计用时'
|
||||
},
|
||||
empty: '明确创建的任务会显示在这里。',
|
||||
@@ -400,11 +399,14 @@ export const workspace = {
|
||||
truncatedDiff: '\n\n[输出超过安全限制,已截断]'
|
||||
},
|
||||
results: {
|
||||
back: '返回成果列表',
|
||||
title: '成果',
|
||||
sectionTitle: '生成与导入成果',
|
||||
loadingImage: '正在加载图片…',
|
||||
import: '导入 PDF、图片或网页',
|
||||
retryImage: '重试加载图片',
|
||||
import: '导入文件',
|
||||
importProjectHelp: '将本地文件加入当前项目成果,不导入知识库。',
|
||||
importGlobalHelp: '将本地文件加入全局成果,不导入知识库。',
|
||||
importTooltip: '支持 PDF、图片、文本、JSON 和本地 HTML 文件;PDF 显示提取的文字。',
|
||||
empty: '生成的文件、图片、报告和手动导入内容会显示在这里。'
|
||||
},
|
||||
browser: {
|
||||
@@ -468,14 +470,7 @@ export const workspace = {
|
||||
cancelled: '已取消',
|
||||
interrupted: '已中断'
|
||||
},
|
||||
mode: {
|
||||
conversation: '跟随会话设置',
|
||||
ask: 'Ask',
|
||||
execute: 'Execute',
|
||||
unavailable: '模式不可用'
|
||||
},
|
||||
fields: {
|
||||
mode: '模式',
|
||||
schedule: '计划',
|
||||
nextRun: '下次运行',
|
||||
outcome: '最近结果'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { stripLegacyConversationModes } from './legacy-conversation-import'
|
||||
|
||||
describe('legacy conversation import', () => {
|
||||
it.each(['ask', 'execute', 'plan', undefined])('strips owned %s metadata without rewriting user data', (workMode) => {
|
||||
const tool = { name: 'custom', input: { workMode: 'ask', defaultWorkMode: 'execute' }, output: 'Ask / Execute' }
|
||||
const subagent = { childTaskId: 'child', workMode, output: 'Use Execute', progress: [tool] }
|
||||
const message = { content: '/ask Keep workMode and Execute in this text', toolCalls: [tool], subagents: [subagent] }
|
||||
const original = { id: 'conversation', title: 'Ask / Execute', workMode, messages: [message] }
|
||||
const result = stripLegacyConversationModes(original) as typeof original
|
||||
|
||||
expect(result).not.toHaveProperty('workMode')
|
||||
expect(result.messages[0]!.subagents[0]).not.toHaveProperty('workMode')
|
||||
expect(result.title).toBe(original.title)
|
||||
expect(result.messages[0]!.content).toBe(message.content)
|
||||
expect(result.messages[0]!.toolCalls).toEqual([tool])
|
||||
expect(result.messages[0]!.subagents[0]!.output).toBe(subagent.output)
|
||||
expect(result.messages[0]!.subagents[0]!.progress).toEqual([tool])
|
||||
expect(original).toHaveProperty('workMode', workMode)
|
||||
expect(subagent).toHaveProperty('workMode', workMode)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Remove only application-owned metadata before validating the old localStorage payload. */
|
||||
export function stripLegacyConversationModes(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
||||
const conversation = { ...value } as Record<string, unknown>
|
||||
delete conversation.workMode
|
||||
if (Array.isArray(conversation.messages)) {
|
||||
conversation.messages = conversation.messages.map((value: unknown) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
||||
const message = { ...value } as Record<string, unknown>
|
||||
if (Array.isArray(message.subagents)) {
|
||||
message.subagents = message.subagents.map((value: unknown) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
||||
const subagent = { ...value } as Record<string, unknown>
|
||||
delete subagent.workMode
|
||||
return subagent
|
||||
})
|
||||
}
|
||||
return message
|
||||
})
|
||||
}
|
||||
return conversation
|
||||
}
|
||||
@@ -15,7 +15,6 @@ const project: AssistantProject = {
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Workspace'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'user',
|
||||
builtInDefault: true,
|
||||
status: 'active',
|
||||
@@ -60,7 +59,6 @@ describe('getProjectDisplayText', () => {
|
||||
{
|
||||
...project,
|
||||
rootPath: 'D:\\Moved',
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: { provider: 'continue' },
|
||||
updatedAt: '2026-08-01T00:00:01.000Z'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type { ConversationStore } from './conversation-store'
|
||||
import { mergePersistedConversations, type ConversationPersistence } from './conversation-persistence'
|
||||
|
||||
export async function restoreProject(
|
||||
projectId: string,
|
||||
store: ConversationStore,
|
||||
persistence: ConversationPersistence,
|
||||
retainedIds: () => Set<string>
|
||||
): Promise<AssistantProject[]> {
|
||||
await window.goodbuddy.projects.setArchived(projectId, false)
|
||||
const [projects, summaries] = await Promise.all([
|
||||
window.goodbuddy.projects.list(false),
|
||||
window.goodbuddy.conversations.listSummaries()
|
||||
])
|
||||
// Publish summaries before exposing the project to the normal selection path.
|
||||
store.set(current => mergePersistedConversations(current, summaries.filter(item => item.projectId === projectId), persistence.acknowledged(), retainedIds()))
|
||||
return projects
|
||||
}
|
||||
@@ -51,11 +51,6 @@ export const settingsCategoryList = [
|
||||
group: 'extensions',
|
||||
translationKey: 'capabilities'
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
group: 'system',
|
||||
translationKey: 'security'
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
group: 'system',
|
||||
|
||||
+80
-64
@@ -5569,6 +5569,42 @@ button > svg {
|
||||
|
||||
.workspace-files__entry {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) 152px;
|
||||
align-items: center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.workspace-files__columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) 152px;
|
||||
padding-right: 32px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.workspace-files[data-created-time="true"] :is(.workspace-files__entry, .workspace-files__columns) {
|
||||
grid-template-columns: minmax(160px, 1fr) 152px 152px;
|
||||
}
|
||||
|
||||
.workspace-files__columns button {
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workspace-files__time {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-files__destination {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workspace-files__open-entry {
|
||||
@@ -5631,7 +5667,11 @@ button > svg {
|
||||
}
|
||||
|
||||
.workspace-files__tree {
|
||||
min-width: 0;
|
||||
min-width: 344px;
|
||||
}
|
||||
|
||||
.workspace-files[data-created-time="true"] .workspace-files__tree {
|
||||
min-width: 496px;
|
||||
}
|
||||
|
||||
.workspace-files__children {
|
||||
@@ -5721,18 +5761,6 @@ button > svg {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__approval-actions {
|
||||
display: flex;
|
||||
margin-top: 9px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__approval-actions button {
|
||||
flex: 1;
|
||||
padding: 7px 5px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__schedule-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -6192,10 +6220,45 @@ button > svg {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__image-preview {
|
||||
.assistant-sidebar__import-help {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__result {
|
||||
min-width: 0;
|
||||
padding-block: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.assistant-sidebar__result > header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.assistant-sidebar__result h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__result time {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__result .assistant-sidebar__image-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
max-width: min(100%, 240px);
|
||||
max-height: 160px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -6207,7 +6270,8 @@ button > svg {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.assistant-sidebar__preview pre {
|
||||
.assistant-sidebar__preview pre,
|
||||
.assistant-sidebar__result pre {
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
@@ -7566,26 +7630,6 @@ button > svg {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.approval-card button {
|
||||
min-height: 29px;
|
||||
padding: 0 10px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.approval-card__deny {
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.approval-card__allow {
|
||||
background: var(--accent-solid);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.agent-question-card {
|
||||
display: flex;
|
||||
padding: var(--space-4);
|
||||
@@ -8484,22 +8528,12 @@ button > svg {
|
||||
width: 148px;
|
||||
}
|
||||
|
||||
.composer-picker--mode > .model-button {
|
||||
width: 108px;
|
||||
}
|
||||
|
||||
.composer-picker--runtime > .model-button,
|
||||
.composer-picker--runtime-action > .model-button {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.composer-picker--ask svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-picker--execute svg {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.composer-picker__menu {
|
||||
right: auto;
|
||||
@@ -11194,23 +11228,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.project-work-mode {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.project-work-mode legend {
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.project-work-mode small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-runtime-selector__scope-help {
|
||||
color: var(--text-muted);
|
||||
@@ -18253,7 +18270,6 @@ textarea.field-control {
|
||||
.approval-card p,
|
||||
.assistant-sidebar__approval code,
|
||||
.assistant-sidebar__approval p,
|
||||
.assistant-sidebar__approval-actions button,
|
||||
.assistant-sidebar__badge,
|
||||
.assistant-sidebar__browser-placeholder,
|
||||
.assistant-sidebar__diff,
|
||||
|
||||
@@ -28,23 +28,6 @@
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.supervisor-workspace__run-status {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.supervisor-workspace__run-status > span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.supervisor-workspace__run-status > .icon-button {
|
||||
grid-column: 3;
|
||||
}
|
||||
.supervisor-workspace__confirm {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SetStateAction } from "react";
|
||||
import type { InteractiveWorkMode } from "../../shared/assistant-contracts";
|
||||
import type { ContextAttachment } from "../../shared/contracts";
|
||||
import type { RuntimeSelectionLayer } from "../../shared/runtime-selection-contracts";
|
||||
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
|
||||
@@ -41,7 +40,6 @@ export type ComposerActions = {
|
||||
selectRuntimeAgent: (agentId: string) => void;
|
||||
selectContinuePreset: (presetId: string) => void;
|
||||
selectRuntimeAction: (value: string) => void;
|
||||
setWorkMode: (mode: InteractiveWorkMode) => void;
|
||||
switchRuntime: (layer: RuntimeSelectionLayer | undefined) => void;
|
||||
prepareNativeClientConversation: () => Promise<string>;
|
||||
openNativeTerminal: (
|
||||
|
||||
@@ -3,6 +3,24 @@ import { conversationActivityTime, getConversationDisplayTitle, type Conversatio
|
||||
import { useConversationStoreSelector, type ConversationStore } from './conversation-store'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { sameArrayItems } from './stable-derived-value'
|
||||
import { formatConversationListTime, type TimeFormatLocale } from './time-format'
|
||||
import { formatMediumDateTime } from './locale-formatters'
|
||||
|
||||
export function useWorkspaceConversationTime(store: ConversationStore, id: string, locale: TimeFormatLocale) {
|
||||
const selector = useCallback(() => {
|
||||
const conversation = store.getConversation(id)
|
||||
if (!conversation) return undefined
|
||||
return {
|
||||
dateTime: new Date(conversation.updatedAt).toISOString(),
|
||||
label: formatConversationListTime(conversation.updatedAt, locale),
|
||||
title: formatMediumDateTime(conversation.updatedAt, locale)
|
||||
}
|
||||
}, [store, id, locale])
|
||||
const subscribe = useCallback((listener: () => void) => store.subscribeConversation(id, listener), [store, id])
|
||||
// Streaming changes below the displayed precision do not need a React commit.
|
||||
return useConversationStoreSelector(store, selector,
|
||||
(a, b) => a?.label === b?.label && a?.title === b?.title, subscribe)
|
||||
}
|
||||
|
||||
export type WorkspaceConversation = {
|
||||
id: string
|
||||
|
||||
@@ -171,8 +171,13 @@
|
||||
|
||||
.workspace-menu__conversation strong { font-weight: 500; }
|
||||
.workspace-menu__conversation small { font-size: var(--font-caption); }
|
||||
.workspace-menu__conversation > span > small { color: var(--text-secondary); }
|
||||
.workspace-menu__conversation > small { flex: 0 0 auto; }
|
||||
.workspace-menu__conversation > span:not(.workspace-menu__metadata) > small { color: var(--text-secondary); }
|
||||
.workspace-menu__metadata {
|
||||
flex: 0 0 auto;
|
||||
justify-items: end;
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.workspace-menu__metadata time { color: var(--text-muted); white-space: nowrap; }
|
||||
.workspace-menu__conversation:hover,
|
||||
.workspace-menu__new:not(:disabled):hover { background: var(--accent-subtle); }
|
||||
.workspace-menu__new:disabled { opacity: 0.5; cursor: default; }
|
||||
@@ -180,11 +185,41 @@
|
||||
.workspace-menu__channel-note { padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
|
||||
|
||||
.workspace-menu__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-2);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.workspace-menu__enter { justify-content: flex-end; }
|
||||
.workspace-menu__archived {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
.workspace-menu__archived:hover,
|
||||
.workspace-menu__archived[aria-pressed="true"] { background: var(--accent-subtle); color: var(--accent); }
|
||||
.workspace-menu__archive-heading,
|
||||
.workspace-menu__archived-project {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
.workspace-menu__archived-project > span { display: grid; gap: var(--space-1); min-width: 0; }
|
||||
.workspace-menu__archived-project strong,
|
||||
.workspace-menu__archived-project small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-menu__archived-project small { color: var(--text-secondary); }
|
||||
.workspace-menu__archived-project button { flex-shrink: 0; }
|
||||
|
||||
.workspace-menu .project-switcher__menu-item .project-activity__counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -257,7 +292,7 @@
|
||||
@media (max-width: 700px) {
|
||||
.workspace-menu__layout { grid-template-columns: minmax(160px, 40%) minmax(0, 1fr); }
|
||||
.workspace-menu__toolbar { padding: var(--space-2); gap: var(--space-1); }
|
||||
.workspace-menu__conversation { flex-direction: column; align-items: flex-start; gap: var(--space-1); }
|
||||
.workspace-menu__conversation { gap: var(--space-1); }
|
||||
.workspace-menu__conversation > span { max-width: 100%; }
|
||||
.workspace-menu__host { flex-wrap: wrap; }
|
||||
.workspace-menu .project-switcher__menu-settings { opacity: 1; pointer-events: auto; }
|
||||
|
||||
Reference in New Issue
Block a user