feat: add app management, local inference controls, and task statistics
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.custom-task-dialog__form {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '暂停',
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user