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__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 5771c5243c..baf0b6b41f 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -21,7 +21,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { SandboxBoundaryRequestEvent } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; -import type { SessionSummary } from '@maka/core/session'; +import type { SessionBlockedReason, SessionSummary } from '@maka/core/session'; import { armLiveTurn, confirmLiveTurn } from '@maka/ui'; import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js'; import { normalizeSessionSummaryForDisplay } from '../../renderer/session-status-presentation.js'; @@ -76,6 +76,31 @@ function seededState(): AppShellSessionUiState { } describe('session live run display state', () => { + it('preserves actionable blocks and normalizes old failures without changing stored facts', () => { + const cases: Array<[SessionBlockedReason | undefined, SessionSummary['status']]> = [ + ['NO_REAL_CONNECTION', 'blocked'], + ['auth', 'blocked'], + ['permission_required', 'blocked'], + ['tool_failed', 'active'], + ['unknown', 'active'], + [undefined, 'active'], + ]; + for (const [blockedReason, expectedStatus] of cases) { + const stored = Object.freeze({ + id: 'blocked-session', status: 'blocked', blockedReason, + } as SessionSummary); + const displayed = normalizeSessionSummaryForDisplay(stored); + assert.equal(displayed.status, expectedStatus, blockedReason ?? 'missing reason'); + if (expectedStatus === 'blocked') { + assert.equal(displayed, stored); + } else { + assert.equal('blockedReason' in displayed, false); + } + assert.equal(stored.status, 'blocked'); + assert.equal(stored.blockedReason, blockedReason); + } + }); + it('keeps persisted running as a fallback only while live state is unknown', () => { const unknown = { id: 'unknown', status: 'running' } as SessionSummary; const knownEmpty = { 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/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index 77ae054556..ba6540992e 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -28,6 +28,7 @@ import { createSessionOpenCommand, deriveSessionRail, sessionMatchesRail, + sessionRailLayoutStore, SessionNavigationServicesProvider, useSessionNavigationController, useSessionNavigationReads, @@ -197,14 +198,57 @@ describe('useSessionNavigationController', () => { describe('useSessionNavigationReads', () => { let latestReads: ReturnType | undefined; + let renderCount = 0; function ReadsProbe(props: Parameters[0]) { + renderCount++; latestReads = useSessionNavigationReads(props); return null; } afterEach(() => { latestReads = undefined; + renderCount = 0; + }); + + it('ignores rail preferences while still reacting to geometry changes', async () => { + const { root } = installReactRenderer(); + const initialLayout = sessionRailLayoutStore.getState(); + try { + await act(async () => root.render(createElement(ReadsProbe, { + sessions: linkedCatalog, + activeSessionId: 'child', + activeSession: linkedCatalog[1], + hiddenSessionIds, + }))); + const initialRenderCount = renderCount; + const initialReads = latestReads; + + await act(async () => sessionRailLayoutStore.setSortMode( + initialLayout.sortMode === 'priority' ? 'updated_at' : 'priority', + )); + await act(async () => sessionRailLayoutStore.setViewMode( + initialLayout.viewMode === 'project' ? 'conversation' : 'project', + )); + assert.equal(renderCount, initialRenderCount, 'rail preferences must not rerender the shell'); + assert.equal(latestReads, initialReads); + + await act(async () => sessionRailLayoutStore.setCollapsed(!initialLayout.collapsed)); + assert.ok(renderCount > initialRenderCount); + assert.equal(latestReads?.layout.collapsed, !initialLayout.collapsed); + + const beforeWidthChange = renderCount; + const nextWidth = initialLayout.width === 300 ? 320 : 300; + await act(async () => sessionRailLayoutStore.setWidth(nextWidth)); + assert.ok(renderCount > beforeWidthChange); + assert.equal(latestReads?.layout.width, nextWidth); + } finally { + await act(async () => root.unmount()); + sessionRailLayoutStore.setCollapsed(initialLayout.collapsed); + sessionRailLayoutStore.setWidth(initialLayout.width); + sessionRailLayoutStore.setViewMode(initialLayout.viewMode); + sessionRailLayoutStore.setSortMode(initialLayout.sortMode); + } }); it('projects linked, archived, hidden, Project, and Runtime Host Sessions once', async () => { 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/controller/use-session-navigation-reads.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts index a84bc26d96..13881f7018 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts @@ -23,7 +23,6 @@ import { deriveBranchBanner, type BranchBanner } from '../model/branch-banner.js import { sessionMatchesRail } from '../model/session-nav-filter.js'; import { deriveSessionRail, type SessionRailProjection } from '../model/session-rail.js'; import { - selectRailLayout, sessionRailLayoutStore, type SessionRailLayoutState, } from '../model/session-rail-layout-store.js'; @@ -33,19 +32,22 @@ import { } from '../model/session-revisions.js'; import type { SessionNavigationSession } from '../ports.js'; +const selectRailCollapsed = (state: SessionRailLayoutState): boolean => state.collapsed; +const selectRailWidth = (state: SessionRailLayoutState): number => state.width; + export interface SessionNavigationReads { /** The rail's membership, derived once and shared with the command palette. */ rail: SessionRailProjection; branchBanner: BranchBanner | undefined; revisionNavigation: SessionRevisionNavigation | undefined; - layout: SessionRailLayoutState; + layout: Pick; } /** * What the shell reads from Session Navigation, as opposed to what it owns. * * Nothing here holds state: three `useMemo`s over the catalog the shell already - * has, and one subscription to the rail's geometry — which the window frame + * has, and subscriptions to the rail's geometry — which the window frame * needs, because `--maka-sidenav-width` is where the titlebar's breadcrumb * starts. The rail's own state lives under `SessionNavigationProvider` and is * not visible from here, which is the point of #4109: a hook called in the @@ -74,6 +76,7 @@ export function useSessionNavigationReads(input: { () => deriveSessionRevisionNavigation(sessions, activeSessionId), [activeSessionId, sessions], ); - const layout = useExternalStoreSelector(sessionRailLayoutStore, selectRailLayout); - return { rail, branchBanner, revisionNavigation, layout }; + const collapsed = useExternalStoreSelector(sessionRailLayoutStore, selectRailCollapsed); + const width = useExternalStoreSelector(sessionRailLayoutStore, selectRailWidth); + return { rail, branchBanner, revisionNavigation, layout: { collapsed, width } }; } 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..0e57879e08 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); + }, }; } @@ -104,8 +114,7 @@ export type SessionRailLayoutStore = ReturnType state; diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index 0995524bc8..e2e211d308 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -47,7 +47,10 @@ export { SESSION_LIST_EXPANDED_MIN_WIDTH, writeSessionListViewMode, } from './model/session-list-layout.js'; -export { createSessionRailLayoutStore } from './model/session-rail-layout-store.js'; +export { + createSessionRailLayoutStore, + sessionRailLayoutStore, +} from './model/session-rail-layout-store.js'; export function createFakeSessionNavigationServices( overrides: Partial = {}, 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..346b38547a 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 presentation rules that only Desktop has: how session summaries + * enter renderer state, and what to offer after a turn fails. * * 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,11 @@ */ import { SANDBOX_BOUNDARY_RESTART_CLOSURE_CLASS } from '@maka/core/sandbox-boundary'; -import type { SessionBlockedReason, SessionSummary } from '@maka/core/session'; +import { isActionableBlocked, type SessionSummary } from '@maka/core/session'; 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..14098a1d7c 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -229,19 +229,31 @@ } /* - * 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. Content-sized grouping leaves room + * for sorting on the same row, with Astryx's stock item padding at every width. */ -.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: 0 1 auto; + min-width: 0; +} + +.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/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index ed9b810a0d..dff24e76e8 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -238,7 +238,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/search-modal.tsx` | dialog-overlay | CommandPalette, CommandPaletteFooter, CommandPaletteInput | aligned — uses Astryx (CommandPalette, CommandPaletteFooter, CommandPaletteInput) | aligned | | `packages/ui/src/session-context-layer.tsx` | shell-chrome-or-panel | BreadcrumbItem, Breadcrumbs, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList, StatusDot, Text, Token, Tooltip | aligned — uses Astryx (BreadcrumbItem, Breadcrumbs, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList) | aligned | | `packages/ui/src/session-history-list.tsx` | shell-chrome-or-panel | Badge, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack | aligned — uses Astryx (Badge, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack) | aligned | -| `packages/ui/src/session-list-panel.tsx` | shell-chrome-or-panel | SegmentedControl, SegmentedControlItem, SideNav | aligned — uses Astryx (SegmentedControl, SegmentedControlItem, SideNav) | aligned | +| `packages/ui/src/session-list-panel.tsx` | shell-chrome-or-panel | DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem, SegmentedControl, SegmentedControlItem, SideNav | aligned — uses Astryx (DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem, SegmentedControl, SegmentedControlItem, SideNav) | aligned | | `packages/ui/src/session-rail-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/session-rename-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned | | `packages/ui/src/session-sidebar-nav.tsx` | shell-chrome-or-panel | Icon, IconButton, SideNavItem, SideNavSection, Tooltip | aligned — uses Astryx (Icon, IconButton, SideNavItem, SideNavSection, Tooltip) | aligned | diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index cbfc7efec3..2fb2cf321e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -73,6 +73,17 @@ export const SESSION_BLOCKED_REASONS = [ export type SessionBlockedReason = (typeof SESSION_BLOCKED_REASONS)[number]; +/** + * Shared by rail priority and Desktop's display normalization. Only these + * blocked states have a current repair action outside the failed turn: + * configure a connection, re-login, or confirm a permission. Other failures + * remain retryable and surface on the failed turn. This display-only rule + * does not change the runtime's authoritative session status. + */ +export function isActionableBlocked(reason: SessionBlockedReason | undefined): boolean { + return reason === 'NO_REAL_CONNECTION' || reason === 'auth' || reason === 'permission_required'; +} + /** Reserved durable role for the one WorkHub coordination conversation owned by a Runtime Host. */ export const WORKHUB_COORDINATION_SESSION_ROLE = 'workhub_coordination' as const; export const WORKHUB_COORDINATION_SESSION_ID = 'maka_workhub_coordination' as const; 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..fd78d630fe 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,65 @@ 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: 'connection', status: 'blocked', blockedReason: 'NO_REAL_CONNECTION', lastMessageAt: 19 }, + { ...session, id: 'permission', status: 'blocked', blockedReason: 'permission_required', lastMessageAt: 18 }, + { ...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', 'connection', 'permission', '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..6dea852f96 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -404,7 +404,11 @@ export interface ConversationCopy { pinned: string; /** Time-sort unpinned section title (SideNavSection). */ recent: string; - groupByTime: string; + allTasks: string; + groupingAllTasks: string; + sortByUpdated: string; + sortByPriority: string; + sortingAriaLabel: string; groupByProject: string; groupingAriaLabel: string; projectActionsAriaLabel: (name: string) => string; @@ -543,7 +547,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: '全部任务', groupingAllTasks: '全部任务', 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 +695,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', groupingAllTasks: 'All', 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..e0c390e0c1 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. Hug the labels instead of equal-width + // segments so grouping and sorting stay on one row at the minimum rail width. // // 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, @@ -75,14 +81,46 @@ export function SessionListPanel() { onChange={(mode) => onViewModeChange(mode as SessionViewMode)} label={copy.groupingAriaLabel} size="sm" - layout="fill" + layout="hug" > - + ) : 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