feat(workspace): unify projects and conversations in one menu
Replace separate project and activity navigation with a fixed-height two-line button and a shared workspace menu. Browse local, remote and channel projects, preview sessions on hover, restore the last opened conversation, and show ten recent conversations alongside active work. Preserve project settings, recovery actions, draft leave guards and completion reminders. Reuse conversation summaries and windowed lists without hover fetches or polling. Validation: focused component and App tests, real Electron layout and interaction checks, typecheck and lint passed. The earlier full suite timed out and reported Supervisor settings/layout failures. Release note: 左上角统一项目与活动入口,支持分类查找、悬停预览和最近 10 个会话;双行按钮整体点击,保留项目设置与跨项目会话切换。
This commit is contained in:
+228
-78
@@ -1498,6 +1498,41 @@ describe("App", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("skips attachment draft reads for empty channel projects and restores local drafts", async () => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000471";
|
||||
const attachment = {
|
||||
id: "00000000-0000-4000-8000-000000000472",
|
||||
name: "restored-draft.txt",
|
||||
size: 12,
|
||||
preview: "Saved draft",
|
||||
kind: "text" as const,
|
||||
};
|
||||
const channelProject = {
|
||||
...project,
|
||||
id: "00000000-0000-4000-8000-000000000473",
|
||||
name: "Empty channel",
|
||||
kind: "channel" as const,
|
||||
channel: "weixin" as const,
|
||||
};
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([project, channelProject]);
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{ id: conversationId, projectId, title: "Saved conversation", updatedAt: 100, messages: [] },
|
||||
]);
|
||||
render(<App />);
|
||||
await waitFor(() => expect(api.context.getDraft).toHaveBeenCalledWith(conversationId));
|
||||
vi.mocked(api.context.getDraft).mockClear();
|
||||
selectProjectOption(channelProject.name);
|
||||
expect(screen.getAllByText("尚无远程会话").length).toBeGreaterThan(0);
|
||||
expect(api.context.getDraft).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(api.context.getDraft).mockResolvedValueOnce([attachment]);
|
||||
selectProjectOption(project.name);
|
||||
await waitFor(() => {
|
||||
expect(api.context.getDraft).toHaveBeenCalledExactlyOnceWith(conversationId);
|
||||
expect(screen.getByText(attachment.name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project activity integration", () => {
|
||||
const otherProject = {
|
||||
...project,
|
||||
@@ -1532,7 +1567,7 @@ describe("App", () => {
|
||||
const search = await screen.findByLabelText("搜索对话");
|
||||
fireEvent.change(search, { target: { value: "no matching conversation" } });
|
||||
await act(async () => initialTasks.resolve([task, { ...task, id: "duplicate-task" }]));
|
||||
const summary = await screen.findByRole("button", { name: /全项目活动/u });
|
||||
const summary = await screen.findByRole("button", { name: "当前项目" });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
expect(screen.queryByTitle("Exact background discussion")).not.toBeInTheDocument();
|
||||
fireEvent.change(search, { target: { value: "Current discussion" } });
|
||||
@@ -1543,22 +1578,23 @@ describe("App", () => {
|
||||
expect(projectRow).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(summary);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
|
||||
const running = screen.getByRole("menu", { name: "Background project" });
|
||||
expect(within(running).getAllByRole("menuitem")).toHaveLength(1);
|
||||
expect(within(running).getByRole("menuitem", { name: /Exact background discussion/u })).toHaveTextContent("运行中");
|
||||
fireEvent.pointerOver(screen.getByRole("menuitemradio", { name: /Background project/u }), { pointerType: 'mouse' });
|
||||
const running = screen.getByRole("list", { name: "会话" });
|
||||
expect(within(running).getAllByRole("button")).toHaveLength(2);
|
||||
expect(within(running).getByRole("button", { name: /Exact background discussion/u })).toHaveTextContent("运行中");
|
||||
|
||||
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 }));
|
||||
fireEvent.click(within(running).getByRole("button", { 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();
|
||||
await waitFor(() => expect(summary).toHaveTextContent("暂无活动"));
|
||||
expect(screen.queryByRole("dialog", { name: "项目与活动" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
expect(within(screen.getByRole("menu")).getByRole("menuitemradio", { name: /Background project/u }))
|
||||
.not.toHaveTextContent("个运行中");
|
||||
expect(within(screen.getByRole('list', { name: '会话' })).getByRole('button', { name: 'Exact background discussion' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each((["foreground", "background"] as const).flatMap((location) =>
|
||||
@@ -1573,7 +1609,7 @@ describe("App", () => {
|
||||
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 });
|
||||
const summary = screen.getByRole("button", { name: "当前项目" });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ header: expect.objectContaining({ id: request.conversationId }), messages: expect.arrayContaining([
|
||||
@@ -1608,8 +1644,8 @@ describe("App", () => {
|
||||
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.pointerOver(screen.getByRole("menuitemradio", { name: new RegExp(project.name, "u") }), { pointerType: 'mouse' });
|
||||
expect(screen.getByRole("list", { name: '会话' })).toHaveTextContent("已完成");
|
||||
fireEvent.click(summary);
|
||||
expect(summary).toHaveTextContent("1 个已完成");
|
||||
selectProjectOption(project.name);
|
||||
@@ -1617,10 +1653,10 @@ describe("App", () => {
|
||||
.findByTitle(saved.header.title));
|
||||
}
|
||||
expect(await screen.findByText("Local completion result")).toBeVisible();
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动"));
|
||||
selectProjectOption(otherProject.name);
|
||||
await screen.findByTitle("Exact background discussion");
|
||||
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
|
||||
await within(container.querySelector<HTMLElement>('.conversation-list')!).findByTitle("Exact background discussion");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动");
|
||||
});
|
||||
|
||||
it.each(["failed", "cancelled"] as const)("does not mark a background local run completed after %s", async (status) => {
|
||||
@@ -1635,11 +1671,11 @@ describe("App", () => {
|
||||
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 个运行中");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个运行中");
|
||||
act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "error", status, message: `Local activity ${status}`,
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动"));
|
||||
expect(screen.getByText("Exact conversation content")).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
expect(within(screen.getByRole("menu", { name: "当前项目" }))
|
||||
@@ -1653,7 +1689,7 @@ describe("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 });
|
||||
const summary = await screen.findByRole("button", { name: "当前项目" });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
|
||||
const settings = await screen.findByRole("dialog", { name: "设置中心" });
|
||||
@@ -1666,7 +1702,97 @@ describe("App", () => {
|
||||
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());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动"));
|
||||
});
|
||||
|
||||
it('promotes an old history visit into the first of ten recent workspace conversations', async () => {
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
const history = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `recent-${index}`, projectId, title: `Recent ${index}`, updatedAt: 1000 - index, messages: [],
|
||||
}));
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([...history, {
|
||||
id: 'old-visit', projectId, title: 'Older visit', updatedAt: 1,
|
||||
messages: [{ id: 'old-message', role: 'assistant', content: 'Opened old history', createdAt: 1, state: 'complete' }],
|
||||
}]);
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByTitle('Older visit'));
|
||||
await screen.findByText('Opened old history');
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
const rows = within(screen.getByRole('list', { name: '会话' })).getAllByRole('button');
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows[0]).toHaveTextContent('Older visit');
|
||||
});
|
||||
|
||||
it('promotes a background completion above newer-message history in the recent ten', async () => {
|
||||
const newerHistory = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `other-recent-${index}`, projectId: otherProject.id, title: `Other recent ${index}`, updatedAt: 1000 - index, messages: [],
|
||||
}));
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([...conversations, ...newerHistory]);
|
||||
render(<App />);
|
||||
const summary = await screen.findByRole('button', { name: '当前项目' });
|
||||
await waitFor(() => expect(summary).toHaveTextContent('1 个运行中'));
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: 'completed' }]);
|
||||
act(() => conversationQueueChangeListener?.(task.conversationId!));
|
||||
await waitFor(() => expect(summary).toHaveTextContent('1 个已完成'));
|
||||
fireEvent.click(summary);
|
||||
fireEvent.pointerOver(screen.getByRole('menuitemradio', { name: /Background project/u }), { pointerType: 'mouse' });
|
||||
const rows = within(screen.getByRole('list', { name: '会话' })).getAllByRole('button');
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows[0]).toHaveTextContent('Exact background discussion');
|
||||
expect(rows[0]).toHaveTextContent('已完成');
|
||||
});
|
||||
|
||||
it("restores each project's last opened conversation and its composer draft", async () => {
|
||||
render(<App />);
|
||||
await screen.findByLabelText('更多会话操作 Current discussion');
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep this local draft' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全部项目' }));
|
||||
fireEvent.click(within(screen.getByRole('list', { name: '会话' })).getByRole('button', { name: /Exact background discussion/u }));
|
||||
await screen.findByText('Exact conversation content');
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep this background draft' } });
|
||||
selectProjectOption(project.name);
|
||||
await waitFor(() => expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep this local draft'));
|
||||
selectProjectOption(otherProject.name);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Exact conversation content')).toBeVisible();
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep this background draft');
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['project', 'new conversation', 'new project'] as const)('guards the workspace %s action before changing project', async (action) => {
|
||||
const createdProject = { ...otherProject, id: 'workspace-created', name: 'Created workspace' };
|
||||
if (action === 'new project') vi.mocked(api.projects.create).mockResolvedValueOnce(createdProject);
|
||||
render(<App />);
|
||||
await screen.findByRole('button', { name: '当前项目' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置' }));
|
||||
await screen.findByRole('heading', { name: '设置中心' });
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }));
|
||||
fireEvent.change(await screen.findByLabelText('默认工作区目录'), { target: { value: 'C:\\Unsaved workspace draft' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
const target = screen.getByRole('menuitemradio', { name: /Background project/u });
|
||||
if (action === 'project') fireEvent.click(target);
|
||||
else if (action === 'new conversation') {
|
||||
fireEvent.pointerOver(target, { pointerType: 'mouse' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建会话' }));
|
||||
} else {
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' });
|
||||
fireEvent.change(within(dialog).getByLabelText('名称'), { target: { value: createdProject.name } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
}
|
||||
const discard = await screen.findByRole('button', { name: '放弃更改并关闭' });
|
||||
expect(screen.getByRole('button', { name: '当前项目' })).not.toHaveTextContent('Background project');
|
||||
expect(screen.getByLabelText('默认工作区目录')).toHaveValue('C:\\Unsaved workspace draft');
|
||||
fireEvent.click(discard);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '当前项目' })).toHaveTextContent(action === 'new project' ? createdProject.name : 'Background project');
|
||||
expect(screen.queryByRole('dialog', { name: '设置中心' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '放弃更改并关闭' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '对话' })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
if (action === 'new conversation') expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('');
|
||||
});
|
||||
|
||||
it("opens the exact off-project conversation and clears task selection, search, menu and narrow sidebar", async () => {
|
||||
@@ -1689,14 +1815,14 @@ describe("App", () => {
|
||||
await waitFor(() => expect(sidebar.querySelector(".conversation-more")).toBeInTheDocument());
|
||||
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全部项目' }));
|
||||
fireEvent.click(within(screen.getByRole('list', { name: '会话' })).getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
expect(sidebar).toHaveClass("sidebar--closed");
|
||||
expect(within(screen.getByRole("region", { name: "当前会话的任务" }))
|
||||
.getByRole("button", { name: /会话任务/u })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "项目与活动" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
expect(screen.getByLabelText("搜索对话")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
|
||||
@@ -1712,23 +1838,21 @@ describe("App", () => {
|
||||
|
||||
it("keeps keyboard focus inside activity without dismissing the narrow sidebar", async () => {
|
||||
const { container } = render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
await screen.findByRole("button", { name: "当前项目" });
|
||||
const originalWidth = window.innerWidth;
|
||||
try {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 680 });
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
const trigger = screen.getByRole("button", { name: /全项目活动/u });
|
||||
const trigger = screen.getByRole("button", { name: "当前项目" });
|
||||
fireEvent.click(trigger);
|
||||
const projectItem = screen.getByRole("menuitem", { name: /Background project/u });
|
||||
expect(projectItem).toHaveFocus();
|
||||
fireEvent.keyDown(projectItem, { key: "ArrowRight" });
|
||||
expect(screen.getByRole("menuitem", { name: /Exact background discussion/u })).toHaveFocus();
|
||||
expect(screen.getByRole('tab', { name: '全部项目' })).toHaveFocus();
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全部项目' }));
|
||||
const conversation = within(screen.getByRole('list', { name: '会话' })).getByRole('button', { name: /Exact background discussion/u });
|
||||
act(() => conversation.focus());
|
||||
expect(conversation).toHaveFocus();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(projectItem).toHaveFocus();
|
||||
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "项目与活动" })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
|
||||
expect(trigger).toHaveFocus();
|
||||
} finally {
|
||||
@@ -1738,14 +1862,14 @@ describe("App", () => {
|
||||
|
||||
it("respects the Settings leave guard before changing project and conversation", async () => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
await screen.findByRole("button", { name: "当前项目" });
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
|
||||
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全部项目' }));
|
||||
fireEvent.click(within(screen.getByRole('list', { name: '会话' })).getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
|
||||
expect(screen.getAllByRole("alert").some((alert) =>
|
||||
alert.textContent?.includes("当前设置有未保存更改")
|
||||
@@ -1755,7 +1879,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
|
||||
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "项目与活动" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["question", "approval"] as const)("preserves live %s across snapshot refresh and clears persisted terminal prompts", async (kind) => {
|
||||
@@ -1792,7 +1916,7 @@ describe("App", () => {
|
||||
await screen.findAllByText(refreshed.title);
|
||||
if (kind === "question") expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
}
|
||||
let resolveReply: (() => void) | undefined;
|
||||
if (kind === "question") {
|
||||
@@ -1816,7 +1940,7 @@ describe("App", () => {
|
||||
expect(screen.getByText("Persisted terminal result")).toBeInTheDocument();
|
||||
if (kind === "question") expect(screen.getByText("问题与回答")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it.each((["question", "approval"] as const).flatMap((kind) =>
|
||||
@@ -1842,7 +1966,7 @@ describe("App", () => {
|
||||
title: "Response approval", description: "Confirm response", toolName: "Bash", argumentSummary: "echo response", allowPermanent: false,
|
||||
};
|
||||
act(() => agentListener?.(kind === "question" ? questionEvent : approvalEvent));
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理 1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" }));
|
||||
await waitFor(() => expect(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
@@ -1854,7 +1978,7 @@ describe("App", () => {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const summary = screen.getByRole("button", { name: /全项目活动/u });
|
||||
const summary = screen.getByRole("button", { name: "当前项目" });
|
||||
expect(summary).toHaveTextContent(outcome === "success" ? "2 个运行中"
|
||||
: outcome === "done" ? "1 个运行中" : "1 个待处理 1 个运行中");
|
||||
if (outcome === "success" || outcome === "done") expect(summary).not.toHaveTextContent("待处理");
|
||||
@@ -1871,7 +1995,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("2 个运行中");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("2 个运行中");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task, {
|
||||
...task, id: "live-task", projectId, conversationId: request.conversationId, status: "waiting_approval",
|
||||
}]);
|
||||
@@ -1885,16 +2009,16 @@ describe("App", () => {
|
||||
});
|
||||
conversationQueueChangeListener?.(request.conversationId!);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
const menu = screen.getByRole("menu", { name: "全项目活动" });
|
||||
expect(within(menu).getAllByRole("menuitem")).toHaveLength(2);
|
||||
fireEvent.click(within(menu).getByRole("menuitem", { name: /1 个待处理/u }));
|
||||
const pending = screen.getByRole("menu", { name: project.name });
|
||||
expect(within(pending).getAllByRole("menuitem")).toHaveLength(1);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理 1 个运行中"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
const menu = screen.getByRole("dialog", { name: "项目与活动" });
|
||||
fireEvent.click(within(screen.getByRole('group', { name: '筛选会话状态' })).getByRole('button', { name: '待处理' }));
|
||||
const pending = screen.getByRole("list", { name: '会话' });
|
||||
expect(within(pending).getAllByRole("button")).toHaveLength(1);
|
||||
expect(pending).toHaveTextContent(kind === "question" ? "等待你的回答" : "等待审批");
|
||||
fireEvent.click(within(menu).getByRole("menuitem", { name: /Background project/u }));
|
||||
expect(screen.getByRole("menu", { name: "Background project" })).toHaveTextContent("Exact background discussion");
|
||||
fireEvent.click(within(screen.getByRole('group', { name: '筛选会话状态' })).getByRole('button', { name: '运行中' }));
|
||||
fireEvent.pointerOver(within(menu).getByRole("menuitemradio", { name: /Background project/u }), { pointerType: 'mouse' });
|
||||
expect(screen.getByRole("list", { name: '会话' })).toHaveTextContent("Exact background discussion");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
act(() => {
|
||||
agentListener?.(kind === "question"
|
||||
@@ -1902,8 +2026,9 @@ describe("App", () => {
|
||||
: { requestId: request.requestId, type: "error", status: "failed", message: "Activity execution failed" });
|
||||
conversationQueueChangeListener?.(request.conversationId!);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
expect(menu).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动"));
|
||||
expect(menu).toBeInTheDocument();
|
||||
fireEvent.keyDown(menu, { key: 'Escape' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2895,7 +3020,7 @@ describe("App", () => {
|
||||
});
|
||||
});
|
||||
await act(async () => { finishRefresh!(snapshots); });
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!).getByText(target.title));
|
||||
expect(await screen.findByText("Retained scheduled history")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "仅此次" })).toBeInTheDocument();
|
||||
@@ -2946,7 +3071,7 @@ describe("App", () => {
|
||||
expect(await screen.findAllByText("请求写入工作区")).not.toHaveLength(0);
|
||||
expect(screen.getByText("仅此次")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: /任务中心/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /项目任务/u }));
|
||||
@@ -2956,8 +3081,8 @@ describe("App", () => {
|
||||
fireEvent.click(within(taskRow).getByRole("button", { name: "仅此次允许" }));
|
||||
await waitFor(() => expect(api.agent.respondApproval).toHaveBeenCalledWith("00000000-0000-4000-8000-000000000834", "once"));
|
||||
await waitFor(() => expect(within(taskRow).queryByLabelText("等待审批: write_file")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中"));
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个运行中"));
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("idle-preloads only the small Heartbeat route at startup", async () => {
|
||||
@@ -8507,7 +8632,8 @@ describe("App", () => {
|
||||
.map((option) => option.querySelector("span")?.textContent),
|
||||
).toEqual(["Ask · 只读问答", "Execute · 完全权限"]);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("新建项目"));
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
const dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
const defaultMode = within(dialog)
|
||||
.getAllByRole("group", { name: "默认模式" })
|
||||
@@ -9697,12 +9823,10 @@ describe("App", () => {
|
||||
fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
||||
|
||||
const menu = screen.getByRole("menu", { name: "当前项目" });
|
||||
expect(
|
||||
within(menu).getByRole("group", { name: "本地项目" }),
|
||||
).toHaveTextContent("本地目录 · C:\\Users\\test");
|
||||
expect(
|
||||
within(menu).getByRole("group", { name: "远程通道" }),
|
||||
).toHaveTextContent("微信 ClawBot · 远程通道 · C:\\Users\\test");
|
||||
const tabs = screen.getByRole('tablist', { name: '项目类型' });
|
||||
expect(within(tabs).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['全部项目', '本地', '消息通道']);
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /本地目录/u })).toHaveTextContent("本地目录 · C:\\Users\\test");
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /微信 ClawBot/u })).toHaveTextContent("微信 ClawBot · 远程通道 · C:\\Users\\test");
|
||||
const selectedProject = within(menu)
|
||||
.getByText(project.name, { selector: "b" })
|
||||
.closest<HTMLButtonElement>('[role="menuitemradio"]');
|
||||
@@ -9710,10 +9834,11 @@ describe("App", () => {
|
||||
name: `管理项目 ${channelProject.name}`,
|
||||
});
|
||||
expect(selectedProject).toHaveAttribute("aria-checked", "true");
|
||||
await waitFor(() => expect(selectedProject).toHaveFocus());
|
||||
await waitFor(() => expect(within(tabs).getByRole('tab', { name: '全部项目' })).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(selectedProject!, { key: "End" });
|
||||
expect(remoteProjectSettings).toHaveFocus();
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /微信 ClawBot/u })).toHaveFocus();
|
||||
act(() => remoteProjectSettings.focus());
|
||||
fireEvent.keyDown(remoteProjectSettings, { key: "Escape" });
|
||||
expect(trigger).toHaveFocus();
|
||||
expect(
|
||||
@@ -12017,8 +12142,9 @@ describe("App", () => {
|
||||
it("opens project creation as an unobscured dialog", async () => {
|
||||
render(<App />);
|
||||
|
||||
const newProjectButton = await screen.findByLabelText("新建项目");
|
||||
fireEvent.click(newProjectButton);
|
||||
const projectButton = await screen.findByRole('button', { name: '当前项目' });
|
||||
fireEvent.click(projectButton);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
let dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
expect(dialog).toHaveClass("project-create-card");
|
||||
expect(within(dialog).getByRole("button", { name: "创建" })).toBeDisabled();
|
||||
@@ -12028,9 +12154,10 @@ describe("App", () => {
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "新建项目" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(newProjectButton).toHaveFocus();
|
||||
expect(projectButton).toHaveFocus();
|
||||
|
||||
fireEvent.click(newProjectButton);
|
||||
fireEvent.click(projectButton);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("名称"), {
|
||||
@@ -12054,7 +12181,8 @@ describe("App", () => {
|
||||
);
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("新建项目"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '当前项目' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
const dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
const nameInput = within(dialog).getByLabelText("名称");
|
||||
fireEvent.change(nameInput, {
|
||||
@@ -13080,7 +13208,7 @@ describe("App", () => {
|
||||
expect(screen.getAllByText("问题与回答")).toHaveLength(3);
|
||||
act(() => agentListener?.(first));
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("removes remotely resolved questions without recording a skip or blocking the next question", async () => {
|
||||
@@ -13103,7 +13231,7 @@ describe("App", () => {
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Retain draft");
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "second" }));
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("does not save a duplicate question-resolved event after the first resolution is persisted", async () => {
|
||||
@@ -15198,7 +15326,7 @@ describe("App", () => {
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('focuses the chat composer after a guarded Magic Notes leave to a new conversation', async () => {
|
||||
it.each(['sidebar', 'project', 'new conversation'] as const)('opens chat after a single guarded Magic Notes discard from %s', async (entry) => {
|
||||
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
||||
const note = {
|
||||
id: '00000000-0000-4000-8000-000000000602', title: 'Focus note', preview: '',
|
||||
@@ -15207,17 +15335,39 @@ describe("App", () => {
|
||||
}
|
||||
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [note], tags: [] })
|
||||
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
|
||||
const targetProject = { ...project, id: '00000000-0000-4000-8000-000000000607', name: 'Guarded workspace' }
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([project, targetProject])
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Focus note.*条记录/ }))
|
||||
fireEvent.change(await screen.findByLabelText('笔记标题'), { target: { value: 'Unsaved focus title' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /新建对话/ }))
|
||||
const title = await screen.findByLabelText('笔记标题')
|
||||
fireEvent.change(title, { target: { value: 'Unsaved focus title' } })
|
||||
if (entry === 'sidebar') fireEvent.click(screen.getByRole('button', { name: /新建对话/ }))
|
||||
else {
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
const target = screen.getByRole('menuitemradio', { name: /Guarded workspace/ })
|
||||
if (entry === 'project') fireEvent.click(target)
|
||||
else {
|
||||
fireEvent.pointerOver(target, { pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建会话' }))
|
||||
}
|
||||
}
|
||||
const discard = await screen.findByRole('button', { name: '放弃草稿并切换' })
|
||||
expect(title).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '当前项目' })).not.toHaveTextContent(targetProject.name)
|
||||
await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)) })
|
||||
// A real pointer click moves focus to the confirmation button before the leave completes.
|
||||
discard.focus()
|
||||
fireEvent.click(discard)
|
||||
await waitFor(() => expect(screen.getByRole('textbox', { name: '向 GoodBuddy 提问' })).toHaveFocus())
|
||||
await waitFor(() => {
|
||||
const composer = screen.getByRole('textbox', { name: '向 GoodBuddy 提问' })
|
||||
expect(composer).toBeVisible()
|
||||
if (entry !== 'project') expect(composer).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '对话' })).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
expect(title).not.toBeVisible()
|
||||
if (entry !== 'sidebar') expect(screen.getByRole('button', { name: '当前项目' })).toHaveTextContent(targetProject.name)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['title', 'text'])('guards real App navigation to multiple apps and discards only after confirmation (%s)', async (draft) => {
|
||||
|
||||
+21
-26
@@ -215,7 +215,6 @@ import {
|
||||
ScopeBadge,
|
||||
} from "./WorkspacePrimitives";
|
||||
import { ProjectSwitcher } from "./ProjectSwitcher";
|
||||
import { ProjectActivity } from "./ProjectActivity";
|
||||
import { useStableHandlers } from "./stable-derived-value";
|
||||
import { useUnviewedCompletions } from "./use-unviewed-completions";
|
||||
import { useExecutionStats } from "./use-execution-stats";
|
||||
@@ -1527,6 +1526,7 @@ function App(): React.JSX.Element {
|
||||
? update(activeConversationIdRef.current)
|
||||
: update;
|
||||
activeConversationIdRef.current = next;
|
||||
conversationStore.rememberOpened(next);
|
||||
if (next) {
|
||||
const now = Date.now();
|
||||
setCachedConversationViews((current) =>
|
||||
@@ -1539,7 +1539,7 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
setActiveIdState(next);
|
||||
}, [activeConversationViewIds]);
|
||||
}, [activeConversationViewIds, conversationStore]);
|
||||
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
|
||||
const [
|
||||
conversationHtmlRenderingEnabled,
|
||||
@@ -1672,7 +1672,7 @@ function App(): React.JSX.Element {
|
||||
}), []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (attachmentsRef.current.has(activeId) || conversationStore.getState().find((conversation) => conversation.id === activeId)?.remote) return;
|
||||
if (!activeId || attachmentsRef.current.has(activeId) || conversationStore.getState().find((conversation) => conversation.id === activeId)?.remote) return;
|
||||
void window.goodbuddy.context.getDraft(activeId).then((saved) => {
|
||||
if (!active || attachmentsRef.current.has(activeId) || !saved.length) return;
|
||||
attachmentsRef.current.set(activeId, saved);
|
||||
@@ -4244,11 +4244,7 @@ function App(): React.JSX.Element {
|
||||
candidateConversations = conversationStore.getState(),
|
||||
): void => {
|
||||
setActiveProjectId(selected.id);
|
||||
const conversation = candidateConversations.find(
|
||||
(candidate) =>
|
||||
candidate.projectId === selected.id &&
|
||||
(selected.kind !== "channel" || candidate.remote !== undefined),
|
||||
);
|
||||
const conversation = conversationStore.projectConversation(selected.id, selected.kind === "channel", candidateConversations);
|
||||
if (conversation) {
|
||||
// Remote projects ignore incompatible conversation choices at resolution time
|
||||
// and say so in the picker, instead of silently rewriting saved conversations.
|
||||
@@ -4264,7 +4260,7 @@ function App(): React.JSX.Element {
|
||||
setConversations((current) => [created, ...current]);
|
||||
setActiveId(created.id);
|
||||
}
|
||||
setView("chat");
|
||||
commitView("chat");
|
||||
};
|
||||
|
||||
const selectProject = (projectId: string): void => {
|
||||
@@ -4290,15 +4286,7 @@ function App(): React.JSX.Element {
|
||||
): Promise<AssistantProject> => {
|
||||
const project = await window.goodbuddy.projects.create(input);
|
||||
setProjects((current) => [project, ...current]);
|
||||
setActiveProjectId(project.id);
|
||||
const conversation = createConversation(
|
||||
project.id,
|
||||
undefined,
|
||||
t("conversation.greeting"),
|
||||
);
|
||||
setConversations((current) => [conversation, ...current]);
|
||||
setActiveId(conversation.id);
|
||||
setView("chat");
|
||||
requestWorkspaceLeave("chat", () => commitProjectSelection(project));
|
||||
return project;
|
||||
};
|
||||
|
||||
@@ -4328,7 +4316,7 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
: [project, ...current],
|
||||
);
|
||||
commitProjectSelection(project);
|
||||
requestWorkspaceLeave("chat", () => commitProjectSelection(project));
|
||||
if (
|
||||
!isProjectRecoveryUnsettled(
|
||||
projectRecoveryByProjectIdRef.current[project.id],
|
||||
@@ -6580,7 +6568,16 @@ function App(): React.JSX.Element {
|
||||
onCreate: createProject,
|
||||
onDelete: deleteProject,
|
||||
onRemoteCommitted: loadCommittedRemoteProject,
|
||||
onSelect: selectProject,
|
||||
onSelect: (projectId: string) => requestWorkspaceLeave('chat', () => { selectProject(projectId); if (narrowWindow) closeNarrowSidebar(); }),
|
||||
onNewConversation: (projectId: string) => requestWorkspaceLeave('chat', () => {
|
||||
startNewConversation(projectId, { ready: (conversation) => {
|
||||
setActiveProjectId(projectId);
|
||||
setActiveId(conversation.id);
|
||||
commitView('chat');
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
} });
|
||||
}),
|
||||
onSelectRoot: () => window.goodbuddy.settings.selectWorkspace(),
|
||||
onUpdate: updateProject,
|
||||
});
|
||||
@@ -6805,6 +6802,10 @@ function App(): React.JSX.Element {
|
||||
|
||||
<ProjectSwitcher
|
||||
activeProjectId={activeProjectId}
|
||||
conversationStore={conversationStore}
|
||||
activities={projectActivity.activities}
|
||||
visible={sidebarOpen}
|
||||
onOpenConversation={openActivityConversation}
|
||||
activityByProjectId={projectActivity.byProjectId}
|
||||
recoveryByProjectId={projectRecoveryByProjectId}
|
||||
runtimeSettings={runtimeSettings}
|
||||
@@ -6813,12 +6814,6 @@ function App(): React.JSX.Element {
|
||||
projects={projects}
|
||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
/>
|
||||
<ProjectActivity
|
||||
activities={projectActivity.activities}
|
||||
projects={projects}
|
||||
visible={sidebarOpen}
|
||||
onOpenConversation={openActivityConversation}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`sidebar-conversation-controls${searchOpen ? " sidebar-conversation-controls--searching" : ""}`}
|
||||
|
||||
@@ -26,6 +26,20 @@ import type {
|
||||
} from '../../shared/remote-project-recovery-contracts'
|
||||
import i18n from './i18n'
|
||||
import { ProjectSwitcher } from './ProjectSwitcher'
|
||||
import { createConversationStores } from './conversation-store'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
|
||||
const menuProps = {
|
||||
conversationStore: createConversationStores([], { flushIntervalMs: 250 }).conversations,
|
||||
activities: [] as ConversationActivity[],
|
||||
onOpenConversation: vi.fn(),
|
||||
onNewConversation: vi.fn()
|
||||
}
|
||||
|
||||
function openCreate(name: '新建项目' | 'New project'): void {
|
||||
if (!screen.queryByRole('menu')) fireEvent.click(screen.getByRole('button', { name: /^(当前项目|Current project)$/u }))
|
||||
fireEvent.click(screen.getByRole('button', { name }))
|
||||
}
|
||||
|
||||
const profileId = '00000000-0000-4000-8000-000000000011'
|
||||
const runtimeSettings: RuntimeSettings = {
|
||||
@@ -116,6 +130,7 @@ function renderSwitcher(
|
||||
const onRetryRecovery = vi.fn(async () => undefined)
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
{...menuProps}
|
||||
activeProjectId={currentProject.id}
|
||||
onArchive={vi.fn(async () => undefined)}
|
||||
onCreate={onCreate}
|
||||
@@ -309,6 +324,26 @@ function installRemoteApi(
|
||||
}
|
||||
|
||||
describe('ProjectSwitcher project activity integration', () => {
|
||||
it('uses one idle button for both rows and restores its keyboard focus', () => {
|
||||
renderSwitcher()
|
||||
const trigger = screen.getByRole('button', { name: '当前项目' })
|
||||
expect(screen.getAllByRole('button')).toEqual([trigger])
|
||||
expect(trigger).toHaveTextContent('Local project')
|
||||
expect(trigger).toHaveAccessibleDescription('暂无活动')
|
||||
fireEvent.click(within(trigger).getByText('暂无活动'))
|
||||
expect(screen.getByRole('region', { name: project.name })).toBeVisible()
|
||||
fireEvent.keyDown(screen.getByRole('dialog', { name: '项目与活动' }), { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
for (const key of ['Enter', ' ', 'ArrowDown']) {
|
||||
fireEvent.keyDown(trigger, { key })
|
||||
const menu = screen.getByRole('dialog', { name: '项目与活动' })
|
||||
expect(trigger).toHaveAttribute('aria-controls', menu.id)
|
||||
fireEvent.keyDown(menu, { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps per-project title counts and menu ordering stable across recovery and Host states', async () => {
|
||||
const api = installRemoteApi({ connectionState: 'connecting' })
|
||||
const remote: AssistantProject = {
|
||||
@@ -317,6 +352,7 @@ describe('ProjectSwitcher project activity integration', () => {
|
||||
}
|
||||
const idle = { ...remote, id: 'idle-project', name: 'Idle remote' }
|
||||
const props = {
|
||||
...menuProps,
|
||||
activeProjectId: project.id, projects: [project, remote, idle],
|
||||
remoteProjectsEnabled: true, onArchive: vi.fn(), onCreate: vi.fn(),
|
||||
onDelete: vi.fn(), onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
|
||||
@@ -329,7 +365,7 @@ describe('ProjectSwitcher project activity integration', () => {
|
||||
const { rerender } = render(<ProjectSwitcher {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
const menu = screen.getByRole('menu', { name: '当前项目' })
|
||||
await within(menu).findByRole('group', { name: 'SSH 主机:Build host' })
|
||||
await within(menu).findByRole('menuitem', { name: /Build host/u })
|
||||
for (const stage of ['agent', 'failed', 'completed'] as const) {
|
||||
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
|
||||
[remote.id]: { projectId: remote.id, requestId: 'activity-recovery', stage,
|
||||
@@ -340,10 +376,12 @@ describe('ProjectSwitcher project activity integration', () => {
|
||||
expect(rows.map((row) => row.querySelector('b')?.textContent))
|
||||
.toEqual(['Local project', 'Remote activity', 'Idle remote'])
|
||||
expect(rows[0]!.querySelector('.project-switcher__project-heading'))
|
||||
.toHaveTextContent('Local project 1 个运行中')
|
||||
.toHaveTextContent('Local project')
|
||||
expect(rows[0]!.querySelector('.project-activity__counts')).toHaveTextContent('1 个运行中')
|
||||
const heading = rows[1]!.querySelector('.project-switcher__project-heading')!
|
||||
expect(heading).toHaveTextContent('Remote activity3 个待处理 2 个运行中')
|
||||
expect(heading.querySelector('b')?.nextElementSibling).toHaveClass('project-activity__counts')
|
||||
expect(heading).toHaveTextContent('Remote activity')
|
||||
expect(heading.nextElementSibling).toHaveClass('project-activity__counts')
|
||||
expect(heading.nextElementSibling).toHaveTextContent('3 个待处理 2 个运行中')
|
||||
expect(rows[2]!.querySelector('.project-activity__counts')).toBeNull()
|
||||
}
|
||||
fireEvent.click(within(menu).getByRole('menuitemradio', { name: /Remote activity/u }))
|
||||
@@ -360,16 +398,17 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(trigger.closest('.project-switcher')).not.toContainElement(menu)
|
||||
expect(menu.closest('.floating-portal')?.parentElement).toBe(document.body)
|
||||
expect(menu.style.width).toBe('380px')
|
||||
const surface = screen.getByRole('dialog', { name: '项目与活动' })
|
||||
expect(surface.style.width).toBe('920px')
|
||||
const selected = within(menu).getByRole('menuitemradio', { checked: true })
|
||||
await waitFor(() => expect(selected).toHaveFocus())
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: '全部项目' })).toHaveFocus())
|
||||
fireEvent.pointerDown(selected)
|
||||
expect(menu).toBeInTheDocument()
|
||||
|
||||
const width = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(360)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(menu.style.width).toBe('328px')
|
||||
expect(menu.style.left).toBe('16px')
|
||||
expect(surface.style.width).toBe('328px')
|
||||
expect(surface.style.left).toBe('16px')
|
||||
width.mockRestore()
|
||||
fireEvent.keyDown(selected, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
@@ -383,14 +422,14 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
it.each(['新建项目', '项目设置'])('keeps %s outside the sidebar and restores focus after closing', (name) => {
|
||||
renderSwitcher()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: name === '项目设置' ? '当前项目' : name
|
||||
name: '当前项目'
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
if (name === '项目设置') {
|
||||
fireEvent.click(screen.getByRole('menuitem', {
|
||||
name: '管理项目 Local project'
|
||||
}))
|
||||
}
|
||||
} else openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name })
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
const body = dialog.querySelector('.project-create-card__body')!
|
||||
@@ -407,7 +446,7 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
it('creates an ordinary project with DeepSeek Harness', async () => {
|
||||
const { onCreate } = renderSwitcher()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.change(within(dialog).getByLabelText('名称'), {
|
||||
target: { value: 'Harness project' }
|
||||
@@ -473,7 +512,7 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
renderSwitcher()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('New project'))
|
||||
openCreate('New project')
|
||||
const dialog = screen.getByRole('dialog', { name: 'New project' })
|
||||
expect(within(dialog).getByRole('group', { name: 'Default Runtime for new conversations' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByLabelText('Execution mode')).not.toHaveValue('')
|
||||
@@ -544,23 +583,31 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
name: 'Current project'
|
||||
})
|
||||
const buildHost = await waitFor(() =>
|
||||
within(menu).getByRole('group', {
|
||||
name: 'SSH host: Build host'
|
||||
within(menu).getByRole('menuitem', {
|
||||
name: /Build host/u
|
||||
})
|
||||
)
|
||||
const deployHost = within(menu).getByRole('group', {
|
||||
name: 'SSH host: Deploy host'
|
||||
const deployHost = within(menu).getByRole('menuitem', {
|
||||
name: /Deploy host/u
|
||||
})
|
||||
expect(buildHost).toHaveTextContent('Connecting')
|
||||
expect(buildHost).toHaveTextContent('/srv/api')
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /\/srv\/api/u })).toHaveTextContent('/srv/api')
|
||||
expect(deployHost).toHaveTextContent('Ready')
|
||||
expect(deployHost).toHaveTextContent('/srv/web')
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /\/srv\/web/u })).toHaveTextContent('/srv/web')
|
||||
expect(within(menu).queryByText(/Managed SSH/u)).not.toBeInTheDocument()
|
||||
|
||||
act(() => api.emitConnectionStatus('ready'))
|
||||
await waitFor(() =>
|
||||
expect(buildHost).toHaveTextContent('Ready')
|
||||
)
|
||||
fireEvent.click(buildHost)
|
||||
expect(buildHost).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(within(menu).queryByRole('menuitemradio', { name: /\/srv\/api/u })).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '/srv/api' } })
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /\/srv\/api/u })).toBeInTheDocument()
|
||||
expect(within(menu).queryByRole('menuitemradio', { name: /\/srv\/web/u })).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
|
||||
expect(buildHost).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('does not let an older Host snapshot overwrite a newer connection event', async () => {
|
||||
@@ -618,8 +665,8 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const hostGroup = await screen.findByRole('group', {
|
||||
name: 'SSH host: Build host'
|
||||
const hostGroup = await screen.findByRole('menuitem', {
|
||||
name: /Build host/u
|
||||
})
|
||||
expect(hostGroup).toHaveTextContent('Ready')
|
||||
expect(hostGroup).not.toHaveTextContent('Disconnected')
|
||||
@@ -669,7 +716,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Current project' })
|
||||
).toHaveTextContent('Restoring remote Agent…')
|
||||
).toHaveAccessibleDescription('No activity Restoring remote Agent…')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Current project' })
|
||||
)
|
||||
@@ -694,6 +741,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
|
||||
}
|
||||
const props = {
|
||||
...menuProps,
|
||||
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
|
||||
onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
|
||||
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
|
||||
@@ -710,7 +758,8 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
)
|
||||
const { rerender, unmount } = render(view(remote.id))
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
expect(within(trigger).getByRole('status')).toHaveTextContent('Recovery completed')
|
||||
expect(trigger).toHaveAccessibleDescription('No activity Recovery completed')
|
||||
expect(trigger).not.toContainElement(screen.getByRole('status'))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
||||
rerender(view(project.id))
|
||||
fireEvent.click(trigger)
|
||||
@@ -729,13 +778,15 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const next = { ...completed, requestId: '00000000-0000-4000-8000-000000000302' }
|
||||
rerender(view(remote.id, { ...next, stage: 'network' }))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(within(trigger).getByRole('status')).toHaveAttribute('aria-busy', 'true')
|
||||
expect(document.getElementById(trigger.getAttribute('aria-describedby')!.split(' ')[1]!)!.querySelector('[role="status"]')).toHaveAttribute('aria-busy', 'true')
|
||||
rerender(view(remote.id, next))
|
||||
expect(screen.getAllByText('Recovery completed')).toHaveLength(2)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
rerender(view(remote.id, { ...next, requestId: 'new-request' }))
|
||||
unmount()
|
||||
// Focusing the shared menu schedules jsdom's zero-delay selectionchange.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -750,6 +801,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const second = { ...remote, id: 'second-project', name: 'Second remote' }
|
||||
const onRetryRecovery = vi.fn(async () => undefined)
|
||||
const props = {
|
||||
...menuProps,
|
||||
activeProjectId: project.id,
|
||||
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
|
||||
onRemoteCommitted: vi.fn(), onSelect: vi.fn(), onRetryRecovery,
|
||||
@@ -807,6 +859,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const onRetryRecovery = vi.fn(async () => undefined)
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
{...menuProps}
|
||||
activeProjectId={remoteProject.id}
|
||||
onArchive={vi.fn(async () => undefined)}
|
||||
onCreate={vi.fn()}
|
||||
@@ -878,7 +931,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(within(menu).queryByText('Remote project')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('New project'))
|
||||
openCreate('New project')
|
||||
const dialog = screen.getByRole('dialog', { name: 'New project' })
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: 'Managed SSH' })
|
||||
@@ -978,7 +1031,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const api = installRemoteApi()
|
||||
const { onSelectRoot } = renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('New project'))
|
||||
openCreate('New project')
|
||||
const dialog = screen.getByRole('dialog', { name: 'New project' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Managed SSH' })
|
||||
@@ -1017,7 +1070,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('starts from the home directory when the typed path is invalid', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1058,7 +1111,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
}
|
||||
)
|
||||
const { onSelectRoot } = renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1120,7 +1173,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('keeps the outer dialog open and restores trigger focus after cancel and Escape', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1180,7 +1233,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('closes the new-project dialog through its top-right button', () => {
|
||||
renderSwitcher()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
const close = within(dialog).getByRole('button', {
|
||||
name: '关闭新建项目'
|
||||
@@ -1206,7 +1259,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('Permission denied'))
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1249,7 +1302,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it.each(['opencode', 'continue'])('saves %s Execute in one request without extra confirmation checklists', async (provider) => {
|
||||
const api = installRemoteApi()
|
||||
const { onRemoteCommitted } = renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1311,7 +1364,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
})
|
||||
)
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1347,7 +1400,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const api = installRemoteApi()
|
||||
api.save.mockRejectedValueOnce(new Error('Runtime unavailable'))
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1380,7 +1433,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const api = installRemoteApi()
|
||||
api.save.mockImplementationOnce(() => new Promise(() => undefined))
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1454,7 +1507,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('uses the local validated Host record and defers remote checks to the requested action', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1531,7 +1584,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('never probes remote environments when the project form opens or reopens', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
let dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1546,7 +1599,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
within(dialog).getByRole('button', { name: '取消' })
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1556,7 +1609,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(api.getSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(api.getSnapshot).toHaveBeenCalledTimes(4)
|
||||
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
expect(api.updateRemoteEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -7,16 +7,14 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
RadioTower,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, memo } from 'react'
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, memo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
@@ -34,7 +32,6 @@ import {
|
||||
} from '../../shared/remote-project-candidate-contracts'
|
||||
import type {
|
||||
SshDirectoryBrowseResult,
|
||||
SshHost,
|
||||
SshHostAgentConnectionState,
|
||||
SshHostsSnapshot
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
@@ -58,12 +55,19 @@ import { ProjectWorkModeFields } from './ProjectWorkModeFields'
|
||||
import { ChannelIcon } from './ChannelIcon'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { FloatingPortal } from './FloatingPortal'
|
||||
import { WorkspaceMenu } from './WorkspaceMenu'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import type { ConversationStore } from './conversation-store'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
|
||||
type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
conversationStore: ConversationStore
|
||||
activities: ConversationActivity[]
|
||||
visible?: boolean
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
onNewConversation: (projectId: string) => void
|
||||
activityByProjectId?: Record<string, ProjectActivityCountsProps>
|
||||
remoteProjectsEnabled?: boolean
|
||||
runtimeSettings?: RuntimeSettings
|
||||
@@ -94,6 +98,8 @@ type RemoteHostReadiness =
|
||||
| { status: 'ready' }
|
||||
| { status: 'unready' }
|
||||
|
||||
const emptyHosts: SshHostsSnapshot['hosts'] = []
|
||||
|
||||
function ProjectRecoveryStatus({
|
||||
state
|
||||
}: {
|
||||
@@ -215,6 +221,11 @@ function isRemoteAbsolutePath(value: string): boolean {
|
||||
function ProjectSwitcherView({
|
||||
projects,
|
||||
activeProjectId,
|
||||
conversationStore,
|
||||
activities,
|
||||
visible = true,
|
||||
onOpenConversation,
|
||||
onNewConversation,
|
||||
activityByProjectId = {},
|
||||
remoteProjectsEnabled = false,
|
||||
runtimeSettings,
|
||||
@@ -229,6 +240,9 @@ function ProjectSwitcherView({
|
||||
recoveryByProjectId = {}
|
||||
}: ProjectSwitcherProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
useLayoutEffect(() => {
|
||||
conversationStore.recordActivityStatuses(activities)
|
||||
}, [activities, conversationStore])
|
||||
const [dialogMode, setDialogMode] = useState<
|
||||
'create' | 'settings'
|
||||
>()
|
||||
@@ -275,18 +289,31 @@ function ProjectSwitcherView({
|
||||
useState<string>()
|
||||
const [remoteDirectoryListing, setRemoteDirectoryListing] =
|
||||
useState<SshDirectoryBrowseResult>()
|
||||
const createButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const directoryPickerTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const directoryPickerRef = useRef<HTMLDivElement>(null)
|
||||
const directoryBrowseRequestRef = useRef(0)
|
||||
const projectPickerRef = useRef<HTMLDivElement>(null)
|
||||
const projectPickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const projectPickerMenuRef = useRef<HTMLDivElement>(null)
|
||||
const menuId = useId()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const restoreFocusTarget = useRef<
|
||||
'create' | 'picker' | undefined
|
||||
>(undefined)
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
|
||||
if (!visible && projectMenuOpen) setProjectMenuOpen(false)
|
||||
const closeProjectMenu = useCallback(() => setProjectMenuOpen(false), [])
|
||||
const visibleActivities = useMemo(() => {
|
||||
const visibleIds = new Set(projects.filter((project) => project.kind === 'channel' ||
|
||||
project.executionSpace.kind !== 'ssh' || remoteProjectsEnabled).map((project) => project.id))
|
||||
return activities.filter((activity) => !activity.projectId || visibleIds.has(activity.projectId))
|
||||
}, [activities, projects, remoteProjectsEnabled])
|
||||
const activityCounts = useMemo(() => {
|
||||
const counts = { attention: 0, running: 0, completed: 0 }
|
||||
for (const activity of visibleActivities) {
|
||||
counts[activity.status === 'running' || activity.status === 'completed' ? activity.status : 'attention'] += 1
|
||||
}
|
||||
return counts
|
||||
}, [visibleActivities])
|
||||
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
|
||||
useState<string>()
|
||||
const [dismissedRecoveryByProjectId, setDismissedRecoveryByProjectId] =
|
||||
@@ -331,13 +358,13 @@ function ProjectSwitcherView({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleRecoveryByProjectId = Object.fromEntries(
|
||||
const visibleRecoveryByProjectId = useMemo(() => Object.fromEntries(
|
||||
Object.entries(recoveryByProjectId).filter(
|
||||
([projectId, state]) =>
|
||||
state.stage !== 'completed' ||
|
||||
dismissedRecoveryByProjectId[projectId] !== state.requestId
|
||||
)
|
||||
)
|
||||
), [recoveryByProjectId, dismissedRecoveryByProjectId])
|
||||
const applySshHostsSnapshot = useCallback(
|
||||
(
|
||||
snapshot: SshHostsSnapshot,
|
||||
@@ -419,58 +446,9 @@ function ProjectSwitcherView({
|
||||
const settingsProject = projects.find(
|
||||
(project) => project.id === settingsProjectId
|
||||
)
|
||||
const localProjects = projects.filter(
|
||||
(project) =>
|
||||
project.kind === 'user' &&
|
||||
project.executionSpace.kind === 'local'
|
||||
)
|
||||
const remoteProjects = projects.filter(
|
||||
(project) =>
|
||||
project.kind === 'user' &&
|
||||
project.executionSpace.kind === 'ssh'
|
||||
)
|
||||
const sshHostById = new Map(
|
||||
(sshHosts?.hosts ?? []).map((host) => [host.id, host])
|
||||
)
|
||||
const remoteProjectGroups = [
|
||||
...remoteProjects.reduce(
|
||||
(
|
||||
groups,
|
||||
project
|
||||
) => {
|
||||
if (project.executionSpace.kind !== 'ssh') {
|
||||
return groups
|
||||
}
|
||||
const hostId = project.executionSpace.hostId
|
||||
const current = groups.get(hostId)
|
||||
if (current) {
|
||||
current.projects.push(project)
|
||||
} else {
|
||||
groups.set(hostId, {
|
||||
hostId,
|
||||
host: sshHostById.get(hostId),
|
||||
projects: [project]
|
||||
})
|
||||
}
|
||||
return groups
|
||||
},
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
hostId: string
|
||||
host?: SshHost
|
||||
projects: AssistantProject[]
|
||||
}
|
||||
>()
|
||||
).values()
|
||||
]
|
||||
const channelProjects = projects.filter(
|
||||
(project) => project.kind === 'channel'
|
||||
)
|
||||
const userProjects = [
|
||||
...localProjects,
|
||||
...(remoteProjectsEnabled ? remoteProjects : [])
|
||||
]
|
||||
const userProjects = useMemo(() => projects.filter((project) =>
|
||||
project.kind === 'user' && (project.executionSpace.kind === 'local' || remoteProjectsEnabled)
|
||||
), [projects, remoteProjectsEnabled])
|
||||
const busy =
|
||||
saving ||
|
||||
remoteSaving ||
|
||||
@@ -741,64 +719,6 @@ function ProjectSwitcherView({
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!projectMenuOpen) return
|
||||
const anchor = projectPickerButtonRef.current
|
||||
const menu = projectPickerMenuRef.current
|
||||
if (!anchor || !menu) return
|
||||
const position = (): void => {
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const width = Math.min(380, window.innerWidth - 32)
|
||||
menu.style.width = `${width}px`
|
||||
menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - width - 16))}px`
|
||||
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - menu.offsetHeight - 16))}px`
|
||||
}
|
||||
position()
|
||||
const observer = new ResizeObserver(position)
|
||||
observer.observe(anchor)
|
||||
observer.observe(menu)
|
||||
window.addEventListener('resize', position)
|
||||
window.addEventListener('scroll', position, true)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', position)
|
||||
window.removeEventListener('scroll', position, true)
|
||||
}
|
||||
}, [projectMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectMenuOpen) {
|
||||
return
|
||||
}
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
const items = Array.from(
|
||||
projectPickerMenuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role^="menuitem"]'
|
||||
) ?? []
|
||||
)
|
||||
const initialItem =
|
||||
items.find((item) => item.getAttribute('aria-checked') === 'true') ??
|
||||
items[0]
|
||||
initialItem?.focus()
|
||||
})
|
||||
const closeOutside = (event: Event): void => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!projectPickerRef.current?.contains(event.target) &&
|
||||
!projectPickerMenuRef.current?.contains(event.target)
|
||||
) {
|
||||
setProjectMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside)
|
||||
document.addEventListener('focusin', closeOutside)
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame)
|
||||
document.removeEventListener('pointerdown', closeOutside)
|
||||
document.removeEventListener('focusin', closeOutside)
|
||||
}
|
||||
}, [projectMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!directoryPickerOpen) {
|
||||
return
|
||||
@@ -818,14 +738,14 @@ function ProjectSwitcherView({
|
||||
return activateModalFocus(
|
||||
() => dialogRef.current?.querySelector<HTMLElement>('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)') ??
|
||||
dialogRef.current?.querySelector<HTMLElement>('button:not(:disabled)') ?? null,
|
||||
() => restoreFocusTarget.current === 'create' ? createButtonRef.current : projectPickerButtonRef.current
|
||||
() => projectPickerButtonRef.current
|
||||
)
|
||||
}, [dialogMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogMode) {
|
||||
if (restoreFocusTarget.current === 'create') {
|
||||
createButtonRef.current?.focus()
|
||||
projectPickerButtonRef.current?.focus()
|
||||
} else if (restoreFocusTarget.current === 'picker') {
|
||||
projectPickerButtonRef.current?.focus()
|
||||
}
|
||||
@@ -1104,9 +1024,9 @@ function ProjectSwitcherView({
|
||||
}
|
||||
|
||||
const renderProjectMenuItem = (
|
||||
project: AssistantProject,
|
||||
ProjectIcon: LucideIcon
|
||||
project: AssistantProject
|
||||
): React.JSX.Element => {
|
||||
const ProjectIcon: LucideIcon = project.kind === 'channel' ? RadioTower : Folder
|
||||
const selected = project.id === activeProjectId
|
||||
const projectDisplay = getProjectDisplayText(project, t)
|
||||
const recovery =
|
||||
@@ -1140,16 +1060,11 @@ function ProjectSwitcherView({
|
||||
<button
|
||||
aria-checked={selected}
|
||||
onClick={() => {
|
||||
if (!selected || project.executionSpace.kind === 'ssh') {
|
||||
onSelect(project.id)
|
||||
}
|
||||
onSelect(project.id)
|
||||
setProjectMenuOpen(false)
|
||||
requestAnimationFrame(() =>
|
||||
projectPickerButtonRef.current?.focus()
|
||||
)
|
||||
}}
|
||||
role="menuitemradio"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
{project.kind === 'channel' && project.channel ? (
|
||||
@@ -1160,10 +1075,10 @@ function ProjectSwitcherView({
|
||||
<span>
|
||||
<span className="project-switcher__project-heading">
|
||||
<b>{projectDisplay.name}</b>
|
||||
{activityByProjectId[project.id] && (
|
||||
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
|
||||
)}
|
||||
</span>
|
||||
{activityByProjectId[project.id] && (
|
||||
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
|
||||
)}
|
||||
<small>{detail}</small>
|
||||
{recovery && <ProjectRecoveryStatus state={recovery} />}
|
||||
</span>
|
||||
@@ -1181,7 +1096,7 @@ function ProjectSwitcherView({
|
||||
disabled={retryingRecoveryProjectId === project.id}
|
||||
onClick={() => retryRecovery(project.id)}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
@@ -1195,7 +1110,7 @@ function ProjectSwitcherView({
|
||||
className="project-switcher__menu-settings"
|
||||
onClick={() => openProjectSettings(project)}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<Settings aria-hidden="true" size={14} />
|
||||
@@ -1213,10 +1128,15 @@ function ProjectSwitcherView({
|
||||
>
|
||||
<button
|
||||
aria-expanded={projectMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={projectMenuOpen ? menuId : undefined}
|
||||
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
className="project-switcher__trigger"
|
||||
onClick={() => setProjectMenuOpen((open) => !open)}
|
||||
aria-describedby={`${menuId}-activity${activeProjectRecovery ? ` ${menuId}-recovery` : ''}`}
|
||||
className="project-switcher__trigger project-switcher__control"
|
||||
onClick={(event) => {
|
||||
event.currentTarget.focus()
|
||||
setProjectMenuOpen((open) => !open)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!projectMenuOpen &&
|
||||
@@ -1232,161 +1152,30 @@ function ProjectSwitcherView({
|
||||
title={activeProjectDisplay?.name}
|
||||
type="button"
|
||||
>
|
||||
<Folder aria-hidden="true" size={16} />
|
||||
<span className="project-switcher__trigger-copy">
|
||||
<span>
|
||||
{activeProjectDisplay?.name ??
|
||||
t('projectSwitcher.selector.empty')}
|
||||
</span>
|
||||
{activeProjectRecovery && (
|
||||
<ProjectRecoveryStatus state={activeProjectRecovery} />
|
||||
)}
|
||||
<span className="project-switcher__activity" id={`${menuId}-activity`}>
|
||||
{visibleActivities.length > 0
|
||||
? <ProjectActivityCounts {...activityCounts} /> : t('projectActivity.idle')}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{projectMenuOpen && (
|
||||
<FloatingPortal anchorRef={projectPickerButtonRef}>
|
||||
<div
|
||||
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
className="project-switcher__menu"
|
||||
onKeyDown={(event) => {
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'[role^="menuitem"]'
|
||||
)
|
||||
)
|
||||
const currentIndex = items.indexOf(
|
||||
document.activeElement as HTMLButtonElement
|
||||
)
|
||||
let nextIndex: number | undefined
|
||||
if (event.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % items.length
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
nextIndex =
|
||||
(currentIndex - 1 + items.length) % items.length
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = items.length - 1
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setProjectMenuOpen(false)
|
||||
projectPickerButtonRef.current?.focus()
|
||||
}
|
||||
const nextItem =
|
||||
nextIndex === undefined ? undefined : items.at(nextIndex)
|
||||
if (nextItem) {
|
||||
event.preventDefault()
|
||||
const currentItem = items.at(currentIndex)
|
||||
if (currentItem) {
|
||||
currentItem.tabIndex = -1
|
||||
}
|
||||
nextItem.tabIndex = 0
|
||||
nextItem.focus()
|
||||
}
|
||||
}}
|
||||
ref={projectPickerMenuRef}
|
||||
role="menu"
|
||||
>
|
||||
{localProjects.length > 0 && (
|
||||
<div
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.userProjects'
|
||||
)}
|
||||
className="project-switcher__group"
|
||||
role="group"
|
||||
>
|
||||
<strong>
|
||||
{t('projectSwitcher.selector.userProjects')}
|
||||
</strong>
|
||||
{localProjects.map((project) =>
|
||||
renderProjectMenuItem(project, Folder)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{remoteProjectsEnabled &&
|
||||
remoteProjectGroups.length > 0 && (
|
||||
<div
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.remoteProjects'
|
||||
)}
|
||||
className="project-switcher__group"
|
||||
role="group"
|
||||
>
|
||||
<strong>
|
||||
{t('projectSwitcher.selector.remoteProjects')}
|
||||
</strong>
|
||||
{remoteProjectGroups.map(
|
||||
({ hostId, host, projects: hostProjects }) => {
|
||||
const hostName =
|
||||
host?.name ??
|
||||
t(
|
||||
'projectSwitcher.selector.unavailableHost'
|
||||
)
|
||||
const state =
|
||||
agentConnectionStatusByHostId[hostId] ??
|
||||
'disconnected'
|
||||
return (
|
||||
<div
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.remoteHostGroup',
|
||||
{ host: hostName }
|
||||
)}
|
||||
className="project-switcher__host-group"
|
||||
key={hostId}
|
||||
role="group"
|
||||
>
|
||||
<div className="project-switcher__host-heading">
|
||||
<span
|
||||
title={
|
||||
host
|
||||
? `${host.username}@${host.hostname}:${host.port}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Server aria-hidden="true" size={14} />
|
||||
<b>{hostName}</b>
|
||||
</span>
|
||||
<span
|
||||
className={`status-badge project-switcher__host-status project-switcher__host-status--${state}`}
|
||||
>
|
||||
{t(
|
||||
`projectSwitcher.selector.connectionStates.${state}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{hostProjects.map((project) =>
|
||||
renderProjectMenuItem(project, Folder)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{channelProjects.length > 0 && (
|
||||
<div
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.channelProjects'
|
||||
)}
|
||||
className="project-switcher__group"
|
||||
role="group"
|
||||
>
|
||||
<strong>
|
||||
{t('projectSwitcher.selector.channelProjects')}
|
||||
</strong>
|
||||
{channelProjects.map((project) =>
|
||||
renderProjectMenuItem(project, RadioTower)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('projectSwitcher.selector.create')}
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
{activeProjectRecovery && <span id={`${menuId}-recovery`} className="project-switcher__recovery-feedback">
|
||||
<ProjectRecoveryStatus state={activeProjectRecovery} />
|
||||
</span>}
|
||||
{projectMenuOpen && <WorkspaceMenu
|
||||
id={menuId} projects={projects} activeProjectId={activeProjectId}
|
||||
activities={visibleActivities} conversationStore={conversationStore} remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
hosts={sshHosts?.hosts ?? emptyHosts} connectionStates={agentConnectionStatusByHostId}
|
||||
anchorRef={projectPickerButtonRef} controlsRef={projectPickerRef}
|
||||
renderProject={renderProjectMenuItem} onClose={closeProjectMenu}
|
||||
onNewConversation={onNewConversation} onOpenConversation={onOpenConversation}
|
||||
onCreateProject={() => {
|
||||
setProjectMenuOpen(false)
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
@@ -1402,12 +1191,8 @@ function ProjectSwitcherView({
|
||||
})
|
||||
restoreFocusTarget.current = 'create'
|
||||
setDialogMode('create')
|
||||
}}
|
||||
ref={createButtonRef}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
}} />}
|
||||
</div>
|
||||
</div>
|
||||
{dialogMode && createPortal(
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type { Conversation } from './chat-conversation'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { createConversationStores } from './conversation-store'
|
||||
import { ProjectSwitcher } from './ProjectSwitcher'
|
||||
import i18n from './i18n'
|
||||
|
||||
const local: AssistantProject = {
|
||||
id: 'local', name: 'Local workspace', description: '', rootPath: 'C:/workspace',
|
||||
executionSpace: { kind: 'local', rootPath: 'C:/workspace' }, defaultWorkMode: 'ask',
|
||||
kind: 'user', status: 'active', createdAt: '2026-10-01T00:00:00Z', updatedAt: '2026-10-01T00:00:00Z'
|
||||
}
|
||||
const other = { ...local, id: 'other', name: 'Other workspace', rootPath: 'C:/other' }
|
||||
const conversation = (id: string, projectId = local.id, updatedAt = 1): Conversation => ({ id, projectId, title: id, updatedAt, messages: [] })
|
||||
|
||||
function setup({ projects = [local, other], conversations = [conversation('Local chat'), conversation('Other chat', other.id)],
|
||||
activities = [] as ConversationActivity[], remoteProjectsEnabled = false } = {}) {
|
||||
const store = createConversationStores(conversations, { flushIntervalMs: 250 }).conversations
|
||||
const actions = { onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(), onRemoteCommitted: vi.fn(),
|
||||
onSelect: vi.fn(), onSelectRoot: vi.fn(), onUpdate: vi.fn(), onOpenConversation: vi.fn(), onNewConversation: vi.fn() }
|
||||
const props = { ...actions, projects, conversationStore: store, activeProjectId: local.id, activities, remoteProjectsEnabled }
|
||||
const renders = vi.fn()
|
||||
const view = render(<Profiler id="workspace" onRender={renders}><ProjectSwitcher {...props} /></Profiler>)
|
||||
return { ...view, actions, store, props, renders }
|
||||
}
|
||||
|
||||
afterEach(async () => { cleanup(); vi.restoreAllMocks(); await i18n.changeLanguage('zh-CN') })
|
||||
|
||||
describe('production workspace menu', () => {
|
||||
it('puts All Projects first in a fixed tab row and filters categories only when activated', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const remote: AssistantProject = { ...other, id: 'remote', name: 'Remote workspace', rootPath: '/srv/workspace',
|
||||
executionSpace: { kind: 'ssh', hostId: 'host', remoteRootPath: '/srv/workspace' } }
|
||||
const channel: AssistantProject = { ...other, id: 'channel', name: 'Team channel', kind: 'channel', channel: 'wecom' }
|
||||
const { actions } = setup({ projects: [local, remote, channel], remoteProjectsEnabled: true,
|
||||
conversations: [conversation('Local chat'), conversation('Remote chat', remote.id)] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const tabs = screen.getByRole('tablist', { name: 'Project categories' })
|
||||
expect(within(tabs).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['All projects', 'Local', 'Remote', 'Channels'])
|
||||
const list = screen.getByRole('menu', { name: 'Current project' })
|
||||
expect(list).not.toContainElement(tabs)
|
||||
expect(Array.from(list.querySelectorAll('.workspace-menu__project-category'), (heading) => heading.textContent))
|
||||
.toEqual(['Local', 'Remote', 'Channels'])
|
||||
const all = within(tabs).getByRole('tab', { name: 'All projects' })
|
||||
fireEvent.click(all)
|
||||
expect(screen.getByRole('region', { name: 'All projects' })).toHaveClass('workspace-menu__activity')
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(3)
|
||||
fireEvent.pointerOver(within(tabs).getByRole('tab', { name: 'Channels' }), { pointerType: 'mouse' })
|
||||
expect(all).toHaveAttribute('aria-selected', 'true')
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(3)
|
||||
|
||||
for (const [label, project] of [['Local', local], ['Remote', remote], ['Channels', channel]] as const) {
|
||||
const tab = within(tabs).getByRole('tab', { name: label })
|
||||
fireEvent.click(tab)
|
||||
expect(tab).toHaveAttribute('aria-selected', 'true')
|
||||
const panel = screen.getByRole('tabpanel', { name: label })
|
||||
expect(tab).toHaveAttribute('aria-controls', panel.id)
|
||||
expect(panel).toContainElement(list)
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(1)
|
||||
expect(within(list).getByRole('menuitemradio', { name: new RegExp(project.name) })).toBeVisible()
|
||||
}
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'workspace' } })
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(2)
|
||||
expect(within(list).getByRole('menuitemradio', { name: /Local workspace/ })).toBeVisible()
|
||||
const remoteRow = within(list).getByRole('menuitemradio', { name: /Remote workspace/ })
|
||||
fireEvent.pointerOver(remoteRow, { pointerType: 'mouse' })
|
||||
const preview = screen.getByRole('region', { name: remote.name })
|
||||
expect(within(preview).getByRole('button', { name: 'Remote chat' })).toBeVisible()
|
||||
expect(within(preview).queryByText(remote.name)).not.toBeInTheDocument()
|
||||
expect(within(tabs).getByRole('tab', { name: 'Channels' })).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(1)
|
||||
expect(within(list).getByRole('menuitemradio', { name: /Team channel/ })).toBeVisible()
|
||||
fireEvent.pointerOver(all, { pointerType: 'mouse' })
|
||||
expect(screen.getByRole('region', { name: remote.name })).toBeVisible()
|
||||
fireEvent.click(all)
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(3)
|
||||
expect(within(screen.getByRole('region', { name: 'All projects' })).getByText(remote.name)).toBeVisible()
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
expect(actions.onOpenConversation).not.toHaveBeenCalled()
|
||||
expect(actions.onNewConversation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps shared tab keyboard selection and omits Remote when it is disabled', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const tabs = screen.getByRole('tablist', { name: 'Project categories' })
|
||||
expect(within(tabs).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['All projects', 'Local', 'Channels'])
|
||||
const all = within(tabs).getByRole('tab', { name: 'All projects' })
|
||||
expect(all).toHaveFocus()
|
||||
fireEvent.keyDown(all, { key: 'ArrowRight' })
|
||||
const localTab = within(tabs).getByRole('tab', { name: 'Local' })
|
||||
expect(localTab).toHaveFocus()
|
||||
expect(localTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(all).toHaveAttribute('tabindex', '-1')
|
||||
fireEvent.keyDown(localTab, { key: 'End' })
|
||||
expect(within(tabs).getByRole('tab', { name: 'Channels' })).toHaveFocus()
|
||||
expect(screen.getByRole('tabpanel', { name: 'Channels' })).not.toContainElement(screen.queryByRole('menuitemradio'))
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
|
||||
expect(all).toHaveFocus()
|
||||
expect(screen.getByRole('region', { name: 'All projects' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('previews without navigation or IPC, keeps current separate, and manages settings independently', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const get = vi.fn()
|
||||
const listSummaries = vi.fn()
|
||||
const original = window.goodbuddy
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { conversations: { get, listSummaries } } })
|
||||
try {
|
||||
const { actions } = setup()
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
fireEvent.click(trigger)
|
||||
const current = screen.getByRole('menuitemradio', { name: /Local workspace/ })
|
||||
const target = screen.getByRole('menuitemradio', { name: /Other workspace/ })
|
||||
fireEvent.pointerOver(target, { pointerType: 'mouse' })
|
||||
const list = screen.getByRole('list', { name: 'Conversations' })
|
||||
expect(within(list).getByRole('button', { name: 'Other chat' })).toBeVisible()
|
||||
expect(within(list).queryByText('Other workspace')).not.toBeInTheDocument()
|
||||
const scopedPane = screen.getByRole('region', { name: 'Other workspace' })
|
||||
expect(scopedPane).toHaveClass('workspace-menu__activity')
|
||||
expect(within(scopedPane).queryByText('Other workspace')).not.toBeInTheDocument()
|
||||
expect(current).toHaveAttribute('aria-checked', 'true')
|
||||
expect(target).toHaveAttribute('aria-checked', 'false')
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
expect(actions.onOpenConversation).not.toHaveBeenCalled()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(listSummaries).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Manage project Other workspace' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Project settings' })).toBeVisible()
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close project settings' }))
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Other workspace/ }))
|
||||
expect(actions.onSelect).toHaveBeenCalledExactlyOnceWith(other.id)
|
||||
expect(screen.queryByRole('dialog', { name: 'Projects and activity' })).not.toBeInTheDocument()
|
||||
} finally {
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
it('opens current-project activity from the unified button and offers all projects inside', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const conversations = Array.from({ length: 12 }, (_, i) => conversation(`Finished ${i}`, local.id, 100 - i))
|
||||
const activities: ConversationActivity[] = conversations.map((row) => ({ conversationId: row.id, projectId: local.id,
|
||||
projectName: local.name, title: row.title, status: 'completed' }))
|
||||
activities.push({ conversationId: 'Running', projectId: other.id, projectName: other.name, title: 'Running chat', status: 'running' })
|
||||
const { actions, rerender, props } = setup({ conversations, activities })
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
expect(screen.getAllByRole('button')).toEqual([trigger])
|
||||
expect(trigger).toHaveAccessibleDescription('1 running 12 completed')
|
||||
fireEvent.click(within(trigger).getByText('12 completed'))
|
||||
const menu = screen.getByRole('dialog', { name: 'Projects and activity' })
|
||||
expect(trigger).toHaveAttribute('aria-controls', menu.id)
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('region', { name: local.name })).toBeVisible()
|
||||
const all = screen.getByRole('tab', { name: 'All projects' })
|
||||
expect(all).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getAllByRole('tab')[0]).toBe(all)
|
||||
const list = screen.getByRole('list', { name: 'Conversations' })
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(10)
|
||||
expect(within(list).queryByText('Running chat')).not.toBeInTheDocument()
|
||||
fireEvent.click(all)
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(11)
|
||||
expect(within(list).getAllByText(local.name)).toHaveLength(10)
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Filter conversation status' })).getByRole('button', { name: 'Completed' }))
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(12)
|
||||
fireEvent.click(within(list).getByRole('button', { name: /Finished 0/ }))
|
||||
expect(actions.onOpenConversation).toHaveBeenCalledExactlyOnceWith('Finished 0')
|
||||
rerender(<ProjectSwitcher {...props} activities={activities.filter((row) => row.conversationId !== 'Finished 0')} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
expect(within(screen.getByRole('list', { name: 'Conversations' })).getByRole('button', { name: 'Finished 0' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: local.name })).toBeVisible()
|
||||
})
|
||||
|
||||
it('searches across categories, creates in preview scope, and explains channel restrictions', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const channel: AssistantProject = { ...other, id: 'channel', name: 'Team channel', kind: 'channel', channel: 'wecom' }
|
||||
const { actions } = setup({ projects: [local, other, channel] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local' }))
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'team' } })
|
||||
expect(screen.queryByRole('menuitemradio', { name: /Local workspace/ })).not.toBeInTheDocument()
|
||||
const channelRow = screen.getByRole('menuitemradio', { name: /Team channel/ })
|
||||
fireEvent.pointerOver(channelRow, { pointerType: 'mouse' })
|
||||
expect(screen.getByRole('button', { name: 'New conversation' })).toBeDisabled()
|
||||
expect(screen.getByText(/automatically/i, { selector: '.workspace-menu__channel-note' })).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'C:/other' } })
|
||||
fireEvent.pointerOver(screen.getByRole('menuitemradio', { name: /Other workspace/ }), { pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New conversation' }))
|
||||
expect(actions.onNewConversation).toHaveBeenCalledExactlyOnceWith(other.id)
|
||||
})
|
||||
|
||||
it('windows large project lists while keyboard End reaches the last row and Escape restores focus', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
setup({ projects: [local, ...Array.from({ length: 400 }, (_, i) => ({ ...other, id: `p${i}`, name: `Project ${i}` }))] })
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('menuitemradio').length).toBeLessThan(120)
|
||||
fireEvent.keyDown(screen.getByRole('menu', { name: 'Current project' }), { key: 'End' })
|
||||
expect(screen.getByRole('menuitemradio', { name: /Project 399/ })).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not rerender the menu for message content deltas with unchanged summaries', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const initial = { ...conversation('Streaming chat'), messages: [{ id: 'm', role: 'assistant' as const, content: 'first', createdAt: 1, state: 'streaming' as const }] }
|
||||
const { store, renders } = setup({ conversations: [initial] })
|
||||
act(() => store.rememberOpened(initial.id))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const activityTimes = store.getActivityTimes()
|
||||
renders.mockClear()
|
||||
act(() => store.set((rows) => rows.map((row) => ({ ...row, updatedAt: Date.now(), messages: [{ ...initial.messages[0]!, content: 'streaming text' }] }))))
|
||||
expect(renders).not.toHaveBeenCalled()
|
||||
expect(store.getActivityTimes()).toBe(activityTimes)
|
||||
})
|
||||
|
||||
it('records activity transitions while closed and promotes task-only completions when opened', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const conversations = Array.from({ length: 12 }, (_, i) => conversation(`Recent ${i}`, local.id, 1000 - i))
|
||||
const activity: ConversationActivity = { conversationId: 'task-only', projectId: local.id,
|
||||
projectName: local.name, title: 'Older background task', status: 'running' }
|
||||
const { rerender, props, store } = setup({ conversations, activities: [activity] })
|
||||
rerender(<ProjectSwitcher {...props} activities={[{ ...activity, status: 'completed' }]} />)
|
||||
expect(store.getActivityTimes().get(activity.conversationId)).toBeGreaterThan(1000)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const rows = within(screen.getByRole('list', { name: 'Conversations' })).getAllByRole('button')
|
||||
expect(rows).toHaveLength(10)
|
||||
expect(rows[0]).toHaveTextContent(activity.title)
|
||||
})
|
||||
|
||||
it('keeps a long completed list bounded and lets the keyboard open its last exact ID', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const conversations = Array.from({ length: 300 }, (_, i) => conversation(`Result ${i}`, local.id, 1000 - i))
|
||||
const activities: ConversationActivity[] = conversations.map((row) => ({ conversationId: row.id, projectId: local.id,
|
||||
projectName: local.name, title: row.title, status: 'completed' }))
|
||||
const { actions } = setup({ conversations, activities })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Filter conversation status' })).getByRole('button', { name: 'Completed' }))
|
||||
const list = screen.getByRole('list', { name: 'Conversations' })
|
||||
expect(within(list).getAllByRole('button').length).toBeLessThan(120)
|
||||
fireEvent.keyDown(list, { key: 'End' })
|
||||
const last = within(list).getByRole('button', { name: 'Result 299 Completed' })
|
||||
expect(last).toHaveFocus()
|
||||
expect(last.closest('[role="listitem"]')).toHaveAttribute('aria-setsize', '300')
|
||||
fireEvent.click(last)
|
||||
expect(actions.onOpenConversation).toHaveBeenCalledExactlyOnceWith('Result 299')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
import { ChevronDown, Plus, Server, X } from 'lucide-react'
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type { SshHost, SshHostAgentConnectionState } from '../../shared/ssh-host-contracts'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import type { ConversationStore } from './conversation-store'
|
||||
import { FloatingPortal } from './FloatingPortal'
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { useListWindow } from './use-list-window'
|
||||
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
|
||||
import { useWorkspaceConversations, workspaceConversationRows, type WorkspaceFilter } from './workspace-menu-selectors'
|
||||
import './workspace-menu.css'
|
||||
|
||||
type ProjectCategory = 'allProjects' | 'local' | 'remote' | 'channels'
|
||||
type ProjectRow =
|
||||
| { id: string; kind: 'host'; hostId: string; label: string; state: SshHostAgentConnectionState }
|
||||
| { id: string; kind: 'project'; project: AssistantProject }
|
||||
|
||||
export type WorkspaceMenuProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
activities: ConversationActivity[]
|
||||
conversationStore: ConversationStore
|
||||
remoteProjectsEnabled: boolean
|
||||
hosts: readonly SshHost[]
|
||||
connectionStates: Record<string, SshHostAgentConnectionState>
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>
|
||||
controlsRef: React.RefObject<HTMLDivElement | null>
|
||||
id: string
|
||||
renderProject: (project: AssistantProject) => ReactNode
|
||||
onClose: () => void
|
||||
onCreateProject: () => void
|
||||
onNewConversation: (projectId: string) => void
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
}
|
||||
|
||||
export function WorkspaceMenu({
|
||||
projects, activeProjectId, activities, conversationStore, remoteProjectsEnabled,
|
||||
hosts, connectionStates, anchorRef, controlsRef, id, renderProject,
|
||||
onClose, onCreateProject, onNewConversation, onOpenConversation
|
||||
}: WorkspaceMenuProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const { t: tApp } = useTranslation('app')
|
||||
const titleId = useId()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const projectListRef = useRef<HTMLDivElement>(null)
|
||||
const conversationListRef = useRef<HTMLDivElement>(null)
|
||||
const categoriesRef = useRef<HTMLDivElement>(null)
|
||||
const [category, setCategory] = useState<ProjectCategory>('allProjects')
|
||||
if (category === 'remote' && !remoteProjectsEnabled) setCategory('allProjects')
|
||||
const [scope, setScope] = useState<string | null>(activeProjectId)
|
||||
const [filter, setFilter] = useState<WorkspaceFilter>('all')
|
||||
const [query, setQuery] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const [keyboardProject, setKeyboardProject] = useState<string>()
|
||||
const [keyboardConversation, setKeyboardConversation] = useState<string>()
|
||||
const activityTimes = useSyncExternalStore(conversationStore.subscribeActivityTimes, conversationStore.getActivityTimes)
|
||||
const conversations = useWorkspaceConversations(conversationStore, tApp('conversation.defaultTitle'), activityTimes)
|
||||
const visibleProjects = useMemo(() => projects.filter((project) =>
|
||||
project.kind === 'channel' || project.executionSpace.kind !== 'ssh' || remoteProjectsEnabled
|
||||
), [projects, remoteProjectsEnabled])
|
||||
const projectById = useMemo(() => new Map(visibleProjects.map((project) => [project.id, project])), [visibleProjects])
|
||||
const availableActivities = useMemo(() => activities.filter((activity) => !activity.projectId || projectById.has(activity.projectId)), [activities, projectById])
|
||||
const availableConversations = useMemo(() => conversations.filter((conversation) => {
|
||||
const project = projectById.get(conversation.projectId ?? '')
|
||||
return (!conversation.projectId || project) && (project?.kind !== 'channel' || conversation.channel)
|
||||
}), [conversations, projectById])
|
||||
const categoryTabs = useMemo(() => {
|
||||
const categories: ProjectCategory[] = ['allProjects', 'local', ...(remoteProjectsEnabled ? ['remote' as const] : []), 'channels']
|
||||
return categories.map((id) => ({ id, label: t(`workspaceMenu.${id}`) }))
|
||||
}, [remoteProjectsEnabled, t])
|
||||
const projectRows = useMemo(() => {
|
||||
const hostById = new Map(hosts.map((host) => [host.id, host]))
|
||||
const search = query.trim().toLocaleLowerCase()
|
||||
const matching = visibleProjects.filter((project) => {
|
||||
const host = project.executionSpace.kind === 'ssh' ? hostById.get(project.executionSpace.hostId) : undefined
|
||||
if (search) return `${getProjectDisplayText(project, t).name} ${project.rootPath} ${host?.name ?? ''} ${host?.hostname ?? ''} ${project.channel ?? ''}`.toLocaleLowerCase().includes(search)
|
||||
const kind = project.kind === 'channel' ? 'channels' : project.executionSpace.kind === 'ssh' ? 'remote' : 'local'
|
||||
return category === 'allProjects' || category === kind
|
||||
})
|
||||
const rows: ProjectRow[] = []
|
||||
const addProjects = (items: AssistantProject[]): void => {
|
||||
for (const project of items) rows.push({ id: project.id, kind: 'project', project })
|
||||
}
|
||||
addProjects(matching.filter((project) => project.kind !== 'channel' && project.executionSpace.kind === 'local'))
|
||||
if (remoteProjectsEnabled) {
|
||||
const groups = new Map<string, AssistantProject[]>()
|
||||
for (const project of matching) {
|
||||
if (project.kind === 'channel' || project.executionSpace.kind !== 'ssh') continue
|
||||
const hostId = project.executionSpace.hostId
|
||||
const group = groups.get(hostId) ?? []
|
||||
group.push(project)
|
||||
groups.set(hostId, group)
|
||||
}
|
||||
for (const [hostId, group] of groups) {
|
||||
rows.push({ id: `host:${hostId}`, kind: 'host', hostId,
|
||||
label: hostById.get(hostId)?.name ?? t('projectSwitcher.selector.unavailableHost'),
|
||||
state: connectionStates[hostId] ?? 'disconnected' })
|
||||
if (search || !collapsed.has(hostId)) addProjects(group)
|
||||
}
|
||||
}
|
||||
addProjects(matching.filter((project) => project.kind === 'channel'))
|
||||
return rows
|
||||
}, [visibleProjects, hosts, query, category, t, remoteProjectsEnabled, connectionStates, collapsed])
|
||||
const rows = useMemo(() => workspaceConversationRows(availableConversations, availableActivities, scope, filter, activityTimes), [availableConversations, availableActivities, scope, filter, activityTimes])
|
||||
const projectIds = useMemo(() => projectRows.map((row) => row.id), [projectRows])
|
||||
const conversationIds = useMemo(() => rows.map((row) => row.id), [rows])
|
||||
const projectWindow = useListWindow({ ids: projectIds, scope: `${category}:${query}`, scrollRef: projectListRef,
|
||||
keepIds: [keyboardProject], enabled: true, resetScrollOnScopeChange: true, estimatedRowHeight: 64 })
|
||||
const conversationWindow = useListWindow({ ids: conversationIds, scope: `${scope}:${filter}`, scrollRef: conversationListRef,
|
||||
keepIds: [keyboardConversation], enabled: true, resetScrollOnScopeChange: true, estimatedRowHeight: 56 })
|
||||
const newProject = projectById.get(scope ?? activeProjectId)
|
||||
const scopeName = scope === null ? t('workspaceMenu.allProjects')
|
||||
: projectById.has(scope) ? getProjectDisplayText(projectById.get(scope)!, t).name : t('projectActivity.unassigned')
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const menu = menuRef.current
|
||||
const anchor = anchorRef.current
|
||||
if (!menu || !anchor) return
|
||||
const position = (): void => {
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const width = Math.min(920, window.innerWidth - 32)
|
||||
menu.style.width = `${width}px`
|
||||
menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - width - 16))}px`
|
||||
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - menu.offsetHeight - 16))}px`
|
||||
}
|
||||
position()
|
||||
const observer = new ResizeObserver(position)
|
||||
observer.observe(anchor)
|
||||
observer.observe(menu)
|
||||
window.addEventListener('resize', position)
|
||||
window.addEventListener('scroll', position, true)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', position)
|
||||
window.removeEventListener('scroll', position, true)
|
||||
}
|
||||
}, [anchorRef])
|
||||
|
||||
useEffect(() => {
|
||||
const menu = menuRef.current
|
||||
const anchor = anchorRef.current
|
||||
categoriesRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]')?.focus()
|
||||
const outside = (event: Event): void => {
|
||||
if (!menu?.contains(event.target as Node) && !controlsRef.current?.contains(event.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener('pointerdown', outside)
|
||||
document.addEventListener('focusin', outside)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', outside)
|
||||
document.removeEventListener('focusin', outside)
|
||||
if (document.activeElement === document.body || menu?.contains(document.activeElement)) {
|
||||
anchor?.focus()
|
||||
}
|
||||
}
|
||||
}, [anchorRef, controlsRef, onClose])
|
||||
|
||||
function moveFocus(event: React.KeyboardEvent, list: 'projects' | 'conversations'): void {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
|
||||
const ids = list === 'projects' ? projectIds : conversationIds
|
||||
if (!ids.length) return
|
||||
const current = (event.target as HTMLElement).closest<HTMLElement>('[data-list-window-row]')?.dataset.listWindowRow
|
||||
const index = current ? ids.indexOf(current) : -1
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? ids.length - 1
|
||||
: (index + (event.key === 'ArrowDown' ? 1 : -1) + ids.length) % ids.length
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
flushSync(() => (list === 'projects' ? setKeyboardProject : setKeyboardConversation)(ids[next]))
|
||||
const element = (list === 'projects' ? projectListRef : conversationListRef).current
|
||||
const row = Array.from(element?.querySelectorAll<HTMLElement>('[data-list-window-row]') ?? [])
|
||||
.find((item) => item.dataset.listWindowRow === ids[next])
|
||||
row?.querySelector<HTMLButtonElement>('button')?.focus()
|
||||
row?.scrollIntoView?.({ block: 'nearest' })
|
||||
}
|
||||
|
||||
return <FloatingPortal anchorRef={anchorRef}>
|
||||
<div className="project-switcher__menu workspace-menu" ref={menuRef} id={id} role="dialog" aria-labelledby={titleId}
|
||||
onKeyDown={(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose() }
|
||||
if (event.key === 'ArrowRight' && projectListRef.current?.contains(event.target as Node) && rows.length) {
|
||||
event.preventDefault()
|
||||
flushSync(() => setKeyboardConversation(rows[0]!.id))
|
||||
conversationListRef.current?.querySelector<HTMLButtonElement>('button')?.focus()
|
||||
}
|
||||
if (event.key === 'ArrowLeft' && conversationListRef.current?.contains(event.target as Node)) {
|
||||
event.preventDefault()
|
||||
flushSync(() => setKeyboardProject(scope ?? undefined))
|
||||
const row = Array.from(projectListRef.current?.querySelectorAll<HTMLElement>('[data-list-window-row]') ?? [])
|
||||
.find((item) => item.dataset.listWindowRow === scope)
|
||||
const target = row?.querySelector<HTMLButtonElement>('button') ?? categoriesRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]')
|
||||
target?.focus()
|
||||
}
|
||||
}}>
|
||||
<header className="workspace-menu__header">
|
||||
<strong id={titleId}>{t('workspaceMenu.title')}</strong>
|
||||
<button type="button" className="icon-button" aria-label={t('workspaceMenu.close')} onClick={onClose}><X size={16} /></button>
|
||||
</header>
|
||||
<div className="workspace-menu__layout">
|
||||
<section className="workspace-menu__projects">
|
||||
<input className="field-control workspace-menu__search" type="search" aria-label={t('workspaceMenu.search')}
|
||||
placeholder={t('workspaceMenu.search')} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<div className="workspace-menu__categories" ref={categoriesRef}>
|
||||
<PageTabs ariaLabel={t('workspaceMenu.categories')} idPrefix={`${id}-categories`} variant="segmented"
|
||||
tabs={categoryTabs} value={category} onChange={(next) => {
|
||||
setCategory(next)
|
||||
if (next === 'allProjects') setScope(null)
|
||||
}} />
|
||||
</div>
|
||||
<div className="workspace-menu__project-panel" role="tabpanel" id={`${id}-categories-panel-${category}`}
|
||||
aria-labelledby={query.trim() ? undefined : `${id}-categories-tab-${category}`}
|
||||
aria-label={query.trim() ? t('workspaceMenu.search') : undefined}>
|
||||
<div className="workspace-menu__project-list" ref={projectListRef} role="menu" aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
onScroll={projectWindow.onScroll} onFocus={projectWindow.onFocus} onBlur={projectWindow.onBlur}
|
||||
onKeyDown={(event) => moveFocus(event, 'projects')}>
|
||||
{projectWindow.segments.map((segment) => {
|
||||
if (segment.kind === 'spacer') return <div key={segment.key} data-list-window-spacer aria-hidden="true" style={{ height: segment.height }} />
|
||||
const row = projectRows[segment.index]!
|
||||
const previous = projectRows[segment.index - 1]
|
||||
const rowCategory = row.kind === 'host' ? 'remote' : row.project.kind === 'channel' ? 'channels' : row.project.executionSpace.kind === 'ssh' ? 'remote' : 'local'
|
||||
const previousCategory = !previous ? undefined : previous.kind === 'host' ? 'remote' : previous.project.kind === 'channel' ? 'channels' : previous.project.executionSpace.kind === 'ssh' ? 'remote' : 'local'
|
||||
return <div key={row.id} data-list-window-row={row.id} ref={projectWindow.rowRef(row.id)} role="presentation"
|
||||
data-preview={row.kind === 'project' && scope === row.project.id ? 'true' : undefined}
|
||||
onPointerOver={(event) => {
|
||||
if (row.kind === 'project' && event.pointerType !== 'touch' && (event.target as HTMLElement).closest('[role="menuitemradio"]')) setScope(row.project.id)
|
||||
}} onFocus={(event) => {
|
||||
if (row.kind === 'project' && (event.target as HTMLElement).matches('[role="menuitemradio"]')) setScope(row.project.id)
|
||||
}}>
|
||||
{(category === 'allProjects' || query.trim()) && rowCategory !== previousCategory &&
|
||||
<h3 className="workspace-menu__project-category">{t(`workspaceMenu.${rowCategory}`)}</h3>}
|
||||
{row.kind === 'project' ? renderProject(row.project)
|
||||
: <button type="button" role="menuitem" className="project-switcher__host-heading workspace-menu__host"
|
||||
aria-expanded={Boolean(query.trim()) || !collapsed.has(row.hostId)} onClick={() => setCollapsed((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(row.hostId)) next.delete(row.hostId); else next.add(row.hostId)
|
||||
return next
|
||||
})}>
|
||||
<span><ChevronDown size={12} aria-hidden="true" /><Server size={14} aria-hidden="true" /><b>{row.label}</b></span>
|
||||
<span className={`status-badge project-switcher__host-status project-switcher__host-status--${row.state}`}>
|
||||
{t(`projectSwitcher.selector.connectionStates.${row.state}`)}
|
||||
</span>
|
||||
</button>}
|
||||
</div>
|
||||
})}
|
||||
{!projectRows.length && <p className="workspace-menu__empty">{t('workspaceMenu.noProjects')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="workspace-menu__footer"><button type="button" className="secondary-button" onClick={onCreateProject}>
|
||||
<Plus size={15} aria-hidden="true" />{t('projectSwitcher.selector.create')}
|
||||
</button></footer>
|
||||
</section>
|
||||
<section className="workspace-menu__activity" aria-label={scopeName}>
|
||||
<div className="workspace-menu__toolbar">
|
||||
<SegmentedControl ariaLabel={t('workspaceMenu.filter')} value={filter} onChange={setFilter}
|
||||
options={(['all', 'attention', 'running', 'completed'] as const).map((value) => ({ value, label: t(`workspaceMenu.filters.${value}`) }))} />
|
||||
<button type="button" className="workspace-menu__new" disabled={!newProject || newProject.kind === 'channel'}
|
||||
title={newProject?.kind === 'channel' ? tApp('notices.channelConversationAutomatic') : t('workspaceMenu.newIn', { name: newProject ? getProjectDisplayText(newProject, t).name : '' })}
|
||||
onClick={() => { if (newProject) { onClose(); onNewConversation(newProject.id) } }}>
|
||||
<Plus size={14} aria-hidden="true" />{t('workspaceMenu.newConversation')}
|
||||
</button>
|
||||
</div>
|
||||
{newProject?.kind === 'channel' && <small className="workspace-menu__channel-note">{tApp('notices.channelConversationAutomatic')}</small>}
|
||||
<div className="workspace-menu__conversations" ref={conversationListRef} role="list" aria-label={t('workspaceMenu.conversations')}
|
||||
onScroll={conversationWindow.onScroll} onFocus={conversationWindow.onFocus} onBlur={conversationWindow.onBlur}
|
||||
onKeyDown={(event) => moveFocus(event, 'conversations')}>
|
||||
{conversationWindow.segments.map((segment) => {
|
||||
if (segment.kind === 'spacer') return <div key={segment.key} data-list-window-spacer aria-hidden="true" style={{ height: segment.height }} />
|
||||
const row = rows[segment.index]!
|
||||
const project = projectById.get(row.projectId ?? '')
|
||||
return <div key={row.id} data-list-window-row={row.id} ref={conversationWindow.rowRef(row.id)} role="listitem"
|
||||
aria-posinset={segment.index + 1} aria-setsize={rows.length}>
|
||||
{(segment.index === 0 || rows[segment.index - 1]?.group !== row.group) &&
|
||||
<h3 className="workspace-menu__category">{t(`workspaceMenu.groups.${row.group}`)}</h3>}
|
||||
<button type="button" className="workspace-menu__conversation" onClick={() => { onClose(); onOpenConversation(row.id) }}>
|
||||
<span><strong>{row.title}</strong>{scope === null && <>{' '}<small>{project ? getProjectDisplayText(project, t).name : t('projectActivity.unassigned')}</small></>}</span>{' '}
|
||||
{row.status && <small className={`project-activity__${row.status === 'running' || row.status === 'completed' ? row.status : 'attention'}`}>{t(`projectActivity.status.${row.status}`)}</small>}
|
||||
</button>
|
||||
</div>
|
||||
})}
|
||||
{!rows.length && <p className="workspace-menu__empty">{t('workspaceMenu.noConversations')}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
}
|
||||
@@ -118,6 +118,13 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(platformFeaturesTabs).toBeDefined()
|
||||
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
it('keeps workspace category tabs fixed outside the scrolling project panel', () => {
|
||||
const workspaceStylesheet = readFileSync(join(process.cwd(), 'src', 'renderer', 'src', 'workspace-menu.css'), 'utf8')
|
||||
expect(workspaceStylesheet).toMatch(/\.workspace-menu__categories\s*\{[^}]*flex:\s*0 0 auto;/u)
|
||||
expect(workspaceStylesheet).toMatch(/\.workspace-menu__project-panel\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;/u)
|
||||
expect(stylesheet).toMatch(/\.page-tabs\s*\{[^}]*overflow-x:\s*auto;[^}]*flex-wrap:\s*nowrap;/u)
|
||||
})
|
||||
|
||||
it('keeps shared segmented controls from collapsing beside scrollable content', () => {
|
||||
const segmentedStyles = stylesheet.match(
|
||||
/\.segmented-control,\s*\.page-tabs--segmented\s*\{(?<rules>[^}]*)\}/u
|
||||
|
||||
@@ -21,16 +21,18 @@ function latestMessageTime(messages: readonly Message[]): number | undefined {
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function conversationActivityTime(conversation: Conversation): number {
|
||||
const summaryTime = conversation.messageSummary?.latestMessageAt;
|
||||
const messageTime = latestMessageTime(conversation.messages);
|
||||
return (summaryTime === undefined ? messageTime : messageTime === undefined
|
||||
? summaryTime : Math.max(summaryTime, messageTime)) ?? conversation.updatedAt;
|
||||
}
|
||||
|
||||
export function sortConversationsForDisplay(conversations: readonly Conversation[]): Conversation[] {
|
||||
// updatedAt tracks snapshot freshness, including every streaming delta.
|
||||
// Compute stable message times once per conversation, not per comparison.
|
||||
return conversations.map(conversation => {
|
||||
const summaryTime = conversation.messageSummary?.latestMessageAt;
|
||||
const messageTime = latestMessageTime(conversation.messages);
|
||||
const latest = summaryTime === undefined
|
||||
? messageTime
|
||||
: messageTime === undefined ? summaryTime : Math.max(summaryTime, messageTime);
|
||||
return { conversation, time: latest ?? conversation.updatedAt };
|
||||
return { conversation, time: conversationActivityTime(conversation) };
|
||||
}).sort((left, right) =>
|
||||
Number(Boolean(right.conversation.pinned)) - Number(Boolean(left.conversation.pinned)) ||
|
||||
right.time - left.time || left.conversation.id.localeCompare(right.conversation.id),
|
||||
|
||||
@@ -34,6 +34,52 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("conversation store", () => {
|
||||
it("tracks visits and status transitions in session memory without changing persisted snapshots", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const initial = [conversation("old", [], { projectId: "project", updatedAt: 10 })];
|
||||
const store = createConversationStore(initial, createLiveMessageStore());
|
||||
const persisted = vi.fn();
|
||||
const activity = vi.fn();
|
||||
store.subscribe(persisted);
|
||||
store.subscribeActivityTimes(activity);
|
||||
store.recordActivityStatuses([{ conversationId: "old", status: "running" }]);
|
||||
expect(store.getActivityTimes().size).toBe(0);
|
||||
store.rememberOpened("old");
|
||||
expect(store.getActivityTimes().get("old")).toBe(1000);
|
||||
const visited = store.getActivityTimes();
|
||||
store.recordActivityStatuses([{ conversationId: "old", status: "running" }]);
|
||||
expect(store.getActivityTimes()).toBe(visited);
|
||||
store.recordActivityStatuses([{ conversationId: "old", status: "completed" }]);
|
||||
expect(store.getActivityTimes().get("old")).toBe(1001);
|
||||
const completed = store.getActivityTimes();
|
||||
store.recordActivityStatuses([]);
|
||||
expect(store.getActivityTimes()).toBe(completed);
|
||||
expect(store.getState()).toBe(initial);
|
||||
expect(store.getConversation("old")?.updatedAt).toBe(10);
|
||||
expect(persisted).not.toHaveBeenCalled();
|
||||
expect(activity).toHaveBeenCalledTimes(2);
|
||||
store.recordActivityStatuses([{ conversationId: "old", status: "running" }]);
|
||||
store.recordActivityStatuses([]);
|
||||
expect(store.getActivityTimes().get("old")).toBe(1003);
|
||||
expect(createConversationStore(initial, createLiveMessageStore()).getActivityTimes().size).toBe(0);
|
||||
});
|
||||
|
||||
it("restores the last opened conversation per project and falls back after deletion", () => {
|
||||
const older = conversation("older", [], { projectId: "project", updatedAt: 10 });
|
||||
const recent = conversation("recent", [], { projectId: "project", updatedAt: 20 });
|
||||
const other = conversation("other", [], { projectId: "other", updatedAt: 30 });
|
||||
const store = createConversationStore([older, other, recent], createLiveMessageStore());
|
||||
expect(store.projectConversation("project", false)?.id).toBe("recent");
|
||||
store.rememberOpened("older");
|
||||
store.rememberOpened("other");
|
||||
expect(store.projectConversation("project", false)?.id).toBe("older");
|
||||
expect(store.projectConversation("other", false)?.id).toBe("other");
|
||||
expect(store.projectConversation("project", true)).toBeUndefined();
|
||||
store.set([recent, other]);
|
||||
expect(store.projectConversation("project", false)?.id).toBe("recent");
|
||||
});
|
||||
|
||||
it("applies updates synchronously, in dispatch order", () => {
|
||||
const store = createConversationStore([conversation("a")], createLiveMessageStore());
|
||||
store.set((current) => [...current, conversation("b")]);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Conversation } from "./chat-conversation";
|
||||
import { conversationActivityTime } from "./chat-conversation";
|
||||
import type { ConversationActivity } from "./conversation-activity";
|
||||
import {
|
||||
createLiveMessageStore,
|
||||
type ConversationsUpdate,
|
||||
@@ -40,6 +42,19 @@ export function createConversationStore(
|
||||
let state = initial;
|
||||
const listeners = new Set<Listener>();
|
||||
const conversationListeners = new Map<string, Set<Listener>>();
|
||||
const lastOpenedByProject = new Map<string, string>();
|
||||
let activityTimes: ReadonlyMap<string, number> = new Map();
|
||||
let activityStatuses: ReadonlyMap<string, ConversationActivity['status']> | undefined;
|
||||
const activityListeners = new Set<Listener>();
|
||||
let lastActivityTime = 0;
|
||||
const touchActivity = (ids: readonly string[]): void => {
|
||||
if (!ids.length) return;
|
||||
lastActivityTime = Math.max(Date.now(), lastActivityTime + 1);
|
||||
const next = new Map(activityTimes);
|
||||
for (const id of ids) next.set(id, lastActivityTime);
|
||||
activityTimes = next;
|
||||
for (const listener of [...activityListeners]) listener();
|
||||
};
|
||||
|
||||
const notify = (previous: readonly Conversation[]): void => {
|
||||
if (conversationListeners.size) {
|
||||
@@ -58,6 +73,41 @@ export function createConversationStore(
|
||||
};
|
||||
|
||||
return {
|
||||
rememberOpened(conversationId: string): void {
|
||||
const conversation = state.find((item) => item.id === conversationId);
|
||||
if (!conversation) return;
|
||||
if (conversation?.projectId) lastOpenedByProject.set(conversation.projectId, conversationId);
|
||||
touchActivity([conversationId]);
|
||||
},
|
||||
getActivityTimes(): ReadonlyMap<string, number> {
|
||||
return activityTimes;
|
||||
},
|
||||
subscribeActivityTimes(listener: Listener): () => void {
|
||||
activityListeners.add(listener);
|
||||
return () => { activityListeners.delete(listener); };
|
||||
},
|
||||
recordActivityStatuses(activities: readonly Pick<ConversationActivity, 'conversationId' | 'status'>[]): void {
|
||||
const next = new Map(activities.map((activity) => [activity.conversationId, activity.status]));
|
||||
const changed: string[] = [];
|
||||
// Initial activity is a baseline. Acknowledging a completion is not a new run.
|
||||
if (activityStatuses) for (const id of new Set([...activityStatuses.keys(), ...next.keys()])) {
|
||||
const before = activityStatuses.get(id);
|
||||
const after = next.get(id);
|
||||
if (before !== after && !(before === 'completed' && after === undefined)) changed.push(id);
|
||||
}
|
||||
activityStatuses = next;
|
||||
touchActivity(changed);
|
||||
},
|
||||
projectConversation(projectId: string, channel: boolean, candidates = state): Conversation | undefined {
|
||||
const remembered = lastOpenedByProject.get(projectId);
|
||||
let recent: Conversation | undefined;
|
||||
for (const item of candidates) {
|
||||
if (item.projectId !== projectId || (channel && !item.remote)) continue;
|
||||
if (item.id === remembered) return item;
|
||||
if (!recent || conversationActivityTime(item) > conversationActivityTime(recent)) recent = item;
|
||||
}
|
||||
return recent;
|
||||
},
|
||||
getState(): Conversation[] {
|
||||
return state;
|
||||
},
|
||||
|
||||
@@ -2,6 +2,15 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
|
||||
|
||||
export const workspace = {
|
||||
workspaceMenu: {
|
||||
title: 'Projects and activity', close: 'Close projects and activity', search: 'Search projects',
|
||||
allProjects: 'All projects', local: 'Local', remote: 'Remote', channels: 'Channels', categories: 'Project categories',
|
||||
filter: 'Filter conversation status', conversations: 'Conversations', newConversation: 'New conversation',
|
||||
newIn: 'New conversation in {{name}}', noProjects: 'No matching projects. Try another search.',
|
||||
noConversations: 'No conversations in this scope match the filter.',
|
||||
filters: { all: 'All', attention: 'Attention', running: 'Running', completed: 'Completed' },
|
||||
groups: { attention: 'Needs attention', running: 'Running', recent: 'Recent conversations', completed: 'Completed' }
|
||||
},
|
||||
projectActivity: {
|
||||
title: 'All project activity',
|
||||
idle: 'No activity',
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export const workspace = {
|
||||
workspaceMenu: {
|
||||
title: '项目与活动', close: '关闭项目与活动', search: '搜索项目',
|
||||
allProjects: '全部项目', local: '本地', remote: '远程', channels: '消息通道', categories: '项目类型',
|
||||
filter: '筛选会话状态', conversations: '会话', newConversation: '新建会话',
|
||||
newIn: '在 {{name}} 中新建会话', noProjects: '没有匹配的项目,请尝试其他关键词。',
|
||||
noConversations: '当前范围内没有符合筛选条件的会话。',
|
||||
filters: { all: '全部', attention: '待处理', running: '运行中', completed: '已完成' },
|
||||
groups: { attention: '待处理', running: '运行中', recent: '最近会话', completed: '已完成' }
|
||||
},
|
||||
projectActivity: {
|
||||
title: '全项目活动',
|
||||
idle: '暂无活动',
|
||||
|
||||
@@ -3334,7 +3334,7 @@ textarea:focus-visible {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: minmax(0, 1fr) 36px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.project-switcher__row > .icon-button {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveConversationActivity } from './conversation-activity'
|
||||
import { sameWorkspaceConversations, workspaceConversationRows, type WorkspaceConversation } from './workspace-menu-selectors'
|
||||
|
||||
const conversations: WorkspaceConversation[] = Array.from({ length: 15 }, (_, index) => ({
|
||||
id: `c${index}`, title: `Conversation ${index}`, projectId: index === 14 ? 'other' : 'project', time: 100 - index, channel: false
|
||||
}))
|
||||
|
||||
describe('workspace conversation summaries', () => {
|
||||
it('uses real attention priority, adds ten non-active recent rows, and does not cap completed', () => {
|
||||
const activities = deriveConversationActivity(conversations.map((row, index) => ({
|
||||
...row, messages: index === 0 ? [{ pendingQuestions: [{}], state: 'streaming' }] : []
|
||||
})), [{ conversationId: 'c0', title: 'task', status: 'running' }], new Set(['c0', 'c1']), [], '', new Set(conversations.slice(2).map((row) => row.id)))
|
||||
const rows = workspaceConversationRows(conversations, activities.activities, 'project', 'all')
|
||||
expect(rows.map((row) => row.id)).toEqual(conversations.slice(0, 12).map((row) => row.id))
|
||||
expect(rows[0]).toMatchObject({ status: 'question', group: 'attention' })
|
||||
expect(workspaceConversationRows(conversations, activities.activities, null, 'attention').map((row) => row.id)).toEqual(['c0'])
|
||||
expect(workspaceConversationRows(conversations, activities.activities, null, 'running').map((row) => row.id)).toEqual(['c1'])
|
||||
expect(workspaceConversationRows(conversations, activities.activities, null, 'completed')).toHaveLength(13)
|
||||
})
|
||||
|
||||
it('keeps viewed completions in recent history after the reminder is acknowledged', () => {
|
||||
const activity = { conversationId: 'c0', projectId: 'project', projectName: 'Project', title: 'Conversation 0', status: 'completed' as const }
|
||||
const before = workspaceConversationRows(conversations, [activity], 'project', 'all')
|
||||
const after = workspaceConversationRows(conversations, [], 'project', 'all')
|
||||
expect(after.map((row) => row.id)).toEqual(before.map((row) => row.id))
|
||||
expect(after[0]?.status).toBeUndefined()
|
||||
expect(workspaceConversationRows(conversations, [], 'project', 'completed')).toEqual([])
|
||||
})
|
||||
|
||||
it('includes task-only activity for exact-ID navigation without requiring hydration', () => {
|
||||
const rows = workspaceConversationRows([], [{ conversationId: 'missing', projectId: 'project', projectName: 'Project', title: 'Background job', status: 'approval' }], 'project', 'all')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ id: 'missing', group: 'attention', status: 'approval' })
|
||||
})
|
||||
|
||||
it('retains equal metadata and notices scope, title and ordering changes', () => {
|
||||
expect(sameWorkspaceConversations(conversations, conversations.map((row) => ({ ...row })))).toBe(true)
|
||||
for (const change of [{ title: 'Changed' }, { time: 101 }, { projectId: 'other' }, { channel: true }]) {
|
||||
expect(sameWorkspaceConversations(conversations, conversations.map((row, index) => index ? row : { ...row, ...change }))).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback } from 'react'
|
||||
import { conversationActivityTime, getConversationDisplayTitle, type Conversation } from './chat-conversation'
|
||||
import { useConversationStoreSelector, type ConversationStore } from './conversation-store'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { sameArrayItems } from './stable-derived-value'
|
||||
|
||||
export type WorkspaceConversation = {
|
||||
id: string
|
||||
projectId?: string
|
||||
title: string
|
||||
time: number
|
||||
channel: boolean
|
||||
}
|
||||
export type WorkspaceFilter = 'all' | 'attention' | 'running' | 'completed'
|
||||
export type WorkspaceConversationRow = WorkspaceConversation & {
|
||||
status?: ConversationActivity['status']
|
||||
group: 'attention' | 'running' | 'recent' | 'completed'
|
||||
}
|
||||
|
||||
export function useWorkspaceConversations(store: ConversationStore, defaultTitle: string, activityTimes: ReadonlyMap<string, number>): WorkspaceConversation[] {
|
||||
const selector = useCallback((conversations: Conversation[]) => conversations.map((conversation) => ({
|
||||
id: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
title: getConversationDisplayTitle(conversation, defaultTitle),
|
||||
time: Math.max(conversationActivityTime(conversation), activityTimes.get(conversation.id) ?? 0),
|
||||
channel: Boolean(conversation.remote)
|
||||
})), [defaultTitle, activityTimes])
|
||||
return useConversationStoreSelector(store, selector, sameWorkspaceConversations)
|
||||
}
|
||||
|
||||
export function sameWorkspaceConversations(a: WorkspaceConversation[], b: WorkspaceConversation[]): boolean {
|
||||
return sameArrayItems(a, b, (left, right) => left.id === right.id && left.projectId === right.projectId &&
|
||||
left.title === right.title && left.time === right.time && left.channel === right.channel)
|
||||
}
|
||||
|
||||
export function workspaceConversationRows(
|
||||
conversations: readonly WorkspaceConversation[],
|
||||
activities: readonly ConversationActivity[],
|
||||
scope: string | null,
|
||||
filter: WorkspaceFilter,
|
||||
activityTimes?: ReadonlyMap<string, number>
|
||||
): WorkspaceConversationRow[] {
|
||||
const activityById = new Map(activities.map((activity) => [activity.conversationId, activity]))
|
||||
const rows = new Map<string, WorkspaceConversationRow>()
|
||||
for (const conversation of conversations) {
|
||||
if (scope !== null && conversation.projectId !== scope) continue
|
||||
const status = activityById.get(conversation.id)?.status
|
||||
const group = status === 'running' ? 'running' : status && status !== 'completed' ? 'attention' : 'recent'
|
||||
rows.set(conversation.id, { ...conversation, status, group })
|
||||
}
|
||||
// Task activity can arrive before its conversation summary. Navigation already
|
||||
// hydrates that exact ID; preview never needs to fetch the conversation.
|
||||
for (const activity of activities) {
|
||||
if (rows.has(activity.conversationId) || (scope !== null && activity.projectId !== scope)) continue
|
||||
rows.set(activity.conversationId, {
|
||||
id: activity.conversationId, projectId: activity.projectId, title: activity.title,
|
||||
time: activityTimes?.get(activity.conversationId) ?? 0, channel: false, status: activity.status,
|
||||
group: activity.status === 'running' ? 'running' : activity.status === 'completed' ? 'recent' : 'attention'
|
||||
})
|
||||
}
|
||||
const ordered = [...rows.values()].sort((a, b) => b.time - a.time || a.id.localeCompare(b.id))
|
||||
if (filter === 'completed') return ordered.filter((row) => row.status === 'completed').map((row) => ({ ...row, group: 'completed' }))
|
||||
if (filter !== 'all') return ordered.filter((row) => row.group === filter)
|
||||
return [
|
||||
...ordered.filter((row) => row.group === 'attention'),
|
||||
...ordered.filter((row) => row.group === 'running'),
|
||||
...ordered.filter((row) => row.group === 'recent').slice(0, 10)
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
.project-switcher__menu.workspace-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 640px;
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-menu__header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.workspace-menu__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 360px) minmax(0, 1fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-menu__projects,
|
||||
.workspace-menu__activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-menu__projects {
|
||||
background: var(--surface-subtle);
|
||||
border-right: 1px solid var(--border-control);
|
||||
}
|
||||
|
||||
.workspace-menu__search {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: calc(100% - 2 * var(--space-3));
|
||||
margin: var(--space-3) var(--space-3) var(--space-2);
|
||||
width: calc(100% - 2 * var(--space-3));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workspace-menu__categories {
|
||||
min-width: 0;
|
||||
padding: 0 var(--space-2) var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.workspace-menu__categories .page-tabs {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace-menu__project-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-menu [data-preview="true"] [role="menuitemradio"] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-menu [role="menuitemradio"][aria-checked="true"]:not(:hover) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-menu [data-preview="true"] [role="menuitemradio"][aria-checked="true"]:not(:hover) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.workspace-menu__project-list,
|
||||
.workspace-menu__conversations {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.workspace-menu__category {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workspace-menu__host {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-menu__host[aria-expanded="false"] svg:first-child {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.workspace-menu__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workspace-menu__toolbar .segmented-control {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.workspace-menu__toolbar .segmented-control__option {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: var(--space-2);
|
||||
font-size: var(--font-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-menu__new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
font-size: var(--font-caption);
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.workspace-menu__conversation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
gap: var(--space-3);
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border-radius: var(--radius-control);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace-menu__conversation > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-menu__conversation strong,
|
||||
.workspace-menu__conversation small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-menu__conversation strong { font-weight: 500; }
|
||||
.workspace-menu__conversation small { font-size: var(--font-caption); }
|
||||
.workspace-menu__conversation > span > small { color: var(--text-secondary); }
|
||||
.workspace-menu__conversation > small { flex: 0 0 auto; }
|
||||
.workspace-menu__conversation:hover,
|
||||
.workspace-menu__new:not(:disabled):hover { background: var(--accent-subtle); }
|
||||
.workspace-menu__new:disabled { opacity: 0.5; cursor: default; }
|
||||
.workspace-menu__empty { padding: var(--space-4); color: var(--text-secondary); }
|
||||
.workspace-menu__channel-note { padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
|
||||
|
||||
.workspace-menu__footer {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-2);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item .project-activity__counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workspace-menu__project-category {
|
||||
margin: var(--space-2) var(--space-2) var(--space-1);
|
||||
padding: var(--space-2) 0 var(--space-1);
|
||||
border-top: 1px solid var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.workspace-menu__project-list > :first-child .workspace-menu__project-category {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item {
|
||||
margin-block: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] {
|
||||
box-sizing: border-box;
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] > span {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] > svg:first-child {
|
||||
color: var(--accent);
|
||||
padding: var(--space-2);
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-control);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.project-switcher__trigger.project-switcher__control {
|
||||
box-sizing: border-box;
|
||||
height: 60px;
|
||||
padding-block: var(--space-1);
|
||||
}
|
||||
|
||||
.project-switcher__activity {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.project-switcher__control .project-activity__counts {
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-switcher__control:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.project-switcher__recovery-feedback {
|
||||
display: block;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.workspace-menu__layout { grid-template-columns: minmax(160px, 40%) minmax(0, 1fr); }
|
||||
.workspace-menu__toolbar { padding: var(--space-2); gap: var(--space-1); }
|
||||
.workspace-menu__conversation { flex-direction: column; align-items: flex-start; gap: var(--space-1); }
|
||||
.workspace-menu__conversation > span { max-width: 100%; }
|
||||
.workspace-menu__host { flex-wrap: wrap; }
|
||||
.workspace-menu .project-switcher__menu-settings { opacity: 1; pointer-events: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.workspace-menu__layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.workspace-menu__new { white-space: normal; }
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] > svg:first-child { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user