fix: browse project activity through cascading menus

Replace the production activity dialog with anchored project and conversation menus, preserving exact conversation navigation and adding keyboard and narrow-window coverage. Include the interactive demo and design documentation.

The demo hides idle activity and removes conversation arrows; production still retains those two elements. Validation: typecheck, lint and 11 ProjectActivity tests passed. The full suite timed out after 240 seconds with an agent-package offline inventory test failure.
This commit is contained in:
mesalogo
2026-09-13 23:06:20 +08:00
parent ba6f6b8f22
commit c71da4b6ae
18 changed files with 675 additions and 261 deletions
+30 -28
View File
@@ -1276,16 +1276,16 @@ describe("App", () => {
expect(projectRow).toHaveTextContent("1 个运行中");
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
fireEvent.click(summary);
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
const running = within(dialog).getByRole("region", { name: "运行中" });
expect(within(running).getAllByRole("button")).toHaveLength(1);
expect(within(running).getByRole("button", { name: /Background project Exact background discussion/u })).toBeInTheDocument();
expect(within(dialog).queryByRole("region", { name: "待处理" })).not.toBeInTheDocument();
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("运行中");
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: "completed" }]);
act(() => conversationQueueChangeListener?.(task.conversationId!));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
await waitFor(() => expect(summary).toHaveTextContent("暂无活动"));
expect(screen.getByRole("menu", { name: "全项目活动" })).toHaveTextContent("暂无运行中或待处理会话");
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
expect(within(screen.getByRole("menu")).getByRole("menuitemradio", { name: /Background project/u }))
.not.toHaveTextContent("个运行中");
@@ -1311,13 +1311,13 @@ describe("App", () => {
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
expect(sidebar.querySelector(".conversation-actions")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
.getByRole("button", { name: /Exact background discussion/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
fireEvent.click(screen.getByRole("menuitem", { 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("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
expect(screen.getByLabelText("搜索对话")).toHaveValue("");
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
@@ -1341,15 +1341,15 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
const trigger = screen.getByRole("button", { name: /全项目活动/u });
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
const close = within(dialog).getByRole("button", { name: "关闭全项目活动" });
expect(close).toHaveFocus();
// Native Tab must remain available between controls, even through a React portal.
expect(fireEvent.keyDown(close, { key: "Tab" })).toBe(true);
fireEvent.keyDown(close, { key: "Tab", shiftKey: true });
expect(within(dialog).getByRole("button", { name: /Exact background discussion/u })).toHaveFocus();
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();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
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(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
expect(trigger).toHaveFocus();
} finally {
@@ -1364,8 +1364,8 @@ describe("App", () => {
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
.getByRole("button", { name: /Exact background discussion/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改");
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("Background project");
@@ -1373,7 +1373,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("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
});
it.each(["question", "approval"] as const)("preserves live %s across snapshot refresh and clears persisted terminal prompts", async (kind) => {
@@ -1497,12 +1497,14 @@ describe("App", () => {
});
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中"));
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
expect(within(dialog).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(["待处理", "运行中"]);
const pending = within(dialog).getByRole("region", { name: "待处理" });
expect(within(pending).getAllByRole("button")).toHaveLength(1);
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);
expect(pending).toHaveTextContent(kind === "question" ? "等待你的回答" : "等待审批");
expect(within(dialog).getByRole("region", { name: "运行中" })).toHaveTextContent("Exact background discussion");
fireEvent.click(within(menu).getByRole("menuitem", { name: /Background project/u }));
expect(screen.getByRole("menu", { name: "Background project" })).toHaveTextContent("Exact background discussion");
vi.mocked(api.tasks.list).mockResolvedValue([]);
act(() => {
agentListener?.(kind === "question"
@@ -1510,8 +1512,8 @@ describe("App", () => {
: { requestId: request.requestId, type: "error", status: "failed", message: "Activity execution failed" });
conversationQueueChangeListener?.(request.conversationId!);
});
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("暂无活动"));
expect(menu).toHaveTextContent("暂无运行中或待处理会话");
});
});
+2
View File
@@ -8353,6 +8353,8 @@ function App(): React.JSX.Element {
/>
<ProjectActivity
activities={projectActivity.activities}
projects={projects}
visible={sidebarOpen}
onOpenConversation={openActivityConversation}
/>
+164 -82
View File
@@ -1,129 +1,211 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProjectActivity, ProjectActivityCounts } from './ProjectActivity'
import type { ConversationActivity } from './conversation-activity'
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
import { workspace } from './i18n/locales/en-US/workspace'
import type { AssistantProject } from '../../shared/assistant-contracts'
const i18n = createInstance()
void i18n.init({
lng: 'en',
initImmediate: false,
resources: { en: { workspace: { projectActivity: {
title: 'All project activity', close: 'Close activity',
attentionCount: '{{count}} need attention', runningCount: '{{count}} running',
groups: { attention: 'Needs attention', running: 'Running' },
status: { running: 'Running', approval: 'Waiting for approval', question: 'Waiting for your answer', attention: 'Needs attention' }
} } } }
})
void i18n.init({ lng: 'en', initImmediate: false, resources: { en: { workspace } } })
const activities: ConversationActivity[] = [
{ conversationId: 'running', projectId: 'local', projectName: 'Local', title: 'Build', status: 'running' },
{ conversationId: 'remote-run', projectId: 'remote', projectName: 'Remote', title: 'Deploy', status: 'running' },
{ conversationId: 'question', projectId: 'remote', projectName: 'Remote', title: 'Choose target', status: 'question' },
{ conversationId: 'approval', projectId: 'remote', projectName: 'Remote', title: 'Approve command', status: 'approval' },
{ conversationId: 'unloaded', projectName: 'Channel', title: 'Background task', status: 'attention' }
{ conversationId: 'unloaded', projectName: 'No project', title: 'Background task', status: 'attention' }
]
function view(rows = activities, onOpenConversation = vi.fn()): React.JSX.Element {
return (
<I18nextProvider i18n={i18n}>
<div className="app-shell">
<button type="button">Destination</button>
<ProjectActivity activities={rows} onOpenConversation={onOpenConversation} />
</div>
</I18nextProvider>
)
function view(rows = activities, onOpenConversation = vi.fn(), projects?: AssistantProject[], visible = true): React.JSX.Element {
return <I18nextProvider i18n={i18n}>
<div className="app-shell">
<button type="button">Destination</button>
<ProjectActivity activities={rows} onOpenConversation={onOpenConversation} projects={projects} visible={visible} />
</div>
</I18nextProvider>
}
afterEach(cleanup)
function open(): HTMLButtonElement {
const trigger = screen.getByRole<HTMLButtonElement>('button', { name: /All project activity/ })
fireEvent.click(trigger)
return trigger
}
afterEach(() => { cleanup(); vi.restoreAllMocks() })
describe('ProjectActivity', () => {
it('hides idle activity and zero counts', () => {
it('keeps an idle summary and an accessible empty menu while hiding zero counts', () => {
render(view([]))
expect(screen.queryByRole('button', { name: /All project activity/ })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const { container } = render(
<I18nextProvider i18n={i18n}><ProjectActivityCounts attention={0} running={0} /></I18nextProvider>
)
const trigger = open()
expect(trigger).toHaveTextContent('No activity')
expect(screen.getByRole('menu')).toHaveTextContent('No running conversations')
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(trigger).toHaveFocus()
const { container } = render(<I18nextProvider i18n={i18n}><ProjectActivityCounts attention={0} running={0} /></I18nextProvider>)
expect(container).toBeEmptyDOMElement()
})
it('shows global counts and attention-first groups with precise labels', () => {
it('shows authoritative counts, stable projects and attention-first conversations', () => {
render(view())
fireEvent.click(screen.getByRole('button', { name: /All project activity 3 need attention 1 running/ }))
const dialog = screen.getByRole('dialog', { name: 'All project activity' })
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(within(dialog).getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent))
.toEqual(['Needs attention', 'Running'])
expect(within(dialog).getByRole('button', { name: 'Remote Choose target Waiting for your answer' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Remote Approve command Waiting for approval' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Channel Background task Needs attention' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Local Build Running' })).toBeInTheDocument()
})
it('isolates the background, traps Tab both ways, and restores focus on Escape', () => {
const { container } = render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog')
const close = within(dialog).getByRole('button', { name: 'Close activity' })
const last = within(dialog).getByRole('button', { name: 'Local Build Running' })
expect(close).toHaveFocus()
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(true)
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(close).toHaveFocus()
fireEvent.keyDown(close, { key: 'Escape' })
expect(open()).toHaveAccessibleName('All project activity: 3 need attention, 2 running')
const menu = screen.getByRole('menu', { name: 'All project activity' })
expect(within(menu).getAllByRole('menuitem').map((row) => row.textContent)).toEqual([
'Local 1 running', 'Remote 2 need attention 1 running', 'No project 1 need attention '
])
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
expect(within(screen.getByRole('menu', { name: 'Remote' })).getAllByRole('menuitem').map((row) => row.textContent))
.toEqual(['Choose target Waiting for your answer', 'Approve command Waiting for approval', 'Deploy Running'])
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).not.toBe(true)
})
it('navigates directly to an unloaded conversation without stealing destination focus', () => {
it('uses project metadata order and keeps SSH hosts grouped regardless of activity priority', () => {
const projects = [
{ id: 'remote', name: 'Remote', kind: 'user', executionSpace: { kind: 'ssh', hostId: 'host-a' } },
{ id: 'channel', name: 'Channel', kind: 'channel', executionSpace: { kind: 'local' } },
{ id: 'local', name: 'Local', kind: 'user', executionSpace: { kind: 'local' } },
{ id: 'other-host', name: 'Other host', kind: 'user', executionSpace: { kind: 'ssh', hostId: 'host-b' } },
{ id: 'same-host', name: 'Same host', kind: 'user', executionSpace: { kind: 'ssh', hostId: 'host-a' } }
] as AssistantProject[]
render(view([...activities, ...projects.slice(1).filter((p) => p.id !== 'local').map((p) => ({
conversationId: p.id, projectId: p.id, projectName: p.name, title: p.id, status: 'running' as const
}))], vi.fn(), projects))
open()
expect(screen.getAllByRole('menuitem').map((row) => row.querySelector('.project-activity__identity > span')?.textContent))
.toEqual(['Local', 'Remote', 'Same host', 'Other host', 'Channel', 'No project'])
})
it('navigates arrows, Home/End, Enter and two-stage Escape before the sidebar listener', () => {
render(view())
const sidebarEscape = vi.fn()
document.addEventListener('keydown', sidebarEscape)
const trigger = open()
const local = screen.getByRole('menuitem', { name: /Local/ })
const remote = screen.getByRole('menuitem', { name: /Remote/ })
expect(local).toHaveFocus()
fireEvent.keyDown(local, { key: 'ArrowDown' })
expect(remote).toHaveFocus()
fireEvent.keyDown(remote, { key: 'ArrowRight' })
const first = screen.getByRole('menuitem', { name: 'Choose target Waiting for your answer' })
expect(first).toHaveFocus()
fireEvent.keyDown(first, { key: 'End' })
expect(screen.getByRole('menuitem', { name: 'Deploy Running' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(screen.getByRole('menuitem', { name: 'Approve command Waiting for approval' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
expect(first).toHaveFocus()
sidebarEscape.mockClear()
fireEvent.keyDown(first, { key: 'Escape' })
expect(remote).toHaveFocus()
expect(screen.queryByRole('menu', { name: 'Remote' })).not.toBeInTheDocument()
fireEvent.keyDown(remote, { key: 'Escape' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(sidebarEscape).not.toHaveBeenCalled()
document.removeEventListener('keydown', sidebarEscape)
})
it('navigates the exact unloaded conversation after closing without stealing destination focus', () => {
const onOpen = vi.fn(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Destination' }).focus()
})
render(view(activities, onOpen))
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
fireEvent.click(screen.getByRole('button', { name: 'Channel Background task Needs attention' }))
open()
fireEvent.click(screen.getByRole('menuitem', { name: /No project/ }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Background task Needs attention' }))
expect(onOpen).toHaveBeenCalledExactlyOnceWith('unloaded')
expect(screen.getByRole('button', { name: 'Destination' })).toHaveFocus()
})
it('updates live rows without refocusing and stays closed after becoming idle', () => {
it('keeps the submenu open across the adjoining pointer boundary', () => {
render(view())
open()
const remote = screen.getByRole('menuitem', { name: /Remote/ })
// jsdom does not provide PointerEvent's pointerType.
const event = new MouseEvent('pointerover', { bubbles: true })
Object.defineProperty(event, 'pointerType', { value: 'mouse' })
fireEvent(remote, event)
const menu = screen.getByRole('menu', { name: 'Remote' })
fireEvent.pointerLeave(remote)
fireEvent.pointerEnter(menu)
expect(menu).toBeInTheDocument()
expect(remote).toHaveAttribute('aria-expanded', 'true')
})
it('dismisses on outside pointer, focus, Tab and toggle with focus restoration', () => {
render(view())
const trigger = open()
fireEvent.pointerDown(document.body)
expect(trigger).toHaveFocus()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
open()
act(() => screen.getByRole('button', { name: 'Destination' }).focus())
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Destination' })).toHaveFocus()
open()
fireEvent.keyDown(document.activeElement!, { key: 'Tab' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
open()
fireEvent.click(trigger)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('preserves live focus and recovers when the selected project drains', () => {
const { rerender } = render(view())
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
const row = screen.getByRole('button', { name: 'Remote Choose target Waiting for your answer' })
row.focus()
open()
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
const row = screen.getByRole('menuitem', { name: 'Choose target Waiting for your answer' })
rerender(view(activities.filter((activity) => activity.status !== 'running')))
expect(row).toHaveFocus()
expect(screen.queryByRole('region', { name: 'Running' })).not.toBeInTheDocument()
rerender(view(activities.filter((activity) => activity.projectId === 'local')))
expect(screen.getByRole('menuitem', { name: /Local/ })).toHaveFocus()
rerender(view([]))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
expect(screen.getByRole('menu')).toHaveTextContent('No running conversations')
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
rerender(view())
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('restores trigger focus when dismissed with the close button', () => {
it('closes when the sidebar is hidden', () => {
const { rerender } = render(view())
open()
rerender(view(activities, vi.fn(), undefined, false))
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('positions beside the anchor, falls left and drills down in a narrow viewport', () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(900)
render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Close activity' }))
expect(trigger).toHaveFocus()
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(600, 100, 200, 32))
open()
const menu = screen.getByRole('menu', { name: 'All project activity' })
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
expect(menu).toHaveAttribute('data-left', 'true')
expect(menu.style.left).toBe('336px')
expect(menu.style.top).toBe('136px')
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(400)
fireEvent(window, new Event('resize'))
expect(menu).toHaveAttribute('data-compact', 'true')
expect(menu.style.width).toBe('264px')
fireEvent.click(screen.getByRole('button', { name: 'Back to projects' }))
expect(screen.getByRole('menuitem', { name: /Remote/ })).toHaveFocus()
expect(menu).toHaveAttribute('data-submenu', 'false')
})
it('still handles Escape when a background update removes the focused row', () => {
const { rerender } = render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
screen.getByRole('button', { name: 'Local Build Running' }).focus()
rerender(view(activities.filter((activity) => activity.status !== 'running')))
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
it('uses shared native-browser occlusion only where menus intersect the viewport', () => {
render(view())
open()
const menu = screen.getByRole('menu')
vi.spyOn(menu, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 10, 264, 200))
const host = screen.getByRole('button', { name: 'Destination' })
expect(isBrowserViewportOccluded(host, new DOMRect(200, 10, 500, 500))).toBe(true)
expect(isBrowserViewportOccluded(host, new DOMRect(400, 10, 500, 500))).toBe(false)
})
})
+186 -95
View File
@@ -1,13 +1,15 @@
import { ChevronRight, CircleAlert, LoaderCircle, X } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useRef, useState } from 'react'
import { ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal, flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { ConversationActivity } from './conversation-activity'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { AssistantProject } from '../../shared/assistant-contracts'
import './project-activity.css'
export type ProjectActivityProps = {
activities: ConversationActivity[]
projects?: readonly AssistantProject[]
visible?: boolean
onOpenConversation: (id: string) => void
}
@@ -38,99 +40,178 @@ export function ProjectActivityCounts({
)
}
function ActivityDialog({
function ActivityMenu({
activities,
projects,
anchorRef,
id,
onOpenConversation,
onClose
}: ProjectActivityProps & { onClose: () => void }): React.JSX.Element {
}: ProjectActivityProps & { anchorRef: React.RefObject<HTMLButtonElement | null>; id: string; onClose: () => void }): React.JSX.Element {
const { t } = useTranslation('workspace')
const dialogRef = useRef<HTMLElement>(null)
const titleId = useId()
const menuRef = useRef<HTMLDivElement>(null)
const projectRef = useRef<HTMLDivElement>(null)
const sessionsRef = useRef<HTMLDivElement>(null)
const [selected, setSelected] = useState<string | null>(null)
const groups = useMemo(() => {
const rows = new Map<string, { id: string; name: string; rows: ConversationActivity[] }>()
// Keep source project order within local, SSH Host and channel groups.
const ordered = new Map<string, AssistantProject[]>()
for (const project of projects ?? []) {
const key = project.kind === 'channel' ? 'channel' : project.executionSpace.kind === 'ssh'
? `ssh:${project.executionSpace.hostId}` : 'local'
const group = ordered.get(key) ?? []
group.push(project)
ordered.set(key, group)
}
const keys = [...ordered.keys()].sort((a, b) =>
(a === 'local' ? 0 : a === 'channel' ? 2 : 1) - (b === 'local' ? 0 : b === 'channel' ? 2 : 1))
for (const key of keys) for (const project of ordered.get(key)!) {
rows.set(project.id, { id: project.id, name: project.name, rows: [] })
}
for (const activity of activities) {
const key = activity.projectId ?? ''
const group = rows.get(key) ?? { id: key, name: activity.projectName, rows: [] }
group.rows.push(activity)
rows.set(key, group)
}
return [...rows.values()].filter((group) => group.rows.length).map((group) => ({
...group,
rows: group.rows.sort((a, b) => Number(a.status === 'running') - Number(b.status === 'running')),
running: group.rows.filter((row) => row.status === 'running').length
}))
}, [activities, projects])
const active = groups.find((group) => group.id === selected)
if (selected !== null && !active) setSelected(null)
const position = useEffectEvent(() => {
const menu = menuRef.current
if (!menu || !anchorRef.current) return
const rect = anchorRef.current.getBoundingClientRect()
const compact = window.innerWidth < 620
const panelWidth = Math.min(264, window.innerWidth - 32)
const baseLeft = Math.max(16, Math.min(rect.left, window.innerWidth - panelWidth - 16))
const leftward = !compact && baseLeft + panelWidth * 2 > window.innerWidth - 16 && baseLeft >= panelWidth + 16
menu.dataset.compact = String(compact)
menu.dataset.left = String(leftward)
const width = panelWidth * (active && !compact ? 2 : 1)
menu.style.width = `${width}px`
menu.style.left = `${Math.max(16, Math.min(baseLeft - (leftward && active ? panelWidth : 0), window.innerWidth - width - 16))}px`
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - menu.offsetHeight - 16))}px`
if (compact && active && projectRef.current?.contains(document.activeElement)) {
sessionsRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
}
})
useLayoutEffect(() => { position() })
const back = (): void => {
const index = groups.findIndex((group) => group.id === selected)
flushSync(() => setSelected(null))
projectRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[Math.max(0, index)]?.focus()
}
const enter = (key: string): void => {
flushSync(() => setSelected(key))
sessionsRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
}
const handleKeyDown = useEffectEvent((event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
onClose()
} else {
trapTabFocus(event, dialogRef.current)
if (active) back()
else onClose()
return
}
if (!menuRef.current?.contains(event.target as Node)) return
if (event.key === 'Tab') { flushSync(onClose); return }
if (event.key === 'ArrowLeft' && active) {
event.preventDefault()
back()
return
}
const projectButtons = projectRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')
const projectIndex = Array.from(projectButtons ?? []).indexOf(document.activeElement as HTMLButtonElement)
if (event.key === 'ArrowRight' && projectIndex >= 0) {
event.preventDefault()
enter(groups[projectIndex]!.id)
return
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const buttons = Array.from((projectIndex >= 0 ? projectRef : sessionsRef).current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1
: (index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length
buttons[next]?.focus()
})
const dismiss = useEffectEvent(onClose)
useEffect(() => {
const releaseFocus = activateModalFocus(
() => dialogRef.current?.querySelector<HTMLButtonElement>('button') ?? null
)
const anchor = anchorRef.current
const menu = menuRef.current
const initialFocus = projectRef.current?.querySelector<HTMLButtonElement>('button') ?? menu
initialFocus?.focus()
const onKeyDown = (event: KeyboardEvent): void => handleKeyDown(event)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
releaseFocus()
const outside = (event: Event): void => {
if (!menuRef.current?.contains(event.target as Node) && !anchor?.contains(event.target as Node)) dismiss()
}
}, [])
const reposition = (): void => position()
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('pointerdown', outside)
document.addEventListener('focusin', outside)
window.addEventListener('resize', reposition)
window.addEventListener('scroll', reposition, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('pointerdown', outside)
document.removeEventListener('focusin', outside)
window.removeEventListener('resize', reposition)
window.removeEventListener('scroll', reposition, true)
if (document.activeElement === document.body || menu?.contains(document.activeElement)) {
anchor?.focus()
}
}
}, [anchorRef])
useLayoutEffect(() => {
if (document.activeElement === document.body) {
const fallback = (active ? sessionsRef : projectRef).current?.querySelector<HTMLButtonElement>('[role="menuitem"]') ?? menuRef.current
fallback?.focus()
}
}, [groups, active])
return createPortal(
<div
className="project-activity__backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}
>
<section
aria-labelledby={titleId}
aria-modal="true"
className="project-activity__dialog"
ref={dialogRef}
role="dialog"
>
<header className="project-activity__header">
<h2 id={titleId}>{t('projectActivity.title')}</h2>
<button
aria-label={t('projectActivity.close')}
className="icon-button"
onClick={onClose}
title={t('projectActivity.close')}
type="button"
>
<X aria-hidden="true" size={16} />
<div className="project-activity__menu" data-submenu={Boolean(active)} ref={menuRef} id={id}
role="menu" aria-label={t('projectActivity.title')} tabIndex={-1}>
<div className="project-activity__projects" ref={projectRef} role="presentation">
<h3>{t('projectActivity.title')}</h3>
{!groups.length && <p>{t('projectActivity.empty')}</p>}
{groups.map((group) => (
<button key={group.id} type="button" role="menuitem" tabIndex={-1}
className="project-activity__row" aria-haspopup="menu" aria-expanded={selected === group.id}
aria-controls={selected === group.id ? `${id}-sessions` : undefined}
onPointerEnter={(event) => {
if (event.pointerType === 'mouse' && menuRef.current?.dataset.compact !== 'true') setSelected(group.id)
}}
onClick={() => enter(group.id)}>
<span className="project-activity__identity"><span>{group.name}</span>{' '}
<ProjectActivityCounts running={group.running} attention={group.rows.length - group.running} />
</span><ChevronRight aria-hidden="true" size={14} />
</button>
</header>
<div className="project-activity__content">
{(['attention', 'running'] as const).map((group) => {
const rows = activities.filter((activity) =>
group === 'running' ? activity.status === 'running' : activity.status !== 'running'
)
if (!rows.length) return null
return (
<section aria-labelledby={`${titleId}-${group}`} key={group}>
<h3 id={`${titleId}-${group}`}>{t(`projectActivity.groups.${group}`)}</h3>
<ul>
{rows.map((activity) => (
<li key={activity.conversationId}>
<button
className="project-activity__row"
onClick={() => {
// Release modal isolation and restore focus before navigation owns it.
flushSync(onClose)
onOpenConversation(activity.conversationId)
}}
type="button"
>
<span className="project-activity__identity">
<small>{activity.projectName}</small>{' '}
<span>{activity.title}</span>
</span>{' '}
<span className={`project-activity__${group}`}>
{t(`projectActivity.status.${activity.status}`)}
</span>
<ChevronRight aria-hidden="true" size={14} />
</button>
</li>
))}
</ul>
</section>
)
})}
</div>
</section>
))}
</div>
{active && <div className="project-activity__sessions" ref={sessionsRef} id={`${id}-sessions`}
role="menu" aria-label={active.name}>
<button className="project-activity__back project-activity__row" type="button" onClick={back}>
<ChevronLeft aria-hidden="true" size={14} />{t('projectActivity.back')}
</button>
<h3>{active.name}</h3>
{active.rows.map((activity) => <button key={activity.conversationId} type="button" role="menuitem" tabIndex={-1}
className="project-activity__row" onClick={() => {
flushSync(onClose)
onOpenConversation(activity.conversationId)
}}>
<span className="project-activity__identity"><span>{activity.title}</span>{' '}
<small className={`project-activity__${activity.status === 'running' ? 'running' : 'attention'}`}>
{t(`projectActivity.status.${activity.status}`)}
</small>
</span><ChevronRight aria-hidden="true" size={14} />
</button>)}
</div>}
</div>,
document.body
)
@@ -138,36 +219,46 @@ function ActivityDialog({
export function ProjectActivity({
activities,
projects,
visible = true,
onOpenConversation
}: ProjectActivityProps): React.JSX.Element | null {
const { t } = useTranslation('workspace')
const [open, setOpen] = useState(false)
if (!activities.length) {
// Adjusting state during render, guarded so it cannot loop: the dialog must
// not stay queued to reopen once the activity it listed has drained.
if (open) setOpen(false)
return null
}
const running = activities.filter((activity) => activity.status === 'running').length
const triggerRef = useRef<HTMLButtonElement>(null)
const id = useId()
if (!visible && open) setOpen(false)
const running = useMemo(() => activities.filter((activity) => activity.status === 'running').length, [activities])
return (
<>
<button
aria-expanded={open}
aria-haspopup="dialog"
aria-haspopup="menu"
aria-controls={open ? id : undefined}
aria-label={`${t('projectActivity.title')}: ${activities.length ? [t('projectActivity.attentionCount', { count: activities.length - running }), t('projectActivity.runningCount', { count: running })].join(', ') : t('projectActivity.idle')}`}
ref={triggerRef}
className="project-activity__summary"
onClick={(event) => {
event.currentTarget.focus()
setOpen(true)
setOpen(!open)
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
}
}}
type="button"
>
<span>{t('projectActivity.title')}</span>{' '}
<ProjectActivityCounts attention={activities.length - running} running={running} />
<ChevronRight aria-hidden="true" size={14} />
{activities.length ? <ProjectActivityCounts attention={activities.length - running} running={running} /> : <span>{t('projectActivity.idle')}</span>}
<ChevronDown aria-hidden="true" size={14} />
</button>
{open && (
<ActivityDialog
{open && visible && (
<ActivityMenu
activities={activities}
projects={projects}
anchorRef={triggerRef}
id={id}
onClose={() => setOpen(false)}
onOpenConversation={onOpenConversation}
/>
@@ -4,6 +4,9 @@ import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
export const workspace = {
projectActivity: {
title: 'All project activity',
idle: 'No activity',
empty: 'No running conversations or conversations needing attention.',
back: 'Back to projects',
close: 'Close project activity',
unassigned: 'No project',
attentionCount: '{{count}} need attention',
@@ -1,6 +1,9 @@
export const workspace = {
projectActivity: {
title: '全项目活动',
idle: '暂无活动',
empty: '暂无运行中或待处理会话。',
back: '返回项目列表',
close: '关闭全项目活动',
unassigned: '未归属项目',
attentionCount: '{{count}} 个待处理',
+23 -44
View File
@@ -27,7 +27,7 @@
.project-activity__summary > span { grid-column: 1; }
.project-activity__summary > span:first-child { color: var(--text-secondary); }
.project-activity__summary > svg { grid-column: 2; grid-row: 1 / 3; }
.project-activity__summary > svg { grid-column: 2; grid-row: 1; }
.project-activity__summary > svg,
.project-activity__row > svg {
@@ -47,7 +47,7 @@
}
.project-activity__summary:focus-visible,
.project-activity__dialog button:focus-visible {
.project-activity__menu button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -82,58 +82,45 @@
animation: none;
}
.project-activity__backdrop {
.project-activity__menu {
position: fixed;
inset: 0;
-webkit-app-region: no-drag;
z-index: var(--z-dialog);
display: grid;
place-items: center;
padding: var(--space-4);
background: var(--overlay-backdrop);
}
.project-activity__dialog {
display: flex;
flex-direction: column;
width: min(100%, 520px);
max-height: 100%;
min-height: 0;
align-items: stretch;
max-width: calc(100vw - 32px);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
box-shadow: var(--shadow-dialog);
font-size: var(--font-body);
}
.project-activity__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
flex: 0 0 auto;
}
.project-activity__header h2 {
margin: 0;
font-size: var(--font-section-title);
}
.project-activity__content {
.project-activity__menu > div {
flex: 1;
min-width: 0;
max-height: min(480px, calc(100dvh - 32px));
overflow-y: auto;
padding: var(--space-3);
padding: var(--space-2);
}
.project-activity__content h3 {
.project-activity__menu h3,
.project-activity__menu p {
margin: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.project-activity__content section + section { margin-top: var(--space-4); }
.project-activity__content ul { margin: 0; padding: 0; list-style: none; }
.project-activity__sessions { border-left: 1px solid var(--border-default); }
.project-activity__menu[data-left="true"] { flex-direction: row-reverse; }
.project-activity__menu[data-left="true"] .project-activity__sessions { border-left: 0; border-right: 1px solid var(--border-default); }
.project-activity__back { display: none; }
.project-activity__menu[data-compact="true"][data-submenu="true"] .project-activity__projects { display: none; }
.project-activity__menu[data-compact="true"] .project-activity__back { display: flex; }
.project-activity__menu[data-compact="true"] .project-activity__sessions { border: 0; }
.project-activity__row[aria-expanded="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); }
.project-activity__identity {
display: grid;
@@ -144,17 +131,9 @@
}
.project-activity__identity small {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.project-activity__row > .project-activity__attention,
.project-activity__row > .project-activity__running {
max-width: 40%;
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.project-activity__summary,
.project-activity__row { transition: none; }
-1
View File
@@ -3763,7 +3763,6 @@ textarea:focus-visible {
.knowledge-confirm-dialog-backdrop,
.knowledge-library-dialog-backdrop,
.knowledge-dialog-backdrop,
.project-activity__backdrop,
.external-knowledge-modal
) {
-webkit-app-region: no-drag;