You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/03 06:07:48 UTC
(maka) 01/01: refactor(ui): group by project as two sibling SideNavSections
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch spike/4613-two-sections
in repository https://gitbox.apache.org/repos/asf/maka.git
commit eae574713ea746db60d4f24a976ca2071d9725b2
Author: AstroHan <le...@outlook.com>
AuthorDate: Thu Sep 3 14:06:56 2026 +0800
refactor(ui): group by project as two sibling SideNavSections
The pinned zone and the project rows are two different Astryx primitives
laid out as siblings. SideNavSection is a group of navigation items, not
one of them: it renders role="group" with aria-labelledby, and its
documented shape is a Section containing Items. Flattening one Section
next to a row of SideNavItems puts a group heading and navigation items
on the same level, and the seam shows. The pinned heading is small grey
text where the project rows are full items with a folder icon; the
project rows collapse and the pinned zone cannot; the project rows carry
a row menu and the pinned zone does not. The one thing the two do share
is the indent under them, so the layout promises a hierarchy it gives no
way to operate.
Render the two as sibling sections instead, "Pinned" and "Projects".
The containment relationship is restored, project rows stay items inside
a section with their icon, disclosure and row menu untouched, and the
two grouping modes end up structurally symmetric: Pinned / Recent by
time, Pinned / Projects by project. Archived projects stay the last item
of the Projects section, because what that row holds is projects.
Hoisting the pinned tasks out of a project also left the project row
describing rows it no longer shows. The row derived its disclosure and
its menu placement from the filtered list while the hover summary read
the unfiltered one, so a project whose only visible task was pinned drew
no chevron and no subtree while its accessible description still
announced 1 task. Drop the second list: the summary reads the same
sessions the row renders, so the count, the chevron and the menu
placement have one source. A story fixture covers that shape.
Generated-by: Claude Code
---
.../__tests__/session-history-row-actions.test.tsx | 65 +++++++++++++++--
packages/ui/src/conversation-copy.ts | 6 +-
packages/ui/src/session-history-list.tsx | 47 ++++++++-----
packages/ui/stories/session-list-panel.stories.tsx | 81 ++++++++++++++++++++++
4 files changed, 174 insertions(+), 25 deletions(-)
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 040d761947..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
@@ -333,15 +344,59 @@ test('renders pinned tasks once above project groups', () => {
);
const { document } = parseHTML(markup);
- const projectRow = document.querySelector('.maka-project-row');
- assert.ok(projectRow);
- assert.match(markup, />Pinned</);
- assert.ok(markup.indexOf('Pinned task') < markup.indexOf('Maka'));
+ 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.doesNotMatch(projectRow.textContent, /Pinned task/);
+ 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,
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: [...]
+ 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: [...]
},
},
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', actions [...]
+ 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', actions [...]
},
},
} satisfies UiCatalog<ConversationCopy>;
diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx
index be3d8bd5ee..f45c1d485d 100644
--- a/packages/ui/src/session-history-list.tsx
+++ b/packages/ui/src/session-history-list.tsx
@@ -491,7 +491,6 @@ function SessionListGroups(props: {
label={group.label}
project={project}
sessions={sessions}
- summarySessions={group.sessions}
streamingSessionIds={rail.streamingSessionIds}
projectActions={rail.projectActions}
onStartRename={(opener) => {
@@ -504,6 +503,11 @@ 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}
@@ -512,20 +516,24 @@ function SessionListGroups(props: {
{pinnedSessions.map((session) => renderSessionRow(session))}
</SideNavSection>
)}
- {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>
+ {(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>
)}
</>
);
@@ -560,7 +568,6 @@ function ProjectNavRow(props: {
label: string;
project?: ProjectRecord;
sessions: SessionSummary[];
- summarySessions?: SessionSummary[];
streamingSessionIds?: ReadonlySet<string>;
projectActions?: ProjectRowActions;
onStartRename(opener: HTMLElement | null): void;
@@ -568,14 +575,18 @@ 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(
props.project,
- props.summarySessions ?? props.sessions,
+ props.sessions,
props.streamingSessionIds,
),
- [props.project, props.sessions, props.streamingSessionIds, props.summarySessions],
+ [props.project, props.sessions, props.streamingSessionIds],
);
// Collapsible only when there is a real session subtree. An empty VStack is
// still truthy children for Astryx (!!children) and fabricates a disclosure.
diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx
index 7224049fc9..9a5700d44d 100644
--- a/packages/ui/stories/session-list-panel.stories.tsx
+++ b/packages/ui/stories/session-list-panel.stories.tsx
@@ -471,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>
+ );
+ },
+};
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org