feat: retain completed project activity until viewed

Keep successful background conversations in project activity until their chat is visible and loaded. Track completion in memory for the current client session; initial history, failures and cancellations do not create completion notices.

Include whole-file companion changes already present in App and shared UI: preserve the workspace beneath Settings, show inline reply-copy feedback, and keep project settings inside the project menu. Include their component tests and documentation; navigation demo source changes remain unstaged.

Validation: 77 focused activity tests, typecheck and lint passed; Electron component fixtures passed 112 checks. Full suite: 4365 passed, 3 failed, 67 skipped (offline inventory timeout and two heartbeat migration failures). Commit-time staged whitespace check passed; tests were not rerun.

Release note: 后台会话完成后保留已完成提示,进入对应聊天后自动清除,避免错过任务结果。
This commit is contained in:
mesalogo
2026-09-16 12:51:58 +08:00
parent 6c045bfe2e
commit e84d7bd185
27 changed files with 815 additions and 190 deletions
+185 -14
View File
@@ -1301,7 +1301,7 @@ describe("App", () => {
vi.mocked(api.tasks.list).mockResolvedValue([task]);
});
it("keeps cross-project task counts independent of initial and current search and clears on refresh", async () => {
it("keeps cross-project counts independent of search and retains completion until opened", async () => {
const initialTasks = deferred<AssistantTask[]>();
vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise);
render(<App />);
@@ -1326,6 +1326,10 @@ describe("App", () => {
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: "completed" }]);
act(() => conversationQueueChangeListener?.(task.conversationId!));
await waitFor(() => expect(summary).toHaveTextContent("1 个已完成"));
expect(summary).not.toHaveTextContent("待处理");
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
await waitFor(() => expect(summary).not.toBeInTheDocument());
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
@@ -1333,6 +1337,114 @@ describe("App", () => {
.not.toHaveTextContent("个运行中");
});
it.each((["foreground", "background"] as const).flatMap((location) =>
(["done", "persisted"] as const).map((source) => ({ location, source })),
))("acknowledges $location local success from $source only when its chat is viewed", async ({ location, source }) => {
vi.mocked(api.tasks.list).mockResolvedValue([]);
const { container } = render(<App />);
await screen.findByLabelText("更多会话操作 Current discussion");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Complete local activity" } });
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
expect(request.conversationId).toBe("activity-current");
const summary = screen.getByRole("button", { name: /全项目活动/u });
expect(summary).toHaveTextContent("1 个运行中");
await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ header: expect.objectContaining({ id: request.conversationId }), messages: expect.arrayContaining([
expect.objectContaining({ role: "assistant", state: "streaming" }),
]) }),
])));
const saved = vi.mocked(api.conversations.saveLocal).mock.calls.at(-1)![0]
.find((conversation) => conversation.header.id === request.conversationId)!;
if (location === "background") {
selectProjectOption(otherProject.name);
fireEvent.click(await screen.findByTitle("Exact background discussion"));
await screen.findByText("Exact conversation content");
}
if (source === "done") {
act(() => {
agentListener?.({ requestId: request.requestId, type: "text", delta: "Local completion result" });
agentListener?.({ requestId: request.requestId, type: "done" });
});
} else {
vi.mocked(api.conversations.list).mockResolvedValue([
{ ...saved.header, updatedAt: saved.header.updatedAt + 120_000,
messages: saved.messages.map((message) => message.role === "assistant"
? { ...message, state: "complete", content: "Local completion result" } : message) },
...conversations.filter((conversation) => conversation.id !== request.conversationId),
]);
act(() => conversationQueueChangeListener?.(request.conversationId!));
}
if (location === "background") {
await waitFor(() => expect(summary).toHaveTextContent("1 个已完成"));
expect(summary).not.toHaveTextContent("运行中");
expect(screen.getByText("Exact conversation content")).toBeVisible();
fireEvent.click(summary);
fireEvent.click(screen.getByRole("menuitem", { name: new RegExp(project.name, "u") }));
expect(screen.getByRole("menu", { name: project.name })).toHaveTextContent("已完成");
fireEvent.click(summary);
expect(summary).toHaveTextContent("1 个已完成");
selectProjectOption(project.name);
fireEvent.click(await within(container.querySelector<HTMLElement>(".conversation-list")!)
.findByTitle(saved.header.title));
}
expect(await screen.findByText("Local completion result")).toBeVisible();
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
selectProjectOption(otherProject.name);
await screen.findByTitle("Exact background discussion");
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
});
it.each(["failed", "cancelled"] as const)("does not mark a background local run completed after %s", async (status) => {
vi.mocked(api.tasks.list).mockResolvedValue([]);
render(<App />);
await screen.findByLabelText("更多会话操作 Current discussion");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Unsuccessful local activity" } });
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
selectProjectOption(otherProject.name);
fireEvent.click(await screen.findByTitle("Exact background discussion"));
await screen.findByText("Exact conversation content");
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中");
act(() => agentListener?.({
requestId: request.requestId, type: "error", status, message: `Local activity ${status}`,
}));
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
expect(screen.getByText("Exact conversation content")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
expect(within(screen.getByRole("menu", { name: "当前项目" }))
.getByRole("menuitemradio", { name: new RegExp(project.name, "u") })).not.toHaveTextContent("已完成");
});
it("retains completion for the selected conversation while Settings is open until chat is actually viewed", async () => {
const currentTask = { ...task, projectId, conversationId: "activity-current" };
vi.mocked(api.tasks.list).mockResolvedValue([currentTask]);
const { container } = render(<App />);
const selected = (await within(container.querySelector<HTMLElement>(".conversation-list")!)
.findByTitle("Current discussion")).closest("button")!;
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 }));
const settings = await screen.findByRole("dialog", { name: "设置中心" });
expect(selected).toHaveClass("conversation-item--active");
vi.mocked(api.tasks.list).mockResolvedValue([{ ...currentTask, status: "completed" }]);
act(() => conversationQueueChangeListener?.(currentTask.conversationId));
await waitFor(() => expect(summary).toHaveTextContent("1 个已完成"));
expect(settings).toBeVisible();
expect(selected).toHaveClass("conversation-item--active");
fireEvent.click(within(settings).getByRole("button", { name: "关闭设置" }));
await waitFor(() => expect(settings).not.toBeInTheDocument());
expect(screen.getByLabelText("向 GoodBuddy 提问")).toBeVisible();
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
});
it("opens the exact off-project conversation and clears task selection, search, menu and narrow sidebar", async () => {
vi.mocked(api.tasks.list).mockResolvedValue([task, {
...task, id: "current-task", projectId, conversationId: "activity-current", title: "Current task", status: "idle",
@@ -1790,7 +1902,10 @@ describe("App", () => {
);
fireEvent.click(screen.getByLabelText("Toggle assistant workspace"));
fireEvent.click(screen.getByLabelText("Project settings"));
fireEvent.click(projectTrigger);
fireEvent.click(screen.getByRole("menuitem", {
name: "Manage project Default project",
}));
const settingsDialog = screen.getByRole("dialog", {
name: "Project settings",
});
@@ -2579,7 +2694,7 @@ describe("App", () => {
expect(route).not.toHaveAttribute("hidden");
});
it("enforces the workspace KeepAlive cap during rapid visits", async () => {
it("keeps Settings outside the workspace KeepAlive cache", async () => {
let now = 2_000_000;
vi.spyOn(Date, "now").mockImplementation(() => ++now);
render(<App />);
@@ -2597,7 +2712,8 @@ describe("App", () => {
expect(document.querySelectorAll(".workspace-route-cache")).toHaveLength(4);
expect(
document.querySelector('[data-route="knowledge"]'),
).not.toBeInTheDocument();
).toBeInTheDocument();
expect(document.querySelector('[data-route="settings"]')).toBeNull();
});
it("preserves title, message, and project filtering with deferred search", async () => {
@@ -2794,7 +2910,7 @@ describe("App", () => {
);
expect(
await screen.findByRole("region", {
await screen.findByRole("dialog", {
name: "Settings",
}),
).toBeInTheDocument();
@@ -3345,7 +3461,8 @@ describe("App", () => {
expect.stringContaining("你好,我是 GoodBuddy"),
),
);
expect(await screen.findByText("回复已复制到剪贴板")).toBeVisible();
expect(await screen.findByRole("button", { name: "回复已复制到剪贴板" })).toBeVisible();
expect(screen.queryByText("回复已复制到剪贴板")).not.toBeInTheDocument();
});
it("reports a desktop clipboard write failure", async () => {
@@ -3359,6 +3476,7 @@ describe("App", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(
"无法访问剪贴板,请检查系统权限",
);
expect(screen.getByRole("button", { name: "复制此回复" })).toBeVisible();
});
it("flushes and opens an independent conversation branch with provenance badges", async () => {
@@ -5007,7 +5125,7 @@ describe("App", () => {
await waitFor(() => expect(knowledgeNavigation).toHaveFocus());
});
it("restores focus outside Settings after closing the page", async () => {
it("restores focus outside Settings after closing the modal", async () => {
render(<App />);
await screen.findByLabelText("向 GoodBuddy 提问");
@@ -5022,6 +5140,40 @@ describe("App", () => {
await waitFor(() => expect(settingsTrigger).toHaveFocus());
});
it.each([
["chat", "对话"],
["knowledge", "知识库"],
["activity", "运行记录"],
])("preserves the %s workspace underneath Settings", async (route, label) => {
render(<App />);
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
fireEvent.change(composer, { target: { value: "Keep this workspace draft" } });
fireEvent.click(screen.getByRole("button", { name: label }));
const workspace = document.querySelector(`[data-route="${route}"]`)!;
workspace.scrollTop = 120;
const trigger = screen.getByRole("button", { name: /本地工作区/u });
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole("dialog", { name: "设置中心" });
expect(dialog.parentElement?.parentElement).toBe(document.body);
expect(document.querySelector(".app-shell")).toHaveProperty("inert", true);
expect(workspace).not.toHaveAttribute("hidden");
expect(document.querySelector(`[data-route="${route}"]`)).toBe(workspace);
fireEvent.mouseDown(dialog.parentElement!);
fireEvent.click(dialog.parentElement!);
expect(dialog).toBeInTheDocument();
expect(screen.getByRole("button", { name: "关闭设置" })).toHaveFocus();
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() => expect(trigger).toHaveFocus());
expect(dialog).not.toBeInTheDocument();
expect(document.querySelector(".app-shell")).toHaveProperty("inert", false);
expect(workspace).not.toHaveAttribute("hidden");
expect(workspace.scrollTop).toBe(120);
fireEvent.click(screen.getByRole("button", { name: "对话" }));
expect(screen.getByLabelText("向 GoodBuddy 提问")).toBe(composer);
expect(composer).toHaveValue("Keep this workspace draft");
});
it("focuses the Composer after closing automatically opened Settings", async () => {
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
id: "setup",
@@ -8271,7 +8423,10 @@ describe("App", () => {
"微信 ClawBot通道项目",
);
fireEvent.click(screen.getByLabelText("项目设置"));
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
fireEvent.click(screen.getByRole("menuitem", {
name: `管理项目 ${weixinProject.name}`,
}));
let dialog = screen.getByRole("dialog", { name: "项目设置" });
const dialogDescription =
within(dialog).getByLabelText("微信 ClawBot 项目说明");
@@ -8350,7 +8505,10 @@ describe("App", () => {
),
);
fireEvent.click(screen.getByLabelText("项目设置"));
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
fireEvent.click(screen.getByRole("menuitem", {
name: `管理项目 ${weixinProject.name}`,
}));
dialog = screen.getByRole("dialog", { name: "项目设置" });
expect(within(dialog).getByLabelText("微信 ClawBot 项目说明")).toHaveValue(
"从消息通道更新",
@@ -9222,10 +9380,18 @@ 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(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 }));
expect(await screen.findByRole('tab', { name: 'Agent Runtime' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
expect(await screen.findByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true');
expect(run).not.toHaveBeenCalled();
expect(api.conversations.imageOperations.regenerate).not.toHaveBeenCalled();
});
@@ -10378,7 +10544,10 @@ describe("App", () => {
]);
render(<App />);
fireEvent.click(await screen.findByLabelText("项目设置"));
fireEvent.click(await screen.findByRole("button", { name: "当前项目" }));
fireEvent.click(screen.getByRole("menuitem", {
name: "管理项目 默认项目",
}));
let dialog = screen.getByRole("dialog", { name: "项目设置" });
expect(within(dialog).getByLabelText("名称")).toHaveValue(project.name);
expect(within(dialog).getByLabelText("根目录")).toHaveValue(
@@ -10413,7 +10582,10 @@ describe("App", () => {
),
);
fireEvent.click(screen.getByLabelText("项目设置"));
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
fireEvent.click(screen.getByRole("menuitem", {
name: "管理项目 默认项目",
}));
dialog = screen.getByRole("dialog", { name: "项目设置" });
expect(dialog).toHaveTextContent("不会删除磁盘上的项目目录或文件");
fireEvent.click(within(dialog).getByRole("button", { name: "删除项目" }));
@@ -11405,10 +11577,9 @@ describe("App", () => {
name: "设置中心",
}),
).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(
screen.getByRole("region", { name: "设置中心" }),
).toBeInTheDocument();
screen.getByRole("dialog", { name: "设置中心" }),
).toHaveAttribute("aria-modal", "true");
expect(
screen.getByRole("tab", { name: "Agent Runtime" }),
).toBeInTheDocument();
+47 -31
View File
@@ -171,6 +171,7 @@ import {
import { ProjectSwitcher } from "./ProjectSwitcher";
import { ProjectActivity } from "./ProjectActivity";
import { deriveConversationActivity } from "./conversation-activity";
import { useUnviewedCompletions } from "./use-unviewed-completions";
import {
RightAssistantSidebar,
type AssistantSidebarTab,
@@ -865,7 +866,7 @@ function ChatHistoryPane({
conversationHtmlRenderingEnabled: boolean;
conversation: Conversation;
locale: TimeFormatLocale;
onCopyMessage: (content: string, kind?: 'tool') => Promise<void>;
onCopyMessage: (content: string, kind?: 'tool') => Promise<boolean>;
onDownloadImage: (item: ImageViewerItem) => void;
onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise<void>;
onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise<void>;
@@ -2391,6 +2392,12 @@ function App(): React.JSX.Element {
Record<string, Record<string, BrowserLiveState>>
>({});
const [view, setViewState] = useState<WorkspaceView>("chat");
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsOpenRef = useRef(false);
const [settingsInitialCategory, setSettingsInitialCategory] =
useState<SettingsCategoryId>();
const [settingsInitialChannel, setSettingsInitialChannel] =
useState<ProjectChannel>();
const settingsEntryFocusRef = useRef<HTMLElement | undefined>(undefined);
const settingsExitFocusRef = useRef<HTMLElement | undefined>(undefined);
const settingsLeaveRequesterRef = useRef<SettingsLeaveRequester | undefined>(
@@ -2407,7 +2414,11 @@ function App(): React.JSX.Element {
cachedConversationViewsRef.current = cachedConversationViews;
}, [cachedConversationViews]);
const commitView = useCallback((next: WorkspaceView): void => {
const previous = viewRef.current;
const wasSettingsOpen = settingsOpenRef.current;
settingsOpenRef.current = false;
setSettingsOpen(false);
setSettingsInitialCategory(undefined);
setSettingsInitialChannel(undefined);
const now = Date.now();
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId),
@@ -2444,7 +2455,7 @@ function App(): React.JSX.Element {
}),
);
setViewState(next);
if (previous === "settings" && next !== "settings") {
if (wasSettingsOpen) {
const preferred = settingsExitFocusRef.current;
const returnTarget = settingsEntryFocusRef.current;
settingsExitFocusRef.current = undefined;
@@ -2468,7 +2479,7 @@ function App(): React.JSX.Element {
const next =
typeof update === "function" ? update(viewRef.current) : update;
if (
viewRef.current !== "settings" &&
!settingsOpenRef.current &&
next === "settings" &&
!settingsEntryFocusRef.current?.isConnected
) {
@@ -2480,7 +2491,12 @@ function App(): React.JSX.Element {
? activeElement
: undefined;
}
if (viewRef.current === "settings" && next !== "settings") {
if (next === "settings") {
settingsOpenRef.current = true;
setSettingsOpen(true);
return;
}
if (settingsOpenRef.current) {
const requestLeave = settingsLeaveRequesterRef.current;
if (requestLeave) {
requestLeave(() => commitView(next));
@@ -2536,10 +2552,6 @@ function App(): React.JSX.Element {
}
setActiveIdState(next);
}, []);
const [settingsInitialCategory, setSettingsInitialCategory] =
useState<SettingsCategoryId>();
const [settingsInitialChannel, setSettingsInitialChannel] =
useState<ProjectChannel>();
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
const [
conversationHtmlRenderingEnabled,
@@ -2844,13 +2856,13 @@ function App(): React.JSX.Element {
);
const navigateFromSidebar = useCallback(
(nextView: WorkspaceView, trigger: HTMLElement): void => {
if (viewRef.current !== "settings" && nextView === "settings") {
if (!settingsOpenRef.current && nextView === "settings") {
settingsEntryFocusRef.current = trigger;
} else if (viewRef.current === "settings" && nextView !== "settings") {
} else if (settingsOpenRef.current && nextView !== "settings") {
settingsExitFocusRef.current = trigger;
}
setView(nextView);
if (narrowWindow) {
if (narrowWindow && nextView !== "settings") {
closeNarrowSidebar();
}
},
@@ -3925,6 +3937,10 @@ function App(): React.JSX.Element {
),
[projects, tWorkspace],
);
const { completedConversationIds, markConversationCompleted, clearConversationCompleted } =
useUnviewedCompletions(assistantTasks,
view === "chat" && !settingsOpen && activeConversation && !activeConversation.messageSummary
? activeId : undefined);
const projectActivity = useMemo(
() => deriveConversationActivity(
conversations.map((conversation) => ({
@@ -3938,14 +3954,15 @@ function App(): React.JSX.Element {
name: projectNames.get(project.id) ?? project.name,
})),
tWorkspace("projectActivity.unassigned"),
completedConversationIds,
),
[conversations, conversationTitles, assistantTasks, activeConversationIds,
projects, projectNames, tWorkspace],
projects, projectNames, tWorkspace, completedConversationIds],
);
const activityByConversationId = useMemo(
() =>
new Map(
projectActivity.activities.map((activity) => [
projectActivity.activities.filter((activity) => activity.status !== "completed").map((activity) => [
activity.conversationId,
activity,
]),
@@ -5070,6 +5087,7 @@ function App(): React.JSX.Element {
};
});
activeRuns.current.delete(event.requestId);
if (event.type === "done") markConversationCompleted(run.conversationId);
setConversationActivity(run.conversationId, false);
releaseConversationQueueAfterRun(run);
flushConversationPersistenceAfterRenderRef.current = true;
@@ -5083,6 +5101,7 @@ function App(): React.JSX.Element {
setConversationActivity,
updateMessage,
updateRequestActivity,
markConversationCompleted,
],
);
@@ -5307,6 +5326,7 @@ function App(): React.JSX.Element {
);
if (state !== undefined && state !== "streaming") {
activeRuns.current.delete(requestId);
if (state === "complete") markConversationCompleted(run.conversationId);
setConversationActivity(run.conversationId, false);
releaseConversationQueueAfterRun(run);
}
@@ -5464,6 +5484,7 @@ function App(): React.JSX.Element {
releaseConversationQueueAfterRun,
retainedConversationDetailIds,
setConversationActivity,
markConversationCompleted,
]);
useEffect(() => {
@@ -6969,18 +6990,20 @@ function App(): React.JSX.Element {
};
const writeClipboardText = useCallback(
async (content: string, successMessage: string): Promise<void> => {
async (content: string, successMessage?: string): Promise<boolean> => {
try {
await window.goodbuddy.clipboard.writeText(content);
notify({
if (successMessage) notify({
tone: "success",
message: successMessage,
});
return true;
} catch {
notify({
tone: "error",
message: t("notices.clipboardUnavailable"),
});
return false;
}
},
[t],
@@ -7017,8 +7040,8 @@ function App(): React.JSX.Element {
};
const copyMessage = useCallback(
(content: string, kind?: 'tool'): Promise<void> =>
writeClipboardText(content, t(kind === 'tool' ? "chat.tools.copied" : "notices.messageCopied")),
(content: string, kind?: 'tool'): Promise<boolean> =>
writeClipboardText(content, kind === 'tool' ? t("chat.tools.copied") : undefined),
[t, writeClipboardText],
);
@@ -7433,6 +7456,7 @@ function App(): React.JSX.Element {
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
preparingConversations.current.add(conversationId);
clearConversationCompleted(conversationId);
setConversationActivity(conversationId, true);
const userMessage: Message = {
id: crypto.randomUUID(),
@@ -8245,7 +8269,7 @@ function App(): React.JSX.Element {
if (narrowWindow) closeNarrowSidebar();
};
const requestLeave = settingsLeaveRequesterRef.current;
if (viewRef.current === "settings" && requestLeave) {
if (settingsOpenRef.current && requestLeave) {
requestLeave(() => { void open(); });
} else {
void open();
@@ -11214,9 +11238,7 @@ function App(): React.JSX.Element {
</PageShell>
</KeepAliveRoute>
)}
{(view === "settings" ||
cachedWorkspaceViewKeys.has("settings")) && (
<KeepAliveRoute active={view === "settings"} route="settings">
{settingsOpen && (
<RouteErrorBoundary
key="settings"
fallback={
@@ -11226,9 +11248,7 @@ function App(): React.JSX.Element {
/>
}
>
<Suspense
fallback={<RouteLoadingStatus label={t("route.loading")} />}
>
<Suspense fallback={null}>
<SettingsPanel
appearanceTheme={appearanceTheme}
brandingFallbackLogo={
@@ -11259,9 +11279,7 @@ function App(): React.JSX.Element {
}}
onClearLocalData={clearLocalData}
onClose={() => {
setSettingsInitialCategory(undefined);
setSettingsInitialChannel(undefined);
commitView("chat");
commitView(viewRef.current);
}}
onExpertsChanged={(experts) => {
setAssistantExperts(experts);
@@ -11299,13 +11317,11 @@ function App(): React.JSX.Element {
}}
onShortcutSettingsChanged={handleShortcutSettingsChanged}
onUpdateProject={updateProject}
open={view === "settings"}
presentation="page"
open={settingsOpen}
projects={projects}
/>
</Suspense>
</RouteErrorBoundary>
</KeepAliveRoute>
)}
{(view === "activity" ||
cachedWorkspaceViewKeys.has("activity")) && (
+22 -7
View File
@@ -1,4 +1,5 @@
import {
act,
cleanup,
fireEvent,
render,
@@ -38,7 +39,7 @@ vi.mock('./MarkdownRenderer', () => ({
const callbacks = {
onArticleRef: vi.fn(),
onCopyMessage: vi.fn(async () => undefined),
onCopyMessage: vi.fn(async () => true),
onDownloadImage: vi.fn(),
onOpenCitationContext: vi.fn(async () => undefined),
onOpenCitationSource: vi.fn(async () => undefined),
@@ -483,7 +484,7 @@ describe('ChatTimeline', () => {
).toHaveTextContent('每日状态')
})
it('copies a completed assistant reply from its bottom action', () => {
it('copies a completed assistant reply and resets its success icon three seconds after the latest copy', async () => {
const messages: Message[] = [
{
id: 'assistant-message',
@@ -532,12 +533,26 @@ describe('ChatTimeline', () => {
screen.getAllByRole('button', { name: '复制此回复' })
).toHaveLength(1)
fireEvent.click(copyButton)
vi.useFakeTimers()
try {
await act(async () => { fireEvent.click(copyButton) })
expect(callbacks.onCopyMessage).toHaveBeenCalledOnce()
expect(callbacks.onCopyMessage).toHaveBeenCalledWith(
'## Answer\n\nKeep the Markdown source.'
)
expect(callbacks.onCopyMessage).toHaveBeenCalledOnce()
expect(callbacks.onCopyMessage).toHaveBeenCalledWith(
'## Answer\n\nKeep the Markdown source.'
)
expect(copyButton).toHaveAccessibleName('回复已复制到剪贴板')
expect(copyButton.querySelector('.message__copy-success')).not.toBeNull()
await act(async () => { vi.advanceTimersByTime(2000) })
await act(async () => { fireEvent.click(copyButton) })
await act(async () => { vi.advanceTimersByTime(2999) })
expect(copyButton).toHaveAccessibleName('回复已复制到剪贴板')
await act(async () => { vi.advanceTimersByTime(1) })
expect(copyButton).toHaveAccessibleName('复制此回复')
expect(copyButton.querySelector('.message__copy-success')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('shows every parallel expert output in its own expandable card', () => {
+19 -11
View File
@@ -185,7 +185,7 @@ function MessageReasoning({
)
}
type CopyContent = (content: string, kind?: 'tool') => Promise<void>
type CopyContent = (content: string, kind?: 'tool') => Promise<boolean>
function ToolDetail({ label, content, formatJson = false, onCopy }: {
label: string
@@ -551,6 +551,18 @@ function ChatMessageRowView({
retryContent
}: ChatMessageRowProps): React.JSX.Element {
const { t } = useTranslation('app')
const [copied, setCopied] = useState(false)
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => () => clearTimeout(copyResetTimer.current), [])
const copyReply = async (): Promise<void> => {
const success = await onCopyMessage(greeting ? t('conversation.greeting') : message.content)
clearTimeout(copyResetTimer.current)
setCopied(success)
if (success) {
copyResetTimer.current = setTimeout(() => setCopied(false), 3000)
}
}
const imageArtifactIds = useMemo(() => [...new Set([
...(message.artifactIds ?? []),
...(message.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])
@@ -1220,19 +1232,15 @@ function ChatMessageRowView({
Boolean(message.content.trim()) && (
<div className="message__actions">
<button
aria-label={t('chat.copyMessage')}
aria-label={t(copied ? 'notices.messageCopied' : 'chat.copyMessage')}
className="icon-button"
onClick={() =>
void onCopyMessage(
greeting
? t('conversation.greeting')
: message.content
)
}
title={t('chat.copyMessage')}
onClick={() => void copyReply()}
title={t(copied ? 'notices.messageCopied' : 'chat.copyMessage')}
type="button"
>
<Copy aria-hidden="true" size={15} />
{copied
? <Check aria-hidden="true" className="message__copy-success" size={15} />
: <Copy aria-hidden="true" size={15} />}
</button>
</div>
)}
+53
View File
@@ -6,6 +6,7 @@ import { ProjectActivity, ProjectActivityCounts } from './ProjectActivity'
import type { ConversationActivity } from './conversation-activity'
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
import { workspace } from './i18n/locales/en-US/workspace'
import { workspace as chineseWorkspace } from './i18n/locales/zh-CN/workspace'
import type { AssistantProject } from '../../shared/assistant-contracts'
const i18n = createInstance()
@@ -75,6 +76,58 @@ describe('ProjectActivity', () => {
.toEqual(['Local', 'Remote', 'Same host', 'Other host', 'Channel', 'No project'])
})
it('counts completed separately and orders attention, running, then completed within a project', () => {
const completed: ConversationActivity = {
conversationId: 'completed', projectId: 'remote', projectName: 'Remote', title: 'Release notes', status: 'completed'
}
render(view([completed, ...activities]))
const trigger = open()
expect(trigger).toHaveAccessibleName('All project activity: 3 need attention, 2 running, 1 completed')
expect(Array.from(trigger.querySelectorAll('.project-activity__counts > span'), (count) => count.textContent))
.toEqual(['3 need attention', '2 running', '1 completed'])
const remote = screen.getByRole('menuitem', { name: 'Remote 2 need attention 1 running 1 completed' })
fireEvent.click(remote)
const rows = within(screen.getByRole('menu', { name: 'Remote' })).getAllByRole('menuitem')
expect(rows.map((row) => row.textContent)).toEqual([
'Choose target Waiting for your answer', 'Approve command Waiting for approval', 'Deploy Running', 'Release notes Completed'
])
expect(rows[3]!.querySelector('.project-activity__completed .lucide-check')).toBeInTheDocument()
expect(rows[3]!.querySelector('.project-activity__attention')).not.toBeInTheDocument()
fireEvent.keyDown(rows[0]!, { key: 'End' })
expect(rows[3]).toHaveFocus()
})
it.each([
{ language: 'en', resource: workspace, title: 'All project activity', count: '1 completed', status: 'Completed' },
{ language: 'zh', resource: chineseWorkspace, title: '全项目活动', count: '1 个已完成', status: '已完成' }
])('keeps completed-only activity navigable in $language and closes before opening the conversation', ({ language, resource, title, count, status }) => {
const localized = createInstance()
void localized.init({ lng: language, initImmediate: false, resources: { [language]: { workspace: resource } } })
const onOpen = vi.fn(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
render(<I18nextProvider i18n={localized}>
<ProjectActivity activities={[{
conversationId: 'completed-only', projectId: 'remote', projectName: 'Remote', title: 'Release notes', status: 'completed'
}]} onOpenConversation={onOpen} />
</I18nextProvider>)
const trigger = screen.getByRole('button', { name: new RegExp(title) })
expect(within(trigger).getByText(count)).toBeInTheDocument()
expect(trigger.querySelector('.project-activity__attention')).not.toBeInTheDocument()
expect(trigger.querySelector('.project-activity__running')).not.toBeInTheDocument()
expect(trigger.querySelector('.project-activity__completed .lucide-check')).toBeInTheDocument()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
const project = screen.getByRole('menuitem', { name: `Remote ${count}` })
expect(project).toHaveFocus()
fireEvent.keyDown(project, { key: 'ArrowRight' })
const conversation = screen.getByRole('menuitem', { name: `Release notes ${status}` })
expect(conversation).toHaveFocus()
fireEvent.click(conversation)
expect(onOpen).toHaveBeenCalledExactlyOnceWith('completed-only')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
it('navigates arrows, Home/End, Enter and two-stage Escape before the sidebar listener', () => {
render(view())
const sidebarEscape = vi.fn()
+23 -10
View File
@@ -1,4 +1,4 @@
import { ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal, flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
@@ -13,14 +13,15 @@ export type ProjectActivityProps = {
onOpenConversation: (id: string) => void
}
export type ProjectActivityCountsProps = { running: number; attention: number }
export type ProjectActivityCountsProps = { running: number; attention: number; completed?: number }
export function ProjectActivityCounts({
running,
attention
attention,
completed = 0
}: ProjectActivityCountsProps): React.JSX.Element | null {
const { t } = useTranslation('workspace')
if (!running && !attention) return null
if (!running && !attention && !completed) return null
return (
<span className="project-activity__counts">
{attention > 0 && (
@@ -36,6 +37,12 @@ export function ProjectActivityCounts({
{t('projectActivity.runningCount', { count: running })}
</span>
)}
{completed > 0 && <>{' '}
<span className="project-activity__completed">
<Check aria-hidden="true" size={13} />
{t('projectActivity.completedCount', { count: completed })}
</span>
</>}
</span>
)
}
@@ -54,6 +61,7 @@ function ActivityMenu({
const sessionsRef = useRef<HTMLDivElement>(null)
const [selected, setSelected] = useState<string | null>(null)
const groups = useMemo(() => {
const priority = { attention: 0, approval: 0, question: 0, running: 1, completed: 2 }
const rows = new Map<string, { id: string; name: string; rows: ConversationActivity[] }>()
// Keep source project order within local, SSH Host and channel groups.
const ordered = new Map<string, AssistantProject[]>()
@@ -77,8 +85,9 @@ function ActivityMenu({
}
return [...rows.values()].filter((group) => group.rows.length).map((group) => ({
...group,
rows: group.rows.sort((a, b) => Number(a.status === 'running') - Number(b.status === 'running')),
running: group.rows.filter((row) => row.status === 'running').length
rows: group.rows.sort((a, b) => priority[a.status] - priority[b.status]),
running: group.rows.filter((row) => row.status === 'running').length,
completed: group.rows.filter((row) => row.status === 'completed').length
}))
}, [activities, projects])
const active = groups.find((group) => group.id === selected)
@@ -202,7 +211,8 @@ function ActivityMenu({
}}
onClick={() => enter(group.id)}>
<span className="project-activity__identity"><span>{group.name}</span>{' '}
<ProjectActivityCounts running={group.running} attention={group.rows.length - group.running} />
<ProjectActivityCounts running={group.running} completed={group.completed}
attention={group.rows.length - group.running - group.completed} />
</span><ChevronRight aria-hidden="true" size={14} />
</button>
))}
@@ -219,7 +229,8 @@ function ActivityMenu({
onOpenConversation(activity.conversationId)
}}>
<span className="project-activity__identity"><span>{activity.title}</span>{' '}
<small className={`project-activity__${activity.status === 'running' ? 'running' : 'attention'}`}>
<small className={`project-activity__${activity.status === 'completed' ? 'completed' : activity.status === 'running' ? 'running' : 'attention'}`}>
{activity.status === 'completed' && <Check aria-hidden="true" size={13} />}
{t(`projectActivity.status.${activity.status}`)}
</small>
</span>
@@ -242,6 +253,8 @@ export function ProjectActivity({
const id = useId()
if ((!visible || activities.length === 0) && open) setOpen(false)
const running = useMemo(() => activities.filter((activity) => activity.status === 'running').length, [activities])
const completed = useMemo(() => activities.filter((activity) => activity.status === 'completed').length, [activities])
const attention = activities.length - running - completed
if (activities.length === 0) return null
return (
<>
@@ -249,7 +262,7 @@ export function ProjectActivity({
aria-expanded={open}
aria-haspopup="menu"
aria-controls={open ? id : undefined}
aria-label={`${t('projectActivity.title')}: ${[t('projectActivity.attentionCount', { count: activities.length - running }), t('projectActivity.runningCount', { count: running })].join(', ')}`}
aria-label={`${t('projectActivity.title')}: ${[t('projectActivity.attentionCount', { count: attention }), t('projectActivity.runningCount', { count: running }), ...(completed > 0 ? [t('projectActivity.completedCount', { count: completed })] : [])].join(', ')}`}
ref={triggerRef}
className="project-activity__summary"
onClick={(event) => {
@@ -264,7 +277,7 @@ export function ProjectActivity({
}}
type="button"
>
<ProjectActivityCounts attention={activities.length - running} running={running} />
<ProjectActivityCounts attention={attention} running={running} completed={completed} />
<ChevronDown aria-hidden="true" size={14} />
</button>
{open && visible && (
+28 -7
View File
@@ -356,8 +356,15 @@ describe('ProjectSwitcher project activity integration', () => {
describe('ProjectSwitcher runtime fields', () => {
it.each(['新建项目', '项目设置'])('keeps %s outside the sidebar and restores focus after closing', (name) => {
renderSwitcher()
const trigger = screen.getByLabelText(name)
const trigger = screen.getByRole('button', {
name: name === '项目设置' ? '当前项目' : name
})
fireEvent.click(trigger)
if (name === '项目设置') {
fireEvent.click(screen.getByRole('menuitem', {
name: '管理项目 Local project'
}))
}
const dialog = screen.getByRole('dialog', { name })
expect(dialog.parentElement?.parentElement).toBe(document.body)
const body = dialog.querySelector('.project-create-card__body')!
@@ -411,7 +418,10 @@ describe('ProjectSwitcher runtime fields', () => {
it('edits an ordinary project to use DeepSeek Harness and switches work mode by keyboard', async () => {
const { onUpdate } = renderSwitcher()
fireEvent.click(screen.getByLabelText('项目设置'))
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
fireEvent.click(screen.getByRole('menuitem', {
name: '管理项目 Local project'
}))
const dialog = screen.getByRole('dialog', { name: '项目设置' })
fireEvent.change(
within(dialog).getByLabelText('新对话默认 Runtime'),
@@ -893,9 +903,14 @@ describe('ProjectSwitcher managed SSH projects', () => {
}
renderSwitcher(remoteProject, { remoteProjectsEnabled: false })
const settings = screen.getByLabelText('项目设置')
expect(settings).toBeDisabled()
fireEvent.click(settings)
expect(screen.queryByRole('button', {
name: '项目设置'
})).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
expect(within(screen.getByRole('menu', { name: '当前项目' }))
.queryByRole('menuitem', {
name: '管理项目 Remote project'
})).not.toBeInTheDocument()
expect(
screen.queryByRole('dialog', { name: '项目设置' })
).not.toBeInTheDocument()
@@ -1401,7 +1416,10 @@ describe('ProjectSwitcher managed SSH projects', () => {
runtimeSelection: { provider: 'opencode' }
}
const { onUpdate } = renderSwitcher(remoteProject)
fireEvent.click(screen.getByLabelText('项目设置'))
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
fireEvent.click(screen.getByRole('menuitem', {
name: '管理项目 Remote project'
}))
const dialog = screen.getByRole('dialog', { name: '项目设置' })
await waitFor(() =>
expect(
@@ -1479,7 +1497,10 @@ describe('ProjectSwitcher managed SSH projects', () => {
runtimeSelection: { provider: 'opencode' }
}
renderSwitcher(remoteProject)
fireEvent.click(screen.getByLabelText('项目设置'))
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
fireEvent.click(screen.getByRole('menuitem', {
name: '管理项目 Remote project'
}))
const dialog = screen.getByRole('dialog', { name: '项目设置' })
expect(
+6 -31
View File
@@ -52,7 +52,7 @@ import {
import { getProjectDisplayText } from './project-display'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import { ProjectActivityCounts } from './ProjectActivity'
import type { ConversationActivitySummary } from './conversation-activity'
import type { ProjectActivityCountsProps } from './ProjectActivity'
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
import { SegmentedControl } from './WorkspacePrimitives'
import { displayErrorMessage } from './error-message'
@@ -60,7 +60,7 @@ import { displayErrorMessage } from './error-message'
type ProjectSwitcherProps = {
projects: AssistantProject[]
activeProjectId: string
activityByProjectId?: ConversationActivitySummary['byProjectId']
activityByProjectId?: Record<string, ProjectActivityCountsProps>
remoteProjectsEnabled?: boolean
runtimeSettings?: RuntimeSettings
onArchive: (projectId: string) => Promise<void>
@@ -272,7 +272,6 @@ export function ProjectSwitcher({
const [remoteDirectoryListing, setRemoteDirectoryListing] =
useState<SshDirectoryBrowseResult>()
const createButtonRef = useRef<HTMLButtonElement>(null)
const settingsButtonRef = useRef<HTMLButtonElement>(null)
const directoryPickerTriggerRef = useRef<HTMLButtonElement>(null)
const directoryPickerRef = useRef<HTMLDivElement>(null)
const directoryBrowseRequestRef = useRef(0)
@@ -281,7 +280,7 @@ export function ProjectSwitcher({
const projectPickerMenuRef = useRef<HTMLDivElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const restoreFocusTarget = useRef<
'create' | 'settings' | 'picker' | undefined
'create' | 'picker' | undefined
>(undefined)
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
@@ -788,8 +787,6 @@ export function ProjectSwitcher({
if (!dialogMode) {
if (restoreFocusTarget.current === 'create') {
createButtonRef.current?.focus()
} else if (restoreFocusTarget.current === 'settings') {
settingsButtonRef.current?.focus()
} else if (restoreFocusTarget.current === 'picker') {
projectPickerButtonRef.current?.focus()
}
@@ -852,10 +849,7 @@ export function ProjectSwitcher({
setSettingsProjectId(undefined)
}
const openProjectSettings = (
project: AssistantProject,
restoreTarget: 'settings' | 'picker'
): void => {
const openProjectSettings = (project: AssistantProject): void => {
if (
!remoteProjectsEnabled &&
project.executionSpace.kind === 'ssh'
@@ -894,7 +888,7 @@ export function ProjectSwitcher({
? channelProjectDraft(nextDraft, runtimeSettings)
: nextDraft
)
restoreFocusTarget.current = restoreTarget
restoreFocusTarget.current = 'picker'
setDialogMode('settings')
}
@@ -1151,7 +1145,7 @@ export function ProjectSwitcher({
{ name: projectDisplay.name }
)}
className="project-switcher__menu-settings"
onClick={() => openProjectSettings(project, 'picker')}
onClick={() => openProjectSettings(project)}
role="menuitem"
tabIndex={-1}
type="button"
@@ -1367,25 +1361,6 @@ export function ProjectSwitcher({
>
<Plus size={15} />
</button>
<button
aria-label={t('projectSwitcher.selector.settings')}
className="icon-button"
disabled={
!activeProject ||
(!remoteProjectsEnabled &&
activeProject.executionSpace.kind === 'ssh')
}
onClick={() => {
if (!activeProject) {
return
}
openProjectSettings(activeProject, 'settings')
}}
ref={settingsButtonRef}
type="button"
>
<Settings size={15} />
</button>
</div>
{dialogMode && createPortal(
<div
+37
View File
@@ -5229,6 +5229,43 @@ describe('SettingsPanel runtime files', () => {
expect(await screen.findByRole('button', { name: '添加自定义' })).toBeEnabled()
})
it('traps modal focus and protects dirty drafts on Escape', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
const workspace = await screen.findByLabelText('默认工作区目录')
const dialog = screen.getByRole('dialog', { name: '设置中心' })
const closeButton = screen.getByRole('button', { name: '关闭设置' })
const focusable = dialog.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
const last = focusable[focusable.length - 1]!
closeButton.focus()
fireEvent.keyDown(closeButton, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(closeButton).toHaveFocus()
fireEvent.change(workspace, { target: { value: 'C:\\Escape draft' } })
workspace.focus()
fireEvent.keyDown(workspace, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '继续编辑' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('button', { name: '继续编辑' }), { key: 'Escape' })
await waitFor(() => expect(workspace).toHaveFocus())
expect(workspace).toHaveValue('C:\\Escape draft')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.keyDown(workspace, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: '放弃更改并关闭' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('retains a disabled or deleted image default and keeps drafts on save failure', async () => {
const imageId = '00000000-0000-4000-8000-000000000099'
getRuntime.mockResolvedValueOnce({
+31 -1
View File
@@ -17,6 +17,8 @@ import {
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { createPortal } from 'react-dom'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type {
AssistantExpert,
AssistantProject,
@@ -821,6 +823,8 @@ export function SettingsPanel({
const runtimeCustomizationRef =
useRef<RuntimeCustomizationSectionHandle>(null)
const keepEditingRef = useRef<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const leaveTriggerRef = useRef<HTMLElement | undefined>(undefined)
const handleRuntimeCustomizationDirtyChange = useCallback(
(dirty: boolean): void => {
@@ -1241,6 +1245,7 @@ export function SettingsPanel({
}
const close = (trigger?: HTMLElement): void => {
if (saving || testing || clearingLocalData) return
if (hasUnsavedDrafts) {
leaveTriggerRef.current =
trigger ??
@@ -1349,6 +1354,13 @@ export function SettingsPanel({
keepEditingRef.current?.focus()
}, [pendingLeave])
useEffect(() => {
if (open && presentation === 'modal') {
return activateModalFocus(() => closeRef.current)
}
return undefined
}, [open, presentation])
useLayoutEffect(() => {
const requestLeave: SettingsLeaveRequester = (proceed) => {
if (hasUnsavedDrafts) {
@@ -2007,7 +2019,7 @@ export function SettingsPanel({
? settings?.defaultModelProfileId
: undefined
return (
const panel = (
<div
className={
presentation === 'page'
@@ -2020,6 +2032,21 @@ export function SettingsPanel({
aria-labelledby="settings-title"
aria-modal={presentation === 'modal' ? 'true' : undefined}
className="settings-panel"
ref={dialogRef}
onKeyDown={(event) => {
if (
presentation !== 'modal' ||
event.defaultPrevented ||
(event.target as HTMLElement).closest('[role="dialog"]') !== dialogRef.current
) return
event.stopPropagation()
if (event.key === 'Escape') {
event.preventDefault()
if (pendingLeave) keepEditing()
else close()
}
trapTabFocus(event, dialogRef.current)
}}
role={presentation === 'modal' ? 'dialog' : 'region'}
>
<div className="settings-panel__header">
@@ -2029,6 +2056,8 @@ export function SettingsPanel({
<button
aria-label={t('center.close')}
className="icon-button"
disabled={saving || testing || clearingLocalData}
ref={closeRef}
onClick={(event) => close(event.currentTarget)}
type="button"
>
@@ -4160,4 +4189,5 @@ export function SettingsPanel({
</section>
</div>
)
return presentation === 'modal' ? createPortal(panel, document.body) : panel
}
@@ -547,22 +547,22 @@ describe('WorkspacePrimitives', () => {
/\.settings-page \.settings-panel__header \.page-header\s*\{[^}]*padding-bottom:\s*var\(--space-4\);[^}]*border-bottom:\s*1px solid var\(--border-subtle\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-panel__body\s*\{[^}]*padding:\s*var\(--space-6\) var\(--page-gutter\) var\(--page-gutter\);[^}]*grid-template-columns:\s*220px minmax\(0,\s*1fr\);/u
/\.settings-backdrop \.settings-panel__body\s*\{[^}]*padding:\s*var\(--space-6\) var\(--page-gutter\) var\(--page-gutter\);[^}]*grid-template-columns:\s*220px minmax\(0,\s*1fr\);/u
)
expect(stylesheet).toMatch(
/@media \(max-width: 1020px\)\s*\{[\s\S]*?\.settings-page \.settings-panel__body\s*\{[^}]*grid-template-columns:\s*196px minmax\(0,\s*1fr\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-tabs\s*\{[^}]*scrollbar-gutter:\s*auto;/u
/\.settings-backdrop \.settings-tabs\s*\{[^}]*scrollbar-gutter:\s*auto;/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-panel__content\s*\{[^}]*width:\s*min\(100%,\s*var\(--content-standard\)\);/u
/\.settings-backdrop \.settings-panel__content\s*\{[^}]*width:\s*min\(100%,\s*var\(--content-standard\)\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
/\.settings-backdrop \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
/\.settings-backdrop \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
})
+12 -1
View File
@@ -64,6 +64,17 @@ describe('deriveConversationActivity', () => {
})),
{ title: 'No conversation', status: 'running' }
], new Set(), projects, 'Unknown')
expect(summary).toEqual({ activities: [], byProjectId: {}, running: 0, attention: 0 })
expect(summary).toEqual({ activities: [], byProjectId: {}, running: 0, attention: 0, completed: 0 })
})
it('retains only unviewed completions and gives live work precedence', () => {
const summary = deriveConversationActivity([
{ id: 'done', title: 'Done', projectId: 'local', messages: [] },
{ id: 'busy', title: 'Busy', projectId: 'local', messages: [] }
], [{ conversationId: 'unloaded', projectId: 'remote', title: 'Remote done', status: 'completed' }],
new Set(['busy']), projects, 'Unknown', new Set(['done', 'busy', 'unloaded']))
expect(summary).toMatchObject({ running: 1, attention: 0, completed: 2,
byProjectId: { local: { running: 1, completed: 1 }, remote: { completed: 1 } } })
expect(summary.activities.map(row => row.status)).toEqual(['completed', 'running', 'completed'])
})
})
+17 -11
View File
@@ -3,14 +3,15 @@ export type ConversationActivity = {
projectId?: string
title: string
projectName: string
status: 'running' | 'approval' | 'question' | 'attention'
status: 'running' | 'approval' | 'question' | 'attention' | 'completed'
}
export type ConversationActivitySummary = {
activities: ConversationActivity[]
byProjectId: Record<string, { running: number; attention: number }>
byProjectId: Record<string, { running: number; attention: number; completed: number }>
running: number
attention: number
completed: number
}
export function deriveConversationActivity(
@@ -28,16 +29,18 @@ export function deriveConversationActivity(
}[],
activeConversationIds: ReadonlySet<string>,
projects: readonly { id: string; name: string }[],
fallbackProjectName: string
fallbackProjectName: string,
completedConversationIds: ReadonlySet<string> = new Set()
): ConversationActivitySummary {
const projectNames = new Map(projects.map((project) => [project.id, project.name]))
const metadata = new Map(conversations.map((conversation) => [conversation.id, conversation]))
const rows = new Map<string, ConversationActivity>()
const priority = { running: 0, attention: 1, approval: 2, question: 3 }
const priority = { completed: -1, running: 0, attention: 1, approval: 2, question: 3 }
for (const conversation of conversations) {
let status: ConversationActivity['status'] | undefined =
activeConversationIds.has(conversation.id) ? 'running' : undefined
activeConversationIds.has(conversation.id) ? 'running'
: completedConversationIds.has(conversation.id) ? 'completed' : undefined
for (const message of conversation.messages) {
const candidate = message.pendingQuestions?.length
? 'question'
@@ -62,11 +65,13 @@ export function deriveConversationActivity(
}
for (const task of tasks) {
if (!task.conversationId || (task.status !== 'running' && task.status !== 'waiting_approval')) {
if (!task.conversationId || (task.status !== 'running' && task.status !== 'waiting_approval'
&& !completedConversationIds.has(task.conversationId))) {
continue
}
// Tasks use waiting_approval for both approvals and questions.
const status = task.status === 'waiting_approval' ? 'attention' : 'running'
const status = task.status === 'waiting_approval' ? 'attention'
: task.status === 'running' ? 'running' : 'completed'
const existing = rows.get(task.conversationId)
if (existing) {
if (priority[status] > priority[existing.status]) existing.status = status
@@ -87,14 +92,15 @@ export function deriveConversationActivity(
activities: [...rows.values()],
byProjectId: {},
running: 0,
attention: 0
attention: 0,
completed: 0
}
const counts = new Map<string, { running: number; attention: number }>()
const counts = new Map<string, { running: number; attention: number; completed: number }>()
for (const activity of summary.activities) {
const category = activity.status === 'running' ? 'running' : 'attention'
const category = activity.status === 'running' || activity.status === 'completed' ? activity.status : 'attention'
summary[category] += 1
if (activity.projectId !== undefined) {
const project = counts.get(activity.projectId) ?? { running: 0, attention: 0 }
const project = counts.get(activity.projectId) ?? { running: 0, attention: 0, completed: 0 }
project[category] += 1
counts.set(activity.projectId, project)
}
@@ -5,15 +5,17 @@ export const workspace = {
projectActivity: {
title: 'All project activity',
idle: 'No activity',
empty: 'No running conversations or conversations needing attention.',
empty: 'No conversation activity.',
back: 'Back to projects',
close: 'Close project activity',
unassigned: 'No project',
attentionCount: '{{count}} need attention',
runningCount: '{{count}} running',
completedCount: '{{count}} completed',
groups: { attention: 'Needs attention', running: 'Running' },
status: {
running: 'Running',
completed: 'Completed',
approval: 'Waiting for approval',
question: 'Waiting for your answer',
attention: 'Needs attention'
@@ -2,15 +2,17 @@ export const workspace = {
projectActivity: {
title: '全项目活动',
idle: '暂无活动',
empty: '暂无运行中或待处理会话。',
empty: '暂无会话活动。',
back: '返回项目列表',
close: '关闭全项目活动',
unassigned: '未归属项目',
attentionCount: '{{count}} 个待处理',
runningCount: '{{count}} 个运行中',
completedCount: '{{count}} 个已完成',
groups: { attention: '待处理', running: '运行中' },
status: {
running: '运行中',
completed: '已完成',
approval: '等待审批',
question: '等待你的回答',
attention: '待处理'
+9
View File
@@ -68,6 +68,12 @@
.project-activity__attention { color: var(--warning); }
.project-activity__running { color: var(--accent); }
.project-activity__completed { color: var(--success); }
.project-activity__completed > svg {
flex: 0 0 auto;
animation: none;
}
.project-switcher__project-heading {
display: grid;
@@ -129,6 +135,9 @@
}
.project-activity__identity small {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--font-caption);
}
+63 -14
View File
@@ -2558,7 +2558,7 @@ textarea:focus-visible {
display: grid;
align-items: center;
gap: 5px;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto;
}
.project-switcher__picker {
@@ -5711,6 +5711,10 @@ button > svg {
justify-content: flex-end;
}
.message__copy-success {
color: var(--success);
}
.message__actions {
display: flex;
margin-top: var(--space-2);
@@ -7861,7 +7865,7 @@ button > svg {
.settings-backdrop {
position: fixed;
z-index: 50;
z-index: var(--z-dialog);
display: grid;
background: var(--overlay-backdrop);
inset: 0;
@@ -7877,10 +7881,11 @@ button > svg {
.settings-panel {
display: grid;
width: min(820px, calc(100vw - 40px));
max-height: calc(100vh - 48px);
width: min(1200px, calc(100vw - 2 * var(--space-6)));
height: min(900px, calc(100vh - 2 * var(--space-6)));
max-height: calc(100vh - 2 * var(--space-6));
border: 1px solid var(--border-default);
border-radius: 10px;
border-radius: var(--radius-card);
overflow: hidden;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
@@ -7900,7 +7905,7 @@ button > svg {
position: relative;
display: flex;
align-items: center;
padding: 22px 24px 18px;
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--border-subtle);
}
@@ -7918,6 +7923,11 @@ button > svg {
width: 100%;
}
.settings-backdrop .settings-panel__header .page-header {
padding-bottom: 0;
border-bottom: 0;
}
.settings-page .settings-panel__header .page-header {
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
@@ -8002,7 +8012,8 @@ button > svg {
font-weight: 400;
}
.settings-page .settings-panel__body {
.settings-page .settings-panel__body,
.settings-backdrop .settings-panel__body {
display: grid;
min-height: 0;
padding: var(--space-6) var(--page-gutter) var(--page-gutter);
@@ -8010,7 +8021,7 @@ button > svg {
align-items: stretch;
grid-template-columns: 220px minmax(0, 1fr);
justify-content: start;
gap: 28px;
gap: var(--space-6);
}
.platform-feature-option {
@@ -8210,7 +8221,8 @@ button > svg {
font-weight: 600;
}
.settings-page .settings-tabs {
.settings-page .settings-tabs,
.settings-backdrop .settings-tabs {
display: flex;
min-height: 0;
max-height: 100%;
@@ -8222,7 +8234,8 @@ button > svg {
scrollbar-gutter: auto;
}
.settings-page .settings-panel__content {
.settings-page .settings-panel__content,
.settings-backdrop .settings-panel__content {
width: min(100%, var(--content-standard));
min-height: 0;
padding-right: var(--space-1);
@@ -8278,7 +8291,9 @@ button > svg {
grid-column: 1 / -1;
}
.settings-page .settings-tabs button {
.settings-page .settings-tabs button,
.settings-backdrop .settings-tabs button {
flex: 0 0 auto;
min-height: 58px;
padding: 10px 12px;
align-items: flex-start;
@@ -8286,19 +8301,22 @@ button > svg {
text-align: left;
}
.settings-page .settings-tabs button strong {
.settings-page .settings-tabs button strong,
.settings-backdrop .settings-tabs button strong {
font-size: var(--font-body);
line-height: 1.35;
}
.settings-page .settings-tabs button small {
.settings-page .settings-tabs button small,
.settings-backdrop .settings-tabs button small {
display: block;
margin-top: 3px;
font-size: var(--font-caption);
line-height: 1.35;
}
.settings-page .settings-tabs button[aria-selected='true'] {
.settings-page .settings-tabs button[aria-selected='true'],
.settings-backdrop .settings-tabs button[aria-selected='true'] {
background: var(--accent-subtle);
box-shadow: none;
}
@@ -16342,7 +16360,20 @@ details.settings-section > :not(summary) + :not(summary) {
}
}
@media (max-width: 1020px), (max-height: 720px) {
.settings-backdrop .settings-panel {
width: calc(100vw - 2 * var(--space-4));
height: calc(100vh - 2 * var(--space-4));
max-height: calc(100vh - 2 * var(--space-4));
}
}
@media (max-width: 1020px) {
.settings-backdrop .settings-panel__body {
grid-template-columns: 180px minmax(0, 1fr);
padding: var(--space-4);
gap: var(--space-4);
}
.sidebar {
flex-basis: var(--primary-sidebar-width, 236px);
}
@@ -16422,6 +16453,24 @@ details.settings-section > :not(summary) + :not(summary) {
}
@media (max-width: 720px) {
.settings-backdrop .settings-panel__body {
grid-template-columns: 120px minmax(0, 1fr);
gap: var(--space-2);
padding: var(--space-2);
}
.settings-backdrop .settings-tabs button {
min-height: 40px;
padding: var(--space-2);
}
.settings-backdrop .settings-tabs button small {
display: none;
}
.settings-backdrop .settings-panel__header {
padding: var(--space-4);
}
.release-notes-backdrop {
padding: var(--space-4);
}
@@ -0,0 +1,52 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useUnviewedCompletions } from './use-unviewed-completions'
afterEach(() => { cleanup(); vi.restoreAllMocks() })
describe('useUnviewedCompletions', () => {
const task = { id: 'task', conversationId: 'chat', status: 'running' }
it('baselines history, ignores child work, and does not restore acknowledged completions on refresh', () => {
const history = { ...task, status: 'completed' }
const initialProps: {
tasks: (typeof task & { parentTaskId?: string })[]; viewed?: string
} = { tasks: [history] }
const { result, rerender } = renderHook(({ tasks, viewed }) =>
useUnviewedCompletions(tasks, viewed), { initialProps })
expect(result.current.completedConversationIds.size).toBe(0)
rerender({ tasks: [task, { ...task, id: 'child', parentTaskId: 'task' }] })
rerender({ tasks: [task, { ...history, id: 'child', parentTaskId: 'task' }] })
expect(result.current.completedConversationIds.size).toBe(0)
rerender({ tasks: [history] })
expect(result.current.completedConversationIds.has('chat')).toBe(true)
rerender({ tasks: [history], viewed: 'chat' })
expect(result.current.completedConversationIds.size).toBe(0)
rerender({ tasks: [{ ...history }] })
expect(result.current.completedConversationIds.size).toBe(0)
rerender({ tasks: [task] })
rerender({ tasks: [history] })
expect(result.current.completedConversationIds.has('chat')).toBe(true)
})
it('retains completion in a hidden window until the selected chat becomes visible', () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
const tasks: typeof task[] = []
const { result } = renderHook(() => useUnviewedCompletions(tasks, 'chat'))
act(() => result.current.markConversationCompleted('chat'))
expect(result.current.completedConversationIds.has('chat')).toBe(true)
visibility.mockReturnValue('visible')
act(() => document.dispatchEvent(new Event('visibilitychange')))
expect(result.current.completedConversationIds.size).toBe(0)
})
it('clears an old completion before a new local run and ignores failed task transitions', () => {
const { result, rerender } = renderHook(({ tasks }) => useUnviewedCompletions(tasks), {
initialProps: { tasks: [task] }
})
act(() => result.current.markConversationCompleted('chat'))
act(() => result.current.clearConversationCompleted('chat'))
rerender({ tasks: [{ ...task, status: 'failed' }] })
expect(result.current.completedConversationIds.size).toBe(0)
})
})
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from 'react'
type Task = { id: string; conversationId?: string; parentTaskId?: string; status: string }
// Session-only notifications: initial task history is a baseline, not unread work.
export function useUnviewedCompletions(tasks: readonly Task[], viewedConversationId?: string): {
completedConversationIds: ReadonlySet<string>
markConversationCompleted: (id: string) => void
clearConversationCompleted: (id: string) => void
} {
const [completed, setCompleted] = useState<Set<string>>(() => new Set())
const [previousTasks, setPreviousTasks] = useState(tasks)
const [visible, setVisible] = useState(() => document.visibilityState !== 'hidden')
useEffect(() => {
const update = (): void => setVisible(document.visibilityState !== 'hidden')
document.addEventListener('visibilitychange', update)
return () => document.removeEventListener('visibilitychange', update)
}, [])
let next = completed
if (previousTasks !== tasks) {
const previous = new Map(previousTasks.map(task => [task.id, task.status]))
next = new Set(completed)
for (const task of tasks) {
if (!task.conversationId || task.parentTaskId || previous.get(task.id) === task.status) continue
if (task.status === 'completed' && previous.has(task.id)) next.add(task.conversationId)
else if (task.status === 'running' || task.status === 'waiting_approval') next.delete(task.conversationId)
}
setPreviousTasks(tasks)
}
if (visible && viewedConversationId && next.has(viewedConversationId)) {
next = new Set(next)
next.delete(viewedConversationId)
}
if (next !== completed) setCompleted(next)
const markConversationCompleted = useCallback((id: string) => {
setCompleted(current => current.has(id) ? current : new Set(current).add(id))
}, [])
const clearConversationCompleted = useCallback((id: string) => {
setCompleted(current => {
if (!current.has(id)) return current
const remaining = new Set(current)
remaining.delete(id)
return remaining
})
}, [])
return { completedConversationIds: next, markConversationCompleted, clearConversationCompleted }
}