Files
Codeman/src/session-order.ts
T
Aamer AkhterandClaude Opus 4.8 8807b3ff6d COD-131 sync tab order across devices via server state
Tab reordering (drag-and-drop + Ctrl+Shift+{/}) persisted only to
localStorage (codeman-session-order), so each device kept its own private
order. Add server-side persistence so the order follows the user across
devices, live. Takes the issue's recommended default (a): one global order,
server authoritative, localStorage as offline fallback.

- session-order.ts (new, pure + unit-tested): normalizeSessionOrder (coerce
  to string[], drop empty/non-string, dedup) and mergeSessionOrder (the
  pushing device's order wins; ids the device hadn't loaded fall to the end
  in their existing relative order, never dropped — graceful for
  closed/remote/parked sessions absent on that device).
- AppState.sessionOrder?: string[]; StateStore get/setSessionOrder + the field
  added to buildPartialJson() (the incremental serializer whitelists fields,
  so without this the value never reached disk / survived a restart).
- PUT /api/session-order (session-routes): parse -> merge -> persist ->
  broadcast session:orderChanged; getLightState() init snapshot now carries
  sessionOrder so a fresh load/reconnect restores it.
- SSE event session:orderChanged registered in sse-events.ts + constants.js.
- app.js: handleInit seeds localStorage from the server snapshot before
  syncSessionOrder(); saveSessionOrder() also PUTs to the server (debounced
  400ms, covers drag + both keyboard moves); _onSessionOrderChanged adopts a
  remote order and re-renders (no-op-guarded to avoid echo flicker).

Verified (orchestrator re-ran all gates): tsc 0, lint 0, frontend-syntax +
prettier clean, build ok; session-order + session-order-routes + state-store
56/56. Functional round-trip on an isolated beta: PUT {a,b,c} -> status
snapshot reflects it; merge PUT {c,a} vs {a,b,c} -> {c,a,b} (b preserved at
end); malformed payload rejected with a clean 400; sessionOrder persisted to
state.json and survived a restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

(cherry picked from commit 79415f2fdfbdf3fbe362a063534e7f84c553eefb)
2026-07-17 16:21:16 -04:00

69 lines
2.5 KiB
TypeScript

/**
* @fileoverview Pure helpers for the global session tab-order (COD-131).
*
* Tab order (drag-and-drop reorder + Ctrl+Shift+{/}) is persisted server-side
* so it follows the user across devices. The server is authoritative; the
* browser's localStorage (`codeman-session-order`) is the offline fallback.
*
* These helpers are pure (no IO) so they can be unit-tested in isolation and
* reused by both the PUT /api/session-order route and the StateStore accessor.
*
* - `normalizeSessionOrder` coerces arbitrary input into a clean string[]
* (non-empty strings only, deduped with first occurrence winning).
* - `mergeSessionOrder` lets the pushing device's order win, while preserving
* any server-only ids the pushing device didn't know about — they fall to the
* END in their existing relative order, never dropped.
*/
/**
* Coerce arbitrary input into a clean ordered list of session ids:
* keep only non-empty strings and dedup (first occurrence wins).
*
* @param order - unknown input (expected to be a string[], but defensive)
* @returns a normalized string[] (empty array for non-array / all-junk input)
*/
export function normalizeSessionOrder(order: unknown): string[] {
if (!Array.isArray(order)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of order) {
if (typeof entry !== 'string' || entry.length === 0) {
continue;
}
if (seen.has(entry)) {
continue;
}
seen.add(entry);
result.push(entry);
}
return result;
}
/**
* Merge an incoming order from a pushing device with the existing server order.
*
* The incoming order wins; any ids present in `existing` but NOT in `incoming`
* are appended at the END, preserving their relative order. This is the
* "server-only ids the pushing device didn't know about fall to the end, never
* dropped" rule.
*
* Both arguments are normalized first, so callers may pass raw input safely.
*
* @param incoming - the order the pushing device wants
* @param existing - the current server-side order
* @returns the merged, normalized order
*/
export function mergeSessionOrder(incoming: string[], existing: string[]): string[] {
const normalizedIncoming = normalizeSessionOrder(incoming);
const incomingSet = new Set(normalizedIncoming);
const merged = [...normalizedIncoming];
for (const id of normalizeSessionOrder(existing)) {
if (!incomingSet.has(id)) {
merged.push(id);
}
}
return merged;
}