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 (
+
+
+
+ );
+ },
+};