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:
@@ -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 菜单关闭,后台新活动不自动重新展开
|
||||
|
||||
@@ -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