From 5d24c3c59c6c1a1292ef0649f564deb297c70998 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:53:41 +0800 Subject: [PATCH 1/5] feat(ui): add priority sorting to the task sidebar Generated-by: OpenAI Codex --- apps/desktop/e2e/sidebar-project-row.spec.ts | 15 +++- .../__tests__/session-list-layout.test.ts | 28 ++++++ .../features/session-navigation/README.md | 9 +- .../model/session-list-layout.ts | 11 ++- .../model/session-rail-layout-store.ts | 12 ++- .../ui/session-navigation-provider.tsx | 4 + .../renderer/session-status-presentation.ts | 27 +----- apps/desktop/src/renderer/styles/sidebar.css | 28 ++++-- .../session-history-row-actions.test.tsx | 57 ++++++++++++ packages/ui/src/components.tsx | 1 + packages/ui/src/conversation-copy.ts | 9 +- packages/ui/src/icons.tsx | 1 + packages/ui/src/session-history-list.tsx | 86 +++++++++++++------ packages/ui/src/session-list-panel.tsx | 53 ++++++++++-- packages/ui/src/session-rail-context.tsx | 4 + .../ui/src/session-status-presentation.ts | 5 ++ .../ui/stories/session-list-panel.stories.tsx | 86 ++++++++++++++++++- packages/ui/stories/session-rail-harness.tsx | 3 + 18 files changed, 366 insertions(+), 73 deletions(-) diff --git a/apps/desktop/e2e/sidebar-project-row.spec.ts b/apps/desktop/e2e/sidebar-project-row.spec.ts index 25df1c485e..009f32e3fd 100644 --- a/apps/desktop/e2e/sidebar-project-row.spec.ts +++ b/apps/desktop/e2e/sidebar-project-row.spec.ts @@ -147,15 +147,20 @@ test('task row action menu accepts pointer selection', async ({ await expect(page.getByRole('dialog', { name: '重命名任务' })).toBeVisible(); }); -test('rail grouping survives a renderer reload', async ({ projectSidebarWindow: page }) => { +test('rail grouping and sorting survive a renderer reload', async ({ projectSidebarWindow: page }) => { await page.keyboard.press('Escape'); await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); const sidebar = page.getByRole('navigation', { name: '任务列表' }); - const byTime = sidebar.getByRole('radio', { name: '按时间', exact: true }); + const allTasks = sidebar.getByRole('radio', { name: '全部任务', exact: true }); const byProject = sidebar.getByRole('radio', { name: '按项目', exact: true }); - await expect(byTime).toBeChecked(); + await expect(allTasks).toBeChecked(); + const sorting = sidebar.getByRole('button', { name: /^任务排序方式:/ }); + await expect(sorting).toHaveAccessibleName('任务排序方式: 最近更新'); + await sorting.click(); + await page.getByRole('menuitemradio', { name: '优先级', exact: true }).click(); + await expect(sorting).toHaveAccessibleName('任务排序方式: 优先级'); await byProject.click(); await expect(byProject).toBeChecked(); await expect @@ -168,6 +173,10 @@ test('rail grouping survives a renderer reload', async ({ projectSidebarWindow: await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); await expect(sidebar.getByRole('radio', { name: '按项目', exact: true })).toBeChecked(); + await expect(sorting).toHaveAccessibleName('任务排序方式: 优先级'); + await sorting.click(); + await expect(page.getByRole('menuitemradio', { name: '优先级', exact: true })).toBeChecked(); + await page.keyboard.press('Escape'); await expect .poll(() => page.evaluate(() => localStorage.getItem('maka-chat-list-view-mode-v1'))) .toBe('project'); diff --git a/apps/desktop/src/main/__tests__/session-list-layout.test.ts b/apps/desktop/src/main/__tests__/session-list-layout.test.ts index 8726f5bb77..c9b5cf6909 100644 --- a/apps/desktop/src/main/__tests__/session-list-layout.test.ts +++ b/apps/desktop/src/main/__tests__/session-list-layout.test.ts @@ -138,3 +138,31 @@ describe('session rail width persistence', () => { assert.equal(memory.store.get(WIDTH_KEY), '400'); }); }); + +describe('session sort preference', () => { + it('defaults safely and restores the selected order without changing grouping or width', () => { + const key = 'maka-chat-list-sort-mode-v1'; + for (const value of ['', 'manual', 'PRIORITY']) { + const memory = installMemoryLocalStorage({ [key]: value }); + try { + assert.equal(createSessionRailLayoutStore().getState().sortMode, 'updated_at'); + } finally { memory.restore(); } + } + const memory = installMemoryLocalStorage({ [VIEW_MODE_KEY]: 'project', [WIDTH_KEY]: '320' }); + try { + const store = createSessionRailLayoutStore(); + const original = store.getState(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { notifications += 1; }); + store.setSortMode('priority'); + store.setSortMode('priority'); + assert.equal(notifications, 1); + assert.deepEqual(store.getState(), { ...original, sortMode: 'priority' }); + assert.equal(memory.store.get(key), 'priority'); + assert.equal(createSessionRailLayoutStore().getState().sortMode, 'priority'); + store.setSortMode('updated_at'); + assert.equal(createSessionRailLayoutStore().getState().sortMode, 'updated_at'); + unsubscribe(); + } finally { memory.restore(); } + }); +}); diff --git a/apps/desktop/src/renderer/features/session-navigation/README.md b/apps/desktop/src/renderer/features/session-navigation/README.md index 498d7d4c12..bff3350187 100644 --- a/apps/desktop/src/renderer/features/session-navigation/README.md +++ b/apps/desktop/src/renderer/features/session-navigation/README.md @@ -24,7 +24,7 @@ owns: - rail membership, linked-session highlighting, Project/Runtime Host grouping, worktree badges, branch banners, and revision navigation; -- collapsed/expanded state, width, grouping mode, and their existing local +- collapsed/expanded state, width, grouping/sort modes, and their local persistence keys; - explicit jumps into a Session, including search turn targets; and - flag, archive, restore, rename, delete, and archived-task purge lifecycles. @@ -63,7 +63,7 @@ through `SessionNavigationPorts`, which the shell composes. revision navigation, and the rail's width. It holds no state. - `createSessionOpenCommand` composes an explicit Session jump out of the shell's own actions. -- `sessionRailLayoutStore` owns collapse, width, and grouping mode, with the +- `sessionRailLayoutStore` owns collapse, width, grouping, and sort mode, with the existing persistence keys. ## Lifecycle invariants @@ -71,6 +71,11 @@ through `SessionNavigationPorts`, which the shell composes. - Archived, linked-subagent, and hidden companion Sessions follow the existing single-rail projection; a linked child highlights its visible root. - Local Sessions group by Project while remote Sessions group by Runtime Host. +- Sorting is a UI preference (`updated_at` by default, or `priority`). Priority + promotes waiting-for-user, actionable blocked, live running, and unread rows + in that order, then uses recency and ID as ties. It only sorts visible roots + within existing groups; child attention is not rolled up to parent rows. +- Sorting does not mark work read, change group membership, or alter execution. - Opening a Session first exits WorkHub, selects the Sessions destination, then activates the Session and replaces or clears the turn-scroll target. - At most one row mutation runs per Session. Mutations retain revision-family diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts index 107962f1f7..3e12ed8cfb 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-list-layout.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { SessionViewMode } from '@maka/ui'; +import type { SessionSortMode, SessionViewMode } from '@maka/ui'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; export const SESSION_LIST_EXPANDED_DEFAULT_WIDTH = 260; @@ -25,6 +25,15 @@ export const SESSION_LIST_EXPANDED_MIN_WIDTH = 180; export const SESSION_LIST_EXPANDED_MAX_WIDTH = 480; const SESSION_LIST_VIEW_MODE_KEY = 'maka-chat-list-view-mode-v1'; +const SESSION_LIST_SORT_MODE_KEY = 'maka-chat-list-sort-mode-v1'; + +export function readSessionListSortMode(): SessionSortMode { + return safeLocalStorageGet(SESSION_LIST_SORT_MODE_KEY) === 'priority' ? 'priority' : 'updated_at'; +} + +export function writeSessionListSortMode(mode: SessionSortMode): void { + safeLocalStorageSet(SESSION_LIST_SORT_MODE_KEY, mode); +} export function readSessionListViewMode(): SessionViewMode { const stored = safeLocalStorageGet(SESSION_LIST_VIEW_MODE_KEY); diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts index e9aa78e26b..aeb6dd6889 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts @@ -18,15 +18,17 @@ */ import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav'; -import type { SessionViewMode } from '@maka/ui'; +import type { SessionSortMode, SessionViewMode } from '@maka/ui'; import { safeLocalStorageSet } from '../../../browser-storage.js'; import { createObservableState } from '../../../observable-state.js'; import { clampSessionListWidth, readSessionListCollapsed, + readSessionListSortMode, readSessionListViewMode, readSessionListWidth, SESSION_LIST_EXPANDED_MIN_WIDTH, + writeSessionListSortMode, writeSessionListViewMode, } from './session-list-layout.js'; @@ -36,6 +38,7 @@ export interface SessionRailLayoutState { readonly collapsed: boolean; readonly width: number; readonly viewMode: SessionViewMode; + readonly sortMode: SessionSortMode; } /** @@ -57,6 +60,7 @@ export function createSessionRailLayoutStore() { collapsed: readSessionListCollapsed(), width: readSessionListWidth(), viewMode: readSessionListViewMode(), + sortMode: readSessionListSortMode(), }); const collapseHandleRef: { current: SideNavImperativeCollapseHandle | null } = { current: null }; let widthPersistHandle: ReturnType | undefined; @@ -96,6 +100,12 @@ export function createSessionRailLayoutStore() { state.replaceState({ ...current, viewMode: next }); writeSessionListViewMode(next); }, + setSortMode(next: SessionSortMode): void { + const current = state.getState(); + if (current.sortMode === next) return; + state.replaceState({ ...current, sortMode: next }); + writeSessionListSortMode(next); + }, }; } diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index c7322afe22..04b6fb97ac 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -152,6 +152,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) const data = useMemo( () => ({ sessions: props.rail.sessions, + sortMode: controller.layout.sortMode, activeId: props.workHubActive ? undefined : props.rail.activeRowId, streamingSessionIds: props.streamingSessionIds, staleSessionIds: props.staleSessionIds, @@ -165,6 +166,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) }), [ controller.layout.viewMode, + controller.layout.sortMode, controller.selectors.groups, controller.selectors.sessionMeta, controller.selectors.worktreeSessionIds, @@ -191,6 +193,8 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) maxWidth: SESSION_LIST_EXPANDED_MAX_WIDTH, viewMode: controller.layout.viewMode, onViewModeChange: sessionRailLayoutStore.setViewMode, + sortMode: controller.layout.sortMode, + onSortModeChange: sessionRailLayoutStore.setSortMode, selection: props.selection, scheduledTasks: props.scheduledTasks, moduleMemory: props.moduleMemory, diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index c039d73e30..328059cee1 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -18,8 +18,8 @@ */ /** - * Renderer-side presentation rules that only Desktop has: which blocked reasons - * are worth acting on, and what to offer after a turn fails. + * Renderer-side normalization and recovery presentation. Actionable blocked + * reasons are shared with the sidebar's priority ordering in @maka/ui. * * Separated from the React component layer so the rules can be unit-tested * without a DOM, mirroring the `session-health-notice.ts` pattern. @@ -34,31 +34,12 @@ */ import { SANDBOX_BOUNDARY_RESTART_CLOSURE_CLASS } from '@maka/core/sandbox-boundary'; -import type { SessionBlockedReason, SessionSummary } from '@maka/core/session'; +import type { SessionSummary } from '@maka/core/session'; +import { isActionableBlocked } from '@maka/ui'; import type { UiLocale } from '@maka/core/ui-locale'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { describeSessionErrorReason } from './session-error-presentation.js'; -/** - * Session-level "blocked" is only worth interrupting the user when - * they can ACT on it: configure a connection, re-login, or confirm a - * permission. `tool_failed` / `unknown` mean "the last run's bookkeeping - * didn't close cleanly" — the conversation itself is intact and - * retryable, and the failure detail already surfaces on the failed - * turn inside the chat. Runtime keeps writing the strict status (the - * #397/#410 terminal-fact invariant is untouched); this is a - * display-layer distinction only. - */ -const ACTIONABLE_BLOCKED_REASONS: ReadonlySet = new Set([ - 'NO_REAL_CONNECTION', - 'auth', - 'permission_required', -]); - -export function isActionableBlocked(reason: SessionBlockedReason | undefined): boolean { - return reason !== undefined && ACTIONABLE_BLOCKED_REASONS.has(reason); -} - /** * Normalize a SessionSummary as it enters renderer state. Authoritative * known-empty live state clears a persisted `running` value that may have diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index a6cbf7c64c..33b30ac152 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -229,19 +229,35 @@ } /* - * Grouping switch (session-list-panel.tsx): the last row of the sticky top + * Grouping and sorting (session-list-panel.tsx): the last row of the sticky top * region, above the scrolling history it governs. * * Block padding only. The zone already insets its content to the same two - * vertical lines the nav rows' boxes sit on, so the control spans exactly the - * 新任务 row's width and the top region reads as one stack; an inline padding - * here would inset it a second time. `layout="fill"` handles the rest — each - * segment takes exactly half. + * vertical lines the nav rows' boxes sit on. Grouping fills the space beside + * the fixed-size sorting button, without adding a second row of chrome. */ -.maka-session-grouping-switch { +.maka-session-list-controls { + display: flex; + align-items: center; + gap: var(--spacing-1); padding-block: var(--spacing-1) var(--spacing-2); } +.maka-session-grouping-switch { + flex: 1; + min-width: 0; +} + +.maka-session-list-controls .maka-session-grouping-switch [role='radio'] { + padding-inline: var(--spacing-1); +} + +.maka-session-sorting-switch { + display: flex; + flex-shrink: 0; + margin-inline-start: auto; +} + /* * Dim only this row's SideNavItem. Child combinators stop before [role=group] * nests under project groups. 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 a901dd7137..dfca50dab6 100644 --- a/packages/ui/src/__tests__/session-history-row-actions.test.tsx +++ b/packages/ui/src/__tests__/session-history-row-actions.test.tsx @@ -71,6 +71,63 @@ const rowActions: SessionRowActions = { onDelete: () => undefined, }; +function renderedSessionIds(props: Partial & { sessions: SessionSummary[] }): string[] { + const { document } = parseHTML(renderToStaticMarkup( + , + )); + return [...document.querySelectorAll('[data-maka-contract="session-row"]')] + .map((row) => row.getAttribute('data-session-id')!); +} + +test('priority sorting promotes actionable work without mistaking stale running or old failures for work', () => { + const rows: SessionSummary[] = [ + { ...session, id: 'ordinary', lastMessageAt: 100 }, + { ...session, id: 'old-running', status: 'running', runningTurnIds: [], lastMessageAt: 90 }, + { ...session, id: 'old-error', status: 'blocked', blockedReason: 'tool_failed', lastMessageAt: 80 }, + { ...session, id: 'unknown-error', status: 'blocked', blockedReason: 'unknown', lastMessageAt: 70 }, + { ...session, id: 'unread', hasUnread: true, lastMessageAt: 60 }, + { ...session, id: 'streaming', runningTurnIds: [], lastMessageAt: 50 }, + { ...session, id: 'host-running', runningTurnIds: ['turn-1'], lastMessageAt: 40 }, + { ...session, id: 'legacy-running', status: 'running', lastMessageAt: 30 }, + { ...session, id: 'auth', status: 'blocked', blockedReason: 'auth', lastMessageAt: 20 }, + { ...session, id: 'waiting', status: 'waiting_for_user', lastMessageAt: 10 }, + ]; + const original = structuredClone(rows); + assert.deepEqual(renderedSessionIds({ + sessions: rows, sortMode: 'priority', streamingSessionIds: new Set(['streaming']), + }), ['waiting', 'auth', 'streaming', 'host-running', 'legacy-running', 'unread', + 'ordinary', 'old-running', 'old-error', 'unknown-error']); + assert.deepEqual(renderedSessionIds({ sessions: rows }), rows.map((row) => row.id)); + assert.deepEqual(rows, original, 'sorting must not mutate the Session catalog'); +}); + +test('priority sorting preserves pinned membership and uses stable timestamp/id ties', () => { + const rows = [ + { ...session, id: 'z', lastMessageAt: 5 }, + { ...session, id: 'a', lastMessageAt: 5 }, + { ...session, id: 'waiting', status: 'waiting_for_user' as const, lastMessageAt: 1 }, + { ...session, id: 'pinned', isFlagged: true, lastMessageAt: 0 }, + { ...session, id: 'pinned-waiting', isFlagged: true, status: 'waiting_for_user' as const }, + ]; + assert.deepEqual(renderedSessionIds({ sessions: rows, sortMode: 'priority' }), + ['pinned-waiting', 'pinned', 'waiting', 'a', 'z']); +}); + +test('pre-grouped history sorts within each group without moving work across groups', () => { + const first = [{ ...session, id: 'first-new', lastMessageAt: 20 }, + { ...session, id: 'first-waiting', status: 'waiting_for_user' as const, lastMessageAt: 10 }]; + const second = [{ ...session, id: 'second-old', lastMessageAt: 1 }, + { ...session, id: 'second-new', lastMessageAt: 100 }]; + const groups = [{ id: 'project-1', label: 'First project', sessions: first }, + { id: 'runtime-host:2', label: 'Remote Host', sessions: second }]; + const props = { sessions: [...first, ...second], groups }; + assert.deepEqual(renderedSessionIds({ ...props, sortMode: 'priority' }), + ['first-waiting', 'first-new', 'second-new', 'second-old']); + assert.deepEqual(renderedSessionIds({ ...props, sortMode: 'updated_at' }), + ['first-new', 'first-waiting', 'second-new', 'second-old']); + assert.deepEqual(groups[0]?.sessions.map((row) => row.id), ['first-new', 'first-waiting']); +}); + const project: ProjectRecord = { id: 'project-1', name: 'Maka', diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index bfde2290b7..5687aa2f69 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -33,6 +33,7 @@ export type { SessionRailChrome, SessionRailData, SessionViewMode, + SessionSortMode, } from './session-rail-context.js'; export type { SidebarUpdateReminder } from './session-sidebar-nav.js'; export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js'; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index d7a4d79cba..03921356f0 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -404,7 +404,10 @@ export interface ConversationCopy { pinned: string; /** Time-sort unpinned section title (SideNavSection). */ recent: string; - groupByTime: string; + allTasks: string; + sortByUpdated: string; + sortByPriority: string; + sortingAriaLabel: string; groupByProject: string; groupingAriaLabel: string; projectActionsAriaLabel: (name: string) => string; @@ -543,7 +546,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}`, + 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: '最近', allTasks: '全部任务', sortByUpdated: '最近更新', sortByPriority: '优先级', sortingAriaLabel: '任务排序方式', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, }, }, en: { @@ -691,7 +694,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}`, + 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', allTasks: 'All tasks', sortByUpdated: 'Last updated', sortByPriority: 'Priority', sortingAriaLabel: 'Sort tasks by', 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}`, }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx index 7c9e29a036..e4d5b7b1d9 100644 --- a/packages/ui/src/icons.tsx +++ b/packages/ui/src/icons.tsx @@ -57,6 +57,7 @@ export { Archive, ArchiveRestore, ArrowDown, + ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 381155b8c2..154bb7dd52 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -21,6 +21,7 @@ import { memo, useCallback, useEffect, + useMemo, useRef, useState, type KeyboardEvent, @@ -53,10 +54,14 @@ import { } from '@astryxdesign/core/SideNav'; import { VStack } from '@astryxdesign/core/Stack'; import { StatusDot, type StatusDotVariant } from '@astryxdesign/core/StatusDot'; -import { describeBlockedReason, presentSessionStatus } from './session-status-presentation.js'; +import { + describeBlockedReason, + isActionableBlocked, + presentSessionStatus, +} from './session-status-presentation.js'; import { dotForStatus } from './status-vocabulary.js'; import { SessionRenameDialog, type SessionRenameTarget } from './session-rename-dialog.js'; -import { useSessionRailData } from './session-rail-context.js'; +import { useSessionRailData, type SessionSortMode } from './session-rail-context.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; @@ -90,6 +95,23 @@ export interface SessionHistoryGroup { export function SessionHistoryList() { const rail = useSessionRailData(); const locale = useUiLocale(); + const { sessions, groups: suppliedGroups, sortMode = 'updated_at', streamingSessionIds } = rail; + const groups = useMemo(() => { + const sort = (rows: readonly SessionSummary[]) => + sortSessionsForHistory(rows, sortMode, streamingSessionIds); + return suppliedGroups + ? suppliedGroups.map((group) => ({ + key: group.id, + label: group.label, + sessions: sort(group.sessions), + project: group.project, + })) + : groupSessionsForHistory(sort(sessions), locale, sortMode).map((group) => ({ + key: group.id, + label: group.label, + sessions: group.sessions, + })); + }, [sessions, suppliedGroups, sortMode, streamingSessionIds, locale]); function handleListKeyDown(event: KeyboardEvent) { if (event.key !== 'Delete' && event.key !== 'Backspace') return; @@ -114,22 +136,7 @@ export function SessionHistoryList() { // handler, nothing an assistive tech user needs to be told about separately. return (
- ({ - key: g.id, - label: g.label, - sessions: g.sessions, - project: g.project, - })) - : groupSessionsForHistory(rail.sessions, locale).map((g) => ({ - key: g.id, - label: g.label, - sessions: g.sessions, - })) - } - /> +
); } @@ -811,14 +818,11 @@ interface SessionGroup { function groupSessionsForHistory( sessions: readonly SessionSummary[], locale: UiLocale, + sortMode: SessionSortMode, ): SessionGroup[] { const copy = getConversationCopy(locale).sessions; - const ordered = [...sessions].sort((a, b) => { - const timestampDelta = (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0); - return timestampDelta || a.id.localeCompare(b.id); - }); - const pinned = ordered.filter((session) => session.isFlagged); - const unpinned = ordered.filter((session) => !session.isFlagged); + const pinned = sessions.filter((session) => session.isFlagged); + const unpinned = sessions.filter((session) => !session.isFlagged); const groups: SessionGroup[] = []; if (pinned.length > 0) { groups.push({ id: 'pinned', label: copy.pinned, sessions: pinned }); @@ -826,7 +830,39 @@ function groupSessionsForHistory( if (unpinned.length > 0) { // Visible SideNavSection title so pinned / recent read as two zones // (empty label used to drop the section chrome and blur the boundary). - groups.push({ id: 'unpinned', label: copy.recent, sessions: unpinned }); + groups.push({ + id: 'unpinned', + label: sortMode === 'priority' ? copy.allTasks : copy.recent, + sessions: unpinned, + }); } return groups; } + +/** Rank only the already-visible rows; linked children and group membership stay unchanged. */ +function sortSessionsForHistory( + sessions: readonly SessionSummary[], + mode: SessionSortMode, + streamingSessionIds?: ReadonlySet, +): SessionSummary[] { + const priority = (session: SessionSummary): number => { + if (session.status === 'waiting_for_user') return 0; + if (session.status === 'blocked' && isActionableBlocked(session.blockedReason)) return 1; + const running = + streamingSessionIds?.has(session.id) || + (session.runningTurnIds !== undefined + ? session.runningTurnIds.length > 0 + : session.status === 'running'); + if (running) return 2; + if (session.hasUnread) return 3; + return 4; + }; + return [...sessions].sort((left, right) => { + const priorityDelta = mode === 'priority' ? priority(left) - priority(right) : 0; + return ( + priorityDelta || + (right.lastMessageAt ?? 0) - (left.lastMessageAt ?? 0) || + left.id.localeCompare(right.id) + ); + }); +} diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx index 1f7522162c..a449302055 100644 --- a/packages/ui/src/session-list-panel.tsx +++ b/packages/ui/src/session-list-panel.tsx @@ -22,10 +22,16 @@ import { SegmentedControlItem, } from '@astryxdesign/core/SegmentedControl'; import { SideNav } from '@astryxdesign/core/SideNav'; +import { + DropdownMenu, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from '@astryxdesign/core/DropdownMenu'; import { SessionHistoryList } from './session-history-list.js'; import { useSessionRailChrome, type SessionViewMode, + type SessionSortMode, } from './session-rail-context.js'; import { SessionSidebarFooter, @@ -33,6 +39,7 @@ import { } from './session-sidebar-nav.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; +import { ArrowDownWideNarrow, ICON_SIZE } from './icons.js'; /** * One element, created once, for the ~1,000 fibers below it. @@ -48,7 +55,7 @@ const SESSION_HISTORY_LIST = ; export function SessionListPanel() { const copy = getConversationCopy(useUiLocale()).sessions; const chrome = useSessionRailChrome(); - const { collapsed, viewMode, onViewModeChange } = chrome; + const { collapsed, viewMode, onViewModeChange, sortMode = 'updated_at', onSortModeChange } = chrome; // A view switch, not a command: two exclusive ways to read the same list. // Astryx spends a SegmentedControl on exactly this — see its own file-explorer @@ -57,10 +64,9 @@ export function SessionListPanel() { // grouping am I in?" and then answered it with a radio dot. // // Text labels, not icons: a clock and a folder are two icons the rail has to - // teach, and it never had anywhere to teach them — 按时间 / 按项目 is the - // whole vocabulary and it fits. The control spans the rail's full width so - // the two segments are one object with a visible current half, rather than a - // pair of small buttons floating beside a title. + // teach, and it never had anywhere to teach them — 全部任务 / 按项目 is the + // whole vocabulary and it fits. Grouping takes the available width beside + // the compact sorting menu, keeping both list controls in one row. // // It lives here, in the sticky top region, and NOT as a list heading's // endContent: the heading is gone (the rail landmark already names the panel, @@ -77,12 +83,40 @@ export function SessionListPanel() { size="sm" layout="fill" > - + ) : undefined; + const sortingLabel = sortMode === 'priority' ? copy.sortByPriority : copy.sortByUpdated; + const sortingSwitch = onSortModeChange && !collapsed ? ( +
+ +
+ ) : undefined; + return ( // Width easing needs an element that survives the collapse. SideNav swaps // its own root element type across the toggle — expanded it wraps the