Files
openchamber/packages/ui/src/sync/session-ordering.test.ts
T
Bohdan Triapitsyn 86ef96302d feat(mobile): mobile app navigation rework and beta-feedback closeout (#2561)
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.
2026-08-01 21:16:36 +03:00

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);
});
});