perf(ui): swap the session activity spinner for a dot and a turn timer

The spinner ran a CSS animation on every active row for the whole turn,
repainting a composited layer at frame rate. Rows now carry a static dot —
primary while running, info while unread — and the metadata slot on the right
shows how long the turn has been going, updating once per second in the dot's
colour. The counter is the motion the spinner used to provide, at 1 fps.
Collapsed groups, folders and projects take the dot only, since one counter
cannot speak for several running turns.

Elapsed time is measured client-side because SessionStatus carries no
timestamps, and starts are persisted so a reload resumes the same count. Two
rules keep that honest. Only a liveness stamp — refreshed while a session is
observed active, stamped as the page hides, and compared against the page's
navigation start so a slow bootstrap is not charged to the absence — and a 90s
adoption window may expire a record; a snapshot that cannot yet see a session
is not evidence its turn ended. And a busy event is never read as a turn
boundary, because OpenCode republishes busy at every step of the agent loop, so
after a reload one of those repeats normally beats the first status snapshot.
Idle and error events do end a turn, and retire the record with it.

Snapshot reconciliation walks the running turns and asks whether the snapshot
covers each one, rather than being handed everything it covers: only a live
start can settle, so the pass scales with timing work instead of with the
directory's session list, and allocates nothing per poll.

