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:
@@ -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("暂无运行中或待处理会话");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8353,6 +8353,8 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
<ProjectActivity
|
||||
activities={projectActivity.activities}
|
||||
projects={projects}
|
||||
visible={sidebarOpen}
|
||||
onOpenConversation={openActivityConversation}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}} 个待处理',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user