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