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)
This commit is contained in:
Aamer Akhter
2026-07-17 16:21:16 -04:00
co-authored by Claude Opus 4.8
parent 6f4b2b8a17
commit 8807b3ff6d
12 changed files with 364 additions and 1 deletions
+40 -1
View File
@@ -287,6 +287,9 @@ const _SSE_HANDLER_MAP = [
// Clipboard
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
];
@@ -2956,6 +2959,13 @@ class CodemanApp {
// on another device).
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
// COD-131: server is authoritative for global tab order. Seed localStorage
// from the server snapshot (if present) so syncSessionOrder() reconciles
// against the cross-device order rather than this device's stale local copy.
if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) {
try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {}
}
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
@@ -3520,13 +3530,42 @@ class CodemanApp {
}
}
// Save session order to localStorage
// Save session order to localStorage and (debounced) sync to the server so it
// follows the user across devices (COD-131). localStorage stays the offline
// fallback; the server is authoritative and echoes back via SSE.
saveSessionOrder() {
try {
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
} catch {
// Ignore storage errors
}
const order = [...this.sessionOrder];
this._debouncedCall('saveSessionOrderServer', () => {
fetch('/api/session-order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
}).catch(() => {});
}, 400);
}
// COD-131: another device (or our own debounced push) reordered tabs. Adopt the
// server order as the new base and reconcile to our currently-open sessions.
// Guard against no-op churn so an echo of our own push doesn't flicker the tabs.
_onSessionOrderChanged(data) {
if (!data || !Array.isArray(data.order)) return;
try {
localStorage.setItem('codeman-session-order', JSON.stringify(data.order));
} catch {
// Ignore storage errors
}
const before = JSON.stringify(this.sessionOrder);
this.syncSessionOrder();
// Only re-render when the reconciled order actually changed (avoids flicker
// when the broadcast is just an echo of the order we already have).
if (JSON.stringify(this.sessionOrder) !== before) {
this._fullRenderSessionTabs();
}
}
// Set up drag-and-drop handlers on tab elements
+3
View File
@@ -474,6 +474,9 @@ const SSE_EVENTS = {
CASE_LINKED: 'case:linked',
CASE_DELETED: 'case:deleted',
CASE_ORDER_CHANGED: 'case:order-changed',
// Session order (global tab order sync)
SESSION_ORDER_CHANGED: 'session:orderChanged',
};
// ═══════════════════════════════════════════════════════════════
+14
View File
@@ -35,7 +35,9 @@ import {
QuickRunSchema,
QuickStartSchema,
InteractiveStartSchema,
SessionOrderUpdateSchema,
} from '../schemas.js';
import { mergeSessionOrder } from '../../session-order.js';
import {
autoConfigureRalph,
CASES_DIR,
@@ -280,6 +282,18 @@ export function registerSessionRoutes(
return ctx.getLightSessionsState();
});
// ========== Session Tab Order (global sync, COD-131) ==========
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
// Server is authoritative but never drops ids it knows about that the
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
ctx.store.setSessionOrder(merged);
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
return { success: true, data: { order: merged } };
});
// ========== Session Creation ==========
app.post('/api/sessions', async (req) => {
+5
View File
@@ -769,6 +769,11 @@ export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
});
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
export const SessionOrderUpdateSchema = z.object({
order: z.array(z.string()),
});
/** POST /api/auth/revoke */
export const RevokeSessionSchema = z.object({
sessionToken: z.string().min(1).max(200).optional(),
+1
View File
@@ -1769,6 +1769,7 @@ export class WebServer extends EventEmitter {
timestamp: now,
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
};
this.cachedLightState = { data: result, timestamp: now };
+6
View File
@@ -370,6 +370,9 @@ export const CaseDeleted = 'case:deleted' as const;
/** Case ordering changed. */
export const CaseOrderChanged = 'case:order-changed' as const;
/** Global session tab order changed (synced across devices). COD-131. */
export const SessionOrderChanged = 'session:orderChanged' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
/**
@@ -551,4 +554,7 @@ export const SseEvent = {
CaseLinked,
CaseDeleted,
CaseOrderChanged,
// Session order (global tab order sync)
SessionOrderChanged,
} as const;