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
@@ -8,7 +8,7 @@
本轮更新实施计划与范围,旧版功能要求、验收和配套设计仍需按新计划同步,此项列为实现前的第一步。
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与弹窗见
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”指当前运行或待处理会话,
与用于审计的“运行记录”不同;已完成未读面板属于后续范围,本次未实现。
@@ -22,6 +22,7 @@
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
| [实施进度](./progress.md) | 项目活动级联的生产实现与验证记录 |
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。
@@ -34,6 +35,12 @@ Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加
附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止,
以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。
这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。
左上角项目选择器下方仅在有运行中或待处理会话时显示活动摘要,全部空闲时隐藏。点击摘要打开项目
列表,悬停或点击项目向右展开会话子菜单,点击会话才切换项目与对话。子菜单与项目列表
相接,项目行保留子菜单箭头,会话行只显示名称与状态。支持上下方向键选择、右键进入、左键或 Esc 返回,顶层 Esc 关闭;窄屏在同一浮层内
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
空闲”四种场景。项目与会话均为演示数据;该级联交互已接入正式产品活动入口,范围与
验证记录见[实施进度](./progress.md)。
按最新讨论,Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
工作栏 Tab 中演示,不列入应用中心。上方正式文档仍保留原设计,待方案确定后同步。
@@ -186,3 +186,11 @@ warming/loading/live/partial/error -> inactive
历史排队项、仅待调度的 Task、终态 Task 和未读标记本身不构成活动。活动结束且没有其他
运行或等待条件时移除该行,不能因历史任务或未读状态继续计数。
级联菜单先按项目归属聚合活动行,沿用本地、SSH Host、通道的稳定项目顺序;缺少项目
元数据的归属组及“未归属项目”组放在已知项目之后。无归属组只用于访问这些会话,不增加
项目计数。每个项目内先列待处理会话,再列运行中会话,同类别保留来源顺序。
展开项目只改变菜单层级,不导航。选中项目的活动全部结束后返回项目列表;全局活动结束后
保留已打开的空状态和“暂无活动”入口。后台更新保留仍存在的焦点项,删除焦点项时将焦点
移至当前层可用项或空菜单。关闭菜单后,新的活动不会自动重新打开菜单。
@@ -5,7 +5,43 @@
--accent: #086fc7; --accent-subtle: #e8f4ff; --accent-selected: #a7cfea;
--font-family-ui: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", system-ui, sans-serif;
--radius-control: 10px; --radius-card: 14px;
--warning: #865200;
}
[data-theme="dark"] { --warning: #f2bd66; }
.project-lab { max-width: 1440px; margin: 0 auto 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; color: var(--text-secondary); font-size: 12px; }
.project-lab select { font: inherit; color: var(--text-primary); background: var(--surface-raised); padding: 4px 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }
.project-entry { position: relative; margin-bottom: 12px; }
#project-trigger { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; border: 1px solid var(--border-default); }
#project-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-waiting { color: var(--warning); font-weight: 600; }
#activity-trigger { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: -8px 0 12px; padding: 4px 8px; color: var(--text-secondary); font-size: 11px; text-align: left; }
#project-activity { position: fixed; inset: auto; margin: 0; padding: 0; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); max-width: calc(100vw - 32px); }
#project-activity:popover-open { display: flex; align-items: stretch; }
#project-activity > section { width: 264px; max-width: calc(100vw - 34px); max-height: min(480px, calc(100dvh - 32px)); overflow: auto; padding: 8px; }
#project-activity h3 { margin: 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); overflow-wrap: anywhere; }
#activity-project-list > p { padding: 8px; }
#activity-project-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; gap: 4px 8px; text-align: left; }
#activity-project-list button > span:first-child { overflow-wrap: anywhere; }
#activity-project-list small { grid-column: 1; font-size: 11px; color: var(--text-secondary); }
#activity-project-list button > span:last-child { grid-column: 2; grid-row: 1 / 3; align-self: center; }
#activity-project-list [aria-expanded="true"] { background: var(--accent-subtle); color: var(--accent); }
#activity-sessions { border-left: 1px solid var(--border-default); }
#activity-back { display: none; font-size: 12px; color: var(--accent); }
#project-activity.activity-left { flex-direction: row-reverse; }
.activity-left #activity-sessions { border-left: 0; border-right: 1px solid var(--border-default); }
.activity-compact.has-submenu #activity-projects { display: none; }
.activity-compact #activity-back { display: block; }
.activity-compact #activity-sessions { border: 0; }
#project-menu { position: fixed; inset: auto; margin: 0; width: min(300px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow: auto; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); }
#project-list h3, #activity-list h3 { margin: 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }
#project-list button { width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; text-align: left; }
.project-row-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
#project-list [aria-current="true"] { background: var(--accent-subtle); color: var(--accent); }
#project-list small { width: 100%; display: flex; gap: 12px; font-size: 11px; color: var(--text-secondary); }
#activity-list button { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; text-align: left; padding: 12px; }
#activity-list strong { font-weight: 500; overflow-wrap: anywhere; }
#activity-list small { grid-column: 1; color: var(--text-secondary); overflow-wrap: anywhere; }
.history button small { display: block; font-size: 11px; color: var(--text-secondary); }
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --accent-selected: #28668f; }
* { box-sizing: border-box; }
body { margin: 0; padding: 24px 32px; font: 13px/1.6 var(--font-family-ui); background: var(--surface-subtle); color: var(--text-primary); }
@@ -21,9 +21,14 @@
<button id="theme">切换深色</button>
</header>
<div class="design-note"><span id="variant-label">A / 紧凑 Dock</span><p id="variant-description"></p><span>交互原型 · 商店与指标均为演示数据</span></div>
<div class="project-lab"><label>项目活动演示 <select id="project-scenario"><option value="waiting">有待处理</option><option value="running">仅运行中</option><option value="current">仅当前项目运行</option><option value="idle">全部空闲</option></select></label><span>有活动时显示摘要,悬停或点击项目向右展开会话。</span></div>
<main class="app" data-variant="dock">
<aside class="sidebar" aria-label="主侧栏">
<div class="brand"><span class="brand-mark">GB</span><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
<div class="project-entry"><button id="project-trigger" popovertarget="project-menu" aria-expanded="false" aria-controls="project-menu"><span id="project-name">GoodBuddy</span><span class="muted" aria-hidden="true">⌄</span></button></div>
<button id="activity-trigger" popovertarget="project-activity" aria-expanded="false" aria-controls="project-activity"><span id="activity-summary"></span><span aria-hidden="true">⌄</span></button>
<div id="project-menu" popover aria-label="切换项目"><div id="project-list"></div></div>
<div id="project-activity" popover aria-label="所有项目活动"><section id="activity-projects"><h3>所有项目活动</h3><div id="activity-project-list"></div></section><section id="activity-sessions" hidden><button id="activity-back">‹ 返回项目列表</button><h3 id="activity-project-title"></h3><div id="activity-list"></div></section></div>
<button class="new-chat" data-page="当前对话"><span>+</span> 新对话</button>
<nav class="navigation" aria-label="应用导航">
<button class="selected" data-page="当前对话"><span data-icon="chat"></span>当前对话</button>
@@ -26,6 +26,112 @@ const variants = {
scenes: ['F / 商店发现', '底部入口强调应用发现,展开后查看商店应用与安装位置。', '先模拟安装,再决定是否常驻;我的应用始终可切换访问。']
};
const $ = selector => document.querySelector(selector);
const demoProjects = [
{ id: 'goodbuddy', name: 'GoodBuddy', group: '本地', conversations: ['优化项目导航', '检查构建问题'] },
{ id: 'website', name: '官网改版', group: '本地', conversations: ['发布首页'] },
{ id: 'remote', name: '客户方案与交付文档工作空间', group: 'SSH', conversations: ['整理报价'] },
{ id: 'channel', name: '团队助手', group: '通道', conversations: ['本周工作计划'] }
];
let currentProject = 'goodbuddy';
let projectActivity = [];
let expandedActivityProject = '';
function renderProjects() {
const scenario = $('#project-scenario').value;
const activity = demoProjects.flatMap(project => project.conversations.map((name, index) => ({ project, name, index,
state: scenario === 'idle' || project.id === 'channel' || (scenario === 'current' && project.id !== currentProject) ? '' : scenario === 'waiting' && project.id === 'website' ? '等待审批' : '运行中'
}))).filter(row => row.state);
projectActivity = activity;
const waiting = activity.filter(row => row.state !== '运行中').length;
const running = activity.length - waiting;
const project = demoProjects.find(entry => entry.id === currentProject);
$('#project-name').textContent = project.name;
$('#project-trigger').setAttribute('aria-label', `切换项目,当前项目:${project.name}`);
$('#activity-trigger').hidden = !activity.length;
if (!activity.length && $('#project-activity').matches(':popover-open')) $('#project-activity').hidePopover();
$('#activity-summary').innerHTML = `${running ? `运行中 ${running}` : ''}${waiting && running ? ' · ' : ''}${waiting ? `<span class="project-waiting">待处理 ${waiting}</span>` : ''}`;
$('#activity-trigger').setAttribute('aria-label', `所有项目活动:${$('#activity-summary').textContent}`);
$('#project-list').innerHTML = ['本地', 'SSH', '通道'].map(group => `<section><h3>${group}</h3>${demoProjects.filter(entry => entry.group === group).map(entry => {
const rows = activity.filter(row => row.project.id === entry.id);
const pending = rows.filter(row => row.state !== '运行中').length;
const active = rows.length - pending;
return `<button data-project="${entry.id}" ${entry.id === currentProject ? 'aria-current="true"' : ''}><span class="project-row-name">${entry.name}</span>${entry.id === currentProject ? '<span aria-hidden="true">✓</span>' : ''}${rows.length ? `<small>${pending ? `<span class="project-waiting">待处理 ${pending}</span>` : ''}${active ? `<span>运行中 ${active}</span>` : ''}</small>` : ''}</button>`;
}).join('')}</section>`).join('');
$('#activity-project-list').innerHTML = demoProjects.map(entry => {
const rows = activity.filter(row => row.project.id === entry.id);
if (!rows.length) return '';
const pending = rows.filter(row => row.state !== '运行中').length;
return `<button data-activity-project="${entry.id}" aria-expanded="false" aria-controls="activity-sessions"><span>${entry.name}</span><small>${pending ? `<span class="project-waiting">待处理 ${pending}</span>` : `运行中 ${rows.length}`}</small><span aria-hidden="true">›</span></button>`;
}).join('') || '<p class="muted">暂无运行中或待处理会话。</p>';
expandActivityProject('');
$('.history').innerHTML = `<span class="eyebrow">当前项目对话</span>${project.conversations.map((name, index) => {
const row = activity.find(entry => entry.project.id === currentProject && entry.index === index);
return `<button data-conversation="${currentProject}" data-conversation-index="${index}">${name}${row ? `<small>${row.state}</small>` : ''}</button>`;
}).join('')}`;
}
$('#project-scenario').onchange = renderProjects;
const projectMenu = $('#project-menu');
function positionProjectMenu() {
const rect = $('#project-trigger').getBoundingClientRect();
projectMenu.style.left = `${Math.max(16, Math.min(rect.left, innerWidth - projectMenu.offsetWidth - 16))}px`;
projectMenu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - projectMenu.offsetHeight - 16))}px`;
}
projectMenu.addEventListener('toggle', event => {
$('#project-trigger').setAttribute('aria-expanded', String(event.newState === 'open'));
if (event.newState === 'open') positionProjectMenu();
});
window.addEventListener('resize', () => { if (projectMenu.matches(':popover-open')) positionProjectMenu(); });
window.addEventListener('scroll', () => { if (projectMenu.matches(':popover-open')) positionProjectMenu(); }, true);
function positionActivityMenu() {
const panel = $('#project-activity');
const rect = $('#activity-trigger').getBoundingClientRect();
panel.classList.toggle('activity-compact', innerWidth < 620);
panel.classList.toggle('activity-left', innerWidth >= 620 && rect.left + panel.offsetWidth > innerWidth - 16 && rect.right >= panel.offsetWidth + 16);
const left = panel.classList.contains('activity-left') ? rect.right - panel.offsetWidth : rect.left;
panel.style.left = `${Math.max(16, Math.min(left, innerWidth - panel.offsetWidth - 16))}px`;
panel.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - panel.offsetHeight - 16))}px`;
}
function expandActivityProject(id, focus = false) {
expandedActivityProject = id;
$('#activity-sessions').hidden = !id;
$('#project-activity').classList.toggle('has-submenu', Boolean(id));
document.querySelectorAll('[data-activity-project]').forEach(button => button.setAttribute('aria-expanded', String(button.dataset.activityProject === id)));
if (id) {
$('#activity-project-title').textContent = demoProjects.find(project => project.id === id).name;
$('#activity-list').innerHTML = projectActivity.filter(row => row.project.id === id).sort((a, b) => Number(a.state === '运行中') - Number(b.state === '运行中')).map(row => `<button data-conversation="${id}" data-conversation-index="${row.index}"><strong>${row.name}</strong><small>${row.state}</small></button>`).join('');
}
if ($('#project-activity').matches(':popover-open')) positionActivityMenu();
if (focus) $('#activity-list button')?.focus();
}
$('#project-activity').addEventListener('toggle', event => {
$('#activity-trigger').setAttribute('aria-expanded', String(event.newState === 'open'));
if (event.newState === 'open') { expandActivityProject(''); positionActivityMenu(); }
});
$('#activity-project-list').addEventListener('pointerover', event => {
const button = event.target.closest('[data-activity-project]');
if (event.pointerType === 'mouse' && innerWidth >= 620 && button && button.dataset.activityProject !== expandedActivityProject) expandActivityProject(button.dataset.activityProject);
});
$('#activity-project-list').addEventListener('click', event => {
const button = event.target.closest('[data-activity-project]');
if (button) expandActivityProject(button.dataset.activityProject, true);
});
$('#activity-back').onclick = () => {
const id = expandedActivityProject;
expandActivityProject('');
$(`[data-activity-project="${id}"]`)?.focus();
};
$('#project-activity').addEventListener('keydown', event => {
const button = event.target.closest('[data-activity-project]');
if (event.key === 'ArrowRight' && button) { event.preventDefault(); expandActivityProject(button.dataset.activityProject, true); }
if ((event.key === 'ArrowLeft' || event.key === 'Escape') && expandedActivityProject) { event.preventDefault(); event.stopPropagation(); $('#activity-back').click(); }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const buttons = [...document.querySelectorAll(button ? '[data-activity-project]' : '#activity-list button')];
const index = buttons.indexOf(document.activeElement);
if (index >= 0) { event.preventDefault(); buttons[(index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length].focus(); }
}
});
window.addEventListener('resize', () => { if ($('#project-activity').matches(':popover-open')) positionActivityMenu(); });
window.addEventListener('scroll', () => { if ($('#project-activity').matches(':popover-open')) positionActivityMenu(); }, true);
renderProjects();
let variant = 'dock';
let catalogMode = 'apps';
let residentApps = ['notes', 'knowledge'];
@@ -125,6 +231,16 @@ function selectTool(id) {
document.addEventListener('click', event => {
const button = event.target.closest('button'); if (!button) return;
const data = button.dataset;
if (data.project || data.conversation) {
currentProject = data.project || data.conversation;
if (projectMenu.matches(':popover-open')) projectMenu.hidePopover();
if ($('#project-activity').matches(':popover-open')) $('#project-activity').hidePopover();
renderProjects();
const project = demoProjects.find(entry => entry.id === currentProject);
openPage(data.conversation ? project.conversations[Number(data.conversationIndex)] : '当前对话');
$('#page-label').textContent = `${project.name} / ${data.conversation ? project.conversations[Number(data.conversationIndex)] : '当前对话'}`;
$('#project-trigger').focus();
}
if (data.variant) selectVariant(data.variant);
if (data.catalog) openCatalog(data.catalog);
if (data.page) openPage(data.page);
@@ -142,7 +142,9 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
### FR-14 项目活动汇总
用户在任一项目中都能查看跨项目的运行中与待处理会话,并从活动列表直接进入所选会话。
没有活动时隐藏汇总入口;项目菜单保留原有分组与顺序,并显示各项目的活动数量。
汇总入口常驻项目选择器下方,没有活动时显示“暂无活动”。点击后先显示有活动会话的项目,
悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示
各项目的活动数量;级联菜单内的项目顺序不随待处理或运行中数量变化。
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
@@ -0,0 +1,29 @@
# 项目活动级联实施进度
## 2026-09-13:生产活动入口
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e项目活动级联)。
本页只记录项目活动入口,不表示应用中心计划已交付。
`App` 已接入 `ProjectActivity` 常驻摘要和锚定级联菜单。会话和项目计数继续使用现有
`deriveConversationActivity` 结果,点击会话继续调用 `openActivityConversation`。未增加
持久化字段、IPC 或 Runtime 状态。项目选择器分组及计数保持原有行为。
组件与 App 定向回归通过 32 项,覆盖空闲入口、稳定项目及 SSH Host 顺序、待处理优先、
跨项目精确跳转、设置离开检查、实时状态清理、键盘返回、外部关闭和窄侧栏焦点。
Windows Electron 视觉 fixture 使用当前 `ProjectActivity`、生产样式和 i18n,活动数据为
受控样例。浅深主题分别验证 `1280×800`、`960×720`、`720×640`、`640×420`、`375×600`;
原生键盘验证 Enter、上下及右方向键、End、两级 Escape、Tab 退出,另测左侧回退和空闲入口。
此项证明组件的实际 Chromium 排版与交互,不代表完整生产 Main/Preload 的端到端验证。
浏览器遮挡复用共享菜单相交机制,组件测试检查相交和非相交结果;本轮未单独驱动原生
`WebContentsView` 可见性。远程 Agent 执行路径未改动,未发出模型或知识库请求。
提交前复核:`npm run typecheck`、`npm run lint` 通过;单独运行
`npx vitest run src/renderer/src/ProjectActivity.test.tsx`,11 项通过。
`npm test` 在 240 秒后超时,超时前 `tests/agent-package.test.ts` 的
`verifies and installs offline dependency inventories larger than one MiB` 用例失败,未取得完整结果。
Demo 已按最新讨论隐藏空闲摘要并移除会话行箭头;本次提交的生产实现仍保留空闲摘要和
会话行箭头,这两处尚未与 Demo 对齐。
@@ -392,7 +392,15 @@ resourceMonitor.getLocalSnapshot(input: {
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
`assistantTasks` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
`ProjectActivity` 渲染汇总与弹窗,`ProjectSwitcher` 复用项目计数。
`ProjectActivity` 渲染常驻汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。
- `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。
菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。
- 两层菜单在同一 body Portal 内相接。布局在打开、层级变化、实时数据更新、窗口缩放和
滚动时按锚点重算;窄窗口隐藏项目面板并保留返回操作。菜单不调用 Modal 焦点隔离,
捕获阶段消费 Escape,防止触发窄侧栏的文档级监听;Tab 关闭后交还正常焦点顺序。
菜单关闭与焦点恢复在导航回调之前同步完成。
- `role="menu"` 使浮层直接进入共享 `browser-viewport-occlusion.ts` 相交检测。未增加
浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
终态不能被旧响应覆盖。
@@ -406,3 +414,6 @@ resourceMonitor.getLocalSnapshot(input: {
列表并合并,再查找同一 ID。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。
设置离开检查通过后才提交项目、会话及界面清理状态。
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
验证记录见[项目活动级联实施进度](./progress.md)。本次只改 Renderer 展示与交互,远程
Runtime、Agent 协议和桌面到 Agent 的执行路径未改变。
@@ -19,7 +19,7 @@
```text
品牌
项目选择
项目活动汇总(有活动时)
项目活动汇总(常驻,空闲显示“暂无活动”)
新对话
搜索
@@ -209,15 +209,25 @@ CPU 4% · 内存 620 MB · 桌面进程 7
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
[活动逻辑](./logic-design.md#9-项目活动汇总)。
- 全局汇总按钮位于项目选择器下方,上行标明“全项目活动”,下行显示待处理与运行中数量;
箭头跨两行居中,无活动时整个按钮隐藏。
- 全局汇总按钮常驻项目选择器下方,紧凑显示待处理与运行中数量,空闲显示“暂无活动”,
右侧显示展开箭头。可访问名称明确数量属于“全项目活动”。
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。
- 点击汇总打开活动对话框,先列“待处理”,再列“运行中”,空分组隐藏。每行显示项目名称、
会话标题及明确状态文字,不展开 Task、Job 或 Run 列表,也不自动切换用户正在查看的会话。
- 点击一行先关闭对话框、释放 Modal 焦点隔离,再请求进入该条会话;允许离开当前页面后,
- 点击汇总在按钮下方打开项目菜单,只列有活动会话的项目,每项显示项目名和同口径计数。
桌面鼠标悬停或点击项目展开右侧会话菜单,右侧空间不足且左侧可容纳时向左展开。两个面板
相接,指针离开项目行进入会话层时不收起。展开项目不切换当前会话。
- 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在
运行中会话之前。不展开 Task、Job 或 Run 列表。
- 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后,
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
设置页沿用未保存修改的离开检查,取消离开时保留原页面。
- 对话框沿用全局 Modal 规范,支持 Tab 循环、Escape、关闭按钮、点击遮罩关闭及焦点恢复;
窄窗口限制宽高并在内容区滚动。活动全部结束时关闭对话框并隐藏入口。
- 菜单挂载到 body,使用 `menu`、`menuitem`、`aria-haspopup`、`aria-expanded` 与控制关系,
不隔离背景、不增加遮罩。原生浏览器相交遮挡沿用共享机制。
- 打开后焦点进入首个项目;上下方向键循环选择当前层项目或会话,Home、End 到首尾,
Enter、Space 激活,右方向键进入会话层。左方向键或 Escape 返回项目层并恢复对应项目
焦点;顶层 Escape 关闭并恢复汇总焦点,不继续关闭窄侧栏。Tab 关闭菜单后继续正常焦点
顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。
- 单面板宽度为 `264px`,窗口小于 `620px` 时就地进入会话层并显示“返回项目列表”,
触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制
高度并独立滚动。活动全部结束时,已打开菜单显示“暂无运行中或待处理会话。”。
@@ -234,3 +234,32 @@
- 不因宽度不足隐藏文件、终端、浏览器、资源监控或设置
- 触控点击区域符合统一界面规范
- 关闭侧栏抽屉后焦点返回原触发按钮
## 6. Epic E:项目活动级联
### US-E1 从摘要进入确切会话
对应 [FR-14](./prd.md#fr-14-项目活动汇总)。用户在当前项目工作时,可从项目选择器下方
查看跨项目活动,先定位项目,再进入需要处理的会话。
- Given 多个项目存在运行中和待处理会话,当前搜索仅匹配当前项目
- When 打开全项目活动并悬停或点击另一个项目
- Then 项目顺序保持稳定,该项目会话按待处理优先展示,当前页面和会话保持原选择
- When 点击会话
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
- Given 设置页有未保存修改
- Then 会话跳转仍需通过既有离开检查,取消离开保留设置草稿和原项目
### US-E2 键盘、窄窗口与实时结束
- Given 菜单已打开
- Then 上下方向键与 Home、End 选择当前层,右方向键或 Enter 进入会话层,左方向键或
Escape 返回;再次 Escape 关闭活动菜单并恢复摘要焦点,窄侧栏保持打开
- Given 窗口小于 `620px`
- Then 点击项目后仅显示会话层,可通过返回按钮回到项目列表;长列表可滚动到最后一项
- Given 鼠标从项目行移向相接的会话面板
- Then 子菜单保持打开,点击会话正常导航
- Given 当前项目的活动全部结束
- Then 返回项目层并恢复可用焦点;全局无活动时摘要显示“暂无活动”,已打开菜单显示空状态
- When 点击外部、焦点离开、按 Tab 或隐藏侧栏
- Then 菜单关闭,后台新活动不自动重新展开
+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;