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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/desktop/e2e/sidebar-project-row.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = {
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/main/__tests__/session-list-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
createSessionOpenCommand,
deriveSessionRail,
sessionMatchesRail,
sessionRailLayoutStore,
SessionNavigationServicesProvider,
useSessionNavigationController,
useSessionNavigationReads,
Expand Down Expand Up @@ -197,14 +198,57 @@ describe('useSessionNavigationController', () => {

describe('useSessionNavigationReads', () => {
let latestReads: ReturnType<typeof useSessionNavigationReads> | undefined;
let renderCount = 0;

function ReadsProbe(props: Parameters<typeof useSessionNavigationReads>[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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -63,14 +63,19 @@ 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

- 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<SessionNavigationSession>;
branchBanner: BranchBanner | undefined;
revisionNavigation: SessionRevisionNavigation | undefined;
layout: SessionRailLayoutState;
layout: Pick<SessionRailLayoutState, 'collapsed' | 'width'>;
}

/**
* 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
Expand Down Expand Up @@ -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 } };
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,23 @@
* 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;
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -36,6 +38,7 @@ export interface SessionRailLayoutState {
readonly collapsed: boolean;
readonly width: number;
readonly viewMode: SessionViewMode;
readonly sortMode: SessionSortMode;
}

/**
Expand All @@ -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<typeof setTimeout> | undefined;
Expand Down Expand Up @@ -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 });
Comment thread
sunheyi6 marked this conversation as resolved.
writeSessionListSortMode(next);
},
};
}

Expand All @@ -104,8 +114,7 @@ export type SessionRailLayoutStore = ReturnType<typeof createSessionRailLayoutSt
export const sessionRailLayoutStore: SessionRailLayoutStore = createSessionRailLayoutStore();

/**
* The whole geometry. Both readers use more than one field of it, and the store
* replaces its state only when a field actually moved, so the identity is
* already the comparison — a per-field selector would buy no granularity.
* The rail consumes both geometry and display preferences. The shell selects
* only collapsed/width so changing a rail preference stays within the rail.
*/
export const selectRailLayout = (state: SessionRailLayoutState): SessionRailLayoutState => state;
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionNavigationServices> = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
const data = useMemo<SessionRailData>(
() => ({
sessions: props.rail.sessions,
sortMode: controller.layout.sortMode,
activeId: props.workHubActive ? undefined : props.rail.activeRowId,
streamingSessionIds: props.streamingSessionIds,
staleSessionIds: props.staleSessionIds,
Expand All @@ -165,6 +166,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
}),
[
controller.layout.viewMode,
controller.layout.sortMode,
controller.selectors.groups,
controller.selectors.sessionMeta,
controller.selectors.worktreeSessionIds,
Expand All @@ -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,
Expand Down
Loading