diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index 2c81b5a68d..b29634fc12 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -640,6 +640,7 @@ test('history asked for at the very top of the scroller still lands above the re const root = document.querySelector(selector); if (!root) throw new Error('the chat scroll container is missing'); root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); }, SCROLLER); await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); diff --git a/packages/ui/src/__tests__/session-history-row-actions.test.tsx b/packages/ui/src/__tests__/session-history-row-actions.test.tsx index 15d7523263..63b74314c0 100644 --- a/packages/ui/src/__tests__/session-history-row-actions.test.tsx +++ b/packages/ui/src/__tests__/session-history-row-actions.test.tsx @@ -85,6 +85,17 @@ const projectActions: ProjectRowActions = { onRestore: () => undefined, }; +/** The list's top-level `SideNavSection`s, in document order, by their title. */ +function readSections(document: Document): Array<{ title: string; element: Element }> { + return [...document.querySelectorAll('.maka-session-list > [role="group"]')].map((element) => { + const labelId = element.getAttribute('aria-labelledby'); + return { + title: (labelId ? document.getElementById(labelId)?.textContent : undefined) ?? '', + element, + }; + }); +} + function assertNoNestedButtons(markup: string): void { // Structural check. A real regression here moves the action menu inside the // navigation control, and the menu always ships wrapped in @@ -303,6 +314,89 @@ test('renders collapsible project navigation and row actions as sibling controls assertNoNestedButtons(markup); }); +test('renders pinned tasks once above project groups', () => { + const pinnedSession: SessionSummary = { + ...session, + id: 'session-pinned', + name: 'Pinned task', + isFlagged: true, + }; + const projectSession: SessionSummary = { + ...session, + id: 'session-project', + name: 'Project task', + }; + const markup = renderToStaticMarkup( + + + , + ); + + const { document } = parseHTML(markup); + const sections = readSections(document); + assert.deepEqual( + sections.map((section) => section.title), + ['Pinned', 'Projects'], + 'pinned tasks and project rows are sibling sections, not a section beside bare items', + ); + const [pinned, projects] = sections; + assert.ok(pinned && projects); + assert.equal(markup.match(/Pinned task/g)?.length, 1); + assert.match(pinned.element.textContent, /Pinned task/); + assert.doesNotMatch(projects.element.textContent, /Pinned task/); + const projectRow = projects.element.querySelector('.maka-project-row'); + assert.ok(projectRow, 'project rows are items inside the Projects section'); + assert.match(projectRow.textContent, /Project task/); +}); + +test('a project whose only task is pinned describes itself as empty', () => { + const pinnedSession: SessionSummary = { + ...session, + id: 'session-pinned', + name: 'Pinned task', + isFlagged: true, + }; + const markup = renderToStaticMarkup( + + + , + ); + + const { document } = parseHTML(markup); + const projectRow = document.querySelector('.maka-project-row'); + assert.ok(projectRow); + const navigation = projectRow.querySelector(':scope > div > button'); + assert.ok(navigation); + assert.equal(navigation.getAttribute('aria-controls'), null, 'no disclosure without a subtree'); + const describedBy = navigation.getAttribute('aria-describedby'); + assert.ok(describedBy); + const description = document.getElementById(describedBy); + assert.ok(description); + assert.match( + description.getAttribute('aria-label') ?? '', + /\b0 tasks\b/, + 'the hover description counts what the row actually shows', + ); + const action = document.querySelector('button[aria-label="Maka project actions"]'); + assert.ok(action); +}); + test('keeps project running totals aligned with renderer-local task streaming', () => { const locallyStreaming = { ...session, diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 98ae0115c0..3042b31f3c 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -416,6 +416,8 @@ export interface ConversationCopy { pinned: string; /** Time-sort unpinned section title (SideNavSection). */ recent: string; + /** Project-sort section title, sibling of `pinned` (SideNavSection). */ + projects: string; groupByTime: string; groupByProject: string; groupingAriaLabel: string; @@ -571,7 +573,7 @@ const CONVERSATION_COPY = { sessions: { status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' }, - listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, pickedAriaLabel: '已选中', pinCount: (count) => `置顶 ${count} 项`, unpinCount: (count) => `取消置顶 ${count} 项`, archiveCount: (count) => `归档 ${count} 项`, + listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', projects: '项目', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, pickedAriaLabel: '已选中', pinCount: (count) => `置顶 ${count} 项`, unpinCount: (count) => `取消置顶 ${count} 项`, archiveCount: (count) => `归档 ${count} 项`, }, }, en: { @@ -729,7 +731,7 @@ const CONVERSATION_COPY = { sessions: { status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', aborted: 'Stopped' }, blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' }, - listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, pickedAriaLabel: 'Selected', pinCount: (count) => `Pin ${count} tasks`, unpinCount: (count) => `Unpin ${count} tasks`, archiveCount: (count) => `Archive ${count} tasks`, + listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', projects: 'Projects', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, pickedAriaLabel: 'Selected', pinCount: (count) => `Pin ${count} tasks`, unpinCount: (count) => `Unpin ${count} tasks`, archiveCount: (count) => `Archive ${count} tasks`, }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 7d9d986871..447c1018c5 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -352,7 +352,8 @@ function SessionListGroups(props: { }>; }) { const rail = useSessionRailData(); - const copy = getConversationCopy(useUiLocale()).sessions; + const locale = useUiLocale(); + const copy = getConversationCopy(locale).sessions; const [renameTarget, setRenameTarget] = useState(null); /** * The control the rename was started from, so focus can go back to it. @@ -470,18 +471,26 @@ function SessionListGroups(props: { if (rail.groupVariant === 'project') { const activeGroups = props.groups.filter((group) => group.project?.archivedAt === undefined); const archivedGroups = props.groups.filter((group) => group.project?.archivedAt !== undefined); + const pinnedGroup = groupSessionsForHistory( + activeGroups.flatMap((group) => group.sessions), + locale, + ).find((group) => group.id === 'pinned'); function renderProjectGroup( group: (typeof props.groups)[number], + includePinned = false, ): ReactNode { const project = group.project; + const sessions = includePinned + ? group.sessions + : group.sessions.filter((session) => !session.isFlagged); return ( { @@ -494,23 +503,37 @@ function SessionListGroups(props: { ); } + // Two sibling sections, the same shape the time view has. A section groups + // items; it is not one of them. Putting the pinned section next to bare + // project rows would make the same level hold both a group heading and + // navigation items, and the pinned zone would be the only one there without + // a folder icon, a disclosure or a row menu. return ( <> {renameDialog} - {activeGroups.map(renderProjectGroup)} - {archivedGroups.length > 0 && ( - setArchivedExpanded(!collapsed), - }} - > - {/* Always mount children: Astryx derives collapsible chrome from - !!children. Nulling on collapse removes the chevron and makes - the controlled isCollapsed prop a no-op. */} - {archivedGroups.map(renderProjectGroup)} - + {pinnedGroup && ( + + {pinnedGroup.sessions.map((session) => renderSessionRow(session))} + + )} + {(activeGroups.length > 0 || archivedGroups.length > 0) && ( + + {activeGroups.map((group) => renderProjectGroup(group))} + {archivedGroups.length > 0 && ( + setArchivedExpanded(!collapsed), + }} + > + {/* Always mount children: Astryx derives collapsible chrome from + !!children. Nulling on collapse removes the chevron and makes + the controlled isCollapsed prop a no-op. */} + {archivedGroups.map((group) => renderProjectGroup(group, true))} + + )} + )} ); @@ -552,6 +575,10 @@ function ProjectNavRow(props: { }) { const containerRef = useRef(null); const hoverDescriptionId = useId(); + // The same list the row draws its subtree from. A summary counting rows that + // were hoisted into the pinned section describes a project row that has no + // disclosure and no children, and puts its menu somewhere else than the count + // implies. const hoverSummary = useMemo( () => createProjectHoverCardSummary( diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 32f89c84ad..9a5700d44d 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -414,6 +414,7 @@ export const ProjectGroups: Story = { makeSession({ id: 'proj-main', name: '主仓会话', + isFlagged: true, lastMessageAt: NOW - 4 * 60 * 1000, }), makeSession({ @@ -470,3 +471,84 @@ export const ProjectGroups: Story = { ); }, }; + +// Group-by-project where a project's only task is pinned, so the project row +// has nothing left to show. What the row says about itself — disclosure, +// action placement, the hover card's task count — has to follow what is +// actually under it, and an archived project sits below the live ones. +export const ProjectGroupsPinnedOnlyTask: Story = { + render: () => { + const solo = makeProject({ + id: 'project-solo', + name: '独苗项目', + preferredPath: '/workspace/solo', + }); + const docs = makeProject({ + id: 'project-docs', + name: '产品文档', + preferredPath: '/workspace/docs', + }); + const retired = makeProject({ + id: 'project-retired', + name: '旧版桌面端', + preferredPath: '/workspace/legacy', + archivedAt: NOW - 30 * 24 * 60 * 60 * 1000, + }); + const sessions = [ + makeSession({ + id: 'solo-only', + name: '唯一的任务', + isFlagged: true, + lastMessageAt: NOW - 6 * 60 * 1000, + }), + makeSession({ + id: 'docs-a', + name: '文档站改版', + lastMessageAt: NOW - 30 * 60 * 1000, + }), + makeSession({ + id: 'retired-a', + name: '旧版遗留任务', + lastMessageAt: NOW - 40 * 24 * 60 * 60 * 1000, + }), + ]; + return ( + + + + ); + }, +};