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:
+185
-14
@@ -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
@@ -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")) && (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '待处理'
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user