Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/e2e/transcript-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
94 changes: 94 additions & 0 deletions packages/ui/src/__tests__/session-history-row-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
<LocaleProvider locale="en">
<Rail
sessions={[pinnedSession, projectSession]}
groups={[
{
id: project.id,
label: project.name,
project,
sessions: [pinnedSession, projectSession],
},
]}
groupVariant="project"
/>
</LocaleProvider>,
);

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(
<LocaleProvider locale="en">
<Rail
sessions={[pinnedSession]}
groups={[{ id: project.id, label: project.name, project, sessions: [pinnedSession] }]}
groupVariant="project"
projectActions={projectActions}
/>
</LocaleProvider>,
);

const { document } = parseHTML(markup);
const projectRow = document.querySelector('.maka-project-row');
assert.ok(projectRow);
const navigation = projectRow.querySelector<HTMLButtonElement>(':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,
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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<ConversationCopy>;
Expand Down
59 changes: 43 additions & 16 deletions packages/ui/src/session-history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionRenameTarget | null>(null);
/**
* The control the rename was started from, so focus can go back to it.
Expand Down Expand Up @@ -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 (
<ProjectNavRow
key={group.key}
groupKey={group.key}
label={group.label}
project={project}
sessions={group.sessions}
sessions={sessions}
streamingSessionIds={rail.streamingSessionIds}
projectActions={rail.projectActions}
onStartRename={(opener) => {
Expand All @@ -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 && (
<SideNavItem
label={copy.archivedProjects}
collapsible={{
isCollapsed: !archivedExpanded,
onCollapsedChange: (collapsed) => 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)}
</SideNavItem>
{pinnedGroup && (
<SideNavSection title={pinnedGroup.label} className="maka-session-group">
{pinnedGroup.sessions.map((session) => renderSessionRow(session))}
</SideNavSection>
)}
{(activeGroups.length > 0 || archivedGroups.length > 0) && (
<SideNavSection title={copy.projects} className="maka-session-group">
{activeGroups.map((group) => renderProjectGroup(group))}
{archivedGroups.length > 0 && (
<SideNavItem
label={copy.archivedProjects}
collapsible={{
isCollapsed: !archivedExpanded,
onCollapsedChange: (collapsed) => 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))}
</SideNavItem>
)}
</SideNavSection>
)}
</>
);
Expand Down Expand Up @@ -552,6 +575,10 @@ function ProjectNavRow(props: {
}) {
const containerRef = useRef<HTMLDivElement | null>(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(
Expand Down
82 changes: 82 additions & 0 deletions packages/ui/stories/session-list-panel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ export const ProjectGroups: Story = {
makeSession({
id: 'proj-main',
name: '主仓会话',
isFlagged: true,
lastMessageAt: NOW - 4 * 60 * 1000,
}),
makeSession({
Expand Down Expand Up @@ -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 (
<StoryFrame height={720}>
<SessionRail
{...panelProps({
sessions,
activeId: 'docs-a',
viewMode: 'project',
groups: [
{
id: `project:${solo.id}`,
label: solo.name,
project: solo,
sessions: [sessions[0]!],
},
{
id: `project:${docs.id}`,
label: docs.name,
project: docs,
sessions: [sessions[1]!],
},
{
id: `project:${retired.id}`,
label: retired.name,
project: retired,
sessions: [sessions[2]!],
},
],
projectActions: {
onNew: noop,
onRename: noop,
onArchive: noop,
onRestore: noop,
onRelink: noop,
},
})}
/>
</StoryFrame>
);
},
};