Also applied to the mobile sessions sheet and session switcher. The shared
duration ticker moves to hooks/ now that it has a second consumer.
This commit is contained in:
Bohdan Triapitsyn
2026-08-05 03:06:35 +03:00
parent ce0e1cea27
commit 094f728777
27 changed files with 1094 additions and 66 deletions
+11
View File
@@ -45,6 +45,7 @@ So:
| `SessionMessageLoader` | Initial message loading, pagination, prefetch, retries, load state, and optimistic reconciliation | One runtime, directory, and session ID |
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime |
| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime |
| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime |
| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
@@ -128,6 +129,16 @@ Cross-directory selectors subscribe to the narrow child-store field they aggrega
Session display order is independent from streaming-frequency `time.updated` publications. `session-ordering.ts` promotes a session exactly when its authoritative activity phase crosses `settled` (`idle`/`error`) and `active` (`busy`/`retry`) in either direction. Repeated busy/retry or idle/error events are no-ops. The first authoritative status snapshot establishes a baseline without synthetic promotions; later snapshots reconcile missed transitions. Root sessions compare lifecycle rank only with other roots, while child sessions compare lifecycle rank only with siblings sharing the same `parentID`, so child activity never moves its root conversation. Pins remain the first ordering bucket. The timestamp/creation fallback is frozen when a session first participates in ordering, so later metadata-only updates cannot reorder it; creation time and ID provide deterministic ties. Runtime switches clear all phases, baselines, and ranks.
`session-activity-timing.ts` measures how long a turn has been running, because `SessionStatus` carries no timestamps. It is driven from the same two write paths as `global-session-status.ts`, so a row can never count a turn that index calls idle. A session gains a start on its first `active` observation and keeps it across repeated busy/retry events; settling converts that start into a finished duration, which rows show only while the session is unread and which is therefore never persisted.
Starts are persisted so a reload resumes the same count, but a persisted start is a lookup table and never a claim of activity. **Nothing in the protocol marks where a turn begins.** OpenCode calls `SessionStatus.set` with `busy` at every step of the agent loop and publishes an event each time, so a busy event means "still running", not "just started"; after a refresh one of those repeats normally beats the first status snapshot, so treating it as a turn boundary reset the counter on nearly every reload. Turn *ends* are marked — `session.idle` and `session.error` fire once, live, and retire the persisted record — while a snapshot that omits a session is not evidence of anything, since it may simply not see it yet.
That leaves the case with no observable answer: a turn that ended, and another that began, entirely while the tab was gone. Two bounds stand in for the evidence the client cannot have. A liveness stamp sits beside the start — refreshed while the session is observed active, at most every 15s, and stamped precisely as the page hides (`pagehide`/`visibilitychange`/`freeze`, written immediately rather than through deferred storage so it cannot lose that race) — and is compared against this page's `performance.timeOrigin`, so the measure is how long the app was absent rather than how long bootstrap took; a 20-second startup must not spend the allowance. Records may only be adopted within 90s of load, after which they are discarded — a backstop for a runtime whose event stream is down and where snapshots are therefore the only signal. A runtime switch resets the module, since the previous instance's turns are not ours.
Reconciliation walks the running turns and asks the snapshot whether it covers each one, rather than being handed everything the snapshot covers. Only a live start can settle, and there are a handful of those against a directory's hundreds of sessions, so the pass stays proportional to the timing work and allocates nothing per poll. Malformed, wrong-shaped, over-age, and future-dated entries are rejected on read. The payload is not runtime-scoped: records live for seconds and are keyed by instance-unique session IDs, whereas the runtime key is derived from injected globals and is not guaranteed stable across early startup — a read under a key the previous page never wrote to is indistinguishable from "no turn was running".
**Only the stamp expires a persisted start.** A snapshot that covers a session without reporting it busy is not proof the turn ended: bootstrap fetches status and sessions in parallel and directory scopes resolve at different times, so a snapshot legitimately arrives before it can see a running session. Treating one of those as a settle deleted the start moments before the real busy snapshot arrived, which reset every counter to zero on reload. Settles therefore act only on sessions that already have a live start in this page session.
The active-session watchdog in `sync-context.tsx` (per-directory status polls and child-session discovery lists) runs its network calls through the shared background-network gate in `@/lib/background-network`, alongside poll-shaped git reads, global session pages, and command/skill discovery. Background fan-out must stay under that gate so the browser's per-origin connection pool keeps free sockets for interactive traffic — an uncapped startup burst previously queued the first session-open message fetch for seconds.
Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call.
+26 -4
View File
@@ -6,6 +6,11 @@ import {
reconcileSessionActivitySnapshot,
removeSessionOrdering,
} from './session-ordering';
import {
observeSessionActivityTiming,
reconcileSessionActivityTiming,
removeSessionActivityTiming,
} from './session-activity-timing';
// Shared live busy/retry index for every directory. Global events update it
// incrementally and authoritative directory snapshots reconcile it, so each
@@ -74,6 +79,8 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event)
type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus,
);
observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active');
// `retry` is still a running turn, so the elapsed counter keeps going.
observeSessionActivityTiming(props.sessionID, type === 'idle' ? 'settled' : 'active');
return;
}
case 'session.idle':
@@ -82,13 +89,17 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event)
if (typeof props?.sessionID === 'string' && props.sessionID) {
setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' });
observeSessionActivityEvent(props.sessionID, 'settled');
observeSessionActivityTiming(props.sessionID, 'settled');
}
return;
}
case 'session.deleted': {
const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined;
const sessionId = props?.sessionID ?? props?.info?.id;
if (sessionId) removeSessionOrdering(sessionId);
if (sessionId) {
removeSessionOrdering(sessionId);
removeSessionActivityTiming(sessionId);
}
return;
}
default:
@@ -108,10 +119,21 @@ export const applyGlobalSessionStatusSnapshot = (
): void => {
const directory = normalizeDirectory(rawDirectory);
const known = new Set(knownSessionIds ?? []);
const activeSessionIds = Object.entries(raw)
.filter(([, status]) => normalizeStatusType(status?.type) !== 'idle')
.map(([sessionId]) => sessionId);
// Built once as a set and shared by both consumers below; only non-idle
// sessions land here, so it stays small however long the directory's list is.
const activeSessionIds = new Set<string>();
for (const [sessionId, status] of Object.entries(raw)) {
if (normalizeStatusType(status?.type) !== 'idle') activeSessionIds.add(sessionId);
}
reconcileSessionActivitySnapshot(activeSessionIds, known);
// Timing asks the coverage question instead of being handed a list: a snapshot
// authoritatively covers the caller's session list plus every id it reports
// itself, and only the handful of sessions actually being timed need an
// answer. Reuses the sets already built above, so this allocates nothing.
reconcileSessionActivityTiming(
activeSessionIds,
(sessionId) => known.has(sessionId) || sessionId in raw,
);
useGlobalSessionStatusStore.setState((state) => {
let changed = false;
const next = new Map(state.statusById);
@@ -0,0 +1,310 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { applyGlobalSessionStatusSnapshot } from './global-session-status';
import {
observeSessionActivityTiming,
reconcileSessionActivityTiming,
removeSessionActivityTiming,
resetSessionActivityTiming,
useSessionActivityTimingStore,
} from './session-activity-timing';
const STORAGE_KEY = 'oc.session-activity.v1';
const startedAt = (sessionId: string): number | undefined =>
useSessionActivityTimingStore.getState().startedAt.get(sessionId);
const settledMs = (sessionId: string): number | undefined =>
useSessionActivityTimingStore.getState().settledMs.get(sessionId);
type PersistedStart = { start: number; seen: number };
const readPersisted = (): Record<string, PersistedStart> | null => {
const raw = getSafeStorage().getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Record<string, PersistedStart>) : null;
};
/**
* Seed a previous page session's record, then simulate the reload.
* `loadedAgoMs` places this page's navigation start in the past, which is how a
* slow bootstrap or an expired adoption window is expressed.
*/
const seedReload = (payload: unknown, loadedAgoMs = 0): void => {
getSafeStorage().setItem(STORAGE_KEY, JSON.stringify(payload));
resetSessionActivityTiming({ pageLoadAt: Date.now() - loadedAgoMs });
};
/** A status snapshot: which sessions it reports busy, and which it covers. */
const snapshot = (activeIds: string[], coveredIds: string[] = activeIds): void => {
const covered = new Set(coveredIds);
reconcileSessionActivityTiming(new Set(activeIds), (sessionId) => covered.has(sessionId));
};
/** A record for a turn that began `ageMs` ago and was alive until the reload. */
const runningUntilReload = (ageMs: number, loadedAgoMs = 0, quietFor = 1_000): PersistedStart => ({
start: Date.now() - ageMs,
seen: Date.now() - loadedAgoMs - quietFor,
});
beforeEach(() => {
getSafeStorage().removeItem(STORAGE_KEY);
resetSessionActivityTiming();
});
afterEach(() => {
getSafeStorage().removeItem(STORAGE_KEY);
resetSessionActivityTiming();
});
describe('session activity timing', () => {
test('starts a turn on the first active observation and keeps it stable', () => {
observeSessionActivityTiming('ses_a', 'active');
const first = startedAt('ses_a');
expect(first).toBeGreaterThan(0);
// Repeated busy/retry status events must not restart the counter.
observeSessionActivityTiming('ses_a', 'active');
expect(startedAt('ses_a')).toBe(first);
});
test('settling converts the start into a duration', () => {
observeSessionActivityTiming('ses_a', 'active');
observeSessionActivityTiming('ses_a', 'settled');
expect(startedAt('ses_a')).toBe(undefined);
expect(settledMs('ses_a')).toBeGreaterThanOrEqual(0);
});
test('a new turn clears the previous settled duration', () => {
observeSessionActivityTiming('ses_a', 'active');
observeSessionActivityTiming('ses_a', 'settled');
expect(settledMs('ses_a')).toBeDefined();
observeSessionActivityTiming('ses_a', 'active');
expect(settledMs('ses_a')).toBe(undefined);
expect(startedAt('ses_a')).toBeDefined();
});
test('settling a session that was never observed active yields no duration', () => {
observeSessionActivityTiming('ses_a', 'settled');
expect(startedAt('ses_a')).toBe(undefined);
expect(settledMs('ses_a')).toBe(undefined);
});
test('snapshot reconciliation starts covered actives and settles the rest', () => {
observeSessionActivityTiming('ses_a', 'active');
observeSessionActivityTiming('ses_b', 'active');
snapshot(['ses_a'], ['ses_a', 'ses_b', 'ses_c']);
expect(startedAt('ses_a')).toBeDefined();
expect(startedAt('ses_b')).toBe(undefined);
expect(settledMs('ses_b')).toBeGreaterThanOrEqual(0);
// Never active, never covered by a start: nothing to report.
expect(settledMs('ses_c')).toBe(undefined);
});
test('a session outside the snapshot scope keeps running', () => {
observeSessionActivityTiming('ses_other_directory', 'active');
const start = startedAt('ses_other_directory');
snapshot([], ['ses_a']);
expect(startedAt('ses_other_directory')).toBe(start);
});
test('persists the start and a liveness stamp for a running turn', () => {
observeSessionActivityTiming('ses_a', 'active');
const persisted = readPersisted();
expect(persisted?.ses_a.start).toBe(startedAt('ses_a') as number);
expect(persisted?.ses_a.seen).toBeGreaterThanOrEqual(persisted?.ses_a.start as number);
});
test('clears the persisted record when the turn ends', () => {
observeSessionActivityTiming('ses_a', 'active');
observeSessionActivityTiming('ses_a', 'settled');
expect(readPersisted()).toBeNull();
});
test('resumes a persisted start when a status snapshot reports the session active', () => {
const record = runningUntilReload(90_000);
seedReload({ ses_a: record });
snapshot(['ses_a'], ['ses_a']);
expect(startedAt('ses_a')).toBe(record.start);
});
// Regression: the server re-publishes `session.status: busy` at every step of
// the agent loop, so after a reload one of those repeats normally arrives
// before the first status snapshot. Reading a busy event as "a turn just
// started" therefore reset the counter on almost every refresh.
test('resumes when a repeated busy event arrives before the first snapshot', () => {
const record = runningUntilReload(90_000);
seedReload({ ses_a: record });
observeSessionActivityTiming('ses_a', 'active');
expect(startedAt('ses_a')).toBe(record.start);
});
test('the turn after a resumed one still counts from zero', () => {
const record = runningUntilReload(90_000);
seedReload({ ses_a: record });
// Reload lands mid-turn: the snapshot resumes it…
snapshot(['ses_a'], ['ses_a']);
expect(startedAt('ses_a')).toBe(record.start);
// …it finishes, which retires the record, so the next turn starts fresh.
observeSessionActivityTiming('ses_a', 'settled');
const before = Date.now();
observeSessionActivityTiming('ses_a', 'active');
expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before);
});
test('a live idle event retires the persisted record', () => {
const record = runningUntilReload(90_000);
seedReload({ ses_a: record });
// The turn ended while the tab was gone; the event arrives on reconnect.
observeSessionActivityTiming('ses_a', 'settled');
// A later snapshot must not resurrect the retired start.
const before = Date.now();
snapshot(['ses_a'], ['ses_a']);
expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before);
});
// The absence is measured from navigation start, not from "now", so a slow
// bootstrap on a slow machine cannot spend the whole allowance before the
// first status snapshot arrives.
test('resumes even when bootstrap takes most of a minute', () => {
const loadedAgoMs = 45_000;
const record = runningUntilReload(300_000, loadedAgoMs);
seedReload({ ses_a: record }, loadedAgoMs);
snapshot(['ses_a'], ['ses_a']);
expect(startedAt('ses_a')).toBe(record.start);
});
test('does not adopt a record once the adoption window has passed', () => {
const loadedAgoMs = 5 * 60_000;
const record = runningUntilReload(300_000, loadedAgoMs);
seedReload({ ses_a: record }, loadedAgoMs);
// A turn starting this long after load is a new turn, not the one that was
// running before the reload.
const before = Date.now();
snapshot(['ses_a'], ['ses_a']);
expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before);
});
// Regression: bootstrap fetches status and sessions in parallel, so a
// snapshot can legitimately cover a session before it can see it busy.
// Treating that as "the turn ended" used to destroy the persisted start
// moments before the real busy snapshot arrived, resetting the counter to 0s.
test('an early snapshot that cannot see the session busy does not lose the start', () => {
const record = runningUntilReload(120_000);
seedReload({ ses_a: record });
applyGlobalSessionStatusSnapshot('/repo', {}, ['ses_a']);
applyGlobalSessionStatusSnapshot('/repo', { ses_a: { type: 'busy' } }, ['ses_a']);
expect(startedAt('ses_a')).toBe(record.start);
});
test('resumes through a snapshot that arrives before the session list loads', () => {
const record = runningUntilReload(120_000);
seedReload({ ses_a: record });
applyGlobalSessionStatusSnapshot('/repo', { ses_a: { type: 'busy' } }, []);
expect(startedAt('ses_a')).toBe(record.start);
});
test('does not resume a record whose liveness stamp has gone quiet', () => {
const before = Date.now();
seedReload({ ses_a: { start: before - 300_000, seen: before - 240_000 } });
snapshot(['ses_a'], ['ses_a']);
expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before);
});
test('does not resume a turn older than the maximum turn age', () => {
const before = Date.now();
seedReload({ ses_a: { start: before - 48 * 60 * 60 * 1000, seen: before - 1_000 } });
snapshot(['ses_a'], ['ses_a']);
expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before);
});
test('ignores malformed persisted payloads', () => {
getSafeStorage().setItem(STORAGE_KEY, 'not json');
resetSessionActivityTiming();
const before = Date.now();
snapshot(['ses_a'], ['ses_a']);
expect(startedAt('ses_a')).toBeGreaterThanOrEqual(before);
});
test('ignores entries of the wrong shape or dated in the future', () => {
const before = Date.now();
seedReload({
ses_a: before - 5_000,
ses_b: { start: 'nope', seen: before },
ses_c: { start: before + 60_000, seen: before },
ses_d: { start: before - 5_000, seen: before + 60_000 },
});
for (const sessionId of ['ses_a', 'ses_b', 'ses_c', 'ses_d']) {
snapshot([sessionId]);
expect(startedAt(sessionId)).toBeGreaterThanOrEqual(before);
}
});
test('a quiet record ages out of storage on the next write', () => {
const before = Date.now();
seedReload({ ses_quiet: { start: before - 300_000, seen: before - 240_000 } });
observeSessionActivityTiming('ses_a', 'active');
expect(readPersisted()?.ses_quiet).toBe(undefined);
expect(readPersisted()?.ses_a.start).toBeDefined();
});
test('deleting a session clears live, settled, and persisted timing', () => {
observeSessionActivityTiming('ses_a', 'active');
observeSessionActivityTiming('ses_b', 'active');
observeSessionActivityTiming('ses_b', 'settled');
removeSessionActivityTiming('ses_a');
removeSessionActivityTiming('ses_b');
expect(startedAt('ses_a')).toBe(undefined);
expect(settledMs('ses_b')).toBe(undefined);
expect(readPersisted()).toBeNull();
});
test('unrelated sessions keep their map references across a no-op update', () => {
observeSessionActivityTiming('ses_a', 'active');
const before = useSessionActivityTimingStore.getState();
observeSessionActivityTiming('ses_a', 'active');
observeSessionActivityTiming('ses_unknown', 'settled');
const after = useSessionActivityTimingStore.getState();
expect(after.startedAt).toBe(before.startedAt);
expect(after.settledMs).toBe(before.settledMs);
});
});
@@ -0,0 +1,444 @@
import { useCallback } from 'react';
import { create } from 'zustand';
import { getSafeStorage } from '@/stores/utils/safeStorage';
// Per-session turn timing behind the sidebar activity readout.
//
// The OpenCode status contract carries no timestamps — `SessionStatus` is a
// bare `busy | retry | idle` union — so how long the current turn has been
// running has to be measured on the client. This module owns that measurement
// and is driven from the same two write paths as `global-session-status`, the
// index rows actually render their live state from, so a row can never count a
// turn that index calls idle.
//
// Two maps with deliberately different lifetimes:
//
// - `startedAt` — sessions observed active right now. Persisted, so reloading
// the page resumes the same count instead of restarting it at zero.
// - `settledMs` — how long the turn that just finished took. In memory only:
// rows show it while the session is unread, and unread state itself does not
// survive a reload, so persisting it would outlive its only consumer.
//
// A persisted start is a lookup table, never a claim of activity. Nothing in
// the protocol marks where a turn begins: the server calls `SessionStatus.set`
// with `busy` at every step of the agent loop and publishes an event each time,
// so a busy event means "still running", not "just started" — it cannot be read
// as a turn boundary, and reading it that way reset every counter on reload,
// because after a refresh one of those repeats almost always beats the first
// status snapshot.
//
// Turn *ends* are marked: `session.idle` and `session.error` events fire once,
// live, and retire the persisted record.
//
// That leaves the case with no observable answer at all: a turn that ended, and
// another that began, entirely while the tab was gone. Two bounds stand in for
// the evidence the client cannot have:
//
// - a liveness stamp beside the start, refreshed while the session is observed
// active and stamped precisely as the page hides, compared against this page's
// navigation start — how long the app was actually absent;
// - an adoption window after load, after which unclaimed records are discarded,
// which backstops a runtime whose event stream is down and where snapshots are
// therefore the only signal.
//
// Nothing else may drop a persisted start. Status snapshots legitimately arrive
// before they can see a session as busy — bootstrap fetches status and sessions
// in parallel, directory scopes resolve at different times — and treating one
// of those as "the turn ended" destroyed the start moments before the real busy
// snapshot arrived, which is exactly the reload-resets-to-zero bug. Absence of
// evidence is not evidence here; only the two bounds above expire a record.
type SessionActivityPhase = 'active' | 'settled';
type SessionActivityTimingState = {
startedAt: ReadonlyMap<string, number>;
settledMs: ReadonlyMap<string, number>;
};
/** Persisted per session: when this turn began, and when it was last alive. */
type PersistedStart = { start: number; seen: number };
/** Finished turns worth remembering at once; each row only needs its own. */
const SETTLED_LIMIT = 200;
/** A turn running longer than this is treated as a stale record, not a turn. */
const MAX_TURN_AGE_MS = 24 * 60 * 60 * 1000;
/**
* How long the app may have been gone and still have its counters resumed,
* measured from the liveness stamp to this page's navigation start not to
* "now". Bootstrap latency belongs to this page, not to the absence, and this
* client has seen 20-second startups; charging those to the gap would refuse
* a legitimate resume on exactly the slowest machines.
*/
const MAX_AWAY_MS = 30_000;
/** Refresh the persisted stamp at most this often during a long turn. */
const LIVENESS_PERSIST_INTERVAL_MS = 15_000;
/**
* How long after page load a persisted record may still be adopted. Past this
* point the app has certainly seen live status, so a record nothing claimed
* describes a turn that is over and a turn starting later is a new one that
* must count from zero.
*/
const RESTORE_ADOPTION_WINDOW_MS = 90_000;
// One key, not one per runtime. These records live for seconds and are keyed by
// instance-unique session IDs, so runtime scoping bought nothing while adding a
// real failure mode: the runtime key is derived from injected globals and is not
// guaranteed stable across early startup, and a read under a key the previous
// page did not write to looks exactly like "no turn was running".
const STORAGE_KEY = 'oc.session-activity.v1';
const EMPTY_ACTIVE: ReadonlySet<string> = new Set();
const EMPTY_RESTORED: ReadonlyMap<string, PersistedStart> = new Map();
export const useSessionActivityTimingStore = create<SessionActivityTimingState>(() => ({
startedAt: new Map(),
settledMs: new Map(),
}));
/** Last moment each live start was observed active, for the liveness stamp. */
const liveSeen = new Map<string, number>();
let lastPersistAt = 0;
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
let restoredStarts: Map<string, PersistedStart> | null = null;
/** Epoch ms of this page's navigation start; the reference for "how long gone". */
const readPageLoadAt = (): number => {
if (typeof performance !== 'undefined' && Number.isFinite(performance.timeOrigin)) {
return performance.timeOrigin;
}
return Date.now();
};
let pageLoadAt = readPageLoadAt();
const isResumable = (entry: PersistedStart, now: number): boolean => (
entry.start <= now
&& now - entry.start <= MAX_TURN_AGE_MS
&& entry.seen <= now
// Negative when this page wrote the stamp itself, which is trivially fresh.
&& pageLoadAt - entry.seen <= MAX_AWAY_MS
);
const parseEntry = (value: unknown): PersistedStart | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const { start, seen } = value as { start?: unknown; seen?: unknown };
if (typeof start !== 'number' || !Number.isFinite(start)) return null;
if (typeof seen !== 'number' || !Number.isFinite(seen)) return null;
return { start, seen };
};
const readRestoredStarts = (): Map<string, PersistedStart> => {
const restored = new Map<string, PersistedStart>();
let raw: string | null = null;
try {
raw = getSafeStorage().getItem(STORAGE_KEY);
} catch {
return restored;
}
if (!raw) return restored;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// Malformed payload is a failed read, not authoritative "no turns were
// running": live status re-seeds every counter from now either way.
return restored;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return restored;
const now = Date.now();
for (const [sessionId, value] of Object.entries(parsed as Record<string, unknown>)) {
const entry = parseEntry(value);
// Rejects stale turns, quiet stamps, and clock-skewed futures rather than
// rendering a counter that reads days long or negative.
if (entry && isResumable(entry, now)) restored.set(sessionId, entry);
}
return restored;
};
const getRestoredStarts = (): Map<string, PersistedStart> => {
restoredStarts ??= readRestoredStarts();
return restoredStarts;
};
/**
* Restored records still eligible to be adopted. Past the adoption window they
* are dropped for good, so a turn that starts later counts from zero instead of
* inheriting the start of whatever ran before the reload.
*/
const getAdoptableStarts = (now: number): ReadonlyMap<string, PersistedStart> => {
if (now - pageLoadAt > RESTORE_ADOPTION_WINDOW_MS) {
restoredStarts?.clear();
return EMPTY_RESTORED;
}
return getRestoredStarts();
};
// Live starts merged over restored-but-unconfirmed ones, so a reload landing
// before the first authoritative snapshot does not drop the starts that
// snapshot is about to confirm. Restored entries whose stamp has gone quiet are
// dropped here, which is the only way they leave storage.
const persistStarts = (startedAt: ReadonlyMap<string, number>, now: number): void => {
const payload: Record<string, PersistedStart> = {};
for (const [sessionId, entry] of getRestoredStarts()) {
if (isResumable(entry, now)) payload[sessionId] = entry;
}
for (const [sessionId, start] of startedAt) {
payload[sessionId] = { start, seen: liveSeen.get(sessionId) ?? now };
}
lastPersistAt = now;
try {
const storage = getSafeStorage();
if (Object.keys(payload).length === 0) {
storage.removeItem(STORAGE_KEY);
return;
}
storage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch {
// Storage is unavailable or full; counters simply restart after a reload.
}
};
// The most accurate liveness stamp available: the page is going away and every
// running turn was still running as of now. Writes are immediate (not deferred)
// so this cannot lose the race against a deferred flush on the same event.
const stampLiveness = (): void => {
const { startedAt } = useSessionActivityTimingStore.getState();
if (startedAt.size === 0) return;
const now = Date.now();
for (const sessionId of startedAt.keys()) liveSeen.set(sessionId, now);
persistStarts(startedAt, now);
};
let lifecycleHooked = false;
const ensureLivenessStampOnHide = (): void => {
if (lifecycleHooked || typeof window === 'undefined') return;
lifecycleHooked = true;
try {
// `pagehide` covers unload and bfcache entry; `visibilitychange`/`freeze`
// cover backgrounding and are the reliable ones in WKWebView. No
// `beforeunload` — it would cost bfcache for a stamp the others already
// wrote.
window.addEventListener('pagehide', stampLiveness, { capture: true });
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') stampLiveness();
});
document.addEventListener('freeze', stampLiveness);
}
} catch {
// Restricted environments can reject listeners; the periodic stamp refresh
// still bounds how quiet a running turn's record can get.
}
};
// ---------------------------------------------------------------------------
// Transitions
// ---------------------------------------------------------------------------
const trimSettled = (settled: Map<string, number>): void => {
while (settled.size > SETTLED_LIMIT) {
const oldest = settled.keys().next();
if (oldest.done) return;
settled.delete(oldest.value);
}
};
/**
* What ends a turn in this pass. An event names its session outright; a snapshot
* only answers whether it covers a given one deliberately the cheaper
* question, since the settle loop walks running turns rather than session lists.
* An `event` idle is a live, one-shot "this turn is over"; a snapshot omitting a
* session is not, because it may simply not see it yet.
*/
type SettleInput =
| { source: 'event'; sessionId: string }
| { source: 'snapshot'; isCovered: (sessionId: string) => boolean };
const applyTransitions = (
activeSessionIds: ReadonlySet<string>,
settle: SettleInput | null,
): void => {
const now = Date.now();
const restored = getAdoptableStarts(now);
const state = useSessionActivityTimingStore.getState();
const next: { started: Map<string, number> | null; settled: Map<string, number> | null } = {
started: null,
settled: null,
};
let sawActive = false;
let restoredChanged = false;
const draftStarted = (): Map<string, number> => (next.started ??= new Map(state.startedAt));
const draftSettled = (): Map<string, number> => (next.settled ??= new Map(state.settledMs));
for (const sessionId of activeSessionIds) {
sawActive = true;
liveSeen.set(sessionId, now);
if ((next.started ?? state.startedAt).has(sessionId)) continue;
// Busy carries no turn boundary from either source: the server re-publishes
// `session.status: busy` on every step of the agent loop, so a busy event
// means "still running", not "just started". Both paths therefore prefer a
// persisted start when one survives; only the bounds below expire it.
draftStarted().set(sessionId, restored.get(sessionId)?.start ?? now);
if ((next.settled ?? state.settledMs).has(sessionId)) draftSettled().delete(sessionId);
}
const settleTurn = (sessionId: string, start: number): void => {
draftStarted().delete(sessionId);
liveSeen.delete(sessionId);
draftSettled().set(sessionId, Math.max(0, now - start));
};
if (settle === null) {
// Nothing ends this pass.
} else if (settle.source === 'event') {
// An idle/error event is a live, unambiguous end of turn, so it also retires
// the persisted record. A snapshot's silence is not: it may simply not see
// the session yet.
if (getRestoredStarts().delete(settle.sessionId)) restoredChanged = true;
const start = state.startedAt.get(settle.sessionId);
// Only a turn watched from its start yields a duration.
if (start !== undefined) settleTurn(settle.sessionId, start);
} else {
// Walk the running turns, not everything the snapshot covers. Only a live
// start can settle, and there are a handful of those against a directory's
// hundreds of sessions — asking "does this snapshot cover that one?" keeps
// the pass proportional to the work instead of to the session list, and
// allocates nothing per poll.
for (const [sessionId, start] of state.startedAt) {
if (activeSessionIds.has(sessionId)) continue;
if (!settle.isCovered(sessionId)) continue;
settleTurn(sessionId, start);
}
}
if (next.settled) trimSettled(next.settled);
if (next.started || next.settled) {
useSessionActivityTimingStore.setState({
startedAt: next.started ?? state.startedAt,
settledMs: next.settled ?? state.settledMs,
});
}
if (next.started) {
if (next.started.size > 0) ensureLivenessStampOnHide();
persistStarts(next.started, now);
return;
}
if (restoredChanged) {
persistStarts(state.startedAt, now);
return;
}
// Nothing structural changed, but a long-running turn still needs its stamp
// refreshed so a reload can tell it apart from one that ended unobserved.
if (sawActive && state.startedAt.size > 0 && now - lastPersistAt >= LIVENESS_PERSIST_INTERVAL_MS) {
persistStarts(state.startedAt, now);
}
};
/**
* Event-driven path: one session changed phase, live. Busy repeats throughout a
* turn and carries no boundary; idle/error fire once and end it, which is why
* only settling here retires the persisted record.
*/
export const observeSessionActivityTiming = (
sessionId: string,
phase: SessionActivityPhase,
): void => {
if (phase === 'active') {
applyTransitions(new Set([sessionId]), null);
return;
}
applyTransitions(EMPTY_ACTIVE, { source: 'event', sessionId });
};
/**
* Authoritative path: a `/session/status` snapshot for one directory. Sessions
* the snapshot covers but does not report active stop their live counters
* that is what recovers a turn whose end event this client missed but their
* persisted records survive, because a snapshot that cannot yet see a session
* looks identical to one whose turn is over.
*/
export const reconcileSessionActivityTiming = (
activeSessionIds: ReadonlySet<string>,
isCoveredBySnapshot: (sessionId: string) => boolean,
): void => {
applyTransitions(activeSessionIds, { source: 'snapshot', isCovered: isCoveredBySnapshot });
};
export const removeSessionActivityTiming = (sessionId: string): void => {
const restoredChanged = getRestoredStarts().delete(sessionId);
const state = useSessionActivityTimingStore.getState();
const hadStart = state.startedAt.has(sessionId);
const hadSettled = state.settledMs.has(sessionId);
liveSeen.delete(sessionId);
if (!hadStart && !hadSettled) {
if (restoredChanged) persistStarts(state.startedAt, Date.now());
return;
}
let startedAt = state.startedAt;
if (hadStart) {
const draft = new Map(state.startedAt);
draft.delete(sessionId);
startedAt = draft;
}
let settledMs = state.settledMs;
if (hadSettled) {
const draft = new Map(state.settledMs);
draft.delete(sessionId);
settledMs = draft;
}
useSessionActivityTimingStore.setState({ startedAt, settledMs });
if (hadStart || restoredChanged) persistStarts(startedAt, Date.now());
};
/**
* Drops in-memory state and the cached restored-start snapshot i.e. treats
* what follows as a fresh page load. Called on a runtime switch, where the
* previous instance's turns are no longer ours, and by tests. `pageLoadAt`
* overrides the navigation-start reference so tests can place a load in the
* past (slow bootstrap, expired window).
*/
export const resetSessionActivityTiming = (options: { pageLoadAt?: number } = {}): void => {
restoredStarts = null;
liveSeen.clear();
lastPersistAt = 0;
pageLoadAt = options.pageLoadAt ?? Date.now();
useSessionActivityTimingStore.setState({ startedAt: new Map(), settledMs: new Map() });
};
// ---------------------------------------------------------------------------
// Leaf subscriptions
// ---------------------------------------------------------------------------
export const useSessionActivityStartedAt = (sessionId: string): number | undefined => (
useSessionActivityTimingStore(useCallback((state) => state.startedAt.get(sessionId), [sessionId]))
);
export const useSessionSettledDurationMs = (sessionId: string): number | undefined => (
useSessionActivityTimingStore(useCallback((state) => state.settledMs.get(sessionId), [sessionId]))
);
/**
* Whether a duration exists to render, without subscribing the caller to the
* value itself a row uses this to decide between the counter and its normal
* metadata, and must not re-render every tick to do so.
*/
export const useHasSessionActivityDuration = (sessionId: string, running: boolean): boolean => (
useSessionActivityTimingStore(useCallback((state) => (
running ? state.startedAt.has(sessionId) : state.settledMs.has(sessionId)
), [running, sessionId]))
);