Navigation model rebuilt around two full-width drawers and a minimal header (sessions / title-switcher / usage ring / workspace): - Left sessions drawer: cross-project tree with live status indicators, swipe actions on sessions (rename/archive/delete) and on group headers (project edit / two-step close, worktree delete), reorder-only edit mode with collapsible project cards and draggable worktrees, app-level footer (connected instance, settings, pending web update). - Right workspace drawer: Changes / Files / Terminal / Notes / MCP as pill tabs (inactive tabs icon-only); panes stay mounted once visited. The full desktop file editor serves the Files tab; read/skill tool taps in chat open the file there at the requested line. - Header session switcher on title tap: 10 cross-project recents with live busy/attention indicators and project · branch metadata; the usage ring opens a metadata overlay with an explicit loading state. - The overflow menu is gone on phones (its destinations moved into the drawers); iPad keeps it until its dedicated layout pass. Correctness and continuity: - /auth/session answers bearer-first, so a stale WebView cookie can no longer mask a revoked device token; cold launches classify failures fast and land on an explicit connect screen. - Authoritative session snapshots raise frozen ordering baselines and stale live ranks — recents stay truthful after the app slept. - Cold launches reopen the last active session per instance (persisted pointer, confirmed against a sessions snapshot; a user-opened draft clears it), with a logo hold instead of a draft flash. Also: collapsed pill composer gains the stop control; chat tool rows share one 36px rhythm; Task subtool rows truncate; larger bottom safe area so the composer clears big-screen corner radii; Capacitor build hides About/Update (store updates apply there); widgets link to the sessions drawer with a list icon; MobileApp split into focused modules; five mobile-surface detectors unified; translucent borders normalized to 70%; all new strings translated across the 10 locales. iPad and foldable layouts are intentionally untouched - separate next version PR.
161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
import {
|
|
compareSessionsByLifecycleOrder,
|
|
observeSessionActivityEvent,
|
|
orderSessionsByLifecycleScopes,
|
|
reconcileSessionActivitySnapshot,
|
|
removeSessionOrdering,
|
|
resetSessionOrdering,
|
|
useSessionOrderingStore,
|
|
raiseSessionOrderingBaselines,
|
|
} from './session-ordering';
|
|
|
|
const session = (
|
|
id: string,
|
|
updated: number,
|
|
parentID?: string,
|
|
): Session => ({
|
|
id,
|
|
parentID,
|
|
time: { created: updated - 1, updated },
|
|
} as Session);
|
|
|
|
beforeEach(() => resetSessionOrdering());
|
|
|
|
describe('session lifecycle ordering', () => {
|
|
test('promotes only meaningful event transitions', () => {
|
|
observeSessionActivityEvent('session-a', 'settled');
|
|
expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(false);
|
|
|
|
observeSessionActivityEvent('session-a', 'active');
|
|
const activeRank = useSessionOrderingStore.getState().rankById.get('session-a');
|
|
expect(typeof activeRank).toBe('number');
|
|
|
|
observeSessionActivityEvent('session-a', 'active');
|
|
expect(useSessionOrderingStore.getState().rankById.get('session-a')).toBe(activeRank);
|
|
|
|
observeSessionActivityEvent('session-a', 'settled');
|
|
expect(useSessionOrderingStore.getState().rankById.get('session-a')).toBeGreaterThan(activeRank ?? 0);
|
|
});
|
|
|
|
test('treats an active event without a snapshot baseline as a real transition', () => {
|
|
observeSessionActivityEvent('session-a', 'active');
|
|
|
|
expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(true);
|
|
});
|
|
|
|
test('seeds the first authoritative snapshot without synthetic promotions', () => {
|
|
reconcileSessionActivitySnapshot(['session-a'], ['session-a', 'session-b']);
|
|
expect(useSessionOrderingStore.getState().rankById.size).toBe(0);
|
|
|
|
reconcileSessionActivitySnapshot([], ['session-a', 'session-b']);
|
|
expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(true);
|
|
expect(useSessionOrderingStore.getState().rankById.has('session-b')).toBe(false);
|
|
});
|
|
|
|
test('uses lifecycle rank only within the same parent scope', () => {
|
|
const rootOlder = session('root-older', 10);
|
|
const rootNewer = session('root-newer', 20);
|
|
const childOlder = session('child-older', 10, 'root-older');
|
|
const childNewer = session('child-newer', 20, 'root-older');
|
|
const otherParentChild = session('other-parent-child', 20, 'root-newer');
|
|
const rankById = new Map([
|
|
['child-older', 100],
|
|
['root-older', 90],
|
|
]);
|
|
|
|
expect(compareSessionsByLifecycleOrder(rootOlder, rootNewer, new Set(), rankById)).toBeLessThan(0);
|
|
expect(compareSessionsByLifecycleOrder(childOlder, childNewer, new Set(), rankById)).toBeLessThan(0);
|
|
expect(compareSessionsByLifecycleOrder(childOlder, otherParentChild, new Set(), rankById)).toBeGreaterThan(0);
|
|
expect(compareSessionsByLifecycleOrder(childOlder, rootNewer, new Set(), rankById)).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('freezes timestamp fallback until a lifecycle transition', () => {
|
|
const older = session('older', 10);
|
|
const newer = session('newer', 20);
|
|
expect(compareSessionsByLifecycleOrder(older, newer, new Set(), new Map())).toBeGreaterThan(0);
|
|
|
|
const metadataOnlyUpdate = session('older', 30);
|
|
expect(compareSessionsByLifecycleOrder(metadataOnlyUpdate, newer, new Set(), new Map())).toBeGreaterThan(0);
|
|
|
|
expect(compareSessionsByLifecycleOrder(
|
|
metadataOnlyUpdate,
|
|
newer,
|
|
new Set(),
|
|
new Map([['older', 40]]),
|
|
)).toBeLessThan(0);
|
|
});
|
|
|
|
test('clears lifecycle state when a session is deleted', () => {
|
|
observeSessionActivityEvent('session-a', 'active');
|
|
removeSessionOrdering('session-a');
|
|
expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(false);
|
|
|
|
observeSessionActivityEvent('session-a', 'settled');
|
|
expect(useSessionOrderingStore.getState().rankById.has('session-a')).toBe(false);
|
|
});
|
|
|
|
test('sorts each forest scope before flattening parent-first', () => {
|
|
const rootOlder = session('root-older', 10);
|
|
const rootNewer = session('root-newer', 20);
|
|
const childOlder = session('child-older', 5, 'root-older');
|
|
const childNewer = session('child-newer', 6, 'root-older');
|
|
|
|
const ordered = orderSessionsByLifecycleScopes(
|
|
[rootNewer, childOlder, rootOlder, childNewer],
|
|
new Set(),
|
|
new Map([
|
|
['root-older', 100],
|
|
['child-older', 90],
|
|
]),
|
|
);
|
|
|
|
expect(ordered.map((item) => item.id)).toEqual([
|
|
'root-older',
|
|
'child-older',
|
|
'child-newer',
|
|
'root-newer',
|
|
]);
|
|
});
|
|
|
|
test('does not promote a root when only its child has lifecycle activity', () => {
|
|
const rootOlder = session('root-older', 10);
|
|
const rootNewer = session('root-newer', 20);
|
|
const activeChild = session('active-child', 5, 'root-older');
|
|
|
|
const ordered = orderSessionsByLifecycleScopes(
|
|
[rootOlder, activeChild, rootNewer],
|
|
new Set(),
|
|
new Map([['active-child', 100]]),
|
|
);
|
|
|
|
expect(ordered.map((item) => item.id)).toEqual([
|
|
'root-newer',
|
|
'root-older',
|
|
'active-child',
|
|
]);
|
|
});
|
|
|
|
test('authoritative snapshot raises frozen baselines without live ranks', () => {
|
|
const older = session('older', 10);
|
|
const newer = session('newer', 20);
|
|
// Freeze both baselines at their first-seen timestamps.
|
|
expect(compareSessionsByLifecycleOrder(older, newer, new Set(), new Map())).toBeGreaterThan(0);
|
|
|
|
// A metadata-only live update must NOT reorder (frozen baseline)...
|
|
const liveBump = session('older', 30);
|
|
expect(compareSessionsByLifecycleOrder(liveBump, newer, new Set(), new Map())).toBeGreaterThan(0);
|
|
|
|
// ...but an authoritative snapshot with the newer stamp raises the baseline.
|
|
raiseSessionOrderingBaselines([liveBump, newer]);
|
|
expect(compareSessionsByLifecycleOrder(liveBump, newer, new Set(), new Map())).toBeLessThan(0);
|
|
});
|
|
|
|
test('store-held stale live rank is raised by an authoritative snapshot', () => {
|
|
useSessionOrderingStore.setState({ rankById: new Map([['stale', 15]]) });
|
|
raiseSessionOrderingBaselines([session('stale', 40)]);
|
|
expect(useSessionOrderingStore.getState().rankById.get('stale')).toBe(40);
|
|
});
|
|
});
|