feat: add app management, local inference controls, and task statistics

This commit is contained in:
mesalogo
2026-09-18 08:20:45 +08:00
parent 6dd6297414
commit bbae893c45
103 changed files with 7962 additions and 1490 deletions
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useEffectEvent, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react'
import { createPortal, flushSync } from 'react-dom'
import './anchored-menu.css'
export function AnchoredMenu({ anchorRef, id, label, onClose, children }: {
anchorRef: RefObject<HTMLButtonElement | null>
id: string
label: string
onClose: () => void
children: ReactNode
}): React.JSX.Element {
const menuRef = useRef<HTMLDivElement>(null)
const dismiss = useEffectEvent(onClose)
const position = useEffectEvent(() => {
const menu = menuRef.current
const anchor = anchorRef.current
if (!menu || !anchor) return
const viewport = window.visualViewport
const left = viewport?.offsetLeft ?? 0
const top = viewport?.offsetTop ?? 0
const width = viewport?.width ?? window.innerWidth
const height = viewport?.height ?? window.innerHeight
const rect = anchor.getBoundingClientRect()
const menuWidth = Math.max(0, Math.min(280, width - 32))
const bottom = Math.max(top + 16, Math.min(rect.top - 8, top + height - 16))
menu.style.width = `${menuWidth}px`
menu.style.maxHeight = `${Math.max(0, bottom - top - 16)}px`
menu.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - menuWidth - 16))}px`
menu.style.top = `${Math.max(top + 16, bottom - menu.getBoundingClientRect().height)}px`
})
useLayoutEffect(() => { position() })
useEffect(() => {
const menu = menuRef.current!
const anchor = anchorRef.current
const focusFirst = (): void => {
if (document.activeElement === document.body || document.activeElement === menu) {
(menu.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled)') ?? menu).focus()
}
}
menu.focus()
focusFirst()
const outside = (event: Event): void => {
if (!menu.contains(event.target as Node) && !anchor?.contains(event.target as Node)) dismiss()
}
const keydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
flushSync(() => dismiss())
anchor?.focus()
return
}
if (!menu.contains(event.target as Node)) return
if (event.key === 'Tab') {
flushSync(() => dismiss())
anchor?.focus()
return
}
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
items[next]?.focus()
}
const reposition = (): void => { position(); focusFirst() }
const observer = new ResizeObserver(reposition)
observer.observe(menu)
if (anchor) observer.observe(anchor)
document.addEventListener('pointerdown', outside)
document.addEventListener('focusin', outside)
document.addEventListener('keydown', keydown, true)
window.addEventListener('resize', reposition)
window.addEventListener('scroll', reposition, true)
window.visualViewport?.addEventListener('resize', reposition)
window.visualViewport?.addEventListener('scroll', reposition)
return () => {
observer.disconnect()
document.removeEventListener('pointerdown', outside)
document.removeEventListener('focusin', outside)
document.removeEventListener('keydown', keydown, true)
window.removeEventListener('resize', reposition)
window.removeEventListener('scroll', reposition, true)
window.visualViewport?.removeEventListener('resize', reposition)
window.visualViewport?.removeEventListener('scroll', reposition)
if (document.activeElement === document.body || menu.contains(document.activeElement)) anchor?.focus()
}
}, [anchorRef])
return createPortal(<div ref={menuRef} id={id} role="menu" aria-label={label}
tabIndex={-1} className="anchored-menu">{children}</div>, document.body)
}
+374 -45
View File
@@ -19,6 +19,7 @@ import type {
} from "../../shared/contracts";
import {
defaultLocalToolEnvironmentSettings,
defaultApplicationNavigation,
type ApplicationSettings,
} from "../../shared/application-settings-contracts";
import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
@@ -181,6 +182,12 @@ const terminalSnapshot: TerminalSnapshot = {
};
const api: DesktopApi = {
localInference: {
openSettings: vi.fn(async () => undefined),
getSnapshot: vi.fn(async () => ({ services: [], tasks: [] })),
act: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined)
},
storageUpgrade: {
getProgress: vi.fn(),
act: vi.fn(async () => undefined)
@@ -572,6 +579,10 @@ const api: DesktopApi = {
},
tasks: {
list: vi.fn(async () => []),
getExecutionStats: vi.fn(async () => ({
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: Date.now(), taskDurations: [],
})),
setStatus: vi.fn(async () => {}),
},
activityHistory: {
@@ -1048,6 +1059,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: enabled,
magicNotesEnabled: false,
@@ -1067,6 +1079,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
return { updateSettings };
}
@@ -1224,7 +1237,7 @@ describe("App", () => {
vi.mocked(api.activityHistory.replace).mockReset().mockResolvedValue();
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([]);
api.channels = undefined;
api.updates = undefined;
installRemoteProjectsSetting(false);
newConversationListener = undefined;
beforeQuitListener = undefined;
browserListener = undefined;
@@ -1431,7 +1444,7 @@ describe("App", () => {
expect(selected).toHaveClass("conversation-item--active");
const summary = await screen.findByRole("button", { name: /全项目活动/u });
expect(summary).toHaveTextContent("1 个运行中");
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
const settings = await screen.findByRole("dialog", { name: "设置中心" });
expect(selected).toHaveClass("conversation-item--active");
vi.mocked(api.tasks.list).mockResolvedValue([{ ...currentTask, status: "completed" }]);
@@ -1514,7 +1527,7 @@ describe("App", () => {
it("respects the Settings leave guard before changing project and conversation", async () => {
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
@@ -1749,6 +1762,105 @@ describe("App", () => {
expect(screen.queryByText("All retained text")).not.toBeInTheDocument();
});
describe("custom task creation", () => {
const currentId = "00000000-0000-4000-8000-000000000811";
const otherId = "00000000-0000-4000-8000-000000000812";
const snapshots: ConversationSnapshot[] = [
{ id: currentId, projectId, title: "Task current", updatedAt: 300, messages: [] },
{ id: otherId, projectId, title: "Task other", updatedAt: 200, messages: [] },
{ id: "00000000-0000-4000-8000-000000000813", projectId, title: "Task remote", updatedAt: 100, messages: [],
remote: { channel: "weixin", accountDisplay: "Remote account", conversationType: "direct" } },
{ id: "00000000-0000-4000-8000-000000000814", projectId: "other-project", title: "Task elsewhere", updatedAt: 50, messages: [] },
];
beforeEach(() => {
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",
enabled: !input.runImmediately,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
});
async function openDialog(): Promise<HTMLElement> {
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "切换助手工作栏" }));
fireEvent.click(await screen.findByRole("tab", { name: "任务中心" }));
fireEvent.click(await screen.findByRole("button", { name: "新建任务" }));
return screen.getByRole("dialog", { name: "新建定制任务" });
}
it("creates immediately in the current conversation with one IPC call and no follow-up runNow", async () => {
const dialog = await openDialog();
expect(within(dialog).getByLabelText("关联会话")).toHaveValue("current");
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "立即检查项目" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
expect(api.schedules.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
conversationId: currentId, projectId, title: "立即检查项目", prompt: "立即检查项目",
recurrence: "once", runImmediately: true,
}));
expect(api.schedules.runNow).not.toHaveBeenCalled();
});
it.each([otherId, "new"])("creates in the selected destination %s while excluding remote and other-project conversations", async (destination) => {
const dialog = await openDialog();
expect(within(dialog).getByRole("option", { name: "Task other" })).toBeInTheDocument();
expect(within(dialog).queryByRole("option", { name: "Task remote" })).not.toBeInTheDocument();
expect(within(dialog).queryByRole("option", { name: "Task elsewhere" })).not.toBeInTheDocument();
fireEvent.change(within(dialog).getByLabelText("关联会话"), { target: { value: destination } });
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "检查所选会话" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
await waitFor(() => expect(api.schedules.create).toHaveBeenCalledOnce());
const input = vi.mocked(api.schedules.create).mock.calls[0]![0];
if (destination === "new") expect(input).not.toHaveProperty("conversationId");
else expect(input.conversationId).toBe(otherId);
expect(input.runImmediately).toBe(true);
expect(api.schedules.runNow).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
});
it("validates future scheduled time before creating and retains recurrence", async () => {
const dialog = await openDialog();
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "每周检查" } });
fireEvent.click(within(dialog).getByRole("button", { name: "定时执行" }));
fireEvent.change(within(dialog).getByLabelText("运行频率"), { target: { value: "weekly" } });
fireEvent.change(within(dialog).getByLabelText("首次运行"), { target: { value: "2020-01-01T12:00" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("首次运行时间必须晚于当前时间");
expect(api.schedules.create).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText("首次运行"), { target: { value: "2099-01-01T12:00" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
await waitFor(() => expect(api.schedules.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
recurrence: "weekly", runImmediately: false, nextRunAt: new Date("2099-01-01T12:00").toISOString(),
})));
expect(api.schedules.runNow).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
});
it("retains the draft after create failure and does not retry creation after a refresh failure", async () => {
const dialog = await openDialog();
vi.mocked(api.schedules.create).mockRejectedValueOnce(new Error("队列写入失败"));
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "保留任务内容" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("队列写入失败");
expect(within(dialog).getByLabelText("任务内容")).toHaveValue("保留任务内容");
expect(api.schedules.create).toHaveBeenCalledOnce();
vi.mocked(api.conversations.listSummaries).mockRejectedValueOnce(new Error("刷新失败"));
vi.mocked(api.tasks.list).mockRejectedValueOnce(new Error("刷新失败"));
vi.mocked(api.schedules.list).mockRejectedValueOnce(new Error("刷新失败"));
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
expect(api.schedules.create).toHaveBeenCalledTimes(2);
expect(api.schedules.runNow).not.toHaveBeenCalled();
});
});
it("provides custom minimize, maximize, and close controls", async () => {
const { unmount } = render(<App />);
@@ -1803,7 +1915,7 @@ describe("App", () => {
expect(
screen.getByRole("button", { name: "Current project" }),
).toHaveTextContent("Default project");
expect(screen.getByText("Project: Default project")).toHaveClass(
expect(screen.getByLabelText("Project: Default project")).toHaveClass(
"scope-badge",
);
expect(
@@ -1877,7 +1989,7 @@ describe("App", () => {
within(assistantSidebar).getByText("Project: Default project"),
).toBeInTheDocument();
const taskIndexHeading = within(assistantSidebar).getByRole("heading", {
name: "Task index",
name: /Project tasks/,
});
const newTaskButton = within(assistantSidebar).getByRole("button", {
name: "New task",
@@ -1891,10 +2003,8 @@ describe("App", () => {
within(taskDialog).getByText("Default project"),
).toBeInTheDocument();
expect(
within(taskDialog).getByRole("button", {
name: "Current conversation",
}),
).toHaveAttribute("aria-pressed", "true");
within(taskDialog).getByLabelText("Conversation"),
).toHaveValue("current");
fireEvent.click(
within(taskDialog).getByRole("button", {
name: "Close new custom task",
@@ -2582,7 +2692,7 @@ describe("App", () => {
fireEvent.click(
await screen.findByRole("button", {
name: /本地工作区/u,
name: /^设置$/u,
}),
);
await screen.findByRole("heading", { name: "设置中心" }, { timeout: 3000 });
@@ -2713,7 +2823,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
await screen.findByRole("heading", { name: "运行记录" });
fireEvent.click(screen.getByRole("button", { name: "对话" }));
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
await screen.findByRole("heading", { name: "设置中心" });
expect(document.querySelectorAll(".workspace-route-cache")).toHaveLength(4);
@@ -2867,6 +2977,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -2878,6 +2989,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(
@@ -2888,7 +3000,7 @@ describe("App", () => {
fireEvent.click(
await screen.findByRole("button", {
name: /本地工作区/u,
name: /^设置$/u,
}),
);
await screen.findByRole(
@@ -2966,6 +3078,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -2978,6 +3091,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -2988,6 +3102,7 @@ describe("App", () => {
check,
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
@@ -3075,6 +3190,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -3087,6 +3203,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -3097,6 +3214,7 @@ describe("App", () => {
check,
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
const first = render(<App />);
@@ -3545,7 +3663,13 @@ describe("App", () => {
target: { value: "方案讨论更新" },
});
fireEvent.submit(renameInput.closest("form")!);
expect(await screen.findAllByText("方案讨论更新")).toHaveLength(2);
expect(
await within(container.querySelector(".conversation-list") as HTMLElement)
.findByText("方案讨论更新"),
).toBeInTheDocument();
expect(container.querySelector(".conversation-title")).toHaveTextContent(
/^方案讨论更新$/,
);
vi.mocked(api.conversations.saveLocal).mockClear();
fireEvent.click(screen.getByLabelText("更多会话操作 方案讨论更新"));
@@ -4442,7 +4566,7 @@ describe("App", () => {
for (const reasoning of completedReasoning) {
expect(reasoning.closest("details")).not.toHaveAttribute("open");
}
expect(screen.getByText("项目:默认项目")).toHaveClass("scope-badge");
expect(screen.getByLabelText("项目:默认项目")).toHaveClass("scope-badge");
});
it("keeps every activity detail in a long tool chain", async () => {
@@ -5088,7 +5212,7 @@ describe("App", () => {
});
});
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "上下文控制" }));
const trigger = await screen.findByLabelText("压缩触发阈值");
@@ -5113,7 +5237,7 @@ describe("App", () => {
it("guards sidebar navigation away from dirty Settings drafts", async () => {
render(<App />);
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.change(await screen.findByLabelText("默认工作区目录"), {
target: { value: "C:\\Unsaved from App" },
@@ -5137,7 +5261,7 @@ describe("App", () => {
await screen.findByLabelText("向 GoodBuddy 提问");
const settingsTrigger = screen.getByRole("button", {
name: /本地工作区/u,
name: /^设置$/u,
});
fireEvent.click(settingsTrigger);
await screen.findByRole("heading", { name: "设置中心" });
@@ -5158,7 +5282,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: label }));
const workspace = document.querySelector(`[data-route="${route}"]`)!;
workspace.scrollTop = 120;
const trigger = screen.getByRole("button", { name: /本地工作区/u });
const trigger = screen.getByRole("button", { name: /^设置$/u });
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole("dialog", { name: "设置中心" });
@@ -5204,6 +5328,7 @@ describe("App", () => {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -5235,6 +5360,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
api.shortcuts = {
getSettings: vi.fn(async () => shortcutSnapshot),
@@ -5256,7 +5382,7 @@ describe("App", () => {
try {
render(<App />);
expect(await screen.findByText("Ctrl+Shift+Space")).toBeInTheDocument();
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
const shortcutInput = await screen.findByLabelText("快捷键");
@@ -5273,7 +5399,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "对话" }));
expect(await screen.findByText("Ctrl+Alt+K")).toBeInTheDocument();
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
const shortcutSwitch = await screen.findByRole("switch", {
@@ -5657,7 +5783,7 @@ describe("App", () => {
);
});
it("restores an independent knowledge scope for each conversation", async () => {
it("restores independent knowledge scopes across conversations and the application center", async () => {
const libraryId = "11111111-1111-4111-8111-111111111111";
const firstConversationId = "22222222-2222-4222-8222-222222222222";
const secondConversationId = "33333333-3333-4333-8333-333333333333";
@@ -5725,6 +5851,14 @@ describe("App", () => {
name: "选择知识库,本次已启用 1 个",
}),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '应用中心' }));
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }));
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '知识库' } });
expect(within(screen.getByRole('dialog')).queryByRole('switch')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }));
openComposerOptions();
expect(screen.getByRole('button', { name: '选择知识库,本次已启用 1 个' })).toBeInTheDocument();
expect(document.querySelector('.composer__option-summary')).toHaveTextContent('每次先检索');
});
it("persists and submits always-retrieve mode for the active conversation", async () => {
@@ -6555,7 +6689,7 @@ describe("App", () => {
it("opens chat and focuses the composer for tray conversations", async () => {
render(<App />);
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
expect(
await screen.findByRole("heading", { name: "设置中心" }),
).toBeInTheDocument();
@@ -7008,7 +7142,7 @@ describe("App", () => {
it("applies and persists a dark appearance from Settings", async () => {
render(<App />);
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" }, { timeout: 3000 });
fireEvent.click(screen.getByRole("tab", { name: "外观" }));
fireEvent.click(screen.getByRole("radio", { name: /暗色/u }));
@@ -8194,7 +8328,7 @@ describe("App", () => {
).toHaveTextContent(remoteProject.name),
);
fireEvent.click(screen.getByText("本地工作区"));
fireEvent.click(screen.getByRole('button', { name: '设置' }));
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
fireEvent.click(
await screen.findByRole("tab", {
@@ -9444,14 +9578,14 @@ describe("App", () => {
render(<App />);
expect(await screen.findByText('当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。')).toBeVisible();
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep my image request' } });
fireEvent.click(screen.getByRole('button', { name: /本地工作区/u }));
fireEvent.click(screen.getByRole('button', { name: /^设置$/u }));
fireEvent.click(await screen.findByRole('tab', { name: '外观' }));
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
expect(await screen.findByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep my image request');
fireEvent.click(screen.getByRole('button', { name: /本地工作区/u }));
fireEvent.click(screen.getByRole('button', { name: /^设置$/u }));
expect(await screen.findByRole('tab', { name: 'Agent Runtime' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
@@ -11635,7 +11769,7 @@ describe("App", () => {
it("configures a runtime without reading an existing API key", async () => {
render(<App />);
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
expect(
await screen.findByRole("heading", {
name: "设置中心",
@@ -11738,7 +11872,7 @@ describe("App", () => {
"true",
);
expect(
screen.getByText("没有活动项目。请选择其他任务范围或先打开一个项目。"),
screen.getByText("没有活动项目。请先打开一个项目以查看和创建任务。"),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "当前会话的任务" }),
@@ -12284,7 +12418,7 @@ describe("App", () => {
it("opens Smart Heartbeat as a first-class workspace", async () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
expect(
await screen.findByRole("heading", { name: "智能心跳" }),
@@ -12378,7 +12512,7 @@ describe("App", () => {
);
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
expect(await screen.findByText("知识库加载失败")).toBeInTheDocument();
expect(screen.getAllByText("知识数据库暂时不可用")).toHaveLength(1);
expect(screen.queryByText("建立第一个知识库")).not.toBeInTheDocument();
@@ -12434,7 +12568,7 @@ describe("App", () => {
});
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
fireEvent.click(
await screen.findByRole(
"button",
@@ -12465,7 +12599,7 @@ describe("App", () => {
);
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
expect(await screen.findByText("智能心跳加载失败")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "配置智能心跳" }),
@@ -12511,7 +12645,7 @@ describe("App", () => {
]);
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
fireEvent.click(await screen.findByRole("tab", { name: "心跳计划" }));
expect(await screen.findAllByText("旧项目心跳")).not.toHaveLength(0);
selectProjectOption(secondProject.name);
@@ -12522,7 +12656,7 @@ describe("App", () => {
it("automatically snapshots the conversation project on new activity", async () => {
render(<App />);
await screen.findByText("项目:默认项目");
expect(await screen.findByLabelText("项目:默认项目")).toHaveClass("scope-badge");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "记录项目范围" },
@@ -12564,7 +12698,7 @@ describe("App", () => {
);
render(<App />);
await screen.findByText("项目:默认项目");
expect(await screen.findByLabelText("项目:默认项目")).toHaveClass("scope-badge");
await waitFor(() => {
expect(api.activityHistory.replace).toHaveBeenCalledWith(
@@ -12588,7 +12722,7 @@ describe("App", () => {
name: "主导航",
});
const chat = within(navigation).getByRole("button", { name: "对话" });
const knowledge = within(navigation).getByRole("button", {
const knowledge = await within(navigation).findByRole("button", {
name: "知识库",
});
@@ -12782,6 +12916,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -12794,6 +12929,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -12804,10 +12940,11 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
await screen.findByText("项目:默认项目");
await screen.findByLabelText("项目:默认项目");
const magicNotesEntry = await screen.findByRole("button", {
name: "魔法笔记",
});
@@ -12842,6 +12979,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -12853,6 +12991,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
@@ -12897,6 +13036,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -12908,6 +13048,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
@@ -12929,6 +13070,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -12941,6 +13083,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -12951,6 +13094,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
@@ -12998,6 +13142,7 @@ describe("App", () => {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -13017,6 +13162,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
render(<App />);
@@ -13025,7 +13171,7 @@ describe("App", () => {
await screen.findByTitle("Agent 回复 HTML 静态预览"),
).toHaveAttribute("sandbox", "");
fireEvent.click(await screen.findByRole("button", { name: /本地工作区/u }));
fireEvent.click(await screen.findByRole("button", { name: /^设置$/u }));
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
const toggle = await screen.findByRole("switch", {
name: "在会话中渲染 HTML",
@@ -13046,12 +13192,13 @@ describe("App", () => {
expect(screen.getByText(/Rendered dashboard/u)).toBeInTheDocument();
});
it("keeps platform-feature switches in Settings without navigating", async () => {
it("omits global notes settings while retaining application center settings without navigating the workspace", async () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -13071,25 +13218,34 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
const { container } = render(<App />);
fireEvent.click(
await screen.findByRole("button", {
name: /本地工作区/u,
name: /^设置$/u,
}),
);
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
fireEvent.click(await screen.findByRole("tab", { name: "魔法笔记" }));
await screen.findByRole("tab", { name: "通用设置" });
expect(screen.queryByRole("tab", { name: "魔法笔记" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
expect(api.updates.updateSettings).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "关闭设置" }));
fireEvent.click(screen.getByRole("button", { name: "应用中心" }));
fireEvent.click(screen.getByRole("menuitem", { name: "管理应用" }));
fireEvent.click(screen.getByRole("button", { name: "魔法笔记 应用设置" }));
const toggle = await screen.findByRole("switch", {
name: "显示魔法笔记入口",
name: "启用应用",
});
await waitFor(() => expect(toggle).toBeEnabled());
fireEvent.click(toggle);
await waitFor(() => expect(toggle).toBeChecked());
expect(
screen.getByRole("heading", { name: "设置中心" }),
screen.getByRole("heading", { name: "魔法笔记" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "魔法笔记" }),
@@ -13101,7 +13257,7 @@ describe("App", () => {
fireEvent.click(toggle);
await waitFor(() => expect(toggle).not.toBeChecked());
expect(
screen.getByRole("heading", { name: "设置中心" }),
screen.getByRole("heading", { name: "魔法笔记" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "魔法笔记" }),
@@ -13111,10 +13267,183 @@ describe("App", () => {
}
});
it('refreshes externally changed navigation before editing and keeps Knowledge in its fixed slot', async () => {
const updates = api.updates!
const original = await updates.getSettings()
render(<App />)
await screen.findByRole('button', { name: '智能心跳' })
const external = {
...original,
applicationNavigation: {
...original.applicationNavigation,
order: ['local-inference', 'magic-notes'] as const,
},
}
vi.mocked(updates.getSettings).mockResolvedValue({ ...external, applicationNavigation: { ...external.applicationNavigation, order: [...external.applicationNavigation.order] } })
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
expect(pin).toBeDisabled()
await waitFor(() => expect(pin).not.toBeDisabled())
const nav = screen.getByRole('navigation', { name: '主导航' })
expect(within(nav).getAllByRole('button').map(button => button.textContent)).toEqual([
'对话', '知识库', '智能心跳', '本机推理', '运行记录',
])
fireEvent.click(pin)
await waitFor(() => expect(updates.updateSettings).toHaveBeenCalledWith({
applicationNavigation: {
order: ['local-inference', 'magic-notes'],
pinned: { ...original.applicationNavigation.pinned, 'local-inference': false },
},
}))
})
it.each(['read', 'mutation'] as const)('keeps a settings event authoritative over a stale %s reply', async (operation) => {
const updates = api.updates!
const original = await updates.getSettings()
let changed!: Parameters<typeof updates.onSettingsChanged>[0]
vi.mocked(updates.onSettingsChanged).mockImplementation(listener => {
changed = listener
return vi.fn()
})
render(<App />)
await screen.findByRole('button', { name: '智能心跳' })
const reply = deferred<ApplicationSettings>()
if (operation === 'read') vi.mocked(updates.getSettings).mockReturnValueOnce(reply.promise)
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
if (operation === 'mutation') {
await waitFor(() => expect(pin).not.toBeDisabled())
vi.mocked(updates.updateSettings).mockReturnValueOnce(reply.promise)
fireEvent.click(pin)
}
const external: ApplicationSettings = {
...original,
applicationNavigation: {
order: ['local-inference', 'magic-notes'],
pinned: { ...original.applicationNavigation.pinned, 'local-inference': false },
},
}
await act(async () => changed(external))
expect(pin).not.toBeChecked()
await act(async () => reply.resolve(original))
await waitFor(() => expect(pin).not.toBeDisabled())
expect(pin).not.toBeChecked()
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理' })).not.toBeInTheDocument()
expect(within(screen.getByRole('navigation', { name: '主导航' })).getByRole('button', { name: '智能心跳' })).toBeInTheDocument()
fireEvent.click(pin)
expect(updates.updateSettings).toHaveBeenLastCalledWith({
applicationNavigation: { ...external.applicationNavigation, pinned: { ...external.applicationNavigation.pinned, 'local-inference': true } },
})
})
it('keeps fixed apps available when application settings cannot be loaded', async () => {
vi.mocked(api.updates!.getSettings).mockRejectedValue(new Error('unavailable'))
render(<App />)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(await screen.findByLabelText('知识工作区')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '智能心跳' }))
expect(await screen.findByRole('heading', { name: '智能心跳' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
expect(api.heartbeats.update).not.toHaveBeenCalled()
})
it.each(['sidebar', 'menu', 'center'])('opens local inference from %s as a modal preserving the workspace', async (entry) => {
render(<App />)
const nav = await screen.findByRole('button', { name: '本机推理' })
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
const workspace = await screen.findByLabelText('知识工作区')
if (entry === 'sidebar') {
nav.focus()
fireEvent.click(nav)
} else {
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
else {
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const item = screen.getByRole('button', { name: '本机推理 应用设置' }).closest('article')!
fireEvent.click(within(item).getByRole('button', { name: '打开' }))
}
}
const dialog = screen.getByRole('dialog', { name: '本机推理' })
expect(workspace).toBeInTheDocument()
expect(workspace.closest('.app-shell')).toHaveProperty('inert', true)
expect(nav).not.toHaveAttribute('aria-current', 'page')
expect(dialog.closest('main')).toBeNull()
const close = within(dialog).getByRole('button', { name: '关闭本机推理' })
expect(close).toHaveFocus()
fireEvent.click(close)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByLabelText('知识工作区')).toBe(workspace)
expect(workspace.closest('.app-shell')).toHaveProperty('inert', false)
expect(entry === 'sidebar' ? nav : screen.getByRole('button', { name: '应用中心' })).toHaveFocus()
})
it('opens an unpinned app and recovers a disabled current page without remounting it', async () => {
let changed!: Parameters<NonNullable<typeof api.updates>['onSettingsChanged']>[0]
vi.mocked(api.updates!.onSettingsChanged).mockImplementation(listener => {
changed = listener
return vi.fn()
})
render(<App />)
const inferenceNav = await screen.findByRole('button', { name: '本机推理' })
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
await waitFor(() => expect(pin).not.toBeDisabled())
fireEvent.click(pin)
await waitFor(() => expect(inferenceNav).not.toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
const trigger = screen.getByRole('button', { name: '应用中心' })
expect(trigger).toHaveFocus()
fireEvent.click(trigger)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
const workspace = await screen.findByRole('region', { name: '本机推理服务' })
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理' })).not.toBeInTheDocument()
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false })))
expect(screen.getByText('应用已关闭')).toBeInTheDocument()
expect(workspace).toBeInTheDocument()
expect(workspace.closest('.local-inference-workspace')).toHaveAttribute('hidden')
fireEvent.click(screen.getByRole('button', { name: '应用设置' }))
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeDisabled())
fireEvent.click(screen.getByRole('switch', { name: '启用应用' }))
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
expect(screen.getByRole('region', { name: '本机推理服务' })).toBe(workspace)
expect(workspace.closest('.local-inference-workspace')).not.toHaveAttribute('hidden')
expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false)
})
it('keeps confirmed application preferences and locks edits until an unknown save is read back', async () => {
render(<App />)
await screen.findByRole('button', { name: '知识库' })
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const updates = api.updates!
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
await waitFor(() => expect(screen.getByRole('switch', { name: '常驻左侧菜单' })).not.toBeDisabled())
vi.mocked(updates.updateSettings).mockRejectedValueOnce(new Error('response lost'))
vi.mocked(updates.getSettings).mockRejectedValueOnce(new Error('read unavailable'))
fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' }))
await screen.findByText('无法确认保存结果,请重新读取后再修改。')
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
expect(pin).toBeChecked()
expect(pin).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '重新读取' }))
await waitFor(() => expect(pin).not.toBeDisabled())
expect(updates.updateSettings).toHaveBeenCalledOnce()
})
it("gives the knowledge workspace the full content width", async () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
expect(await screen.findByLabelText("知识工作区")).toBeInTheDocument();
expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument();
+271 -113
View File
@@ -1,8 +1,24 @@
import { ImageCapabilityNotice } from "./ImageCapabilityNotice";
import LocalInferencePage from "./LocalInferencePage";
import { ApplicationMenu } from './ApplicationMenu';
import {
ApplicationAvailability,
ApplicationCenter,
ApplicationSettingsNavigation,
applicationDefinitions,
isApplicationEnabled,
} from "./ApplicationCenter";
import {
defaultApplicationNavigation,
type ApplicationSettings,
type ApplicationSettingsUpdate,
type EditableApplicationId,
} from "../../shared/application-settings-contracts";
import type { ImageOperation } from "../../shared/image-generation-contracts";
import {
ArrowDown,
Bot,
ChartColumn,
Check,
CheckCircle2,
ChevronDown,
@@ -14,7 +30,7 @@ import {
Edit3,
FileText,
GitFork,
HeartPulse,
Grid2X2,
Info,
Library,
LoaderCircle,
@@ -172,6 +188,7 @@ import { ProjectSwitcher } from "./ProjectSwitcher";
import { ProjectActivity } from "./ProjectActivity";
import { deriveConversationActivity } from "./conversation-activity";
import { useUnviewedCompletions } from "./use-unviewed-completions";
import { useExecutionStats } from "./use-execution-stats";
import {
RightAssistantSidebar,
type AssistantSidebarTab,
@@ -180,6 +197,7 @@ import {
} from "./RightAssistantSidebar";
import {
CustomTaskDialog,
type CustomTaskCreateOptions,
type CustomTaskDestination,
} from "./CustomTaskDialog";
import { ConversationTaskStrip } from "./ConversationTaskStrip";
@@ -508,7 +526,7 @@ type ActiveRun = {
};
type WorkspaceView =
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "activity" | "settings";
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "activity" | "settings";
const intentRoutePreloaders: Partial<
Record<WorkspaceView, () => Promise<unknown>>
@@ -2399,6 +2417,17 @@ function App(): React.JSX.Element {
>({});
const [view, setViewState] = useState<WorkspaceView>("chat");
const [settingsOpen, setSettingsOpen] = useState(false);
const [localInferenceOpen, setLocalInferenceOpen] = useState(false);
const [applicationCenterOpen, setApplicationCenterOpen] = useState(false);
const [applicationMenuOpen, setApplicationMenuOpen] = useState(false);
const applicationMenuTriggerRef = useRef<HTMLButtonElement>(null);
const [applicationSettingsTarget, setApplicationSettingsTarget] = useState<EditableApplicationId>();
const [applicationSettings, setApplicationSettings] = useState<ApplicationSettings>();
const [applicationSettingsPending, setApplicationSettingsPending] = useState(false);
const [applicationSettingsUnconfirmed, setApplicationSettingsUnconfirmed] = useState(false);
const applicationSettingsPendingRef = useRef(0);
const applicationSettingsRevisionRef = useRef(0);
const [applicationSettingsError, setApplicationSettingsError] = useState<string>();
const settingsOpenRef = useRef(false);
const [settingsInitialCategory, setSettingsInitialCategory] =
useState<SettingsCategoryId>();
@@ -2484,6 +2513,10 @@ function App(): React.JSX.Element {
(update: SetStateAction<WorkspaceView>): void => {
const next =
typeof update === "function" ? update(viewRef.current) : update;
if (next === "local-inference") {
setLocalInferenceOpen(true);
return;
}
if (
!settingsOpenRef.current &&
next === "settings" &&
@@ -2569,6 +2602,78 @@ function App(): React.JSX.Element {
setMagicNotesShowIncompleteTodoCount,
] = useState(true);
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0);
const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation;
const visibleApplications = useMemo(() => ['knowledge' as const, 'heartbeat' as const, ...applicationNavigation.order].filter(id =>
id === 'knowledge' || id === 'heartbeat' || (applicationNavigation.pinned[id] && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id)))
), [applicationNavigation, applicationSettings, magicNotesEnabled]);
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
setApplicationSettings(settings);
setMagicNotesEnabled(settings.magicNotesEnabled);
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false);
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
}, []);
const reloadApplicationSettings = useCallback(async (): Promise<void> => {
const revision = ++applicationSettingsRevisionRef.current;
applicationSettingsPendingRef.current++;
setApplicationSettingsPending(true);
try {
if (!window.goodbuddy.updates) throw new Error(t('applications.loadFailed'));
const settings = await window.goodbuddy.updates.getSettings();
if (revision !== applicationSettingsRevisionRef.current) return;
applyApplicationSettings(settings);
setApplicationSettingsUnconfirmed(false);
setApplicationSettingsError(undefined);
} catch (error) {
if (revision !== applicationSettingsRevisionRef.current) return;
setApplicationSettingsUnconfirmed(true);
setApplicationSettingsError(displayErrorMessage(error, t('applications.loadFailed')));
} finally {
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
}
}, [applyApplicationSettings, t]);
const updateApplicationSettings = useCallback(async (patch: ApplicationSettingsUpdate): Promise<boolean> => {
if (applicationSettingsPendingRef.current || applicationSettingsUnconfirmed) return false;
const revision = ++applicationSettingsRevisionRef.current;
applicationSettingsPendingRef.current++;
setApplicationSettingsPending(true);
setApplicationSettingsError(undefined);
try {
if (!window.goodbuddy.updates) throw new Error(t('applications.loadFailed'));
const settings = await window.goodbuddy.updates.updateSettings(patch);
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
return true;
} catch (error) {
if (revision !== applicationSettingsRevisionRef.current) return false;
setApplicationSettingsError(displayErrorMessage(error, t('applications.saveFailed')));
try {
if (!window.goodbuddy.updates) throw error;
const settings = await window.goodbuddy.updates.getSettings();
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
} catch {
if (revision !== applicationSettingsRevisionRef.current) return false;
// Keep edits locked until Retry obtains the authoritative settings.
setApplicationSettingsError(t('applications.confirmFailed'));
setApplicationSettingsUnconfirmed(true);
}
return false;
} finally {
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
}
}, [applicationSettingsUnconfirmed, applyApplicationSettings, t]);
const openApplicationSettings = useCallback((id: EditableApplicationId): void => {
const open = (): void => {
setApplicationSettingsTarget(id);
setApplicationMenuOpen(false);
setApplicationCenterOpen(true);
void reloadApplicationSettings();
};
if (settingsOpenRef.current) {
const leave = (): void => { commitView(viewRef.current); requestAnimationFrame(open); };
if (settingsLeaveRequesterRef.current) settingsLeaveRequesterRef.current(leave);
else leave();
} else open();
}, [commitView, reloadApplicationSettings]);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const deferredSearchQuery = useDeferredValue(searchQuery);
@@ -3144,27 +3249,33 @@ function App(): React.JSX.Element {
saveAppearanceTheme(appearanceTheme);
}, [appearanceTheme]);
useEffect(() => {
const revision = applicationSettingsRevisionRef;
const remove = window.goodbuddy.updates?.onSettingsChanged((settings) => {
// A persisted change supersedes reads and mutation replies already in flight.
revision.current++;
applyApplicationSettings(settings);
setApplicationSettingsUnconfirmed(false);
setApplicationSettingsError(undefined);
});
return () => {
remove?.();
revision.current++;
};
}, [applyApplicationSettings]);
useEffect(() => {
const updates = window.goodbuddy.updates;
if (!updates || startupUpdateCheckStartedRef.current) {
return;
}
startupUpdateCheckStartedRef.current = true;
const revision = applicationSettingsRevisionRef.current;
let updateCheckSource: "github" | "mirror" | undefined;
void updates
.getSettings()
.then(async (settings) => {
setConversationHtmlRenderingEnabled(
settings.conversationHtmlRenderingEnabled !== false,
);
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
setMagicNotesEnabled(settings.magicNotesEnabled);
setMagicNotesShowIncompleteTodoCount(
settings.magicNotesShowIncompleteTodoCount,
);
if (!settings.magicNotesEnabled) {
setView((current) => (current === "magic-notes" ? "chat" : current));
}
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
if (!settings.checkUpdatesOnStartup) {
return;
}
@@ -3183,6 +3294,8 @@ function App(): React.JSX.Element {
})
.catch((reason: unknown) => {
if (!updateCheckSource) {
if (revision !== applicationSettingsRevisionRef.current) return;
setApplicationSettingsError(displayErrorMessage(reason, i18n.t('applications.loadFailed', { ns: 'app' })));
return;
}
notify({
@@ -3201,7 +3314,7 @@ function App(): React.JSX.Element {
dedupeKey: "startup-update-check",
});
});
}, [i18n, setView]);
}, [i18n, applyApplicationSettings]);
useEffect(() => {
const magicNotes = window.goodbuddy.magicNotes;
@@ -3903,6 +4016,25 @@ function App(): React.JSX.Element {
),
[assistantTasks],
);
const executionStatsRevision = useMemo(
() => assistantTasks.map((task) => `${task.id}:${task.status}:${task.completedAt ?? ""}`).join("|"),
[assistantTasks],
);
const statsConversation = activeConversation?.projectId === activeProjectId
? activeConversation : undefined;
const statsMessageCount = statsConversation?.messageSummary?.count ?? statsConversation?.messages.length ?? 0;
const executionStats = useExecutionStats(
statsConversation?.id,
activeProjectId || undefined,
`${executionStatsRevision}:${statsMessageCount}:${activeConversationIds.has(activeId)}`,
assistantSidebarOpen && assistantSidebarTab === "tasks",
);
const taskDurations = useMemo(() => new Map(
executionStats.project?.taskDurations.map((task) => [task.id, {
durationMs: task.durationMs,
incomplete: task.incompleteRequestCount > 0,
}]) ?? [],
), [executionStats.project]);
const tasksByConversation = useMemo(() => {
const grouped = new Map<string, AssistantTask[]>();
for (const task of productAssistantTasks) {
@@ -3945,7 +4077,7 @@ function App(): React.JSX.Element {
);
const { completedConversationIds, markConversationCompleted, clearConversationCompleted } =
useUnviewedCompletions(assistantTasks,
view === "chat" && !settingsOpen && activeConversation && !activeConversation.messageSummary
view === "chat" && !settingsOpen && !applicationCenterOpen && activeConversation && !activeConversation.messageSummary
? activeId : undefined);
const projectActivity = useMemo(
() => deriveConversationActivity(
@@ -8331,12 +8463,19 @@ function App(): React.JSX.Element {
const createCustomTask = async (
input: Parameters<typeof window.goodbuddy.schedules.create>[0],
options?: CustomTaskCreateOptions,
): Promise<AssistantSchedule> => {
if (!options?.runImmediately && !(Date.parse(input.nextRunAt) > Date.now())) {
throw new Error(t("customTask.errors.futureTime"));
}
if (input.conversationId) {
persistLocalConversationChanges();
await conversationPersistenceQueueRef.current;
}
const schedule = await window.goodbuddy.schedules.create(input);
const schedule = await window.goodbuddy.schedules.create({
...input,
runImmediately: options?.runImmediately ?? false,
});
setAssistantSchedules((current) => [
schedule,
...current.filter((item) => item.id !== schedule.id),
@@ -8682,6 +8821,7 @@ function App(): React.JSX.Element {
};
return (
<ApplicationSettingsNavigation value={openApplicationSettings}>
<div className="app-shell">
<aside
aria-label={
@@ -8797,78 +8937,34 @@ function App(): React.JSX.Element {
<MessageSquare aria-hidden="true" size={17} />
<span>{t("navigation.chat")}</span>
</button>
{magicNotesEnabled && (
<button
aria-current={view === "magic-notes" ? "page" : undefined}
className={
view === "magic-notes"
? "nav-item nav-item--active"
: "nav-item"
}
onFocus={() => preloadWorkspaceRouteOnIntent("magic-notes")}
onClick={(event) =>
navigateFromSidebar("magic-notes", event.currentTarget)
}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent("magic-notes")
}
type="button"
>
<Sparkles aria-hidden="true" size={17} />
<span>{t("navigation.magicNotes")}</span>
{magicNotesShowIncompleteTodoCount &&
incompleteMagicTodoCount > 0 && (
<span
aria-label={t("navigation.incompleteTodos", {
count: incompleteMagicTodoCount,
})}
className="nav-item__badge"
>
{incompleteMagicTodoCount > 99
? "99+"
: incompleteMagicTodoCount}
{visibleApplications.map((id) => {
const definition = applicationDefinitions[id];
const Icon = definition.icon;
return (
<button
key={id}
type="button"
className={view === id ? "nav-item nav-item--active" : "nav-item"}
aria-current={view === id ? "page" : undefined}
onClick={(event) => navigateFromSidebar(id, event.currentTarget)}
onFocus={() => preloadWorkspaceRouteOnIntent(id)}
onPointerEnter={() => preloadWorkspaceRouteOnIntent(id)}
>
<Icon size={17} aria-hidden="true" />
<span>{t(definition.title)}</span>
{id === "magic-notes" && magicNotesShowIncompleteTodoCount && incompleteMagicTodoCount > 0 && (
<span className="nav-item__badge" aria-label={t("navigation.incompleteTodos", { count: incompleteMagicTodoCount })}>
{incompleteMagicTodoCount > 99 ? "99+" : incompleteMagicTodoCount}
</span>
)}
</button>
)}
<button
aria-current={view === "knowledge" ? "page" : undefined}
className={
view === "knowledge" ? "nav-item nav-item--active" : "nav-item"
}
onFocus={() => preloadWorkspaceRouteOnIntent("knowledge")}
onClick={(event) =>
navigateFromSidebar("knowledge", event.currentTarget)
}
onPointerEnter={() => preloadWorkspaceRouteOnIntent("knowledge")}
type="button"
>
<Library aria-hidden="true" size={17} />
<span>{t("navigation.knowledge")}</span>
</button>
<button
aria-current={view === "heartbeat" ? "page" : undefined}
className={
view === "heartbeat" ? "nav-item nav-item--active" : "nav-item"
}
onClick={(event) =>
navigateFromSidebar("heartbeat", event.currentTarget)
}
type="button"
>
<HeartPulse aria-hidden="true" size={17} />
<span>{t("navigation.heartbeat")}</span>
{pendingHeartbeatSuggestionCount > 0 && (
<span
aria-label={t("navigation.pendingSuggestions", {
count: pendingHeartbeatSuggestionCount,
})}
className="nav-item__badge"
>
{pendingHeartbeatSuggestionCount}
</span>
)}
</button>
{id === "heartbeat" && pendingHeartbeatSuggestionCount > 0 && (
<span className="nav-item__badge" aria-label={t("navigation.pendingSuggestions", { count: pendingHeartbeatSuggestionCount })}>
{pendingHeartbeatSuggestionCount}
</span>
)}
</button>
);
})}
<button
aria-current={view === "activity" ? "page" : undefined}
className={
@@ -8881,7 +8977,7 @@ function App(): React.JSX.Element {
onPointerEnter={() => preloadWorkspaceRouteOnIntent("activity")}
type="button"
>
<TerminalSquare aria-hidden="true" size={17} />
<ChartColumn aria-hidden="true" size={17} />
<span>{t("navigation.activity")}</span>
</button>
</nav>
@@ -9415,9 +9511,34 @@ function App(): React.JSX.Element {
) : null}
</div>
<div className="sidebar-footer">
<div className="sidebar-footer sidebar-footer--applications">
<button
className="user-card"
className="nav-item"
type="button"
ref={applicationMenuTriggerRef}
aria-haspopup="menu"
aria-expanded={applicationMenuOpen}
aria-controls="application-menu"
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
if (!applicationMenuOpen) {
setApplicationMenuOpen(true);
void reloadApplicationSettings();
}
}}
onClick={() => {
setApplicationMenuOpen(!applicationMenuOpen);
if (!applicationMenuOpen) void reloadApplicationSettings();
}}
>
<Grid2X2 size={17} aria-hidden="true" />
<span>{t("applications.title")}</span>
</button>
<button
className="icon-button"
aria-label={t('navigation.settings')}
title={t('navigation.settings')}
type="button"
onFocus={() => preloadWorkspaceRouteOnIntent("settings")}
onClick={(event) =>
@@ -9425,15 +9546,6 @@ function App(): React.JSX.Element {
}
onPointerEnter={() => preloadWorkspaceRouteOnIntent("settings")}
>
<span className="avatar">GB</span>
<span className="user-card__copy">
<strong>{t("sidebar.localWorkspace")}</strong>
<small>
{appInfo
? `${appInfo.platform} · ${appInfo.arch}`
: t("sidebar.loading")}
</small>
</span>
<Settings size={16} />
</button>
</div>
@@ -10830,13 +10942,13 @@ function App(): React.JSX.Element {
</PageShell>
</KeepAliveRoute>
)}
{magicNotesEnabled &&
(view === "magic-notes" ||
{(view === "magic-notes" ||
cachedWorkspaceViewKeys.has("magic-notes")) && (
<KeepAliveRoute
active={view === "magic-notes"}
route="magic-notes"
>
<ApplicationAvailability id="magic-notes" enabled={magicNotesEnabled}>
<PageShell variant="master-detail">
<RouteErrorBoundary
key="magic-notes"
@@ -10852,10 +10964,11 @@ function App(): React.JSX.Element {
<RouteLoadingStatus label={t("route.loading")} />
}
>
<MagicNotesWorkspace onNotify={notify} />
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} />
</Suspense>
</RouteErrorBoundary>
</PageShell>
</ApplicationAvailability>
</KeepAliveRoute>
)}
{(view === "knowledge" ||
@@ -11247,6 +11360,51 @@ function App(): React.JSX.Element {
</PageShell>
</KeepAliveRoute>
)}
{localInferenceOpen && (
<LocalInferencePage
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
onClose={() => setLocalInferenceOpen(false)}
restoreFocus={() => applicationMenuTriggerRef.current?.closest('[aria-hidden="true"]')
? document.querySelector<HTMLElement>('.sidebar-toggle')
: applicationMenuTriggerRef.current}
/>
)}
{applicationCenterOpen && (
<ApplicationCenter
settings={applicationSettings}
pending={applicationSettingsPending}
locked={applicationSettingsUnconfirmed}
error={applicationSettingsError}
initialApplication={applicationSettingsTarget}
onClose={() => setApplicationCenterOpen(false)}
onOpen={(id) => {
if (!isApplicationEnabled(applicationSettings, id)) return;
setApplicationCenterOpen(false);
setView(id);
if (window.innerWidth < 900) setSidebarOpen(false);
}}
onUpdate={updateApplicationSettings}
onRetry={() => void reloadApplicationSettings()}
/>
)}
{applicationMenuOpen && (
<ApplicationMenu
anchorRef={applicationMenuTriggerRef}
settings={applicationSettings}
pending={applicationSettingsPending || (!applicationSettings && !applicationSettingsError)}
error={applicationSettingsError}
onClose={() => setApplicationMenuOpen(false)}
onOpen={(id) => {
setView(id);
if (window.innerWidth < 900) setSidebarOpen(false);
}}
onManage={() => {
setApplicationSettingsTarget(undefined);
setApplicationCenterOpen(true);
}}
onRetry={() => void reloadApplicationSettings()}
/>
)}
{settingsOpen && (
<RouteErrorBoundary
key="settings"
@@ -11303,18 +11461,6 @@ function App(): React.JSX.Element {
setSelectedExpertId("");
}
}}
onMagicNotesEnabledChange={(enabled) => {
setMagicNotesEnabled(enabled);
if (!enabled) {
setIncompleteMagicTodoCount(0);
}
}}
onMagicNotesShowIncompleteTodoCountChange={(enabled) => {
setMagicNotesShowIncompleteTodoCount(enabled);
if (!enabled) {
setIncompleteMagicTodoCount(0);
}
}}
onRemoteProjectsEnabledChange={
handleRemoteProjectsEnabledChange
}
@@ -11462,6 +11608,10 @@ function App(): React.JSX.Element {
)}
{customTaskDialog && activeProject?.kind === "user" && (
<CustomTaskDialog
conversations={conversations.filter(
(conversation) => !conversation.remote &&
conversation.projectId === activeProject.id,
)}
currentConversationAvailable={Boolean(
activeConversation &&
!activeConversation.remote &&
@@ -11480,6 +11630,13 @@ function App(): React.JSX.Element {
)}
<RightAssistantSidebar
activeConversationId={activeId}
conversationStats={statsConversation && executionStats.conversation ? {
conversationId: statsConversation.id,
messageCount: statsMessageCount,
replyDurationMs: executionStats.conversation.durationMs,
incomplete: executionStats.conversation.incompleteRequestCount > 0,
} : undefined}
taskDurations={taskDurations}
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
browserStates={browserStates}
@@ -11559,6 +11716,7 @@ function App(): React.JSX.Element {
</div>
</div>
</div>
</ApplicationSettingsNavigation>
);
}
+236
View File
@@ -0,0 +1,236 @@
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
applicationSettingsSchema,
defaultApplicationNavigation,
defaultLocalToolEnvironmentSettings,
} from '../../shared/application-settings-contracts'
import {
ApplicationAvailability,
ApplicationCenter,
ApplicationSettingsNavigation,
ApplicationSettingsView,
} from './ApplicationCenter'
const settings = applicationSettingsSchema.parse({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
})
const props = () => ({
settings,
pending: false,
onClose: vi.fn(),
onOpen: vi.fn(),
onUpdate: vi.fn(async () => true),
onRetry: vi.fn(),
})
afterEach(cleanup)
describe('Application Center', () => {
it('returns from compact settings through header navigation without losing the search', () => {
render(<ApplicationCenter {...props()} />)
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '笔记' } })
const body = screen.getByRole('searchbox').closest('.application-center__body')!
body.scrollTop = 200
fireEvent.click(screen.getByRole('button', { name: '魔法笔记 应用设置' }))
expect(body.scrollTop).toBe(0)
const dialog = screen.getByRole('dialog', { name: '魔法笔记' })
expect(dialog).toHaveClass('application-center--detail')
const back = within(dialog).getByRole('button', { name: '返回应用中心' })
expect(back.closest('.settings-panel__header')).not.toBeNull()
fireEvent.click(back)
expect(screen.getByRole('searchbox')).toHaveValue('笔记')
expect(screen.getByRole('searchbox')).toHaveFocus()
expect(dialog).not.toHaveClass('application-center--detail')
})
it.each([['知识库', 'knowledge'], ['智能心跳', 'heartbeat']])('keeps %s fixed with Open and no settings, pin, or order controls', (title, id) => {
const handlers = props()
render(<ApplicationCenter {...handlers} />)
const card = screen.getByText(title, { exact: true }).closest('article')!
expect(card).toHaveAttribute('draggable', 'false')
expect(within(card).queryByRole('switch')).not.toBeInTheDocument()
expect(within(card).getAllByRole('button')).toHaveLength(1)
fireEvent.click(within(card).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).toHaveBeenCalledWith(id)
const notes = screen.getByText('魔法笔记', { exact: true }).closest('article')!
fireEvent.dragStart(notes)
fireEvent.drop(card)
expect(handlers.onUpdate).not.toHaveBeenCalled()
})
it('searches disabled and unpinned applications, opens without pinning, and restores focus', () => {
const handlers = props()
const trigger = document.createElement('button')
document.body.append(trigger)
trigger.focus()
const { unmount } = render(
<ApplicationCenter
{...handlers}
settings={{
...settings,
localInferenceEnabled: false,
applicationNavigation: {
...defaultApplicationNavigation,
pinned: {
...defaultApplicationNavigation.pinned,
'magic-notes': false,
},
},
}}
/>,
)
const search = screen.getByRole('searchbox', { name: '搜索内置应用' })
expect(search).toHaveFocus()
fireEvent.change(search, { target: { value: '笔记' } })
fireEvent.click(screen.getByRole('button', { name: '打开' }))
expect(handlers.onOpen).toHaveBeenCalledWith('magic-notes')
expect(handlers.onUpdate).not.toHaveBeenCalled()
fireEvent.change(search, { target: { value: '推理' } })
expect(screen.getByText('已关闭', { exact: false })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '打开' }),
).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeChecked()
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(handlers.onClose).toHaveBeenCalledOnce()
unmount()
expect(trigger).toHaveFocus()
trigger.remove()
})
it('saves complete order and independent pin preferences without optimistic changes', () => {
const handlers = props()
render(<ApplicationCenter {...handlers} />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: {
...defaultApplicationNavigation,
pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': false },
},
})
expect(
screen.getByRole('switch', { name: '常驻左侧菜单' }),
).toBeChecked()
fireEvent.click(screen.getByRole('button', { name: '上移 本机推理' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: {
...defaultApplicationNavigation,
order: ['local-inference', 'magic-notes'],
},
})
expect(screen.getByRole('button', { name: '下移 本机推理' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '返回应用中心' }))
fireEvent.click(screen.getByRole('button', { name: '魔法笔记 应用设置' }))
expect(screen.getByRole('button', { name: '上移 魔法笔记' })).toBeDisabled()
})
it('uses the same order patch for drag and keyboard controls', () => {
const handlers = props()
render(<ApplicationCenter {...handlers} />)
const notes = screen
.getByText('魔法笔记', { exact: true })
.closest('article')!
const inference = screen
.getByText('本机推理', { exact: true })
.closest('article')!
fireEvent.dragStart(inference)
fireEvent.dragOver(notes)
fireEvent.drop(notes)
expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: {
...defaultApplicationNavigation,
order: ['local-inference', 'magic-notes'],
},
})
})
it('locks all preference controls while saving or awaiting a confirmed read', () => {
const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} pending initialApplication="local-inference" />)
for (const toggle of screen.getAllByRole('switch'))
expect(toggle).toBeDisabled()
expect(screen.getByRole('button', { name: '上移 本机推理' })).toBeDisabled()
rerender(
<ApplicationCenter {...handlers} locked error="无法确认保存结果" />,
)
expect(screen.getByRole('alert')).toHaveTextContent('无法确认保存结果')
for (const toggle of screen.getAllByRole('switch'))
expect(toggle).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '重新读取' }))
expect(handlers.onRetry).toHaveBeenCalledOnce()
})
it('provides a recoverable search empty state and no market or system tools', () => {
render(<ApplicationCenter {...props()} />)
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.queryByText('安装')).not.toBeInTheDocument()
expect(screen.queryByText('终端')).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox'), {
target: { value: 'absent' },
})
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(screen.getAllByRole('article')).toHaveLength(4)
})
it('edits all notes preferences through the shared settings form', () => {
const onUpdate = vi.fn(async () => true)
render(
<ApplicationSettingsView
id="magic-notes"
settings={settings}
pending={false}
onUpdate={onUpdate}
/>,
)
fireEvent.click(screen.getByRole('switch', { name: '启用应用' }))
expect(onUpdate).toHaveBeenLastCalledWith({ magicNotesEnabled: false })
fireEvent.click(screen.getByRole('switch', { name: '显示未完成待办数量' }))
expect(onUpdate).toHaveBeenLastCalledWith({
magicNotesShowIncompleteTodoCount: false,
})
fireEvent.click(screen.getByRole('button', { name: '保存后自动' }))
expect(onUpdate).toHaveBeenLastCalledWith({
magicNoteCommentMode: 'after-save-auto',
})
fireEvent.click(screen.getByRole('button', { name: '要点' }))
expect(onUpdate).toHaveBeenLastCalledWith({
magicNoteCommentFormat: 'structured',
})
})
it('keeps disabled page drafts mounted with a recovery entry', () => {
const open = vi.fn()
const page = (enabled: boolean) => (
<ApplicationSettingsNavigation value={open}>
<ApplicationAvailability id="local-inference" enabled={enabled}>
<input aria-label="draft" defaultValue="original" />
</ApplicationAvailability>
</ApplicationSettingsNavigation>
)
const { rerender, container } = render(page(true))
const draft = screen.getByRole('textbox', { name: 'draft' })
fireEvent.change(draft, { target: { value: 'keep this draft' } })
rerender(page(false))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(draft).toBeInTheDocument()
expect(screen.getByText('应用已关闭')).toBeInTheDocument()
fireEvent.click(within(container).getByRole('button', { name: '应用设置' }))
expect(open).toHaveBeenCalledWith('local-inference')
rerender(page(true))
expect(screen.getByRole('textbox')).toHaveValue('keep this draft')
})
})
+519
View File
@@ -0,0 +1,519 @@
import {
createContext,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import {
ArrowDown,
ArrowLeft,
ArrowUp,
Cpu,
HeartPulse,
Library,
Settings,
Sparkles,
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
defaultApplicationNavigation,
type ApplicationSettings,
type ApplicationSettingsUpdate,
type BuiltInApplicationId,
type EditableApplicationId,
} from '../../shared/application-settings-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import './application-center.css'
export const applicationDefinitions = {
'magic-notes': {
icon: Sparkles,
title: 'navigation.magicNotes',
enabled: 'magicNotesEnabled',
},
knowledge: {
icon: Library,
title: 'navigation.knowledge',
},
heartbeat: {
icon: HeartPulse,
title: 'navigation.heartbeat',
},
'local-inference': {
icon: Cpu,
title: 'applications.localInference',
enabled: 'localInferenceEnabled',
},
} as const
export function isApplicationEnabled(
settings: ApplicationSettings | undefined,
id: BuiltInApplicationId,
): boolean {
if (id === 'knowledge' || id === 'heartbeat') return true
return (
settings !== undefined &&
settings[applicationDefinitions[id].enabled] !== false
)
}
export const ApplicationSettingsNavigation = createContext<
((id: EditableApplicationId) => void) | undefined
>(undefined)
export function ApplicationSettingsLink({
id,
}: {
id: EditableApplicationId
}): React.JSX.Element | null {
const open = useContext(ApplicationSettingsNavigation)
const { t } = useTranslation('app')
return open ? (
<button className="secondary-button" type="button" onClick={() => open(id)}>
<Settings size={15} aria-hidden="true" />
{t('applications.settings')}
</button>
) : null
}
export function ApplicationAvailability({
id,
enabled,
children,
}: {
id: EditableApplicationId
enabled: boolean
children: ReactNode
}): React.JSX.Element {
const { t } = useTranslation('app')
return (
<div className="application-page">
<div className="application-page-controls">
{!enabled && <strong>{t('applications.disabledPage')}</strong>}
<ApplicationSettingsLink id={id} />
</div>
<div
className="application-page-content"
hidden={!enabled}
inert={!enabled}
>
{children}
</div>
</div>
)
}
export function ApplicationSettingsView({
id,
settings,
pending,
onUpdate,
}: {
id: EditableApplicationId
settings: ApplicationSettings
pending: boolean
onUpdate: (patch: ApplicationSettingsUpdate) => Promise<boolean>
}): React.JSX.Element {
const { t } = useTranslation(['app', 'settingsSections'])
const navigation =
settings.applicationNavigation ?? defaultApplicationNavigation
return (
<div className="application-settings">
<div className="application-settings__group">
<label className="toggle-row">
<input
role="switch"
type="checkbox"
checked={isApplicationEnabled(settings, id)}
disabled={pending}
onChange={(event) =>
void onUpdate({
[applicationDefinitions[id].enabled]: event.target.checked,
})
}
/>
<span>{t('applications.enable')}</span>
</label>
<label className="toggle-row">
<input
role="switch"
type="checkbox"
checked={navigation.pinned[id]}
disabled={pending}
onChange={(event) =>
void onUpdate({
applicationNavigation: {
...navigation,
pinned: { ...navigation.pinned, [id]: event.target.checked },
},
})
}
/>
<span>{t('applications.pin')}</span>
</label>
<p className="settings-notice">{t('applications.enableHelp')}</p>
{id === 'magic-notes' && (
<label className="toggle-row">
<input
role="switch"
type="checkbox"
checked={settings.magicNotesShowIncompleteTodoCount}
disabled={pending}
onChange={(event) =>
void onUpdate({
magicNotesShowIncompleteTodoCount: event.target.checked,
})
}
/>
<span>
{t('platformFeatures.magicNotes.showIncompleteTodoCount', {
ns: 'settingsSections',
})}
</span>
</label>
)}
</div>
{id === 'magic-notes' && (
<div className="application-settings__group">
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentMode', {
ns: 'settingsSections',
})}
</span>
<SegmentedControl
ariaLabel={t('platformFeatures.magicNotes.commentModeAria', {
ns: 'settingsSections',
})}
disabled={pending}
value={settings.magicNoteCommentMode}
onChange={(magicNoteCommentMode) =>
void onUpdate({ magicNoteCommentMode })
}
options={[
{
value: 'immediate',
label: t('platformFeatures.magicNotes.modes.immediate', {
ns: 'settingsSections',
}),
},
{
value: 'after-save-auto',
label: t('platformFeatures.magicNotes.modes.afterSaveAuto', {
ns: 'settingsSections',
}),
},
{
value: 'after-save-manual',
label: t(
'platformFeatures.magicNotes.modes.afterSaveManual',
{ ns: 'settingsSections' },
),
},
]}
/>
</div>
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentFormat', {
ns: 'settingsSections',
})}
</span>
<SegmentedControl
ariaLabel={t('platformFeatures.magicNotes.commentFormatAria', {
ns: 'settingsSections',
})}
disabled={pending}
value={settings.magicNoteCommentFormat}
onChange={(magicNoteCommentFormat) =>
void onUpdate({ magicNoteCommentFormat })
}
options={[
{
value: 'combined',
label: t('platformFeatures.magicNotes.formats.combined', {
ns: 'settingsSections',
}),
},
{
value: 'narrative',
label: t('platformFeatures.magicNotes.formats.narrative', {
ns: 'settingsSections',
}),
},
{
value: 'structured',
label: t('platformFeatures.magicNotes.formats.structured', {
ns: 'settingsSections',
}),
},
]}
/>
</div>
</div>
)}
</div>
)
}
export function ApplicationCenter({
settings,
pending,
locked = false,
error,
initialApplication,
onClose,
onOpen,
onUpdate,
onRetry,
}: {
settings?: ApplicationSettings
pending: boolean
locked?: boolean
error?: string
initialApplication?: EditableApplicationId
onClose: () => void
onOpen: (id: BuiltInApplicationId) => void
onUpdate: (patch: ApplicationSettingsUpdate) => Promise<boolean>
onRetry: () => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const [query, setQuery] = useState('')
const [selected, setSelected] = useState(initialApplication)
const [dragged, setDragged] = useState<EditableApplicationId>()
const [announcement, setAnnouncement] = useState('')
const dialog = useRef<HTMLElement>(null)
const body = useRef<HTMLDivElement>(null)
const search = useRef<HTMLInputElement>(null)
const close = useRef<HTMLButtonElement>(null)
useLayoutEffect(
() => activateModalFocus(() => search.current ?? close.current),
[],
)
useLayoutEffect(() => {
if (body.current) body.current.scrollTop = 0
;(selected ? close.current : search.current)?.focus()
}, [selected])
const navigation =
settings?.applicationNavigation ?? defaultApplicationNavigation
const editingDisabled = pending || locked
const results = useMemo(
() =>
['knowledge' as const, 'heartbeat' as const, ...navigation.order].filter((id) =>
`${t(applicationDefinitions[id].title)} ${t(`applications.descriptions.${id}`)}`
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
),
[navigation.order, query, t],
)
const move = async (
from: EditableApplicationId,
to: EditableApplicationId,
): Promise<void> => {
if (editingDisabled || from === to) return
const order = [...navigation.order]
order.splice(order.indexOf(from), 1)
order.splice(navigation.order.indexOf(to), 0, from)
if (await onUpdate({ applicationNavigation: { ...navigation, order } }))
setAnnouncement(t('applications.orderUpdated'))
}
return createPortal(
<div className="settings-backdrop" role="presentation">
<section
className={`settings-panel application-center${selected ? ' application-center--detail' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="application-center-title"
id="application-center"
ref={dialog}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === 'Escape') {
event.preventDefault()
onClose()
}
trapTabFocus(event, dialog.current)
}}
>
<div className="settings-panel__header">
{selected && (
<button
type="button"
className="icon-button"
aria-label={t('applications.back')}
title={t('applications.back')}
onClick={() => setSelected(undefined)}
>
<ArrowLeft size={19} aria-hidden="true" />
</button>
)}
<PageHeader
headingId="application-center-title"
title={
selected
? t(applicationDefinitions[selected].title)
: t('applications.title')
}
description={t(
selected
? `applications.descriptions.${selected}`
: 'applications.description',
)}
actions={
<button
ref={close}
type="button"
className="icon-button"
aria-label={t('applications.close')}
title={t('applications.close')}
onClick={onClose}
>
<X size={19} />
</button>
}
/>
</div>
<div ref={body} className="application-center__body" aria-busy={pending}>
{error && (
<div role="alert" className="settings-warning">
{error}{' '}
<button
type="button"
className="secondary-button"
disabled={pending}
onClick={onRetry}
>
{t('applications.retry')}
</button>
</div>
)}
{!settings ? (
<p role="status">{t('route.loading')}</p>
) : selected ? (
<>
<ApplicationSettingsView
id={selected}
settings={settings}
pending={editingDisabled}
onUpdate={onUpdate}
/>
<div className="application-center__order">
{([-1, 1] as const).map((offset) => {
const target =
navigation.order[navigation.order.indexOf(selected) + offset]
const Icon = offset === -1 ? ArrowUp : ArrowDown
return (
<button
key={offset}
className="secondary-button"
type="button"
disabled={editingDisabled || !target}
onClick={() => { if (target) void move(selected, target) }}
>
<Icon size={16} aria-hidden="true" />
{t(offset === -1 ? 'applications.moveUp' : 'applications.moveDown', {
name: t(applicationDefinitions[selected].title),
})}
</button>
)
})}
</div>
</>
) : (
<>
<label className="application-center__search">
<span>{t('applications.search')}</span>
<input
ref={search}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
{results.length === 0 && (
<div>
<p>{t('applications.noResults')}</p>
<button
className="secondary-button"
type="button"
onClick={() => setQuery('')}
>
{t('applications.clear')}
</button>
</div>
)}
<div className="application-center__grid">{results.map((id) => {
const definition = applicationDefinitions[id]
const Icon = definition.icon
const enabled = isApplicationEnabled(settings, id)
const fixed = id === 'knowledge' || id === 'heartbeat'
return (
<article
className="application-center__item"
key={id}
draggable={!fixed && !editingDisabled && !query}
onDragStart={() => { if (!fixed) setDragged(id) }}
onDragEnd={() => setDragged(undefined)}
onDragOver={(event) => {
if (!fixed && dragged && !editingDisabled) event.preventDefault()
}}
onDrop={(event) => {
event.preventDefault()
if (!fixed && dragged) void move(dragged, id)
setDragged(undefined)
}}
>
<div className="application-center__summary">
<Icon size={22} aria-hidden="true" />
<div>
<div className="application-center__title">
<strong>{t(definition.title)}</strong>
<small>
{fixed
? t('applications.fixed')
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
</small>
</div>
<p>{t(`applications.descriptions.${id}`)}</p>
</div>
</div>
<div className="application-center__actions">
<button
className="secondary-button"
type="button"
disabled={!enabled}
onClick={() => onOpen(id)}
>
{t('applications.open')}
</button>
{!fixed && (
<button
className="secondary-button"
type="button"
title={t('applications.settings')}
aria-label={`${t(definition.title)} ${t('applications.settings')}`}
onClick={() => setSelected(id)}
>
<Settings size={16} aria-hidden="true" />
{t('applications.settings')}
</button>
)}
</div>
</article>
)
})}</div>
</>
)}
<span className="sr-only" role="status">{announcement}</span>
</div>
</section>
</div>,
document.body,
)
}
+105
View File
@@ -0,0 +1,105 @@
import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings, type ApplicationSettings } from '../../shared/application-settings-contracts'
import { ApplicationMenu } from './ApplicationMenu'
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
const settings = applicationSettingsSchema.parse({
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
})
function Harness({ value = settings, pending = false, error, onOpen = vi.fn(), onManage = vi.fn(), onRetry = vi.fn() }: {
value?: ApplicationSettings | null; pending?: boolean; error?: string
onOpen?: (id: string) => void; onManage?: () => void; onRetry?: () => void
}): React.JSX.Element {
const anchorRef = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
return <><button ref={anchorRef} onClick={() => setOpen(!open)}>Launcher</button><button>Outside</button>
{open && <ApplicationMenu anchorRef={anchorRef} settings={value ?? undefined} pending={pending} error={error}
onClose={() => setOpen(false)} onOpen={onOpen} onManage={onManage} onRetry={onRetry} />}</>
}
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('lists enabled apps regardless of pins and keeps Knowledge and Heartbeat fixed before filtering', () => {
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true,
applicationNavigation: { order: ['local-inference', 'magic-notes'] as ApplicationSettings['applicationNavigation']['order'],
pinned: { 'magic-notes': false, 'local-inference': false } } }
const onOpen = vi.fn()
const { rerender } = render(<Harness value={value} onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '魔法笔记', '管理应用'])
rerender(<Harness value={{ ...value, magicNotesEnabled: false }} onOpen={onOpen} />)
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '管理应用'])
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
expect(onOpen).toHaveBeenCalledWith('local-inference')
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('shows loading and actionable errors without treating unknown settings as disabled', () => {
const onRetry = vi.fn()
const { rerender } = render(<Harness value={null} pending onRetry={onRetry} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '知识库' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '智能心跳' })).toBeInTheDocument()
rerender(<Harness value={null} error="Cannot read settings" onRetry={onRetry} />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('Cannot read settings')
fireEvent.click(screen.getByRole('menuitem', { name: '重新读取' }))
expect(onRetry).toHaveBeenCalledOnce()
})
it('supports arrow, Home, End, Escape, outside focus and pointer dismissal and management focus handoff', () => {
const onManage = vi.fn()
render(<Harness onManage={onManage} />)
const trigger = screen.getByText('Launcher')
fireEvent.click(trigger)
const items = screen.getAllByRole('menuitem')
expect(items[0]).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'End' })
expect(items.at(-1)).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
expect(items[0]).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(items.at(-1)).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
expect(items[0]).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(trigger).toHaveFocus()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
act(() => screen.getByText('Outside').focus())
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.getByText('Outside')).toHaveFocus()
fireEvent.click(trigger)
fireEvent.pointerDown(screen.getByText('Outside'))
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
expect(onManage).toHaveBeenCalledOnce()
expect(trigger).toHaveFocus()
})
it('anchors upward, clamps to a narrow viewport, repositions on resize and uses shared native viewport occlusion', () => {
vi.stubGlobal('innerWidth', 320)
vi.stubGlobal('innerHeight', 400)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'menu' ? new DOMRect(24, 92, 280, 200) : new DOMRect(270, 300, 34, 34)
})
render(<Harness />)
fireEvent.click(screen.getByText('Launcher'))
const menu = screen.getByRole('menu')
expect(menu).toHaveStyle({ left: '24px', top: '92px', width: '280px', maxHeight: '276px' })
const host = screen.getByText('Outside')
expect(isBrowserViewportOccluded(host, new DOMRect(200, 100, 100, 100))).toBe(true)
expect(isBrowserViewportOccluded(host, new DOMRect(400, 100, 100, 100))).toBe(false)
vi.stubGlobal('innerWidth', 240)
vi.stubGlobal('innerHeight', 220)
fireEvent(window, new Event('resize'))
expect(menu).toHaveStyle({ left: '16px', top: '16px', width: '208px', maxHeight: '188px' })
fireEvent.keyDown(menu, { key: 'Tab' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(isBrowserViewportOccluded(host, new DOMRect(200, 100, 100, 100))).toBe(false)
})
+42
View File
@@ -0,0 +1,42 @@
import { useMemo, type RefObject } from 'react'
import { Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
import { AnchoredMenu } from './AnchoredMenu'
export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: {
anchorRef: RefObject<HTMLButtonElement | null>
settings?: ApplicationSettings
pending: boolean
error?: string
onClose: () => void
onOpen: (id: BuiltInApplicationId) => void
onManage: () => void
onRetry: () => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const applications = useMemo(() => {
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
return ['knowledge' as const, 'heartbeat' as const, ...order]
.filter(id => isApplicationEnabled(settings, id))
}, [settings])
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
{pending && <p role="status">{t('route.loading')}</p>}
{error && <>
<p role="alert">{error}</p>
<button type="button" role="menuitem" tabIndex={-1} disabled={pending} onClick={onRetry}>{t('applications.retry')}</button>
</>}
{applications.map(id => {
const definition = applicationDefinitions[id]
const Icon = definition.icon
return <button key={id} type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); onOpen(id) }}>
<Icon size={16} aria-hidden="true" /><span>{t(definition.title)}</span>
</button>
})}
<div role="separator" />
<button type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); anchorRef.current?.focus(); onManage() }}>
<Settings size={16} aria-hidden="true" /><span>{t('applications.manage')}</span>
</button>
</AnchoredMenu>
}
+155 -8
View File
@@ -69,18 +69,16 @@ describe('CustomTaskDialog', () => {
screen.getByRole('dialog', { name: '新建定制任务' })
).toHaveAttribute('aria-modal', 'true')
expect(screen.queryByRole('button', { name: 'Execute' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '当前会话' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByLabelText('任务名称')).toHaveFocus()
expect(screen.getByLabelText('关联会话')).toHaveValue('current')
expect(screen.getByLabelText('任务内容')).toHaveFocus()
fireEvent.change(screen.getByLabelText('任务名称'), {
target: { value: '每周项目总结' }
})
fireEvent.change(screen.getByLabelText('任务要求'), {
fireEvent.change(screen.getByLabelText('任务内容'), {
target: { value: '总结完成和失败的工作' }
})
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
fireEvent.change(screen.getByLabelText('首次运行'), {
target: { value: futureLocalDateTime() }
})
@@ -93,7 +91,8 @@ describe('CustomTaskDialog', () => {
title: '每周项目总结',
prompt: '总结完成和失败的工作',
recurrence: 'once'
})
}),
{ runImmediately: false }
)
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('workMode')
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('runtimeSelection')
@@ -112,6 +111,154 @@ describe('CustomTaskDialog', () => {
)
expect(screen.queryByRole('button', { name: 'Ask' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '当前会话' })).toBeDisabled()
expect(screen.getByRole('option', { name: '当前会话' })).toBeDisabled()
expect(screen.getByLabelText('关联会话')).toHaveValue('new')
})
const conversationId = '00000000-0000-4000-8000-000000000801'
const projectId = '00000000-0000-4000-8000-000000000804'
const otherId = '00000000-0000-4000-8000-000000000805'
const props = {
currentConversationAvailable: true,
currentConversationId: conversationId,
projectId,
projectName: 'GoodBuddy Desktop',
workspaceLabel: 'C:\\Workspace',
onClose: vi.fn(),
onCreate: vi.fn()
}
it('requires content and generates a bounded name for immediate execution by default', async () => {
const onCreate = vi.fn()
render(<CustomTaskDialog {...props} onCreate={onCreate} />)
expect(screen.getByRole('button', { name: '立即执行' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.queryByLabelText('首次运行')).not.toBeInTheDocument()
expect(screen.queryByLabelText('运行频率')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: ' \n ' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
expect(onCreate).not.toHaveBeenCalled()
expect(screen.getByLabelText('任务内容')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('alert')).toHaveTextContent('请输入任务内容。')
const content = ` 检查项目\n ${'内容'.repeat(80)} `
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: content } })
const before = Date.now()
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce())
const [input, options] = onCreate.mock.calls[0]!
expect(input).toMatchObject({
projectId,
conversationId,
title: content.trim().replace(/\s+/gu, ' ').slice(0, 120),
prompt: content.trim(),
recurrence: 'once'
})
expect(Date.parse(input.nextRunAt)).toBeGreaterThanOrEqual(before)
expect(Date.parse(input.nextRunAt)).toBeLessThanOrEqual(Date.now())
expect(options).toEqual({ runImmediately: true })
})
it.each(['daily', 'weekly'])('preserves %s scheduling and delegates future-time validation to App', async (recurrence) => {
const onCreate = vi.fn()
render(<CustomTaskDialog {...props} onCreate={onCreate} />)
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
fireEvent.change(screen.getByLabelText('运行频率'), { target: { value: recurrence } })
fireEvent.change(screen.getByLabelText('首次运行'), { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
expect(onCreate).not.toHaveBeenCalled()
expect(screen.getByLabelText('首次运行')).toHaveAttribute('aria-invalid', 'true')
fireEvent.change(screen.getByLabelText('首次运行'), { target: { value: '2020-01-01T12:00' } })
fireEvent.click(screen.getByRole('button', { name: '立即执行' }))
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
expect(screen.getByLabelText('运行频率')).toHaveValue(recurrence)
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ recurrence, nextRunAt: new Date('2020-01-01T12:00').toISOString() }),
{ runImmediately: false }
))
})
it('only offers other conversations in this project and submits the chosen conversation', async () => {
const onCreate = vi.fn()
render(<CustomTaskDialog {...props} onCreate={onCreate} conversations={[
{ id: conversationId, projectId, title: '当前项目会话' },
{ id: otherId, projectId, title: '另一个会话' },
{ id: 'elsewhere', projectId: 'another-project', title: '其他项目会话' }
]} />)
expect(screen.queryByRole('option', { name: '其他项目会话' })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: '当前项目会话' })).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('关联会话'), { target: { value: otherId } })
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: ' 自定义名称 ' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ conversationId: otherId, title: '自定义名称' }),
{ runImmediately: true }
))
})
it('rejects a removed conversation without silently creating a new one', () => {
const onCreate = vi.fn()
const { rerender } = render(<CustomTaskDialog {...props} onCreate={onCreate}
conversations={[{ id: otherId, projectId, title: '另一个会话' }]} />)
fireEvent.change(screen.getByLabelText('关联会话'), { target: { value: otherId } })
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
rerender(<CustomTaskDialog {...props} onCreate={onCreate} conversations={[]} />)
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
expect(onCreate).not.toHaveBeenCalled()
expect(screen.getByLabelText('关联会话')).toHaveAttribute('aria-invalid', 'true')
})
it('creates a new conversation without capturing execution settings or hidden recurrence', async () => {
const onCreate = vi.fn()
render(<CustomTaskDialog {...props} currentConversationId={undefined} onCreate={onCreate} />)
expect(screen.getByLabelText('关联会话')).toHaveValue('new')
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
fireEvent.change(screen.getByLabelText('运行频率'), { target: { value: 'weekly' } })
fireEvent.click(screen.getByRole('button', { name: '立即执行' }))
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce())
expect(onCreate.mock.calls[0]![0]).toEqual({
projectId, title: '检查项目', prompt: '检查项目', recurrence: 'once', nextRunAt: expect.any(String)
})
})
it('locks submission and dismissal while pending, then preserves the draft on failure', async () => {
let rejectCreate!: (reason: Error) => void
const onCreate = vi.fn(() => new Promise<never>((_, reject) => { rejectCreate = reject }))
const onClose = vi.fn()
render(<CustomTaskDialog {...props} onCreate={onCreate} onClose={onClose} />)
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '保留草稿' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
expect(screen.getByLabelText('任务内容')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '创建中…' }))
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
fireEvent.mouseDown(screen.getByRole('dialog').parentElement!)
expect(onClose).not.toHaveBeenCalled()
expect(onCreate).toHaveBeenCalledOnce()
rejectCreate(new Error('请选择未来时间'))
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('请选择未来时间'))
expect(screen.getByLabelText('任务内容')).toHaveValue('保留草稿')
expect(screen.getByLabelText('任务内容')).toBeEnabled()
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('portals to the body, traps focus and restores the opener on close', () => {
const opener = document.createElement('button')
document.body.append(opener)
opener.focus()
const { unmount, container } = render(<CustomTaskDialog {...props} />)
const dialog = screen.getByRole('dialog')
expect(container).not.toContainElement(dialog)
const create = screen.getByRole('button', { name: '创建任务' })
create.focus()
fireEvent.keyDown(create, { key: 'Tab' })
expect(screen.getByRole('button', { name: '关闭新建定制任务' })).toHaveFocus()
unmount()
expect(opener).toHaveFocus()
opener.remove()
})
})
+159 -117
View File
@@ -8,18 +8,32 @@ import type {
} from '../../shared/assistant-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
import './custom-task-dialog.css'
export type CustomTaskDestination = 'current' | 'new'
// App translates the UI execution choice into the schedule creation request.
export type CustomTaskCreateOptions = { runImmediately?: boolean }
export type CustomTaskConversation = {
id: string
title: string
projectId?: string
}
const emptyConversations: readonly CustomTaskConversation[] = []
type CustomTaskDialogProps = {
currentConversationAvailable: boolean
currentConversationId?: string
defaultDestination: CustomTaskDestination
defaultDestination?: CustomTaskDestination
conversations?: readonly CustomTaskConversation[]
projectId?: string
projectName: string
workspaceLabel: string
onClose: () => void
onCreate: (input: ScheduleCreateInput) => Promise<AssistantSchedule>
onCreate: (
input: ScheduleCreateInput,
options?: CustomTaskCreateOptions
) => Promise<AssistantSchedule>
}
function toLocalDateTimeValue(date: Date): string {
@@ -30,7 +44,8 @@ function toLocalDateTimeValue(date: Date): string {
export function CustomTaskDialog({
currentConversationAvailable,
currentConversationId,
defaultDestination,
defaultDestination = 'current',
conversations = emptyConversations,
projectId,
projectName,
workspaceLabel,
@@ -39,81 +54,80 @@ export function CustomTaskDialog({
}: CustomTaskDialogProps): React.JSX.Element {
const { t } = useTranslation('app')
const dialogRef = useRef<HTMLElement>(null)
const titleRef = useRef<HTMLInputElement>(null)
const promptRef = useRef<HTMLTextAreaElement>(null)
const submittingRef = useRef(false)
const [title, setTitle] = useState('')
const [prompt, setPrompt] = useState('')
const [destination, setDestination] = useState<CustomTaskDestination>(
defaultDestination === 'current' && currentConversationAvailable
const [destination, setDestination] = useState<string>(
defaultDestination === 'current' &&
currentConversationAvailable && currentConversationId
? 'current'
: 'new'
)
const [timing, setTiming] = useState<'now' | 'scheduled'>('now')
const [recurrence, setRecurrence] =
useState<ScheduleCreateInput['recurrence']>('once')
const [nextRunAt, setNextRunAt] = useState(() =>
toLocalDateTimeValue(new Date(Date.now() + 60 * 60 * 1_000))
)
const [errors, setErrors] = useState<
Partial<Record<'title' | 'prompt' | 'destination' | 'nextRunAt' | 'form', string>>
Partial<Record<'prompt' | 'destination' | 'nextRunAt' | 'form', string>>
>({})
const [submitting, setSubmitting] = useState(false)
useEffect(
() => activateModalFocus(() => titleRef.current),
() => activateModalFocus(() => promptRef.current),
[]
)
const destinationOptions = useMemo(
() => [
{
value: 'current' as const,
label: t('customTask.destination.current'),
disabled: !currentConversationAvailable
},
{
value: 'new' as const,
label: t('customTask.destination.new')
}
],
[currentConversationAvailable, t]
const otherConversations = useMemo(
() => conversations.filter((conversation) =>
conversation.projectId === projectId &&
conversation.id !== currentConversationId
),
[conversations, projectId, currentConversationId]
)
const generatedTitle = prompt.trim().replace(/\s+/gu, ' ').slice(0, 120)
const submit = async (): Promise<void> => {
if (submittingRef.current) return
const nextErrors: typeof errors = {}
if (!title.trim()) {
nextErrors.title = t('customTask.errors.title')
}
if (!prompt.trim()) {
nextErrors.prompt = t('customTask.errors.instructions')
}
if (
destination === 'current' &&
(!currentConversationAvailable || !currentConversationId)
(destination === 'current' &&
(!currentConversationAvailable || !currentConversationId)) ||
(destination !== 'current' && destination !== 'new' &&
!otherConversations.some((conversation) => conversation.id === destination))
) {
nextErrors.destination = t('customTask.errors.destination')
}
const runAt = new Date(nextRunAt)
if (!nextRunAt || Number.isNaN(runAt.getTime())) {
const runAt = timing === 'now' ? new Date() : new Date(nextRunAt)
if (Number.isNaN(runAt.getTime())) {
nextErrors.nextRunAt = t('customTask.errors.time')
} else if (runAt.getTime() <= Date.now()) {
nextErrors.nextRunAt = t('customTask.errors.futureTime')
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
return
}
submittingRef.current = true
setSubmitting(true)
try {
await onCreate({
...(projectId ? { projectId } : {}),
...(destination === 'current' && currentConversationId
? { conversationId: currentConversationId }
: {}),
title: title.trim(),
prompt: prompt.trim(),
recurrence,
nextRunAt: runAt.toISOString()
})
await onCreate(
{
...(projectId ? { projectId } : {}),
...(destination !== 'new'
? { conversationId: destination === 'current' ? currentConversationId : destination }
: {}),
title: title.trim() || generatedTitle,
prompt: prompt.trim(),
recurrence: timing === 'now' ? 'once' : recurrence,
nextRunAt: runAt.toISOString()
},
{ runImmediately: timing === 'now' }
)
onClose()
} catch (reason) {
setErrors((current) => ({
@@ -124,6 +138,7 @@ export function CustomTaskDialog({
: t('customTask.errors.create')
}))
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
@@ -176,38 +191,24 @@ export function CustomTaskDialog({
</button>
</header>
<div className="custom-task-dialog__content">
<fieldset
className="custom-task-dialog__content custom-task-dialog__form"
disabled={submitting}
>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.name')}</span>
<input
aria-describedby={errors.title ? 'custom-task-title-error' : undefined}
aria-invalid={Boolean(errors.title)}
maxLength={120}
onChange={(event) => {
setTitle(event.target.value)
setErrors((current) => ({ ...current, title: undefined }))
}}
ref={titleRef}
value={title}
/>
{errors.title && (
<small id="custom-task-title-error" role="alert">
{errors.title}
</small>
)}
</label>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.instructions')}</span>
<span id="custom-task-prompt-label">{t('customTask.fields.instructions')}</span>
<textarea
aria-labelledby="custom-task-prompt-label"
aria-describedby={errors.prompt ? 'custom-task-prompt-error' : undefined}
aria-invalid={Boolean(errors.prompt)}
aria-required="true"
maxLength={100_000}
onChange={(event) => {
setPrompt(event.target.value)
setErrors((current) => ({ ...current, prompt: undefined }))
}}
rows={5}
ref={promptRef}
value={prompt}
/>
{errors.prompt && (
@@ -217,73 +218,114 @@ export function CustomTaskDialog({
)}
</label>
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.destination')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.destination')}
onChange={(value) => {
setDestination(value)
<label className="custom-task-dialog__field">
<span id="custom-task-name-label">{t('customTask.fields.name')}</span>
<input
aria-labelledby="custom-task-name-label"
aria-describedby="custom-task-name-help"
maxLength={120}
onChange={(event) => setTitle(event.target.value)}
placeholder={generatedTitle}
value={title}
/>
<small id="custom-task-name-help">{t('customTask.nameHelp')}</small>
</label>
<label className="custom-task-dialog__field">
<span id="custom-task-destination-label">{t('customTask.fields.destination')}</span>
<select
aria-labelledby="custom-task-destination-label"
aria-describedby={errors.destination ? 'custom-task-destination-error' : 'custom-task-destination-help'}
aria-invalid={Boolean(errors.destination)}
onChange={(event) => {
setDestination(event.target.value)
setErrors((current) => ({
...current,
destination: undefined
}))
}}
options={destinationOptions}
value={destination}
/>
<small>
{destination === 'current'
? t('customTask.destination.currentHelp')
: t('customTask.destination.newHelp')}
>
<option value="current" disabled={!currentConversationAvailable || !currentConversationId}>
{t('customTask.destination.current')}
</option>
{otherConversations.map((conversation) => (
<option key={conversation.id} value={conversation.id}>{conversation.title}</option>
))}
<option value="new">{t('customTask.destination.new')}</option>
</select>
<small id="custom-task-destination-help">
{destination === 'new'
? t('customTask.destination.newHelp')
: t('customTask.destination.currentHelp')}
</small>
{!currentConversationAvailable && (
{(!currentConversationAvailable || !currentConversationId) && (
<small>{t('customTask.destination.currentUnavailable')}</small>
)}
{errors.destination && (
<small role="alert">{errors.destination}</small>
)}
</div>
<div className="custom-task-dialog__two-columns">
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.recurrence')}</span>
<select
onChange={(event) =>
setRecurrence(
event.target.value as ScheduleCreateInput['recurrence']
)
}
value={recurrence}
>
<option value="once">{t('customTask.recurrence.once')}</option>
<option value="daily">{t('customTask.recurrence.daily')}</option>
<option value="weekly">{t('customTask.recurrence.weekly')}</option>
</select>
</label>
</div>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.time')}</span>
<input
aria-describedby={errors.nextRunAt ? 'custom-task-time-error' : undefined}
aria-invalid={Boolean(errors.nextRunAt)}
onChange={(event) => {
setNextRunAt(event.target.value)
setErrors((current) => ({
...current,
nextRunAt: undefined
}))
}}
type="datetime-local"
value={nextRunAt}
/>
{errors.nextRunAt && (
<small id="custom-task-time-error" role="alert">
{errors.nextRunAt}
</small>
<small id="custom-task-destination-error" role="alert">{errors.destination}</small>
)}
</label>
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.timing')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.timing')}
onChange={(value) => {
setTiming(value)
setErrors((current) => ({ ...current, nextRunAt: undefined }))
}}
options={[
{ value: 'now', label: t('customTask.timing.now'), disabled: submitting },
{ value: 'scheduled', label: t('customTask.timing.scheduled'), disabled: submitting }
]}
value={timing}
/>
</div>
{timing === 'scheduled' && (
<>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.recurrence')}</span>
<select
onChange={(event) =>
setRecurrence(
event.target.value as ScheduleCreateInput['recurrence']
)
}
value={recurrence}
>
<option value="once">{t('customTask.recurrence.once')}</option>
<option value="daily">{t('customTask.recurrence.daily')}</option>
<option value="weekly">{t('customTask.recurrence.weekly')}</option>
</select>
</label>
<label className="custom-task-dialog__field">
<span id="custom-task-time-label">{t('customTask.fields.time')}</span>
<input
aria-labelledby="custom-task-time-label"
aria-describedby={errors.nextRunAt ? 'custom-task-time-error' : undefined}
aria-invalid={Boolean(errors.nextRunAt)}
onChange={(event) => {
setNextRunAt(event.target.value)
setErrors((current) => ({
...current,
nextRunAt: undefined
}))
}}
type="datetime-local"
value={nextRunAt}
/>
{errors.nextRunAt && (
<small id="custom-task-time-error" role="alert">
{errors.nextRunAt}
</small>
)}
</label>
</>
)}
<section
aria-label={t('customTask.scope.title')}
className="custom-task-dialog__scope"
@@ -316,7 +358,7 @@ export function CustomTaskDialog({
{errors.form}
</p>
)}
</div>
</fieldset>
<footer className="custom-task-dialog__actions">
<button
@@ -0,0 +1,228 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LocalInferencePage from './LocalInferencePage'
import type { LocalInferenceSnapshot } from '../../shared/local-inference-contracts'
import * as ocrBridge from './document-ocr-bridge'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const styles = readFileSync(join(process.cwd(), 'src/renderer/src/local-inference.css'), 'utf8')
const snapshot: LocalInferenceSnapshot = {
services: [{ id: 'embedding', name: '向量生成', engine: 'Granite', ownership: 'managed-process', state: 'running', detail: '共享执行服务', actions: ['stop', 'restart'] }],
tasks: [{ id: 'real-task', serviceId: 'embedding', source: '知识索引', state: 'running', startedAt: 1 }]
}
const api = { getSnapshot: vi.fn(), act: vi.fn(), cancel: vi.fn() }
beforeEach(() => {
vi.resetAllMocks()
api.getSnapshot.mockResolvedValue(snapshot)
api.act.mockResolvedValue(undefined)
api.cancel.mockResolvedValue(undefined)
vi.stubGlobal('goodbuddy', { localInference: api })
})
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('LocalInferencePage', () => {
it('shows services directly with truthful resource availability and no task history or external endpoints', async () => {
const name = 'A very long inference source and model service name '.repeat(8).trim()
api.getSnapshot.mockResolvedValue({
...snapshot,
tasks: [...snapshot.tasks, { ...snapshot.tasks[0], id: 'finished', source: '历史任务', state: 'completed', finishedAt: 2 }],
services: [{ ...snapshot.services[0], name }],
externalConnections: [{ id: 'external', name: '外部连接', model: 'remote' }],
})
render(<LocalInferencePage onClose={vi.fn()} />)
const serviceHeading = await screen.findByRole('heading', { name })
expect(serviceHeading).not.toHaveTextContent('运行中')
expect(within(serviceHeading.parentElement!).getByText('运行中')).toBeInTheDocument()
expect(screen.getByText('CPU / 内存:暂不可用')).toBeInTheDocument()
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.queryByText('知识索引')).not.toBeInTheDocument()
expect(screen.queryByText('历史任务')).not.toBeInTheDocument()
expect(screen.queryByText('外部连接')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '取消任务' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled()
expect(screen.getByRole('button', { name: '重启服务' })).toBeEnabled()
})
it('keeps the service toolbar responsive', async () => {
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
const toolbar = screen.getByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心').parentElement!
expect(toolbar).toHaveClass('local-inference-toolbar')
expect(within(toolbar).getByRole('button', { name: '刷新状态' })).toBeInTheDocument()
expect(styles).toMatch(/\.local-inference-toolbar\s*\{[^}]*flex: 0 0 auto/)
expect(screen.getByRole('region', { name: '本机推理服务' })).toBeInTheDocument()
})
it('does not present an active ASR request as a running service', async () => {
api.getSnapshot.mockResolvedValue({
services: [{ ...snapshot.services[0], id: 'asr', name: '语音识别', state: 'unknown', actions: [] }],
tasks: [{ ...snapshot.tasks[0], serviceId: 'asr' }]
})
render(<LocalInferencePage onClose={vi.fn()} />)
expect(await screen.findByText('按需运行(状态未知)')).toBeInTheDocument()
expect(screen.queryByText('运行中')).not.toBeInTheDocument()
})
it('shows process scope, measured units and shared-worker unavailability per service', async () => {
api.getSnapshot.mockResolvedValue({ ...snapshot, services: [
{ ...snapshot.services[0], resources: { scope: 'service-process', pid: 42, sampledAt: 1, cpuPercent: 150, workingSetBytes: 2097152 } },
{ ...snapshot.services[0], id: 'asr', name: '语音识别', resources: { scope: 'unavailable', reason: '识别线程与主进程共享资源,无法独立统计 CPU / 内存' } }
] })
render(<LocalInferencePage onClose={vi.fn()} />)
expect(await screen.findByText('服务进程 PID 42 · CPU 150.0% · 工作集 2.0 MiB')).toBeInTheDocument()
expect(screen.getByText('CPU / 内存:识别线程与主进程共享资源,无法独立统计 CPU / 内存')).toBeInTheDocument()
api.getSnapshot.mockResolvedValue({ ...snapshot, services: [{ ...snapshot.services[0], resources: { scope: 'service-process', pid: 43, sampledAt: 2, cpuUnavailableReason: '正在建立采样基线', workingSetBytes: 1048576 } }] })
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }))
expect(await screen.findByText('服务进程 PID 43 · CPU 正在建立采样基线 · 工作集 1.0 MiB')).toBeInTheDocument()
expect(screen.queryByText(/PID 42/)).not.toBeInTheDocument()
})
it('blocks service actions on stale data until refresh succeeds', async () => {
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
api.getSnapshot.mockRejectedValueOnce(new Error('offline'))
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }))
expect(await screen.findByRole('alert')).toHaveTextContent('上次成功读取')
expect(screen.getByRole('button', { name: '停止服务' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled())
expect(api.act).not.toHaveBeenCalled()
})
it('only shows affected active tasks when confirming service stop', async () => {
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
const dialog = screen.getByRole('dialog', { name: '停止服务:向量生成' })
expect(within(dialog).getByText(/知识索引/)).toBeInTheDocument()
expect(api.act).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: '确认停止服务' }))
await waitFor(() => expect(api.act).toHaveBeenCalledWith({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: ['real-task'] }))
})
it('recovers from initial load failure and does not stop anything on unmount', async () => {
api.getSnapshot.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ services: [], tasks: [] })
const page = render(<LocalInferencePage onClose={vi.fn()} />)
expect(await screen.findByRole('alert')).toHaveTextContent('offline')
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('暂无本机推理服务')).toBeInTheDocument()
page.unmount()
expect(api.act).not.toHaveBeenCalled()
expect(api.cancel).not.toHaveBeenCalled()
})
it('refreshes after a rejected confirmation and requires confirmation of the new affected tasks', async () => {
api.act.mockRejectedValueOnce(new Error('任务已变化'))
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
api.getSnapshot.mockResolvedValue({ ...snapshot, tasks: [...snapshot.tasks, { ...snapshot.tasks[0], id: 'new-task', source: '新索引' }] })
fireEvent.click(screen.getByRole('button', { name: '确认停止服务' }))
expect(await screen.findByRole('alert')).toHaveTextContent('任务已变化')
expect(screen.queryByRole('dialog', { name: '停止服务:向量生成' })).not.toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
expect(screen.getByText(/新索引/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '确认停止服务' }))
await waitFor(() => expect(api.act).toHaveBeenLastCalledWith({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: ['real-task', 'new-task'] }))
})
it('isolates nested Escape, traps focus, and restores the service action', async () => {
const onClose = vi.fn()
render(<LocalInferencePage onClose={onClose} />)
expect(screen.getByRole('button', { name: '关闭本机推理' })).toHaveFocus()
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.keyDown(screen.getByRole('button', { name: '关闭本机推理' }), { key: 'Tab', shiftKey: true })
expect(screen.getByRole('button', { name: '重启服务' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('button', { name: '重启服务' }), { key: 'Tab' })
expect(screen.getByRole('button', { name: '关闭本机推理' })).toHaveFocus()
const stop = screen.getByRole('button', { name: '停止服务' })
stop.focus()
fireEvent.click(stop)
const cancel = screen.getByRole('button', { name: '保留运行状态' })
const confirm = screen.getByRole('button', { name: '确认停止服务' })
expect(cancel).toHaveFocus()
expect(confirm).toHaveClass('danger-button')
fireEvent.keyDown(cancel, { key: 'Tab', shiftKey: true })
expect(confirm).toHaveFocus()
fireEvent.keyDown(confirm, { key: 'Tab' })
expect(cancel).toHaveFocus()
fireEvent.keyDown(cancel, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
expect(stop).toHaveFocus()
expect(screen.getAllByRole('dialog')).toHaveLength(1)
fireEvent.keyDown(stop, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('blocks dismissal and duplicate execution while a service operation is pending', async () => {
let resolve!: () => void
api.act.mockReturnValue(new Promise<void>((done) => { resolve = done }))
const onClose = vi.fn()
render(<LocalInferencePage onClose={onClose} />)
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
const confirm = screen.getByRole('button', { name: '确认停止服务' })
fireEvent.click(confirm)
fireEvent.click(confirm)
fireEvent.keyDown(confirm, { key: 'Escape' })
expect(screen.getByRole('button', { name: '保留运行状态' })).toBeDisabled()
expect(screen.getByRole('button', { name: '关闭本机推理' })).toBeDisabled()
expect(onClose).not.toHaveBeenCalled()
expect(api.act).toHaveBeenCalledOnce()
resolve()
await waitFor(() => expect(screen.queryByRole('dialog', { name: '停止服务:向量生成' })).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: '关闭本机推理' })).not.toBeDisabled()
})
it('restores fallback focus when the entry was removed and clears background isolation', () => {
const background = document.createElement('div')
background.className = 'app-shell'
const entry = document.createElement('button')
const fallback = document.createElement('button')
background.append(entry, fallback)
document.body.append(background)
entry.focus()
const modal = render(<LocalInferencePage onClose={vi.fn()} restoreFocus={() => fallback} />)
expect(background.inert).toBe(true)
entry.remove()
modal.unmount()
expect(background.inert).toBe(false)
expect(fallback).toHaveFocus()
background.remove()
})
it('confirms OCR release with a specific danger action and leaves the management modal open', async () => {
vi.spyOn(ocrBridge, 'getOcrInferenceState').mockReturnValue({ state: 'running', loaded: true, busy: false })
const release = vi.spyOn(ocrBridge, 'releaseOcrInference').mockImplementation(() => undefined)
api.getSnapshot.mockResolvedValue({ services: [{ id: 'ocr', name: '文字识别', engine: 'Paddle', ownership: 'renderer-worker', state: 'running', detail: '', actions: [] }], tasks: [] })
const onClose = vi.fn()
render(<LocalInferencePage onClose={onClose} />)
await screen.findByRole('heading', { name: '文字识别' })
fireEvent.click(screen.getByRole('button', { name: '释放模型' }))
const confirmation = screen.getByRole('dialog', { name: '释放模型:文字识别' })
expect(confirmation).toHaveClass('local-inference-confirm')
expect(within(confirmation).getByText('当前没有已登记的活动任务。执行前将再次核对。')).toBeInTheDocument()
const confirm = within(confirmation).getByRole('button', { name: '确认释放模型' })
expect(confirm).toHaveClass('danger-button')
expect(release).not.toHaveBeenCalled()
fireEvent.click(confirm)
await waitFor(() => expect(confirmation).not.toBeInTheDocument())
expect(release).toHaveBeenCalledOnce()
expect(api.act).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('dialog', { name: '本机推理' })).toBeInTheDocument()
})
it('keeps keyboard focus in the modal when the application content is disabled', () => {
render(<LocalInferencePage onClose={vi.fn()} enabled={false} />)
const close = screen.getByRole('button', { name: '关闭本机推理' })
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
expect(close).toHaveFocus()
fireEvent.keyDown(close, { key: 'Tab' })
expect(close).toHaveFocus()
expect(api.getSnapshot).not.toHaveBeenCalled()
})
})
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { InferenceAction, InferenceService, InferenceTask, LocalInferenceSnapshot } from '../../shared/local-inference-contracts'
import { PageHeader } from './WorkspacePrimitives'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { getOcrInferenceState, loadOcrInference, releaseOcrInference } from './document-ocr-bridge'
import { ApplicationSettingsLink } from './ApplicationCenter'
import './local-inference.css'
const stateLabels: Record<string, string> = {
idle: '未加载 / 按需运行', running: '运行中', starting: '启动中', stopping: '停止中',
stopped: '已停止', error: '异常', unavailable: '不可用', unknown: '未知',
cancelling: '等待取消确认', completed: '已完成', cancelled: '已取消', failed: '失败'
}
const actionLabels = { start: '启动服务', stop: '停止服务', restart: '重启服务', load: '加载模型', release: '释放模型' }
type Control = { service: InferenceService; action: InferenceAction['action'] | 'load' | 'release'; tasks: InferenceTask[] }
function ImpactConfirmation({ control, busy, onClose, onConfirm }: {
control: Control; busy: boolean; onClose: () => void; onConfirm: () => void
}): React.JSX.Element {
const cancel = useRef<HTMLButtonElement>(null)
useLayoutEffect(() => activateModalFocus(() => cancel.current, () => document.querySelector<HTMLElement>('.local-inference-modal')), [])
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-confirm" role="dialog" aria-modal="true" aria-labelledby="inference-confirm-title" aria-describedby="inference-confirm-description" tabIndex={-1} onKeyDown={(event) => {
event.stopPropagation()
trapTabFocus(event, event.currentTarget)
if (event.key === 'Escape') { event.preventDefault(); if (!busy) onClose() }
}}><div className="custom-task-dialog__content">
<h2 id="inference-confirm-title">{actionLabels[control.action]}:{control.service.name}</h2>
<p id="inference-confirm-description">此操作影响整个执行服务。被中断的任务不会在服务重新启动后自动重放。</p>
{control.tasks.length ? <ul>{control.tasks.map((task) => <li key={task.id}>{task.source} · {new Date(task.startedAt).toLocaleTimeString()} · {stateLabels[task.state]}</li>)}</ul> : <p>当前没有已登记的活动任务。执行前将再次核对。</p>}
</div><div className="custom-task-dialog__actions">
<button ref={cancel} type="button" className="secondary-button" disabled={busy} onClick={onClose}>保留运行状态</button>
<button type="button" className="danger-button" disabled={busy} onClick={onConfirm}>{busy ? '操作中…' : `确认${actionLabels[control.action]}`}</button>
</div>
</section></div>, document.body)
}
export default function LocalInferencePage({ onClose, enabled = true, restoreFocus }: {
onClose: () => void; enabled?: boolean; restoreFocus?: () => HTMLElement | null
}): React.JSX.Element {
const close = useRef<HTMLButtonElement>(null)
const restoreFocusRef = useRef(restoreFocus)
useLayoutEffect(() => activateModalFocus(() => close.current, () => restoreFocusRef.current?.() ?? null), [])
const [snapshot, setSnapshot] = useState<LocalInferenceSnapshot>()
const [error, setError] = useState('')
const [operationError, setOperationError] = useState('')
const [busy, setBusy] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [control, setControl] = useState<Control>()
const [reload, setReload] = useState(0)
const operation = useRef(false)
const [ocr, setOcr] = useState(getOcrInferenceState)
useEffect(() => {
let alive = true
let inFlight = false
const refresh = async (): Promise<void> => {
if (inFlight || !enabled || document.hidden || document.getElementById('local-inference-title')?.closest('[hidden], [inert]')) return
inFlight = true
setRefreshing(true)
try {
if (!window.goodbuddy.localInference) throw new Error('本机推理管理接口不可用,请重启应用后重试')
const next = await window.goodbuddy.localInference.getSnapshot()
if (alive) { setSnapshot(next); setOcr(getOcrInferenceState()); setError('') }
} catch (reason) {
if (alive) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
inFlight = false
if (alive) setRefreshing(false)
}
}
void refresh()
const timer = window.setInterval(() => void refresh(), 5000)
const onVisible = (): void => { void refresh() }
document.addEventListener('visibilitychange', onVisible)
return () => { alive = false; window.clearInterval(timer); document.removeEventListener('visibilitychange', onVisible) }
}, [reload, enabled])
const services = useMemo(() => snapshot?.services.map((service) => service.id === 'ocr' ? {
...service,
state: ocr.state === 'idle' && (service.state === 'unavailable' || service.state === 'error') ? service.state : ocr.state,
model: ocr.model ?? service.model, error: ocr.error ?? service.error
} : service), [snapshot, ocr])
const activeTasks = useMemo(() => snapshot?.tasks.filter((task) => task.finishedAt === undefined) ?? [], [snapshot])
const execute = async (selected: Control): Promise<void> => {
if (operation.current) return
operation.current = true
setBusy(true)
setOperationError('')
try {
if (selected.action === 'load') await loadOcrInference()
else if (selected.action === 'release') releaseOcrInference()
else await window.goodbuddy.localInference.act({ serviceId: selected.service.id, action: selected.action, confirmedTaskIds: selected.tasks.map((task) => task.id) })
setControl(undefined)
} catch (reason) {
setOperationError(reason instanceof Error ? reason.message : String(reason))
setControl(undefined)
} finally {
operation.current = false
setBusy(false)
setOcr(getOcrInferenceState())
setReload((value) => value + 1)
}
}
const selectAction = (service: InferenceService, action: Control['action']): void => {
if (operation.current) return
const selected = { service, action, tasks: activeTasks.filter((task) => task.serviceId === service.id) }
setOperationError('')
if (action === 'start' || action === 'load') void execute(selected)
else setControl(selected)
}
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-modal" role="dialog" aria-modal="true" aria-label="本机推理" tabIndex={-1} onKeyDown={(event) => {
if (event.target instanceof Element && event.target.closest('[aria-modal="true"]') !== event.currentTarget) return
trapTabFocus(event, event.currentTarget)
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (!operation.current && !control) onClose()
}
}}>
<div className="custom-task-dialog__header">
<PageHeader headingId="local-inference-title" title="本机推理" scope={{ kind: 'global' }}
actions={<button ref={close} type="button" className="icon-button" aria-label="关闭本机推理" title="关闭本机推理" disabled={busy || Boolean(control)} onClick={() => { if (!operation.current && !control) onClose() }}><X size={20} aria-hidden="true" /></button>} />
</div>
<div className="local-inference-modal__body">
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong><ApplicationSettingsLink id="local-inference" /></div>}
<div className="local-inference-workspace" hidden={!enabled} inert={!enabled}>
<div className="local-inference-toolbar">
<p className="local-inference-note">每 5 秒刷新 · CPU 100% = 一个逻辑核心</p>
<div className="local-inference-actions"><button className="secondary-button" type="button" disabled={busy} onClick={() => void window.goodbuddy.localInference.openSettings().catch((reason: unknown) => setOperationError(String(reason)))}>模型与连接设置</button><button className="secondary-button" type="button" disabled={refreshing || busy} onClick={() => setReload((value) => value + 1)}>刷新状态</button></div>
</div>
{error && <div className="local-inference-feedback" role="alert"><p>{snapshot ? '状态刷新失败;以下为上次成功读取的状态,操作已禁用。' : '无法读取本机推理状态。'} {error}</p><button type="button" className="secondary-button" onClick={() => setReload((value) => value + 1)}>重试</button></div>}
{!snapshot && !error && <p role="status">正在读取服务状态…</p>}
{operationError && !control && <div className="local-inference-feedback" role="alert">{operationError}<button type="button" className="secondary-button" onClick={() => setOperationError('')}>关闭提示</button></div>}
{snapshot && <section aria-label="本机推理服务" className="local-inference-content">
{services?.length === 0 && <p role="status">暂无本机推理服务</p>}
{services?.map((service) => <article className="local-inference-row" key={service.id}>
<div><div className="local-inference-row__heading"><h2>{service.name}</h2><span className="local-inference-state">{service.id === 'asr' && service.state === 'unknown' ? '按需运行(状态未知)' : stateLabels[service.state]}</span></div>
<p className="local-inference-metadata">{service.engine}{service.model ? ` · ${service.model}` : ''}</p>
<p className="local-inference-metadata">
{service.resources?.scope === 'service-process'
? `服务进程 PID ${service.resources.pid} · CPU ${service.resources.cpuPercent === undefined ? service.resources.cpuUnavailableReason : `${service.resources.cpuPercent.toFixed(1)}%`} · 工作集 ${service.resources.workingSetBytes === undefined ? service.resources.memoryUnavailableReason : `${(service.resources.workingSetBytes / 1024 / 1024).toFixed(1)} MiB`}`
: `CPU / 内存:${service.resources?.reason ?? '暂不可用'}`}
</p>
{service.error && <p className="local-inference-error">{service.error}</p>}
</div>
<div className="local-inference-actions">
{service.actions.map((action) => <button type="button" className="secondary-button" key={action} disabled={busy || refreshing || Boolean(error)} onClick={() => selectAction(service, action)}>{actionLabels[action]}</button>)}
{service.id === 'ocr' && <button type="button" className="secondary-button" disabled={busy || refreshing || Boolean(error) || ocr.busy || service.state === 'unavailable'} onClick={() => selectAction(service, ocr.loaded ? 'release' : 'load')}>{ocr.loaded ? '释放模型' : '加载模型'}</button>}
</div>
</article>)}
</section>}
{control && <ImpactConfirmation control={control} busy={busy} onClose={() => { if (!operation.current) { setControl(undefined); setOperationError('') } }} onConfirm={() => void execute(control)} />}
</div></div></section></div>, document.body)
}
@@ -15,10 +15,8 @@ import {
import { useTranslation } from 'react-i18next'
import type {
ApplicationSettings,
MagicNoteCommentMode,
ModelDownloadSource
} from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import type {
AgentPackageDownloadProgress,
AgentPackageInventory
@@ -34,8 +32,7 @@ import {
} from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications'
import {
PageTabs,
SegmentedControl
PageTabs
} from './WorkspacePrimitives'
import {
SettingsCategoryHeader,
@@ -47,10 +44,6 @@ type PlatformFeaturesSettingsSectionProps = {
onConversationHtmlRenderingEnabledChange: (
enabled: boolean
) => void
onMagicNotesEnabledChange: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange: (
enabled: boolean
) => void
onRemoteProjectsEnabledChange: (enabled: boolean) => void
onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
@@ -62,7 +55,6 @@ type PlatformFeaturesSettingsSectionProps = {
type PlatformFeaturesTab =
| 'general'
| 'remote-projects'
| 'magic-notes'
const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode,
@@ -78,8 +70,6 @@ const shortcutErrorTranslationKeys: Record<
export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange,
onMagicNotesEnabledChange,
onMagicNotesShowIncompleteTodoCountChange,
onRemoteProjectsEnabledChange,
onNotify,
onDirtyChange,
@@ -472,26 +462,6 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeMagicNotes = async (enabled: boolean): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
magicNotesEnabled: enabled
})
setSettings(nextSettings)
onMagicNotesEnabledChange(nextSettings.magicNotesEnabled)
} catch {
setError(t('platformFeatures.errors.saveMagicNotesFailed'))
} finally {
setSaving(false)
}
}
const changeConversationHtmlRendering = async (
enabled: boolean
): Promise<void> => {
@@ -546,72 +516,6 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeCommentMode = async (
magicNoteCommentMode: MagicNoteCommentMode
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
setSettings(
await updates.updateSettings({ magicNoteCommentMode })
)
} catch {
setError(t('platformFeatures.errors.saveCommentModeFailed'))
} finally {
setSaving(false)
}
}
const changeIncompleteTodoCount = async (
magicNotesShowIncompleteTodoCount: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
magicNotesShowIncompleteTodoCount
})
setSettings(nextSettings)
onMagicNotesShowIncompleteTodoCountChange(
nextSettings.magicNotesShowIncompleteTodoCount
)
} catch {
setError(
t('platformFeatures.errors.saveIncompleteTodoCountFailed')
)
} finally {
setSaving(false)
}
}
const changeCommentFormat = async (
magicNoteCommentFormat: MagicNoteCommentFormat
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
setSettings(
await updates.updateSettings({ magicNoteCommentFormat })
)
} catch {
setError(t('platformFeatures.errors.saveCommentFormatFailed'))
} finally {
setSaving(false)
}
}
return (
<>
<SettingsCategoryHeader
@@ -633,10 +537,6 @@ export function PlatformFeaturesSettingsSection({
{
id: 'remote-projects',
label: t('platformFeatures.remoteProjects.title')
},
{
id: 'magic-notes',
label: t('platformFeatures.tabs.magicNotes')
}
]}
value={activeSection}
@@ -1146,141 +1046,6 @@ export function PlatformFeaturesSettingsSection({
</article>
</section>
<section
aria-labelledby="platform-features-tab-magic-notes"
className="settings-section"
hidden={activeSection !== 'magic-notes'}
id="platform-features-panel-magic-notes"
role="tabpanel"
>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>{t('platformFeatures.magicNotes.title')}</strong>
<small>
{t('platformFeatures.magicNotes.description')}
</small>
</div>
</div>
<label className="toggle-row">
<input
checked={settings.magicNotesEnabled}
disabled={saving}
onChange={(event) =>
void changeMagicNotes(event.target.checked)
}
role="switch"
type="checkbox"
/>
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
</label>
<label className="toggle-row">
<input
checked={
settings.magicNotesShowIncompleteTodoCount
}
disabled={saving}
onChange={(event) =>
void changeIncompleteTodoCount(
event.target.checked
)
}
role="switch"
type="checkbox"
/>
<span>
{t(
'platformFeatures.magicNotes.showIncompleteTodoCount'
)}
</span>
</label>
<p className="settings-notice">
{t(
'platformFeatures.magicNotes.showIncompleteTodoCountHelp'
)}
</p>
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentMode')}
</span>
<SegmentedControl
ariaLabel={t(
'platformFeatures.magicNotes.commentModeAria'
)}
disabled={saving}
onChange={(value) => void changeCommentMode(value)}
options={[
{
value: 'immediate',
label: t(
'platformFeatures.magicNotes.modes.immediate'
)
},
{
value: 'after-save-auto',
label: t(
'platformFeatures.magicNotes.modes.afterSaveAuto'
)
},
{
value: 'after-save-manual',
label: t(
'platformFeatures.magicNotes.modes.afterSaveManual'
)
}
]}
value={settings.magicNoteCommentMode}
/>
<small>
{t('platformFeatures.magicNotes.commentModeHelp')}
</small>
</div>
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentFormat')}
</span>
<SegmentedControl
ariaLabel={t(
'platformFeatures.magicNotes.commentFormatAria'
)}
disabled={saving}
onChange={(value) => void changeCommentFormat(value)}
options={[
{
value: 'combined',
label: t(
'platformFeatures.magicNotes.formats.combined'
)
},
{
value: 'narrative',
label: t(
'platformFeatures.magicNotes.formats.narrative'
)
},
{
value: 'structured',
label: t(
'platformFeatures.magicNotes.formats.structured'
)
}
]}
value={settings.magicNoteCommentFormat}
/>
<small>
{t('platformFeatures.magicNotes.commentFormatHelp')}
</small>
</div>
</article>
) : (
!error && (
<p className="settings-notice" role="status">
{t('platformFeatures.loading')}
</p>
)
)}
</section>
</>
)
}
@@ -151,12 +151,16 @@ function sidebarElement({
workspaceProjectId,
restoreFocusRef,
activeConversationId,
conversationStats,
taskDurations,
selectedTaskId,
browserStates,
onBackBrowser,
onNavigateBrowser,
onReloadBrowser,
onStopLoadingBrowser,
onOpenTask = vi.fn(),
onCreateCustomTask = vi.fn(),
onRespondApproval = vi.fn(),
onTabChange = vi.fn()
}: {
@@ -185,12 +189,16 @@ function sidebarElement({
workspaceProjectId?: string
restoreFocusRef?: { current: HTMLElement | null }
activeConversationId?: string
conversationStats?: React.ComponentProps<typeof RightAssistantSidebar>['conversationStats']
taskDurations?: React.ComponentProps<typeof RightAssistantSidebar>['taskDurations']
selectedTaskId?: string
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
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 {
@@ -200,6 +208,9 @@ function sidebarElement({
<RightAssistantSidebar
approvals={approvals}
activeConversationId={activeConversationId}
conversationStats={conversationStats}
taskDurations={taskDurations}
selectedTaskId={selectedTaskId}
artifacts={artifacts}
browserStates={browserStates}
schedules={[]}
@@ -207,7 +218,7 @@ function sidebarElement({
conversationTitles={new Map()}
currentProject={activeProject ?? undefined}
projectNames={new Map()}
onCreateCustomTask={vi.fn()}
onCreateCustomTask={onCreateCustomTask}
onBackBrowser={onBackBrowser}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={onListWorkspaceDirectory}
@@ -336,6 +347,87 @@ describe('RightAssistantSidebar tab titles', () => {
})
describe('RightAssistantSidebar resizing', () => {
it('updates conversation statistics without narrowing project tasks and rejects stale statistics', () => {
const tasks: AssistantTask[] = ['a', 'b'].map((id) => ({
id, conversationId: id, projectId: currentProject.id, title: `Project task ${id}`,
instructions: '', origin: 'schedule', status: id === 'a' ? 'running' : 'completed',
createdAt: '2026-09-01T00:00:00Z', startedAt: '2026-09-01T00:00:00Z',
completedAt: id === 'b' ? '2026-09-17T00:00:00Z' : undefined
}))
const props = {
tasks, selectedTaskId: 'a', activeConversationId: 'a',
conversationStats: { conversationId: 'a', title: 'Conversation A', messageCount: 24, replyDurationMs: 3_661_000, incomplete: true },
taskDurations: new Map([['a', { durationMs: 125_000, incomplete: true }]])
}
const view = render(sidebarElement(props))
const stats = within(screen.getByRole('region', { name: '当前会话统计' }))
expect(stats.getByText('01:01:01')).toBeVisible()
expect(stats.getByText('24')).toBeVisible()
expect(stats.getByText('部分回复缺少计时记录,时长仅包含已知记录。')).toBeVisible()
expect(screen.getByText('任务累计用时: 00:02:05 (部分记录)')).toBeVisible()
expect(screen.getByText('Project task a').closest('button')).toHaveAttribute('aria-current', 'true')
expect(screen.getByText('Project task b').closest('article')).not.toHaveTextContent('任务累计用时')
view.rerender(sidebarElement({ ...props, activeConversationId: 'b' }))
expect(stats.queryByText('Conversation A')).not.toBeInTheDocument()
expect(stats.getAllByText('暂无统计')).toHaveLength(2)
for (const task of tasks) expect(screen.getByText(task.title)).toBeVisible()
view.rerender(sidebarElement({ ...props, activeConversationId: 'b', conversationStats: {
conversationId: 'b', title: 'Conversation B', messageCount: 0, replyDurationMs: 0, incomplete: false
} }))
expect(stats.getByText('00:00:00')).toBeVisible()
expect(stats.getByText('0')).toBeVisible()
expect(stats.queryByText('部分回复缺少计时记录,时长仅包含已知记录。')).not.toBeInTheDocument()
view.rerender(sidebarElement({ ...props, activeProject: { ...currentProject, id: 'other' } }))
for (const task of tasks) expect(screen.queryByText(task.title)).not.toBeInTheDocument()
})
it('keeps an applied filter visible when collapsed and clears it without opening the filter panel', () => {
renderSidebar({ tasks: [{
id: 'completed', projectId: currentProject.id, title: 'Completed task', instructions: '',
origin: 'schedule', status: 'completed', createdAt: '2026-09-17T00:00:00Z'
}] })
const toggle = screen.getByRole('button', { name: '筛选任务' })
expect(screen.getByText('Completed task')).toBeVisible()
fireEvent.click(toggle)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(toggle).toHaveAttribute('data-active', 'true')
expect(screen.queryByRole('group', { name: '筛选任务' })).not.toBeInTheDocument()
expect(screen.getByText('筛选:进行中 · 0 项')).toBeVisible()
expect(screen.getByText('当前筛选条件下没有任务。')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(toggle).toHaveAttribute('data-active', 'false')
expect(screen.getByText('Completed task')).toBeVisible()
})
it('scrolls the focused task filter into view when keyboard navigation reaches either end', () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const filters = within(screen.getByRole('group', { name: '筛选任务' }))
const first = filters.getByRole('button', { name: '全部' })
const last = filters.getByRole('button', { name: '已结束' })
const scrollFirst = vi.fn()
const scrollLast = vi.fn()
Object.defineProperty(first, 'scrollIntoView', { value: scrollFirst })
Object.defineProperty(last, 'scrollIntoView', { value: scrollLast })
act(() => first.focus())
scrollFirst.mockClear()
fireEvent.keyDown(first, { key: 'End' })
expect(last).toHaveFocus()
expect(last).toHaveAttribute('aria-pressed', 'true')
expect(scrollLast).toHaveBeenCalledExactlyOnceWith({ block: 'nearest', inline: 'nearest' })
expect(scrollFirst).not.toHaveBeenCalled()
fireEvent.keyDown(last, { key: 'Home' })
expect(first).toHaveFocus()
expect(first).toHaveAttribute('aria-pressed', 'true')
expect(scrollFirst).toHaveBeenCalledExactlyOnceWith({ block: 'nearest', inline: 'nearest' })
expect(scrollLast).toHaveBeenCalledTimes(1)
})
it('resizes with pointer capture and preserves compact pane minima', () => {
const sidebar = renderSidebar()
const separator = screen.getByRole('separator', {
@@ -553,12 +645,13 @@ describe('RightAssistantSidebar resizing', () => {
})
it('keeps the product Task index in the task center', () => {
renderSidebar({ tab: 'tasks' })
const onCreateCustomTask = vi.fn()
renderSidebar({ tab: 'tasks', onCreateCustomTask })
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
expect(screen.queryByText(i18n.t('sidebar.tasks.noApprovals', { ns: 'workspace' }))).not.toBeInTheDocument()
const taskIndexHeading = screen.getByRole('heading', {
name: '任务索引'
name: '项目任务 0'
})
const newTaskButton = screen.getByRole('button', {
name: '新建任务'
@@ -566,9 +659,12 @@ describe('RightAssistantSidebar resizing', () => {
expect(taskIndexHeading.parentElement).toContainElement(
newTaskButton
)
fireEvent.click(newTaskButton)
expect(onCreateCustomTask).toHaveBeenCalledOnce()
expect(
screen.getByRole('group', { name: '筛选任务' })
).toBeInTheDocument()
screen.queryByRole('group', { name: '筛选任务' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '筛选任务' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByLabelText('定时任务标题')).not.toBeInTheDocument()
expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
})
@@ -600,9 +696,11 @@ describe('RightAssistantSidebar resizing', () => {
approvals: [approval], onOpenTask, onRespondApproval
})
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const filters = within(screen.getByRole('group', { name: '筛选任务' }))
expect(filters.getAllByRole('button').map((button) => button.textContent))
.toEqual(['进行中', '暂停', '已结束'])
.toEqual(['全部', '进行中', '暂停', '已结束'])
fireEvent.click(filters.getByRole('button', { name: '进行中' }))
expect(filters.getByRole('button', { name: '进行中' })).toHaveAttribute('aria-pressed', 'true')
for (const task of tasks.slice(0, 6)) {
const row = screen.getByText(task.title).closest('article')!
@@ -644,6 +742,7 @@ describe('RightAssistantSidebar resizing', () => {
}
const onRespondApproval = vi.fn()
renderSidebar({ tasks: [task], approvals: [approval], onRespondApproval })
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const row = screen.getByText(task.title).closest('article')!
expect(within(row).getByLabelText('等待审批: Confirm write')).toBeVisible()
expect(screen.queryByRole('heading', { name: '等待审批' })).not.toBeInTheDocument()
@@ -685,9 +784,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(within(row).queryByText(`Approval ${index}`)).not.toBeInTheDocument()
}
expect(screen.queryByText('Approval 4')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '所有项目' }))
expect(screen.getByText('Approval 4')).toBeVisible()
expect(within(row).queryByText('Approval 4')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '所有项目' })).not.toBeInTheDocument()
expect(screen.getAllByText('Approval 0')).toHaveLength(1)
})
@@ -842,18 +939,11 @@ describe('RightAssistantSidebar resizing', () => {
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
expect(screen.queryByText('全局审批')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '所有项目' }
)
)
expect(screen.getByText('全局审批')).toBeInTheDocument()
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 2')).toBeInTheDocument()
expect(screen.queryByRole('group', { name: '任务范围' })).not.toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 1')).toBeInTheDocument()
})
it('scopes tasks to the current or all projects, labels unbound tasks and persists the choice', () => {
it('keeps task scope fixed to the current project', () => {
const createTask = (
id: string,
title: string,
@@ -882,44 +972,31 @@ describe('RightAssistantSidebar resizing', () => {
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.queryByText('全局任务')).not.toBeInTheDocument()
expect(screen.queryByText('其他项目任务')).not.toBeInTheDocument()
expect(within(screen.getByRole('group', { name: '任务范围' })).getAllByRole('button'))
.toHaveLength(2)
expect(screen.queryByRole('group', { name: '任务范围' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '全局' })).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '所有项目' }
)
)
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.getByText('其他项目任务')).toBeInTheDocument()
expect(screen.getAllByText('未绑定项目')).toHaveLength(1)
expect(screen.getByText('项目:00000000-0000-4000-8000-000000000302')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!))
.toEqual(expect.objectContaining({ taskScope: 'all-projects' }))
.toEqual(expect.objectContaining({ taskScope: 'current-project' }))
})
it('restores the legacy global scope as all projects and saves the updated preference', () => {
it.each(['global', 'all-projects'])('ignores the persisted %s scope and saves current project scope', (taskScope) => {
localStorage.setItem('goodbuddy.workbar-layout.v1', JSON.stringify({
instances: DEFAULT_WORKBAR_INSTANCES,
activeInstanceId: DEFAULT_WORKBAR_INSTANCES[0]!.id,
expanded: true,
dock: 'right',
widthRatio: 0.3,
taskScope: 'global'
taskScope
}))
renderSidebar({ activeProject: null, tasks: [{
id: 'legacy-unbound-task', title: '历史任务', instructions: '历史任务',
origin: 'schedule', status: 'running', createdAt: '2026-09-09T00:00:00.000Z'
}] })
expect(screen.getByRole('button', { name: '所有项目' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('历史任务')).toBeInTheDocument()
expect(screen.getByText('未绑定项目')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '所有项目' })).not.toBeInTheDocument()
expect(screen.queryByText('历史任务')).not.toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!).taskScope)
.toBe('all-projects')
.toBe('current-project')
})
it('shows a clear current-project empty state when no project is active', () => {
@@ -927,7 +1004,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(
screen.getByText(
'没有活动项目。请选择其他任务范围或先打开一个项目。'
'没有活动项目。请先打开一个项目以查看和创建任务。'
)
).toBeInTheDocument()
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
+87 -51
View File
@@ -7,6 +7,7 @@ import {
Copy,
ExternalLink,
FileText,
Filter,
ClockFading,
Maximize2,
Minimize2,
@@ -60,7 +61,6 @@ import {
normalizeWorkbarLayoutPreferences,
workbarLayoutPreferencesSchema,
type WorkbarAppDefinition,
type WorkbarTaskScope,
type WorkbarTabInstance,
type WorkbarTargetRef
} from '../../shared/workbar-contracts'
@@ -71,6 +71,7 @@ import {
} from './WorkbarShell'
import type { TerminalAdapter } from './TerminalPanel'
import './terminal-panel.css'
import './task-center.css'
const TerminalPanel = lazy(async () => {
const module = await import('./TerminalPanel')
@@ -102,7 +103,22 @@ export type PendingSidebarApproval = {
toolName?: string
}
type RightAssistantSidebarProps = {
export type SidebarConversationStats = {
// Must match activeConversationId; late results for other conversations are ignored.
conversationId: string
title?: string
messageCount: number
// Authoritative cumulative reply time, excluding idle gaps. Never a lifecycle delta.
replyDurationMs: number
incomplete?: boolean
}
export type SidebarTaskDuration = {
durationMs: number
incomplete?: boolean
}
export type RightAssistantSidebarProps = {
open: boolean
tab: AssistantSidebarTab
approvals: PendingSidebarApproval[]
@@ -115,6 +131,8 @@ type RightAssistantSidebarProps = {
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
activeConversationId?: string
conversationStats?: SidebarConversationStats
taskDurations?: ReadonlyMap<string, SidebarTaskDuration>
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
currentProject?: AssistantProject
restoreFocusRef?: { current: HTMLElement | null }
@@ -210,8 +228,7 @@ function persistWorkbarLayout(
instances: readonly WorkbarTabInstance[],
activeInstanceId: string | null,
expanded: boolean,
widthRatio: number,
taskScope: WorkbarTaskScope
widthRatio: number
): void {
try {
localStorage.setItem(
@@ -223,7 +240,7 @@ function persistWorkbarLayout(
expanded,
dock: 'right',
widthRatio,
taskScope
taskScope: 'current-project'
})
)
)
@@ -664,6 +681,8 @@ export function RightAssistantSidebar({
workspaceChanges,
workspaceProjectId,
activeConversationId,
conversationStats,
taskDurations,
browserStates = {},
currentProject,
restoreFocusRef,
@@ -688,6 +707,13 @@ export function RightAssistantSidebar({
}: RightAssistantSidebarProps): React.JSX.Element {
const { i18n, t } = useTranslation('workspace')
const locale = i18n.resolvedLanguage || 'zh-CN'
const currentStats = activeConversationId && conversationStats?.conversationId === activeConversationId
? conversationStats
: undefined
const formatDuration = (durationMs: number): string => {
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, {
@@ -857,13 +883,9 @@ export function RightAssistantSidebar({
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
useState('')
const [taskFilter, setTaskFilter] = useState<
'active' | 'paused' | 'finished'
>('active')
const [taskScope, setTaskScope] = useState<Exclude<WorkbarTaskScope, 'global'>>(
initialLayout?.taskScope === 'global'
? 'all-projects'
: initialLayout?.taskScope ?? 'current-project'
)
'all' | 'active' | 'paused' | 'finished'
>('all')
const [taskFiltersExpanded, setTaskFiltersExpanded] = useState(false)
const [actionErrorState, setActionErrorState] = useState({
scope: { instanceId: activeWorkbarInstanceId, open },
message: ''
@@ -950,12 +972,9 @@ export function RightAssistantSidebar({
sidebarWidthLimits.maximum > sidebarWidthLimits.minimum
const taskMatchesScope = useCallback(
(projectId?: string): boolean => {
if (taskScope === 'all-projects') {
return true
}
return Boolean(currentProject && projectId === currentProject.id)
},
[currentProject, taskScope]
[currentProject]
)
const scopedApprovals = useMemo(
() => approvals.filter((approval) => taskMatchesScope(approval.projectId)),
@@ -988,6 +1007,7 @@ export function RightAssistantSidebar({
const filteredTasks = useMemo(
() =>
topLevelTasks.filter((task) => {
if (taskFilter === 'all') return true
if (approvalsByTask.has(task.id)) return taskFilter === 'active'
if (taskFilter === 'active') {
return (
@@ -1067,14 +1087,12 @@ export function RightAssistantSidebar({
workbarInstances,
activeWorkbarInstanceId,
open,
sidebarRatio,
taskScope
sidebarRatio
)
}, [
activeWorkbarInstanceId,
open,
sidebarRatio,
taskScope,
workbarInstances
])
@@ -1914,59 +1932,61 @@ export function RightAssistantSidebar({
</Suspense>
) : null}
{instance.appId === 'tasks' && (
<section className="assistant-sidebar__section">
<div className="task-center__filters">
<SegmentedControl
ariaLabel={t('sidebar.tasks.scope.ariaLabel')}
onChange={setTaskScope}
options={[
{
value: 'current-project',
label: t('sidebar.tasks.scope.currentProject')
},
{
value: 'all-projects',
label: t('sidebar.tasks.scope.allProjects')
}
]}
value={taskScope}
/>
</div>
{taskScope === 'current-project' && !currentProject ? (
<section className="assistant-sidebar__section task-center">
<p className="task-center__scope">{currentProject
? t('sidebar.tasks.projectScope', { project: currentProject.name })
: t('sidebar.tasks.scope.currentProject')}</p>
<section className="task-center__stats" aria-label={t('sidebar.tasks.stats.label')}>
<span className="task-center__eyebrow">{t('sidebar.tasks.stats.currentConversation')}</span>
<h3>{activeConversationId
? currentStats?.title ?? conversationTitles.get(activeConversationId) ?? t('sidebar.tasks.stats.currentConversation')
: t('sidebar.tasks.stats.noConversation')}</h3>
<dl className="task-center__stat-grid">
<div><dt>{t('sidebar.tasks.stats.replyDuration')}</dt><dd>{currentStats ? formatDuration(currentStats.replyDurationMs) : t('sidebar.tasks.stats.unavailable')}</dd></div>
<div><dt>{t('sidebar.tasks.stats.messages')}</dt><dd>{currentStats ? currentStats.messageCount.toLocaleString(locale) : t('sidebar.tasks.stats.unavailable')}</dd></div>
</dl>
{currentStats?.incomplete && <p className="task-center__incomplete">{t('sidebar.tasks.stats.incomplete')}</p>}
<details><summary>{t('sidebar.tasks.stats.timeHelp')}</summary><p>{t('sidebar.tasks.stats.timeDescription')}</p></details>
</section>
{!currentProject ? (
<p className="assistant-sidebar__empty">
{t('sidebar.tasks.scope.noCurrentProject')}
</p>
) : (
<>
{unassociatedApprovals.length > 0 && <>
<h3>
<ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')}
</h3>
{unassociatedApprovals.map((approval) => (
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
))}
</>}
<div className="task-center__index-heading">
<h3>
<ClockFading size={15} />
{t('sidebar.tasks.taskIndexTitle')}
{t('sidebar.tasks.taskIndexTitle')}{' '}
<span className="task-center__count">{topLevelTasks.length}</span>
</h3>
<button
className="secondary-button task-center__create"
aria-label={t('taskStrip.create')}
title={t('taskStrip.create')}
onClick={onCreateCustomTask}
type="button"
>
<Plus aria-hidden="true" size={13} />
{t('taskStrip.create')}
</button>
<button
className="secondary-button task-center__filter-toggle"
aria-label={t('sidebar.tasks.filters.ariaLabel')}
title={t('sidebar.tasks.filters.ariaLabel')}
aria-expanded={taskFiltersExpanded}
aria-controls={`task-filters-${instance.id}`}
data-active={taskFilter !== 'all'}
onClick={() => setTaskFiltersExpanded((expanded) => !expanded)}
type="button"
><Filter aria-hidden="true" size={14} /></button>
</div>
<div className="task-center__filters">
<div className="task-center__filters" id={`task-filters-${instance.id}`} hidden={!taskFiltersExpanded}
onFocusCapture={(event) => event.target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })}>
<SegmentedControl
ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
onChange={setTaskFilter}
options={[
{ value: 'all', label: t('sidebar.tasks.filters.all') },
{
value: 'active',
label: t('sidebar.tasks.filters.active')
@@ -1983,6 +2003,16 @@ export function RightAssistantSidebar({
value={taskFilter}
/>
</div>
{taskFilter !== 'all' && <div className="task-center__filter-summary">
<span>{t('sidebar.tasks.filters.summary', { filter: t(`sidebar.tasks.filters.${taskFilter}`), count: filteredTasks.length })}</span>
<button type="button" onClick={() => setTaskFilter('all')}>{t('sidebar.tasks.filters.clear')}</button>
</div>}
{unassociatedApprovals.length > 0 && <>
<h3><ShieldAlert size={15} />{t('sidebar.tasks.approvalsTitle')}</h3>
{unassociatedApprovals.map((approval) => (
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
))}
</>}
{filteredTasks.length === 0 ? (
<p className="assistant-sidebar__empty">
{topLevelTasks.length === 0
@@ -1998,6 +2028,7 @@ export function RightAssistantSidebar({
const projectName = task.projectId
? projectNames.get(task.projectId)
: undefined
const duration = taskDurations?.get(task.id)
return (
<article
className={
@@ -2010,6 +2041,7 @@ export function RightAssistantSidebar({
<button
className="task-center__item-main"
onClick={() => onOpenTask(task)}
aria-current={selectedTaskId === task.id ? 'true' : undefined}
type="button"
>
<span
@@ -2045,6 +2077,10 @@ export function RightAssistantSidebar({
: 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)}
{duration.incomplete && ` (${t('sidebar.tasks.stats.partial')})`}
</span>}
</div>
<p>
{task.error ??
+28 -98
View File
@@ -25,6 +25,7 @@ import type {
} from '../../shared/capability-contracts'
import {
defaultLocalToolEnvironmentSettings,
defaultApplicationNavigation,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type {
@@ -547,6 +548,7 @@ let applicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -838,6 +840,7 @@ describe('SettingsPanel runtime files', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -998,7 +1001,8 @@ describe('SettingsPanel runtime files', () => {
updateSettings: updateApplicationSettings,
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
},
localToolEnvironment: {
getSnapshot: getLocalToolEnvironmentSnapshot,
@@ -1500,71 +1504,19 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('toggles Magic Notes navigation settings', async () => {
const onMagicNotesEnabledChange = vi.fn()
const onMagicNotesShowIncompleteTodoCountChange = vi.fn()
it('omits Magic Notes from global platform settings without changing stored settings', async () => {
const storedSettings = structuredClone(applicationSettings)
render(
<SettingsPanel
{...heartbeatSettingsProps}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', { name: '魔法笔记' })
)
const toggle = await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
expect(toggle).not.toBeChecked()
expect(screen.getByText(/默认关闭/)).toBeInTheDocument()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesEnabled: true
})
)
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
const countToggle = screen.getByRole('switch', {
name: '显示未完成待办数量'
})
expect(countToggle).toBeChecked()
fireEvent.click(countToggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesShowIncompleteTodoCount: false
})
)
expect(
onMagicNotesShowIncompleteTodoCountChange
).toHaveBeenCalledWith(false)
fireEvent.click(
screen.getByRole('button', { name: '保存后自动' })
)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNoteCommentMode: 'after-save-auto'
})
)
expect(
screen.getByRole('button', { name: '长评 + 要点' })
).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: '要点' }))
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNoteCommentFormat: 'structured'
})
)
expect(await screen.findByRole('tab', { name: '通用设置' })).toHaveAttribute('aria-selected', 'true')
expect(screen.queryByRole('tab', { name: '魔法笔记', hidden: true })).not.toBeInTheDocument()
expect(document.getElementById('platform-features-panel-magic-notes')).toBeNull()
expect(screen.queryByRole('switch', { name: '显示魔法笔记入口' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
expect(updateApplicationSettings).not.toHaveBeenCalled()
expect(applicationSettings).toEqual(storedSettings)
})
it('switches the global model download source from General settings', async () => {
@@ -1609,9 +1561,9 @@ describe('SettingsPanel runtime files', () => {
dedupeKey: 'model-download-source'
})
fireEvent.click(screen.getByRole('tab', { name: '魔法笔记' }))
fireEvent.click(screen.getByRole('tab', { name: '远程项目(技术预览)' }))
expect(
screen.getByRole('switch', { name: '显示魔法笔记入口' })
screen.getByRole('tabpanel', { name: '远程项目(技术预览)' })
).toBeInTheDocument()
})
@@ -1712,24 +1664,17 @@ describe('SettingsPanel runtime files', () => {
})
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
function Harness(): React.JSX.Element {
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
return (
<SettingsPanel
{...heartbeatSettingsProps}
magicNotesEnabled={magicNotesEnabled}
onMagicNotesEnabledChange={setMagicNotesEnabled}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
}
render(
<Harness />
const panel = (magicNotesEnabled: boolean): React.JSX.Element => (
<SettingsPanel
{...heartbeatSettingsProps}
magicNotesEnabled={magicNotesEnabled}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const { rerender } = render(panel(false))
openCapabilitySettingsTab('MCP')
const noteServerToggle = await screen.findByRole('button', {
@@ -1739,22 +1684,7 @@ describe('SettingsPanel runtime files', () => {
'capability-card--disabled'
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', { name: '魔法笔记' })
)
fireEvent.click(
await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesEnabled: true
})
)
openCapabilitySettingsTab('MCP')
rerender(panel(true))
await waitFor(() =>
expect(
screen
-10
View File
@@ -242,11 +242,7 @@ type SettingsPanelProps = {
onConversationHtmlRenderingEnabledChange?: (
enabled: boolean
) => void
onMagicNotesEnabledChange?: (enabled: boolean) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange?: (
enabled: boolean
) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
@@ -655,9 +651,7 @@ export function SettingsPanel({
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onConversationHtmlRenderingEnabledChange = () => {},
onMagicNotesEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onMagicNotesShowIncompleteTodoCountChange = () => {},
onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
@@ -2301,10 +2295,6 @@ export function SettingsPanel({
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
@@ -0,0 +1,48 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantSchedule } from '../../shared/assistant-contracts'
import { TaskScheduleActions } from './TaskScheduleActions'
afterEach(cleanup)
const triggeredAt = '2026-09-17T12:00:00.000Z'
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',
recurrence: 'once', enabled: false,
nextRunAt: triggeredAt, lastRunAt: triggeredAt,
createdAt: triggeredAt, updatedAt: triggeredAt
}
describe('TaskScheduleActions', () => {
it('offers an explicit new run, not automatic resume, for a cancelled immediate task', async () => {
const onRunSchedule = vi.fn().mockResolvedValue(undefined)
const onSetScheduleEnabled = vi.fn()
render(<TaskScheduleActions schedule={schedule} taskStatus="cancelled" taskTitle={schedule.title}
onError={vi.fn()} onRemoveSchedule={vi.fn()} onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled} />)
expect(screen.queryByRole('button', { name: '恢复' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '立即运行' }))
await waitFor(() => expect(onRunSchedule).toHaveBeenCalledExactlyOnceWith(schedule.id))
expect(onSetScheduleEnabled).not.toHaveBeenCalled()
})
it('does not allow resume or another run while the immediate occurrence is queued', () => {
render(<TaskScheduleActions schedule={schedule} taskStatus="queued" taskTitle={schedule.title}
onError={vi.fn()} onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()}
onSetScheduleEnabled={vi.fn()} />)
expect(screen.queryByRole('button', { name: '恢复' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '立即运行' })).toBeDisabled()
})
it.each(['once', 'daily', 'weekly'] as const)('retains resume for a paused untriggered %s schedule', async (recurrence) => {
const onSetScheduleEnabled = vi.fn().mockResolvedValue(undefined)
render(<TaskScheduleActions schedule={{ ...schedule, recurrence, lastRunAt: undefined }}
taskStatus="paused" taskTitle={schedule.title} onError={vi.fn()}
onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()} onSetScheduleEnabled={onSetScheduleEnabled} />)
fireEvent.click(screen.getByRole('button', { name: '恢复' }))
await waitFor(() => expect(onSetScheduleEnabled).toHaveBeenCalledExactlyOnceWith(schedule.id, true))
})
})
@@ -8,6 +8,7 @@ import {
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
defaultLocalToolEnvironmentSettings,
defaultApplicationNavigation,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type { DesktopApi } from '../../shared/contracts'
@@ -25,6 +26,7 @@ describe('UpdateSettingsSection', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -88,7 +90,8 @@ describe('UpdateSettingsSection', () => {
updateSettings,
check,
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
@@ -193,7 +196,8 @@ describe('UpdateSettingsSection', () => {
)
}),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
@@ -237,7 +241,8 @@ describe('UpdateSettingsSection', () => {
openReleasePage: vi.fn(async () => {
throw new Error('Shell refused the release URL')
}),
onResult: vi.fn(() => () => {})
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
+11 -4
View File
@@ -23,6 +23,10 @@ const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
)
const taskCenterStylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'task-center.css'),
'utf8'
)
const rendererEntry = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'main.tsx'),
'utf8'
@@ -346,11 +350,14 @@ describe('WorkspacePrimitives', () => {
expect(stylesheet).not.toMatch(
/@media \(max-width: 720px\)\s*\{[\s\S]*?\.conversation-task-strip__header\s*\{[^}]*flex-direction:\s*column;/u
)
expect(stylesheet).toMatch(
/\.task-center__filters \.segmented-control\s*\{[^}]*width:\s*100%;[^}]*overflow:\s*hidden;/u
})
it('keeps narrow task filters scrollable without shrinking their labels', () => {
expect(taskCenterStylesheet).toMatch(
/\.task-center \.task-center__filters \.segmented-control\s*\{[^}]*overflow-x:\s*auto;/u
)
expect(stylesheet).toMatch(
/\.task-center__filters \.segmented-control__option\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*1;/u
expect(taskCenterStylesheet).toMatch(
/\.task-center \.task-center__filters \.segmented-control__option\s*\{[^}]*flex:\s*1 0 auto;[^}]*min-width:\s*max-content;/u
)
})
+37
View File
@@ -0,0 +1,37 @@
.anchored-menu {
position: fixed;
z-index: calc(var(--z-dialog) - 1);
box-sizing: border-box;
overflow-y: auto;
padding: var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
font-size: var(--font-body);
box-shadow: var(--shadow-dialog);
-webkit-app-region: no-drag;
}
.anchored-menu [role="menuitem"] {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: 32px;
padding: var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.anchored-menu [role="menuitem"] svg { flex-shrink: 0; color: var(--text-secondary); }
.anchored-menu [role="menuitem"]:hover { background: var(--surface-subtle); }
.anchored-menu [role="menuitem"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.anchored-menu [role="separator"] { border-top: 1px solid var(--border-subtle); margin: var(--space-2) 0; }
.anchored-menu p { margin: var(--space-2); color: var(--text-secondary); overflow-wrap: anywhere; }
+199
View File
@@ -0,0 +1,199 @@
.settings-backdrop .settings-panel.application-center {
width: min(960px, calc(100vw - 2 * var(--space-4)));
height: auto;
max-height: calc(100vh - 2 * var(--space-4));
}
.settings-backdrop .settings-panel.application-center--detail {
width: min(640px, calc(100vw - 2 * var(--space-4)));
}
.application-center .settings-panel__header {
gap: var(--space-3);
flex: 0 0 auto;
}
.application-center .settings-panel__header > .icon-button {
flex: 0 0 auto;
}
.application-center .settings-panel__header .page-header {
flex: 1;
align-items: center;
flex-direction: row;
}
.application-center .settings-panel__header .page-header__content {
padding-right: 0;
}
.application-center .settings-panel__header .page-header .page-header__actions {
position: static;
flex: 0 0 auto;
}
.application-center .settings-panel__header h1 {
font-size: var(--font-section-title);
}
.application-center__body {
overflow: auto;
min-height: 0;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-0);
font-size: var(--font-body);
}
.application-center__search {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-3);
color: var(--text-secondary);
}
.application-center__search input {
background: var(--surface-raised);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
padding: var(--space-2) var(--space-3);
min-height: 36px;
min-width: 0;
}
.application-center__item {
display: flex;
flex-direction: column;
gap: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
padding: var(--space-4);
min-width: 0;
}
.application-center__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.application-center__summary {
display: flex;
align-items: flex-start;
gap: var(--space-3);
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.application-center__summary > svg {
flex-shrink: 0;
color: var(--text-secondary);
}
.application-center__title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-1) var(--space-2);
}
.application-center__title strong {
font-size: var(--font-section-title);
}
.application-center__summary p {
color: var(--text-secondary);
margin: var(--space-1) 0 0;
line-height: 1.6;
}
.application-center__summary small {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.application-center__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-top: auto;
}
.application-center__order {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
border-top: 1px solid var(--border-subtle);
margin-top: var(--space-4);
padding-top: var(--space-4);
}
.application-settings {
display: grid;
gap: var(--space-4);
}
.application-settings__group {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.application-settings__group + .application-settings__group {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-4);
}
.application-settings .toggle-row {
justify-content: flex-start;
}
.application-settings .toggle-row span {
font-weight: 500;
}
.application-settings .settings-notice {
margin: 0 0 var(--space-2);
font-size: var(--font-body);
line-height: 1.6;
}
.application-settings .platform-feature-option {
grid-template-columns: minmax(100px, 1fr) auto;
align-items: center;
border: 0;
padding: 0;
gap: var(--space-3);
}
.application-settings .segmented-control {
width: fit-content;
max-width: 100%;
}
@media (max-width: 720px) {
.application-center .settings-panel__header {
padding: var(--space-4);
}
.application-center__body {
padding: var(--space-4);
}
.application-center__grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 540px) {
.application-settings .platform-feature-option {
grid-template-columns: minmax(0, 1fr);
}
}
.application-page {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.application-page-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-4);
padding: var(--space-2) var(--space-4);
flex: 0 0 auto;
}
.application-page-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.application-page-content[hidden] {
display: none;
}
.sidebar-footer--applications {
display: flex;
align-items: center;
gap: var(--space-2);
}
.sidebar-footer--applications > .nav-item {
flex: 1;
}
+5
View File
@@ -0,0 +1,5 @@
.custom-task-dialog__form {
min-width: 0;
margin: 0;
border: 0;
}
+9 -3
View File
@@ -14,8 +14,9 @@ export function trapTabFocus(
if (event.key !== 'Tab' || !container) {
return
}
const focusable =
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(focusableSelector)
).filter((element) => !element.closest('[hidden], [inert], [aria-hidden="true"]'))
if (focusable.length === 0) {
event.preventDefault()
container.focus()
@@ -36,7 +37,8 @@ export function trapTabFocus(
}
export function activateModalFocus(
initialFocus: () => HTMLElement | null
initialFocus: () => HTMLElement | null,
fallbackFocus?: () => HTMLElement | null
): () => void {
const restoreFocus =
document.activeElement instanceof HTMLElement
@@ -86,6 +88,10 @@ export function activateModalFocus(
for (const { element, wasInert } of isolatedElements) {
element.inert = wasInert
}
restoreFocus?.focus()
const canRestore = restoreFocus?.isConnected &&
restoreFocus !== document.body &&
!restoreFocus.closest('[hidden], [inert], [aria-hidden="true"], :disabled')
const target = canRestore ? restoreFocus : fallbackFocus?.()
target?.focus()
}
}
@@ -83,6 +83,29 @@ async function setup() {
}
describe('document OCR worker idle release', () => {
it('reports a failed model load and recovers on the next real request', async () => {
const api = await setup()
const { getOcrInferenceState } = await import('./document-ocr-bridge')
api.getOcrAssets.mockRejectedValueOnce(new Error('model unavailable'))
await api.start()
expect(getOcrInferenceState()).toMatchObject({ state: 'error', loaded: false, busy: false, error: 'model unavailable' })
const next = await api.start()
expect(getOcrInferenceState()).toMatchObject({ state: 'running', loaded: true, error: undefined })
await api.finish(next)
})
it('reports the actual worker and refuses manual release while busy', async () => {
const api = await setup()
const { getOcrInferenceState, releaseOcrInference } = await import('./document-ocr-bridge')
expect(getOcrInferenceState()).toMatchObject({ state: 'idle', loaded: false })
const input = await api.start()
expect(getOcrInferenceState()).toMatchObject({ state: 'running', loaded: true, busy: true })
expect(() => releaseOcrInference()).toThrow('活动或排队任务')
expect(TestWorker.instances[0]!.terminate).not.toHaveBeenCalled()
await api.finish(input)
releaseOcrInference()
expect(TestWorker.instances[0]!.terminate).toHaveBeenCalledOnce()
expect(getOcrInferenceState()).toMatchObject({ state: 'idle', loaded: false })
})
it('releases after 60 idle seconds and reloads the model for the next request', async () => {
const api = await setup()
const input = await api.start()
+56 -1
View File
@@ -18,6 +18,10 @@ type PendingWorkerRequest = {
}
let worker: Worker | undefined
let workerLoaded = false
let workerError: string | undefined
let managementLoading = false
let startupTimer: number | undefined
let workerModelId: string | undefined
let workerReady: Promise<void> | undefined
let resolveWorkerReady: (() => void) | undefined
@@ -55,9 +59,12 @@ function safeError(error: unknown): string {
function terminateWorker(error: Error): void {
clearIdleTimer()
window.clearTimeout(startupTimer)
startupTimer = undefined
worker?.terminate()
rejectWorkerReady?.(error)
worker = undefined
workerLoaded = false
workerModelId = undefined
workerReady = undefined
resolveWorkerReady = undefined
@@ -97,15 +104,27 @@ async function ensureWorker(modelId: string): Promise<Worker> {
new URL('./document-ocr-worker.ts', import.meta.url),
{ type: 'module', name: 'goodbuddy-document-ocr' }
)
workerModelId = modelId
workerReady = new Promise<void>((resolve, reject) => {
resolveWorkerReady = resolve
rejectWorkerReady = reject
})
const ready = workerReady
void ready.catch(() => undefined)
// A worker that never becomes ready must not leave manual loading locked forever.
startupTimer = window.setTimeout(() => {
workerError = '本地 OCR 模型加载超时,请重试'
terminateWorker(new Error(workerError))
}, 120_000)
worker.addEventListener(
'message',
(event: MessageEvent<WorkerOutput>) => {
const output = event.data
if (output.type === 'ready') {
window.clearTimeout(startupTimer)
startupTimer = undefined
workerLoaded = true
workerError = undefined
resolveWorkerReady?.()
return
}
@@ -141,6 +160,7 @@ async function ensureWorker(modelId: string): Promise<Worker> {
}
)
worker.addEventListener('error', (event) => {
workerError = event.message || '本地 OCR Worker 异常'
terminateWorker(
new Error(event.message || '本地 OCR Worker 异常')
)
@@ -159,8 +179,9 @@ async function ensureWorker(modelId: string): Promise<Worker> {
assets.dictionary
]
)
await workerReady
await ready
} catch (error) {
workerError = safeError(error)
terminateWorker(
error instanceof Error ? error : new Error('本地 OCR 初始化失败')
)
@@ -169,6 +190,40 @@ async function ensureWorker(modelId: string): Promise<Worker> {
return worker
}
export function getOcrInferenceState(): {
state: 'idle' | 'running' | 'starting' | 'error'
model?: string
busy: boolean
loaded: boolean
error?: string
} {
return {
state: worker ? workerLoaded ? 'running' : 'starting' : workerError ? 'error' : 'idle',
model: workerModelId, loaded: workerLoaded,
busy: managementLoading || queuedRequestCount > 0 || Boolean(activeRequestId) || pending.size > 0,
error: workerError
}
}
export async function loadOcrInference(): Promise<void> {
if (getOcrInferenceState().busy) throw new Error('OCR 正在处理任务,请等待完成后再加载')
managementLoading = true
try {
const snapshot = await window.goodbuddy.documentParsing?.getSnapshot()
if (!snapshot) throw new Error('文档解析服务不可用')
if (!snapshot.status.localOcr.available) throw new Error(snapshot.status.localOcr.detail)
await ensureWorker(snapshot.settings.localOcrModelId)
} finally {
managementLoading = false
scheduleIdleRelease()
}
}
export function releaseOcrInference(): void {
if (getOcrInferenceState().busy) throw new Error('OCR 仍有活动或排队任务,无法释放共享 Worker;请等待任务结束')
terminateWorker(new Error('用户已释放 OCR 模型'))
}
async function recognize(
request: DocumentOcrRequest
): Promise<DocumentOcrResult> {
+25 -5
View File
@@ -2,6 +2,22 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
applications: {
fixed: 'Fixed entry', optional: 'Optional',
manage: 'Manage Apps',
title: 'Application Center', description: 'Open built-in applications and manage optional apps, pins, and order. Knowledge and Smart Heartbeat have fixed sidebar entries.',
localInference: 'Local Inference', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
enableHelp: 'Pinning only controls the sidebar shortcut. Disabling preserves data and pins without stopping existing requests or background services.',
disabledPage: 'Application is disabled', close: 'Close application center', retry: 'Reload settings', back: 'Back to application center',
search: 'Search built-in applications', noResults: 'No matching applications', clear: 'Clear search', builtin: 'Built-in',
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
descriptions: {
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
heartbeat: 'Review heartbeat suggestions and schedules.', 'local-inference': 'Inspect local inference tasks and execution services.'
}
},
notifications: {
success: 'Success',
error: 'Error',
@@ -39,6 +55,7 @@ export const app = {
}
},
navigation: {
settings: 'Settings',
label: 'Main navigation',
chat: 'Chat',
magicNotes: 'Magic Notes',
@@ -137,11 +154,14 @@ export const app = {
eyebrow: 'CUSTOM TASK',
title: 'New custom task',
description:
'Send task instructions to an ordinary conversation on schedule. Replies stay in the conversation just like manually sent messages.',
'Send task content to a conversation now or on schedule. Replies stay in the linked conversation.',
nameHelp: 'Optional. Leave blank to generate a name from the task content.',
timing: { now: 'Run immediately', scheduled: 'Schedule' },
close: 'Close new custom task',
fields: {
name: 'Task name',
instructions: 'Task instructions',
instructions: 'Task content',
timing: 'Execution time',
destination: 'Conversation',
mode: 'Work mode',
recurrence: 'Frequency',
@@ -151,11 +171,11 @@ export const app = {
current: 'Current conversation',
new: 'New conversation',
currentHelp:
'Use this conversation\'s history, Runtime, work mode, and enabled capabilities when the message is sent.',
'Use the selected conversation\'s history, Runtime, work mode, 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 a new conversation.'
'The current conversation cannot host this task. Choose another conversation in this project or a new conversation.'
},
mode: {
execute: 'Execute',
@@ -183,7 +203,7 @@ export const app = {
},
errors: {
title: 'Enter a task name.',
instructions: 'Enter task instructions.',
instructions: 'Enter task content.',
destination: 'Choose an available conversation.',
time: 'Choose a valid first run time.',
futureTime: 'The first run must be in the future.',
@@ -557,8 +557,7 @@ export const settingsSections = {
label: 'Platform feature options',
tabs: {
ariaLabel: 'Platform feature settings',
general: 'General',
magicNotes: 'Magic Notes'
general: 'General'
},
conversationHtml: {
title: 'Conversation HTML rendering',
@@ -680,7 +679,7 @@ export const settingsSections = {
magicNotes: {
title: 'Magic Notes',
description:
'Off by default. Enable it to capture notes and to-dos and analyze content with AI.',
'Capture notes and to-dos and analyze content with AI. Manage preferences in Application settings.',
showEntry: 'Show Magic Notes',
showIncompleteTodoCount: 'Show incomplete to-do count',
showIncompleteTodoCountHelp:
@@ -322,7 +322,20 @@ export const workspace = {
noApprovals: 'There are no operations awaiting approval.',
deny: 'Deny',
allowOnce: 'Allow once',
taskIndexTitle: 'Task index',
taskIndexTitle: 'Project tasks',
stats: {
label: 'Current conversation statistics',
currentConversation: 'Current conversation',
noConversation: 'No conversation selected',
replyDuration: 'Cumulative Agent reply time',
messages: 'Messages',
unavailable: 'Not available',
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.',
taskDuration: 'Cumulative task time'
},
empty: 'Explicitly created tasks will appear here.',
noFilterResults: 'No tasks match this filter.',
conversationUnavailable: 'Conversation unavailable',
@@ -336,9 +349,12 @@ export const workspace = {
currentProject: 'Current project',
allProjects: 'All projects',
noCurrentProject:
'No project is active. Choose another task scope or open a project.'
'No project is active. Open a project to view and create tasks.'
},
filters: {
all: 'All',
summary: 'Filter: {{filter}} · {{count}} tasks',
clear: 'Clear filter',
ariaLabel: 'Filter tasks',
active: 'Active',
paused: 'Paused',
+25 -5
View File
@@ -1,4 +1,20 @@
export const app = {
applications: {
fixed: '固定入口', optional: '可选',
manage: '管理应用',
title: '应用中心', description: '打开内置应用,管理可选应用的启用、常驻和顺序。知识库和智能心跳固定显示在左侧菜单。',
localInference: '本机推理', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
enableHelp: '常驻只控制左侧捷径;关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
descriptions: {
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
heartbeat: '查看智能心跳建议与计划。', 'local-inference': '查看本机推理任务与执行服务。'
}
},
notifications: {
success: '成功',
error: '错误',
@@ -36,6 +52,7 @@ export const app = {
}
},
navigation: {
settings: '设置',
label: '主导航',
chat: '对话',
magicNotes: '魔法笔记',
@@ -129,11 +146,14 @@ export const app = {
customTask: {
eyebrow: '定制任务',
title: '新建定制任务',
description: '按计划向普通会话发送任务要求,回复和手动发送的消息一样保留在会话中。',
description: '立即或按计划向会话发送任务内容,回复保留在关联会话中。',
nameHelp: '可选,留空时从任务内容生成名称。',
timing: { now: '立即执行', scheduled: '定时执行' },
close: '关闭新建定制任务',
fields: {
name: '任务名称',
instructions: '任务要求',
instructions: '任务内容',
timing: '执行时间',
destination: '关联会话',
mode: '执行模式',
recurrence: '运行频率',
@@ -142,9 +162,9 @@ export const app = {
destination: {
current: '当前会话',
new: '新建会话',
currentHelp: '发送时使用当前会话的历史、Runtime、工作模式和已启用能力。',
currentHelp: '发送时使用所选会话的历史、Runtime、工作模式和已启用能力。',
newHelp: '创建独立的普通会话,使用项目默认设置,以任务名称作为标题。',
currentUnavailable: '当前会话不能关联此任务,请选择新建会话。'
currentUnavailable: '当前会话不能关联此任务,请选择本项目其他会话或新建会话。'
},
mode: {
execute: 'Execute',
@@ -170,7 +190,7 @@ export const app = {
},
errors: {
title: '请输入任务名称。',
instructions: '请输入任务要求。',
instructions: '请输入任务内容。',
destination: '请选择可用的关联会话。',
time: '请选择有效的首次运行时间。',
futureTime: '首次运行时间必须晚于当前时间。',
@@ -528,8 +528,7 @@ export const settingsSections = {
label: '平台功能选项',
tabs: {
ariaLabel: '平台功能设置',
general: '通用设置',
magicNotes: '魔法笔记'
general: '通用设置'
},
conversationHtml: {
title: '会话 HTML 渲染',
@@ -634,7 +633,7 @@ export const settingsSections = {
},
magicNotes: {
title: '魔法笔记',
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
description: '记录笔记与待办,使用 AI 分析内容;相关配置统一在应用设置中管理。',
showEntry: '显示魔法笔记入口',
showIncompleteTodoCount: '显示未完成待办数量',
showIncompleteTodoCountHelp:
@@ -314,7 +314,20 @@ export const workspace = {
noApprovals: '当前没有等待审批的操作。',
deny: '拒绝',
allowOnce: '仅此次允许',
taskIndexTitle: '任务索引',
taskIndexTitle: '项目任务',
stats: {
label: '当前会话统计',
currentConversation: '当前会话',
noConversation: '尚未选择会话',
replyDuration: 'Agent 累计回复时长',
messages: '消息数',
unavailable: '暂无统计',
incomplete: '部分回复缺少计时记录,时长仅包含已知记录。',
partial: '部分记录',
timeHelp: '时间说明',
timeDescription: '累计每次 Agent 回复的实际用时,不包含回复之间的空闲时间。缺少计时记录的回复不按会话或任务的起止时间估算。',
taskDuration: '任务累计用时'
},
empty: '明确创建的任务会显示在这里。',
noFilterResults: '当前筛选条件下没有任务。',
conversationUnavailable: '关联会话不可用',
@@ -327,9 +340,12 @@ export const workspace = {
ariaLabel: '任务范围',
currentProject: '当前项目',
allProjects: '所有项目',
noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。'
noCurrentProject: '没有活动项目。请先打开一个项目以查看和创建任务。'
},
filters: {
all: '全部',
summary: '筛选:{{filter}} · {{count}} 项',
clear: '清除筛选',
ariaLabel: '筛选任务',
active: '进行中',
paused: '暂停',
+38
View File
@@ -0,0 +1,38 @@
.local-inference-content { display: flex; flex-direction: column; font-size: var(--font-body); line-height: 1.6; }
.local-inference-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--space-3) var(--space-4); padding-block: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
.local-inference-row:last-child { border-bottom: 0; }
.local-inference-row > div:first-child { min-width: 0; overflow-wrap: anywhere; }
.local-inference-row h2 { margin: 0; font-size: var(--font-section-title); }
.local-inference-row__heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); }
.local-inference-state { flex: 0 0 auto; font-size: var(--font-caption); color: var(--text-secondary); }
.local-inference-row p { margin: var(--space-1) 0 0; color: var(--text-secondary); }
.local-inference-row .local-inference-metadata { font-size: var(--font-caption); }
.local-inference-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.local-inference-actions:empty { display: none; }
.local-inference-row > .local-inference-actions { justify-content: flex-end; }
.local-inference-row button { white-space: nowrap; }
.local-inference-note { color: var(--text-secondary); margin: 0; font-size: var(--font-body); line-height: 1.6; }
.local-inference-feedback { padding: var(--space-4); margin-block: var(--space-4); border: 1px solid var(--danger-border); border-radius: var(--radius-control); }
.local-inference-row .local-inference-error, .local-inference-feedback { color: var(--danger); }
.custom-task-dialog__surface.local-inference-modal { width: min(960px, 100%); height: auto; max-height: min(720px, calc(100dvh - 2 * var(--space-6))); grid-template-rows: auto minmax(0, 1fr); }
.local-inference-modal .custom-task-dialog__header { align-items: center; padding: var(--space-3) var(--space-4); }
.local-inference-modal .custom-task-dialog__header .page-header { width: 100%; margin: 0; padding: 0; border: 0; flex-direction: row; align-items: center; }
.local-inference-modal .custom-task-dialog__header .page-header .page-header__actions { width: auto; flex-shrink: 0; justify-content: flex-end; }
.local-inference-modal .custom-task-dialog__header h1 { font-size: var(--font-section-title); }
.local-inference-modal .custom-task-dialog__header .icon-button { min-width: 40px; min-height: 40px; }
.local-inference-modal__body { min-height: 0; overflow: auto; padding: var(--space-4); }
.local-inference-toolbar { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2) var(--space-4); }
.local-inference-workspace[hidden] { display: none; }
.custom-task-dialog__surface.local-inference-confirm { width: min(480px, 100%); grid-template-rows: minmax(0, 1fr) auto; }
.local-inference-confirm .custom-task-dialog__content { padding: var(--space-4); gap: var(--space-3); font-size: var(--font-body); line-height: 1.6; }
.local-inference-confirm h2 { font-size: var(--font-section-title); }
.local-inference-confirm h2, .local-inference-confirm p, .local-inference-confirm ul { margin: 0; }
.local-inference-confirm ul { padding-left: var(--space-4); overflow-wrap: anywhere; }
.local-inference-confirm .custom-task-dialog__actions { padding: var(--space-3) var(--space-4); gap: var(--space-2); justify-content: flex-end; flex-wrap: wrap; }
.local-inference-confirm h2 { overflow-wrap: anywhere; }
@media (max-width: 720px) {
.local-inference-row { grid-template-columns: minmax(0, 1fr); }
.local-inference-row > .local-inference-actions { justify-content: flex-start; }
.local-inference-row > button { justify-self: start; }
.local-inference-toolbar > .local-inference-actions { width: 100%; }
}
+1
View File
@@ -3739,6 +3739,7 @@ textarea:focus-visible {
* rather than adding a per-dialog copy.
*/
:is(
.custom-task-dialog,
.project-create-backdrop,
.remote-directory-picker-backdrop,
.html-preview-viewer-backdrop,
+112
View File
@@ -0,0 +1,112 @@
.task-center .task-center__scope {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.task-center__stats {
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.task-center__eyebrow,
.task-center__stats dt,
.task-center__stats details,
.task-center__incomplete {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.task-center .task-center__stats h3 {
display: block;
margin: var(--space-2) 0 var(--space-4);
overflow-wrap: anywhere;
}
.task-center__stat-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--space-3);
margin: 0 0 var(--space-3);
}
.task-center__stat-grid dd {
margin: var(--space-1) 0 0;
color: var(--text-primary);
font-size: var(--font-section-title);
font-weight: 600;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.task-center__stats summary { cursor: pointer; }
.task-center__stats details p { margin-bottom: 0; }
.task-center .task-center__index-heading {
display: flex;
gap: var(--space-2);
flex: 0 0 auto;
}
.task-center .task-center__index-heading h3 {
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.task-center__count { color: var(--text-muted); }
.task-center .task-center__create,
.task-center .task-center__filter-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
padding: 0;
}
.task-center .task-center__filter-toggle[data-active='true'],
.task-center .task-center__filter-toggle[aria-expanded='true'] {
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
}
.task-center .task-center__filters { flex: 0 0 auto; }
.task-center .task-center__filters .segmented-control { overflow-x: auto; }
.task-center .task-center__filters .segmented-control__option {
flex: 1 0 auto;
min-width: max-content;
}
.task-center .task-center__filters[hidden] { display: none; }
.task-center__filter-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-caption);
}
.task-center__filter-summary button {
border: 0;
background: transparent;
color: var(--accent);
padding: var(--space-2);
cursor: pointer;
font: inherit;
}
.task-center__filter-summary button:hover { background: var(--accent-subtle); }
.task-center button:focus-visible,
.task-center summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -0,0 +1,264 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
import { useExecutionStats } from './use-execution-stats'
function stats(durationMs: number): ExecutionStats {
return {
durationMs, requestCount: 1, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: 1_000, taskDurations: []
}
}
function deferred() {
let resolve!: (value: ExecutionStats) => void
let reject!: (reason: Error) => void
const promise = new Promise<ExecutionStats>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const empty = { conversation: undefined, project: undefined }
const initialProps = {
conversationId: 'chat-a' as string | undefined,
projectId: 'project-a' as string | undefined,
revision: '1', enabled: true
}
function renderStats() {
return renderHook(({ conversationId, projectId, revision, enabled }) =>
useExecutionStats(conversationId, projectId, revision, enabled), { initialProps })
}
const getExecutionStats = vi.fn<(input: ExecutionStatsInput) => Promise<ExecutionStats>>()
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
getExecutionStats.mockReset().mockResolvedValue(stats(10))
vi.stubGlobal('goodbuddy', { tasks: { getExecutionStats } })
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('useExecutionStats', () => {
it.each(['project', 'conversation'] as const)(
'clears the previous %s selection and ignores its late polling result', async (scope) => {
const { result, rerender } = renderStats()
await act(async () => {})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
const oldConversation = deferred()
const oldProject = deferred()
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
await act(async () => { vi.advanceTimersByTime(5_000) })
const newConversation = deferred()
const newProject = deferred()
getExecutionStats.mockReturnValueOnce(newConversation.promise).mockReturnValueOnce(newProject.promise)
rerender({
...initialProps, conversationId: 'chat-b',
projectId: scope === 'project' ? 'project-b' : 'project-a'
})
expect(result.current).toEqual({
conversation: undefined, project: scope === 'project' ? undefined : stats(10)
})
expect(getExecutionStats.mock.calls.slice(-2)).toEqual([
[{ conversationId: 'chat-b' }],
[{ projectId: scope === 'project' ? 'project-b' : 'project-a' }]
])
await act(async () => {
newConversation.resolve(stats(20))
newProject.resolve(stats(30))
})
await act(async () => {
oldConversation.resolve(stats(90))
oldProject.resolve(stats(99))
})
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
}
)
it('does not resurrect cancelled results when switching back to the same IDs', async () => {
const oldConversation = deferred()
const oldProject = deferred()
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
const { result, rerender } = renderStats()
rerender({ ...initialProps, conversationId: 'chat-b', projectId: 'project-b' })
await act(async () => {})
const currentConversation = deferred()
const currentProject = deferred()
getExecutionStats.mockReturnValueOnce(currentConversation.promise).mockReturnValueOnce(currentProject.promise)
rerender(initialProps)
await act(async () => {
oldConversation.resolve(stats(90))
oldProject.resolve(stats(99))
})
expect(result.current).toEqual(empty)
await act(async () => {
currentConversation.resolve(stats(20))
currentProject.resolve(stats(30))
})
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
})
it('does not overlap polling, focus or visibility refreshes while either query is pending', async () => {
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
const { result } = renderStats()
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
conversation.resolve(stats(20))
})
await act(async () => {
vi.advanceTimersByTime(5_000)
window.dispatchEvent(new Event('focus'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(2)
expect(result.current).toEqual(empty)
await act(async () => { project.resolve(stats(30)) })
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
await act(async () => { vi.advanceTimersByTime(4_999) })
expect(getExecutionStats).toHaveBeenCalledTimes(2)
await act(async () => { vi.advanceTimersByTime(1) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it('replaces the poller on revision changes and removes timers and listeners on unmount', async () => {
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
const { result, rerender, unmount } = renderStats()
await act(async () => { vi.advanceTimersByTime(2_000) })
rerender({ ...initialProps, revision: '2' })
await act(async () => {})
expect(vi.getTimerCount()).toBe(1)
await act(async () => {
conversation.resolve(stats(90))
project.resolve(stats(99))
vi.advanceTimersByTime(3_000)
})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
await act(async () => { vi.advanceTimersByTime(2_000) })
expect(getExecutionStats).toHaveBeenCalledTimes(6)
const lastConversation = deferred()
const lastProject = deferred()
getExecutionStats.mockReturnValueOnce(lastConversation.promise).mockReturnValueOnce(lastProject.promise)
await act(async () => { window.dispatchEvent(new Event('focus')) })
unmount()
expect(vi.getTimerCount()).toBe(0)
await act(async () => {
lastConversation.resolve(stats(50))
lastProject.resolve(stats(60))
})
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(8)
})
it.each(['conversation', 'project', 'both'] as const)(
'clears stale statistics when %s queries fail and recovers on the next poll', async (failure) => {
const { result } = renderStats()
await act(async () => {})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
getExecutionStats.mockImplementation(async (input) => {
if (failure === 'both' ||
(failure === 'conversation' && 'conversationId' in input) ||
(failure === 'project' && 'projectId' in input)) {
throw new Error('stats unavailable')
}
return stats(20)
})
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current).toEqual({
conversation: failure === 'project' ? stats(20) : undefined,
project: failure === 'conversation' ? stats(20) : undefined
})
getExecutionStats.mockResolvedValue(stats(30))
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current).toEqual({ conversation: stats(30), project: stats(30) })
}
)
it('does not query while hidden and resumes when visible', async () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
const { result } = renderStats()
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).not.toHaveBeenCalled()
expect(result.current).toEqual(empty)
visibility.mockReturnValue('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
expect(getExecutionStats).toHaveBeenCalledTimes(2)
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
visibility.mockReturnValue('hidden')
await act(async () => {
vi.advanceTimersByTime(10_000)
window.dispatchEvent(new Event('focus'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(2)
visibility.mockReturnValue('visible')
await act(async () => { window.dispatchEvent(new Event('focus')) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it.each(['disabled', 'no-project'] as const)('stops querying and hides results when %s', async (mode) => {
const { result, rerender } = renderStats()
await act(async () => {})
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
await act(async () => { vi.advanceTimersByTime(5_000) })
rerender({
...initialProps, enabled: mode !== 'disabled',
projectId: mode === 'no-project' ? undefined : initialProps.projectId
})
expect(result.current).toEqual(empty)
expect(vi.getTimerCount()).toBe(0)
await act(async () => {
conversation.resolve(stats(90))
project.reject(new Error('late failure'))
})
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(4)
expect(result.current).toEqual(empty)
rerender(initialProps)
await act(async () => {})
expect(getExecutionStats).toHaveBeenCalledTimes(6)
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
})
it('queries only the project when no conversation is selected', async () => {
const { result, rerender } = renderStats()
await act(async () => {})
getExecutionStats.mockClear()
rerender({ ...initialProps, conversationId: undefined })
expect(result.current.conversation).toBeUndefined()
await act(async () => {})
expect(getExecutionStats.mock.calls).toEqual([[{ projectId: 'project-a' }]])
expect(result.current).toEqual({ conversation: undefined, project: stats(10) })
})
})
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import type { ExecutionStats } from '../../shared/assistant-contracts'
// Keep timing evidence in Main; never extrapolate a stopped or disconnected run.
export function useExecutionStats(
conversationId: string | undefined,
projectId: string | undefined,
revision: string,
enabled: boolean
): { conversation?: ExecutionStats; project?: ExecutionStats } {
const [snapshot, setSnapshot] = useState<{
conversationId?: string; projectId?: string
conversation?: ExecutionStats; project?: ExecutionStats
}>({})
useEffect(() => {
if (!enabled || !projectId) return
let cancelled = false
let pending = false
const refresh = async (): Promise<void> => {
if (pending || document.visibilityState === 'hidden') return
pending = true
const [conversation, project] = await Promise.allSettled([
conversationId
? window.goodbuddy.tasks.getExecutionStats({ conversationId })
: Promise.resolve(undefined),
window.goodbuddy.tasks.getExecutionStats({ projectId })
])
pending = false
if (!cancelled) setSnapshot({
conversationId, projectId,
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
project: project.status === 'fulfilled' ? project.value : undefined
})
}
void refresh()
const interval = window.setInterval(() => { void refresh() }, 5_000)
const onFocus = (): void => { void refresh() }
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
return () => {
cancelled = true
window.clearInterval(interval)
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
}
}, [conversationId, projectId, revision, enabled])
return {
conversation: enabled && snapshot.projectId === projectId && snapshot.conversationId === conversationId
? snapshot.conversation : undefined,
project: enabled && snapshot.projectId === projectId ? snapshot.project : undefined
}
}