diff --git a/.changeset/calm-tabs-foundation.md b/.changeset/calm-tabs-foundation.md new file mode 100644 index 00000000..a1fbb58d --- /dev/null +++ b/.changeset/calm-tabs-foundation.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Add the owner-scoped tab-layout model, persistence, API, lifecycle repair, and synchronized legacy ordering foundation. diff --git a/.changeset/cod-358-upstream-vertical-rail.md b/.changeset/cod-358-upstream-vertical-rail.md new file mode 100644 index 00000000..180ec6a5 --- /dev/null +++ b/.changeset/cod-358-upstream-vertical-rail.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Add an optional resizable vertical session rail with responsive layout, complete labels, accessible controls, and stable inline rename. diff --git a/CLAUDE.md b/CLAUDE.md index 65ec25c6..d15912c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -338,7 +338,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### SSE Event Registry -155 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 155 = 155, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown. +156 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 156 = 156, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events. ### API Routes diff --git a/config/test-suites.ts b/config/test-suites.ts index a1effdc7..cef400cc 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -19,6 +19,9 @@ * why these are a runnable suite (`npm run test:browser`) rather than skipped. */ export const BROWSER_TEST_GLOBS = [ + 'test/tab-rail-resize.browser.test.ts', + 'test/session-sidebar-ux.browser.test.ts', + 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', 'test/opencode-resize.test.ts', 'test/webgl-fallback.test.ts', diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 325b3521..46bf7dc6 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -119,7 +119,7 @@ Shell and external CLI sessions accept `idle`, `working`, and `exit`. ## SSE -`GET /api/events` is the live event stream. 155 event names, kept in sync between server and +`GET /api/events` is the live event stream. 156 event names, kept in sync between server and client with a test that fails on drift. The heartbeat is a **named** `sse:heartbeat` event rather than an SSE comment, because diff --git a/scripts/build.mjs b/scripts/build.mjs index eac90a18..d7236f5f 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -86,6 +86,7 @@ run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify -- run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite'); run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); +run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite'); run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite'); run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite'); run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite'); @@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting'); 'input-cjk.js', 'sanitize-html.js', 'app.js', + 'tab-rail-resize.js', 'terminal-ui.js', 'respawn-ui.js', 'ralph-panel.js', diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index e07ed92d..c42be352 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -426,7 +426,7 @@ export class CronService { piConfig, owner: job.owner, }); - this.deps.addSession(session); + await this.deps.addSession(session); this.store.incrementSessionsCreated(); this.deps.persistSessionState(session); await this.deps.setupSessionListeners(session); diff --git a/src/state-store.ts b/src/state-store.ts index 444e4ea3..6afaddbf 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -40,6 +40,8 @@ import { } from './types.js'; import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js'; import { dataPath, CODEMAN_INSTANCE } from './config/instance.js'; +import { normalizeSessionOrder } from './session-order.js'; +import { validateTabLayout, type TabLayout } from './tab-layout.js'; /** Debounce delay for batching state writes (ms) */ const SAVE_DEBOUNCE_MS = 500; @@ -281,6 +283,9 @@ export class StateStore { if (this.state.sessionOrder) { parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`); } + if (this.state.tabLayouts !== undefined) { + parts.push(`"tabLayouts":${JSON.stringify(this.state.tabLayouts)}`); + } return `{${parts.join(',')}}`; } @@ -514,22 +519,28 @@ export class StateStore { */ cleanupStaleSessions(activeSessionIds: Set): { count: number; - cleaned: Array<{ id: string; name?: string }>; + cleaned: Array<{ id: string; name?: string; owner?: string }>; } { - const allSessionIds = Object.keys(this.state.sessions); - const cleaned: Array<{ id: string; name?: string }> = []; + const staleIds = new Set(Object.keys(this.state.sessions).filter((sessionId) => !activeSessionIds.has(sessionId))); + return this.cleanupSessionsByIds(staleIds); + } - for (const sessionId of allSessionIds) { - if (!activeSessionIds.has(sessionId)) { - if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session - const name = this.state.sessions[sessionId]?.name; - cleaned.push({ id: sessionId, name }); - delete this.state.sessions[sessionId]; - this.cachedSessionJsons.delete(sessionId); - this.dirtySessions.delete(sessionId); - // Also clean up Ralph state for this session - this.ralphStates.delete(sessionId); - } + /** Deletes only confirmed stale session IDs, retaining records pinned after confirmation. */ + cleanupSessionsByIds(sessionIds: ReadonlySet): { + count: number; + cleaned: Array<{ id: string; name?: string; owner?: string }>; + } { + const cleaned: Array<{ id: string; name?: string; owner?: string }> = []; + + for (const sessionId of sessionIds) { + const session = this.state.sessions[sessionId]; + if (!session || session.pinned === true) continue; // COD-142: pinned records persist even with no live session + cleaned.push({ id: sessionId, name: session.name, owner: session.owner }); + delete this.state.sessions[sessionId]; + this.cachedSessionJsons.delete(sessionId); + this.dirtySessions.delete(sessionId); + // Also clean up Ralph state for this session + this.ralphStates.delete(sessionId); } if (cleaned.length > 0) { @@ -664,6 +675,41 @@ export class StateStore { this.save(); } + /** Returns an owner layout, or null before that owner has been migrated. */ + getTabLayout(owner: string): TabLayout | null { + const layouts = this.state.tabLayouts; + return layouts && Object.hasOwn(layouts, owner) ? layouts[owner] : null; + } + + /** Returns a defensive snapshot of every stored owner layout. */ + getTabLayouts(): Record { + return structuredClone(this.state.tabLayouts ?? {}); + } + + /** Validates and atomically persists one owner layout. */ + setTabLayout(owner: string, layout: TabLayout): void { + const validated = validateTabLayout(layout); + this.state.tabLayouts = { ...(this.state.tabLayouts ?? {}), [owner]: validated }; + this.save(); + } + + /** Atomically publishes validated owner layouts and their latest global compatibility projection. */ + commitTabLayoutProjection( + layouts: Readonly>, + projectOrder: (latest: readonly string[]) => readonly string[] + ): { layouts: Record; sessionOrder: string[] } { + const validated = Object.fromEntries( + Object.entries(layouts).map(([owner, layout]) => [owner, validateTabLayout(layout)]) + ); + const sessionOrder = normalizeSessionOrder(projectOrder([...(this.state.sessionOrder ?? [])])); + const nextLayouts = { ...(this.state.tabLayouts ?? {}), ...validated }; + + this.state.tabLayouts = nextLayouts; + this.state.sessionOrder = sessionOrder; + this.save(); + return { layouts: structuredClone(validated), sessionOrder: [...sessionOrder] }; + } + /** Resets all state to initial values and saves immediately. */ reset(): void { this.state = createInitialState(); diff --git a/src/tab-layout-legacy-order.ts b/src/tab-layout-legacy-order.ts new file mode 100644 index 00000000..d5054a90 --- /dev/null +++ b/src/tab-layout-legacy-order.ts @@ -0,0 +1,81 @@ +/** + * @fileoverview Pure compatibility translation between legacy session order and owner tab layouts. + */ + +import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js'; +import { + normalizeTabLayout, + validateTabLayout, + type TabLayout, + type TabRef, + type TabRefMetadata, +} from './tab-layout.js'; + +export interface OwnerOrderProjection { + owner: string; + ownedIds: readonly string[]; + order: readonly string[]; +} + +export function applyLegacySessionRank( + input: TabLayout, + requestedOrder: readonly string[], + metadata: readonly TabRefMetadata[] +): TabLayout { + const layout = validateTabLayout(input); + const requestedRank = new Map(normalizeSessionOrder(requestedOrder).map((id, index) => [id, index])); + const sessionMetadata = new Map(); + for (const item of metadata) { + if (item.kind !== 'session' || !item.ownerValid || !item.visible || sessionMetadata.has(item.id)) continue; + sessionMetadata.set(item.id, item); + } + + const isRanked = (ref: TabRef): boolean => + ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id); + const prepare = (ref: TabRef): TabRef => { + if (ref.kind !== 'session') return { ...ref }; + const item = sessionMetadata.get(ref.id); + const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId); + return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref }; + }; + const rankContainer = (refs: readonly TabRef[]): TabRef[] => { + const ranked = refs + .filter(isRanked) + .map(prepare) + .sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!); + let rankedIndex = 0; + return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref })); + }; + + const transformed: TabLayout = { + ...layout, + groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })), + ungrouped: rankContainer(layout.ungrouped), + }; + return normalizeTabLayout(transformed, metadata); +} + +export function recomposeGlobalSessionOrder( + current: readonly string[], + projections: readonly OwnerOrderProjection[], + preferred?: readonly string[] +): string[] { + let result = mergeSessionOrder([...(preferred ?? current)], [...current]); + for (const projection of projections) { + const ownedIds = normalizeSessionOrder(projection.ownedIds); + const owned = new Set(ownedIds); + const canonical = normalizeSessionOrder(projection.order).filter((id) => owned.has(id)); + const canonicalSet = new Set(canonical); + for (const id of ownedIds) { + if (canonicalSet.has(id)) continue; + canonicalSet.add(id); + canonical.push(id); + } + + let canonicalIndex = 0; + const recomposed = result.map((id) => (owned.has(id) ? canonical[canonicalIndex++] : id)); + recomposed.push(...canonical.slice(canonicalIndex)); + result = normalizeSessionOrder(recomposed); + } + return result; +} diff --git a/src/tab-layout-persistence.ts b/src/tab-layout-persistence.ts new file mode 100644 index 00000000..4aca0b67 --- /dev/null +++ b/src/tab-layout-persistence.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview Owner-scoped tab-layout persistence and legacy migration primitives. + * + * This module is deliberately independent of routes and runtime managers. Callers + * provide persisted/live session facts plus saved webviews in server-store order. + */ + +import { normalizeTabLayout, type TabLayout, type TabRef, type TabRefMetadata } from './tab-layout.js'; + +export const SINGLE_USER_LAYOUT_OWNER = '@single'; + +export interface TabLayoutSessionRecord { + id: string; + owner?: string; + createdAt: number; + parentSessionId?: string; +} + +export interface TabLayoutWebviewRecord { + id: string; + owner?: string; +} + +export interface TabLayoutMigrationInput { + owner: string; + layouts?: Readonly>; + sessionOrder?: readonly string[]; + persistedSessions: readonly TabLayoutSessionRecord[]; + liveSessions: readonly TabLayoutSessionRecord[]; + /** Saved webviews in authoritative server-store order. */ + webviews: readonly TabLayoutWebviewRecord[]; + /** Required only when creating a layout, making migration deterministic in tests. */ + updatedAt?: string; +} + +export interface TabLayoutMigrationResult { + layout: TabLayout; + layouts: Record; + created: boolean; +} + +/** Resolve the persistence key without accepting an owner key from a client. */ +export function ownerLayoutKey(username?: string): string { + return username || SINGLE_USER_LAYOUT_OWNER; +} + +function recordOwner(record: { owner?: string }): string { + return record.owner ?? SINGLE_USER_LAYOUT_OWNER; +} + +function compareSessions(a: TabLayoutSessionRecord, b: TabLayoutSessionRecord): number { + return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +} + +function collectSessions(input: TabLayoutMigrationInput): Map { + const sessions = new Map(); + for (const record of input.persistedSessions) sessions.set(record.id, { ...record }); + // A matching live record is authoritative as a whole. In particular, absent + // optional owner/parent fields mean single-user ownership and root lineage; + // retaining those fields from a stale persisted copy changes their semantics. + for (const record of input.liveSessions) sessions.set(record.id, { ...record }); + return sessions; +} + +function buildMetadata( + input: TabLayoutMigrationInput, + sessions: ReadonlyMap +): TabRefMetadata[] { + const ownerSessions = [...sessions.values()] + .filter((record) => recordOwner(record) === input.owner) + .sort(compareSessions); + const sessionOrder = new Map(ownerSessions.map((record, index) => [record.id, index])); + const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({ + kind: 'session', + id: record.id, + ownerValid: recordOwner(record) === input.owner, + visible: true, + order: sessionOrder.get(record.id) ?? record.createdAt, + parentSessionId: record.parentSessionId, + })); + const webviewOffset = ownerSessions.length; + input.webviews.forEach((record, index) => { + metadata.push({ + kind: 'webview', + id: record.id, + ownerValid: recordOwner(record) === input.owner, + visible: true, + order: webviewOffset + index, + }); + }); + return metadata; +} + +/** + * Normalize an existing owner layout, or idempotently migrate legacy flat order. + * Unknown stored refs remain unknown to metadata and are therefore preserved. + * No input object is mutated; validation/capacity failure is atomic. + */ +export function normalizeOrMigrateOwnerTabLayout(input: TabLayoutMigrationInput): TabLayoutMigrationResult { + const sessions = collectSessions(input); + const metadata = buildMetadata(input, sessions); + const existing = input.layouts && Object.hasOwn(input.layouts, input.owner) ? input.layouts[input.owner] : undefined; + if (existing) { + const layout = normalizeTabLayout(existing, metadata); + return { layout, layouts: { ...(input.layouts ?? {}), [input.owner]: layout }, created: false }; + } + + const ownerSessions = [...sessions.values()].filter((record) => recordOwner(record) === input.owner); + const ownerSessionById = new Map(ownerSessions.map((record) => [record.id, record])); + const liveOwnerIds = new Set( + input.liveSessions.filter((record) => recordOwner(record) === input.owner).map((record) => record.id) + ); + const seen = new Set(); + const orderedSessions: TabLayoutSessionRecord[] = []; + for (const id of input.sessionOrder ?? []) { + const record = ownerSessionById.get(id); + if (!record || seen.has(id)) continue; + seen.add(id); + orderedSessions.push(record); + } + for (const record of ownerSessions.filter((item) => !seen.has(item.id)).sort(compareSessions)) { + seen.add(record.id); + orderedSessions.push(record); + } + + const refs: TabRef[] = orderedSessions.map((record) => { + const manual = record.parentSessionId !== undefined && liveOwnerIds.has(record.parentSessionId); + return manual ? { kind: 'session', id: record.id, placement: 'manual' } : { kind: 'session', id: record.id }; + }); + for (const webview of input.webviews) { + if (recordOwner(webview) === input.owner) refs.push({ kind: 'webview', id: webview.id }); + } + + const layout = normalizeTabLayout( + { + version: 0, + groups: [], + ungrouped: refs, + updatedAt: input.updatedAt ?? new Date().toISOString(), + }, + metadata + ); + return { layout, layouts: { ...(input.layouts ?? {}), [input.owner]: layout }, created: true }; +} diff --git a/src/tab-layout-service.ts b/src/tab-layout-service.ts new file mode 100644 index 00000000..146be473 --- /dev/null +++ b/src/tab-layout-service.ts @@ -0,0 +1,663 @@ +/** + * @fileoverview Owner-scoped authoritative tab-layout coordination. + * + * This is the single mutation boundary between the pure layout model, persisted + * state, live sessions, saved webviews, and SSE. Lifecycle callers describe one + * completed server action; this service performs at most one versioned write. + */ +import type { StateStore } from './state-store.js'; +import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js'; +import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layout-legacy-order.js'; +import { + flattenOwnerSessionOrder, + materializeOrphans, + normalizeTabLayout, + TabLayoutValidationError, + validateTabLayout, + type TabLayout, + type TabRef, + type TabRefMetadata, +} from './tab-layout.js'; +import { + normalizeOrMigrateOwnerTabLayout, + SINGLE_USER_LAYOUT_OWNER, + type TabLayoutSessionRecord, + type TabLayoutWebviewRecord, +} from './tab-layout-persistence.js'; +import { SseEvent } from './web/sse-events.js'; + +export interface TabLayoutSessionLike { + id: string; + owner?: string; + createdAt: number; + parentSessionId?: string; +} + +interface TabLayoutServiceDeps { + store: Pick< + StateStore, + 'getTabLayout' | 'getTabLayouts' | 'getSessions' | 'getSessionOrder' | 'commitTabLayoutProjection' + >; + sessions: ReadonlyMap; + readWebviews(): Promise; + broadcast(event: string, data: unknown): void; + broadcastSessionOrder(change: SessionOrderProjectionChange): void; + now?: () => string; +} + +export type TabLayoutPutResult = { status: 'updated'; layout: TabLayout } | { status: 'conflict'; layout: TabLayout }; + +export interface LegacyOrderActor { + owner: string; + isAdmin: boolean; +} + +export interface SessionOrderProjectionChange { + changedOwnerOrders: Record; + globalOrder: string[]; + globalChanged: boolean; +} + +export interface LegacyOrderPutResult extends SessionOrderProjectionChange { + order: string[]; +} + +export interface RemovedTabLayoutSession { + id: string; + owner?: string; +} + +interface PreparedOwnerLayout { + current: TabLayout | null; + authoritative: TabLayout; + metadata: TabRefMetadata[]; + needsReconciliationCommit: boolean; +} + +interface OwnerProjectionPublication { + owner: string; + previous: TabLayout | null; + next: TabLayout; + metadata: readonly TabRefMetadata[]; + excludedSessionIds?: ReadonlySet; +} + +interface PreparedOrderProjection { + owner: string; + previousOrder: string[]; + authoritativeBeforeIds: string[]; + excludedIds: string[]; + currentIds: string[]; + order: string[]; +} + +const ownerOf = (record: { owner?: string }): string => record.owner ?? SINGLE_USER_LAYOUT_OWNER; +const refKey = (ref: Pick): string => `${ref.kind}\u0000${ref.id}`; +const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) === JSON.stringify(b); +const sameOrder = (a: readonly string[], b: readonly string[]): boolean => + a.length === b.length && a.every((id, index) => id === b[index]); + +export class TabLayoutService { + private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending'; + private readonly ownerQueues = new Map>(); + + constructor(private readonly deps: TabLayoutServiceDeps) {} + + private async withOwner(owner: string, task: () => Promise): Promise { + const previous = this.ownerQueues.get(owner) ?? Promise.resolve(); + const run = previous.catch(() => undefined).then(task); + const tail = run.then( + () => undefined, + () => undefined + ); + this.ownerQueues.set(owner, tail); + try { + return await run; + } finally { + if (this.ownerQueues.get(owner) === tail) this.ownerQueues.delete(owner); + } + } + + /** Acquire multiple owner queues in stable order so overlapping bulk cleanups cannot deadlock. */ + private async withOwners(owners: readonly string[], task: () => Promise, index = 0): Promise { + if (index >= owners.length) return task(); + return this.withOwner(owners[index], () => this.withOwners(owners, task, index + 1)); + } + + markRestorationComplete(): void { + this.restorationState = 'complete'; + } + + markRestorationFailed(): void { + this.restorationState = 'failed'; + } + + markRestorationSkipped(): void { + this.restorationState = 'skipped'; + } + + assertDeletionReady(): void { + if (this.restorationState === 'complete' || this.restorationState === 'skipped') return; + throw new Error(`Tab layout restoration is ${this.restorationState}; destructive deletion is unavailable`); + } + + /** Repair/migrate every owner visible after startup restoration. */ + async reconcileAfterRestoration(): Promise { + if (this.restorationState !== 'complete') return; + const { persisted, live } = this.sessionRecords(); + const webviews = await this.deps.readWebviews(); + const owners = new Set(); + for (const record of [...persisted, ...live, ...webviews]) owners.add(ownerOf(record)); + for (const owner of owners) await this.get(owner); + } + + private sessionRecords(): { persisted: TabLayoutSessionRecord[]; live: TabLayoutSessionRecord[] } { + const persisted = Object.entries(this.deps.store.getSessions()).map(([id, record]) => ({ + id, + owner: record.owner, + createdAt: record.createdAt, + parentSessionId: record.parentSessionId, + })); + const live = [...this.deps.sessions.values()].map((record) => ({ + id: record.id, + owner: record.owner, + createdAt: record.createdAt, + parentSessionId: record.parentSessionId, + })); + return { persisted, live }; + } + + private async facts(owner: string): Promise<{ + persisted: TabLayoutSessionRecord[]; + live: TabLayoutSessionRecord[]; + webviews: readonly TabLayoutWebviewRecord[]; + metadata: TabRefMetadata[]; + }> { + const { persisted, live } = this.sessionRecords(); + const webviews = await this.deps.readWebviews(); + const sessions = new Map(); + for (const record of persisted) sessions.set(record.id, record); + for (const record of live) sessions.set(record.id, record); + const ownedSessions = [...sessions.values()] + .filter((record) => ownerOf(record) === owner) + .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const sessionOrder = new Map(ownedSessions.map((record, index) => [record.id, index])); + const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({ + kind: 'session', + id: record.id, + ownerValid: ownerOf(record) === owner, + visible: true, + order: sessionOrder.get(record.id) ?? record.createdAt, + parentSessionId: record.parentSessionId, + })); + const offset = ownedSessions.length; + webviews.forEach((record, index) => + metadata.push({ + kind: 'webview', + id: record.id, + ownerValid: ownerOf(record) === owner, + visible: true, + order: offset + index, + }) + ); + return { persisted, live, webviews, metadata }; + } + + private prepareCommit(base: TabLayout, next: TabLayout): TabLayout { + return validateTabLayout({ + ...next, + version: base.version + 1, + updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(), + }); + } + + private prepareOrderProjection(item: OwnerProjectionPublication): PreparedOrderProjection { + const excluded = item.excludedSessionIds ?? new Set(); + const authoritativeBeforeIds = item.metadata + .filter((fact) => fact.kind === 'session' && fact.ownerValid && fact.visible) + .map((fact) => fact.id); + const facts = authoritativeBeforeIds.filter((id) => !excluded.has(id)); + const visible = new Set(facts); + const rawPrevious = item.previous ? flattenOwnerSessionOrder(item.previous) : []; + const rawNext = flattenOwnerSessionOrder(item.next); + const previousOrder = rawPrevious.filter((id) => visible.has(id) || excluded.has(id)); + const order = rawNext.filter((id) => visible.has(id) && !excluded.has(id)); + const excludedIds = normalizeSessionOrder([...excluded]); + return { + owner: item.owner, + previousOrder, + authoritativeBeforeIds: normalizeSessionOrder([...authoritativeBeforeIds, ...excluded]), + excludedIds, + currentIds: normalizeSessionOrder([...order, ...facts]), + order, + }; + } + + private projectOrder( + latest: readonly string[], + projections: readonly PreparedOrderProjection[], + preferred?: readonly string[] + ): string[] { + const before = normalizeSessionOrder(latest); + const removed = new Set( + projections.flatMap((projection) => projection.excludedIds.filter((id) => !projection.currentIds.includes(id))) + ); + return recomposeGlobalSessionOrder( + before.filter((id) => !removed.has(id)), + projections.map((projection) => ({ + owner: projection.owner, + ownedIds: projection.currentIds, + order: projection.order, + })), + preferred + ); + } + + private publish( + layouts: Readonly>, + publications: readonly OwnerProjectionPublication[], + preferred?: readonly string[] + ): SessionOrderProjectionChange { + const projections = publications.map((item) => this.prepareOrderProjection(item)); + let beforeOrder: string[] = []; + const accepted = this.deps.store.commitTabLayoutProjection(layouts, (latest) => { + beforeOrder = normalizeSessionOrder(latest); + return this.projectOrder(beforeOrder, projections, preferred); + }); + const changedEntries: Array<[string, string[]]> = []; + for (const projection of projections) { + const beforeIds = new Set(projection.authoritativeBeforeIds); + const currentIds = new Set(projection.currentIds); + const persistedBefore = beforeOrder.filter((id) => beforeIds.has(id)); + const persistedAfter = accepted.sessionOrder.filter((id) => currentIds.has(id)); + const layoutOrderChanged = !sameOrder(projection.previousOrder, projection.order); + const persistedOwnerSliceChanged = !sameOrder(persistedBefore, persistedAfter); + if (layoutOrderChanged || persistedOwnerSliceChanged) { + changedEntries.push([projection.owner, persistedAfter]); + } + } + const change: SessionOrderProjectionChange = { + changedOwnerOrders: Object.fromEntries(changedEntries), + globalOrder: [...accepted.sessionOrder], + globalChanged: !sameOrder(beforeOrder, accepted.sessionOrder), + }; + for (const [owner, layout] of Object.entries(accepted.layouts)) { + this.deps.broadcast(SseEvent.TabLayoutChanged, { owner, version: layout.version }); + } + if (changedEntries.length > 0 || change.globalChanged) this.deps.broadcastSessionOrder(change); + return change; + } + + private commit( + owner: string, + base: TabLayout, + next: TabLayout, + metadata: readonly TabRefMetadata[], + previous: TabLayout | null = base.version < 0 ? null : base + ): TabLayout { + const stored = this.prepareCommit(base, next); + this.publish({ [owner]: stored }, [{ owner, previous, next: stored, metadata }]); + return stored; + } + + private async prepareUnlocked(owner: string): Promise { + const facts = await this.facts(owner); + const current = this.deps.store.getTabLayout(owner); + const authoritative = normalizeOrMigrateOwnerTabLayout({ + owner, + layouts: current ? { [owner]: current } : undefined, + sessionOrder: this.deps.store.getSessionOrder(), + persistedSessions: facts.persisted, + liveSessions: facts.live, + webviews: facts.webviews, + updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(), + }).layout; + return { + current, + authoritative, + metadata: facts.metadata, + needsReconciliationCommit: !current || !sameLayout(current, authoritative), + }; + } + + private async getUnlocked(owner: string): Promise { + const prepared = await this.prepareUnlocked(owner); + if (!prepared.needsReconciliationCommit) { + const publication = { + owner, + previous: prepared.current, + next: prepared.authoritative, + metadata: prepared.metadata, + }; + const latest = this.deps.store.getSessionOrder(); + const projected = this.projectOrder(latest, [this.prepareOrderProjection(publication)]); + if (!sameOrder(normalizeSessionOrder(latest), projected)) this.publish({}, [publication]); + return prepared.authoritative; + } + const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; + return this.commit(owner, base, prepared.authoritative, prepared.metadata); + } + + async get(owner: string): Promise { + return this.withOwner(owner, () => this.getUnlocked(owner)); + } + + async put(owner: string, desired: unknown, baseVersion: number): Promise { + return this.withOwner(owner, async () => { + const prepared = await this.prepareUnlocked(owner); + if (baseVersion !== prepared.authoritative.version) return { status: 'conflict', layout: prepared.authoritative }; + const validated = validateTabLayout(desired); + const owned = new Set(prepared.metadata.filter((item) => item.ownerValid && item.visible).map(refKey)); + const refs = [...validated.groups.flatMap((group) => group.refs), ...validated.ungrouped]; + const invalid = refs.find((ref) => !owned.has(refKey(ref))); + if (invalid) + throw new TabLayoutValidationError(`ref is not owned by layout owner: ${invalid.kind}:${invalid.id}`); + const normalized = normalizeTabLayout( + { ...validated, version: prepared.authoritative.version }, + prepared.metadata + ); + return { + status: 'updated', + layout: this.commit(owner, prepared.authoritative, normalized, prepared.metadata, prepared.current), + }; + }); + } + + async putLegacyOrder(actor: LegacyOrderActor, requested: readonly string[]): Promise { + return actor.isAdmin ? this.putAdminLegacyOrder(requested) : this.putOwnerLegacyOrder(actor.owner, requested); + } + + private async putOwnerLegacyOrder(owner: string, requested: readonly string[]): Promise { + return this.withOwner(owner, async () => { + const prepared = await this.prepareUnlocked(owner); + const normalized = normalizeSessionOrder(requested); + const visible = new Set( + prepared.metadata + .filter((item) => item.kind === 'session' && item.ownerValid && item.visible) + .map((item) => item.id) + ); + const invalid = normalized.find((id) => !visible.has(id)); + if (invalid) throw new TabLayoutValidationError(`session is not owned by layout owner: ${invalid}`); + const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id)); + const effective = mergeSessionOrder(normalized, currentKnown); + const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata); + const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked); + const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; + const next = needsLayout ? this.prepareCommit(base, ranked) : prepared.authoritative; + const change = this.publish(needsLayout ? { [owner]: next } : {}, [ + { owner, previous: prepared.current, next, metadata: prepared.metadata }, + ]); + return { order: flattenOwnerSessionOrder(next).filter((id) => visible.has(id)), ...change }; + }); + } + + private async putAdminLegacyOrder(requested: readonly string[]): Promise { + const discoverOwners = (): string[] => { + const owners = new Set(Object.keys(this.deps.store.getTabLayouts())); + const { persisted, live } = this.sessionRecords(); + for (const record of [...persisted, ...live]) owners.add(ownerOf(record)); + return [...owners].sort(); + }; + for (;;) { + const owners = discoverOwners(); + const result = await this.withOwners(owners, async (): Promise => { + if (!sameOrder(owners, discoverOwners())) return null; + const normalized = normalizeSessionOrder(requested); + const knownOwners = new Map(); + const { persisted, live } = this.sessionRecords(); + for (const record of persisted) knownOwners.set(record.id, ownerOf(record)); + for (const record of live) knownOwners.set(record.id, ownerOf(record)); + const invalid = normalized.find((id) => !knownOwners.has(id)); + if (invalid) throw new TabLayoutValidationError(`session is not visible machine-wide: ${invalid}`); + + const publications: OwnerProjectionPublication[] = []; + const updates: Record = Object.create(null) as Record; + for (const owner of owners) { + const prepared = await this.prepareUnlocked(owner); + const visible = new Set( + prepared.metadata + .filter((item) => item.kind === 'session' && item.ownerValid && item.visible) + .map((item) => item.id) + ); + const requestedOwner = normalized.filter((id) => visible.has(id)); + const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id)); + const effective = mergeSessionOrder(requestedOwner, currentKnown); + const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata); + const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked); + const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; + const next = needsLayout ? this.prepareCommit(base, ranked) : prepared.authoritative; + if (needsLayout) updates[owner] = next; + publications.push({ owner, previous: prepared.current, next, metadata: prepared.metadata }); + } + const change = this.publish(updates, publications, normalized); + return { order: [...change.globalOrder], ...change }; + }); + if (result) return result; + } + } + + /** Reconcile one completed session creation into one versioned mutation. */ + async sessionCreated(owner: string): Promise { + return this.get(owner); + } + + /** Reconcile one completed saved-webview creation into one versioned mutation. */ + async webviewCreated(owner: string): Promise { + return this.get(owner); + } + + async sessionsRemoved(removed: readonly RemovedTabLayoutSession[]): Promise { + if (this.restorationState !== 'complete' || removed.length === 0) return; + const byOwner = new Map(); + for (const item of removed) { + const owner = ownerOf(item); + const ids = byOwner.get(owner) ?? []; + ids.push(item.id); + byOwner.set(owner, ids); + } + const owners = [...byOwner.keys()].sort(); + await this.withOwners(owners, async () => { + const publications: OwnerProjectionPublication[] = []; + const updates: Record = Object.create(null) as Record; + for (const owner of owners) { + const ids = byOwner.get(owner) ?? []; + const prepared = await this.prepareUnlocked(owner); + const current = prepared.current; + // Normalize and prune together so stale cleanup, orphan materialization, + // and missing-ref repair remain one versioned server mutation. + const next = normalizeTabLayout( + materializeOrphans(prepared.authoritative, ids, prepared.metadata), + prepared.metadata + ); + const stored = current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null; + if (stored) updates[owner] = stored; + publications.push({ + owner, + previous: current, + next: stored ?? next, + metadata: prepared.metadata, + excludedSessionIds: new Set(ids), + }); + } + if (publications.length > 0) this.publish(updates, publications); + }); + } + + /** + * Hold the owner mutation lock across an irreversible session deletion. + * All failure-prone normalization happens before `action`; the prepared layout + * commits only after the resource cleanup finishes. + */ + async runSessionDeletion(removed: readonly RemovedTabLayoutSession[], action: () => Promise): Promise { + this.assertDeletionReady(); + if (this.restorationState === 'skipped' || removed.length === 0) return action(); + const owners = new Set(removed.map(ownerOf)); + if (owners.size !== 1) throw new Error('A session deletion transaction must contain exactly one owner'); + const owner = owners.values().next().value as string; + const ids = removed.map((item) => item.id); + return this.withOwner(owner, async () => { + const prepared = await this.prepareUnlocked(owner); + const current = prepared.current; + // Prepare while the soon-to-be-deleted sessions are still known, so + // direct children can be materialized before their parent ref is removed. + const next = materializeOrphans(prepared.authoritative, ids, prepared.metadata); + const stored = current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null; + const result = await action(); + this.publish(stored ? { [owner]: stored } : {}, [ + { + owner, + previous: current, + next: stored ?? next, + metadata: prepared.metadata, + excludedSessionIds: new Set(ids), + }, + ]); + return result; + }); + } + + /** + * Prepare every affected owner layout before bulk stale-state deletion. + * The StateStore action remains synchronous in production, so the candidate + * snapshot cannot change between successful preparation and resource removal. + */ + async runStaleSessionCleanup( + activeSessionIds: ReadonlySet, + action: (ids: ReadonlySet) => T | Promise + ): Promise { + this.assertDeletionReady(); + const candidates = Object.entries(this.deps.store.getSessions()) + .filter(([id, record]) => !activeSessionIds.has(id) && record.pinned !== true) + .map(([id, record]) => ({ id, owner: record.owner })); + if (this.restorationState === 'skipped') return action(new Set(candidates.map((item) => item.id))); + if (candidates.length === 0) return action(new Set()); + + const byOwner = new Map(); + for (const item of candidates) { + const owner = ownerOf(item); + const ids = byOwner.get(owner) ?? []; + ids.push(item.id); + byOwner.set(owner, ids); + } + const owners = [...byOwner.keys()].sort(); + return this.withOwners(owners, async () => { + const webviews = await this.deps.readWebviews(); + const persistedState = this.deps.store.getSessions(); + const persisted = Object.entries(persistedState).map(([id, record]) => ({ + id, + owner: record.owner, + createdAt: record.createdAt, + parentSessionId: record.parentSessionId, + })); + const liveIds = new Set(this.deps.sessions.keys()); + const confirmed = candidates.filter((candidate) => { + const record = persistedState[candidate.id]; + return ( + record !== undefined && + ownerOf(record) === ownerOf(candidate) && + record.pinned !== true && + !activeSessionIds.has(candidate.id) && + !liveIds.has(candidate.id) + ); + }); + const confirmedByOwner = new Map(); + for (const item of confirmed) { + const owner = ownerOf(item); + const ids = confirmedByOwner.get(owner) ?? []; + ids.push(item.id); + confirmedByOwner.set(owner, ids); + } + + const prepared: Array<{ + owner: string; + current: TabLayout | null; + next: TabLayout; + stored: TabLayout | null; + metadata: TabRefMetadata[]; + excludedSessionIds: ReadonlySet; + }> = []; + for (const owner of owners) { + const ids = confirmedByOwner.get(owner) ?? []; + if (ids.length === 0) continue; + const current = this.deps.store.getTabLayout(owner); + const sessions = new Map(); + for (const record of persisted) sessions.set(record.id, record); + for (const record of this.deps.sessions.values()) sessions.set(record.id, record); + const ownedSessions = [...sessions.values()] + .filter((record) => ownerOf(record) === owner) + .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const sessionOrder = new Map(ownedSessions.map((record, index) => [record.id, index])); + const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({ + kind: 'session', + id: record.id, + ownerValid: ownerOf(record) === owner, + visible: true, + order: sessionOrder.get(record.id) ?? record.createdAt, + parentSessionId: record.parentSessionId, + })); + const offset = ownedSessions.length; + webviews.forEach((record, index) => + metadata.push({ + kind: 'webview', + id: record.id, + ownerValid: ownerOf(record) === owner, + visible: true, + order: offset + index, + }) + ); + const authoritative = normalizeOrMigrateOwnerTabLayout({ + owner, + layouts: current ? { [owner]: current } : undefined, + sessionOrder: this.deps.store.getSessionOrder(), + persistedSessions: persisted, + liveSessions: [...this.deps.sessions.values()], + webviews, + updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(), + }).layout; + const next = materializeOrphans(authoritative, ids, metadata); + prepared.push({ + owner, + current, + next, + stored: current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null, + metadata, + excludedSessionIds: new Set(ids), + }); + } + + const result = await action(new Set(confirmed.map((item) => item.id))); + if (prepared.length > 0) { + this.publish( + Object.fromEntries(prepared.filter((item) => item.stored).map((item) => [item.owner, item.stored!])), + prepared.map((item) => ({ + owner: item.owner, + previous: item.current, + next: item.stored ?? item.next, + metadata: item.metadata, + excludedSessionIds: item.excludedSessionIds, + })) + ); + } + return result; + }); + } + + async webviewDeleted(owner: string, id: string): Promise { + this.assertDeletionReady(); + if (this.restorationState === 'skipped') return; + await this.withOwner(owner, async () => { + const current = this.deps.store.getTabLayout(owner); + if (!current) return; + const strip = (refs: readonly TabRef[]): TabRef[] => + refs.filter((ref) => ref.kind !== 'webview' || ref.id !== id).map((ref) => ({ ...ref })); + const stripped: TabLayout = { + ...current, + groups: current.groups.map((group) => ({ ...group, refs: strip(group.refs) })), + ungrouped: strip(current.ungrouped), + }; + const { metadata } = await this.facts(owner); + const next = normalizeTabLayout(stripped, metadata); + if (!sameLayout(current, next)) this.commit(owner, current, next, metadata); + }); + } +} diff --git a/src/tab-layout.ts b/src/tab-layout.ts new file mode 100644 index 00000000..34cda478 --- /dev/null +++ b/src/tab-layout.ts @@ -0,0 +1,547 @@ +/** + * @fileoverview Framework-independent tab layout model. + * + * Callers provide owner-scoped session/webview metadata. This module deliberately + * has no dependency on session runtime, persistence, routes, or browser state. + */ + +export const MAX_TAB_GROUPS = 32; +export const MAX_TAB_GROUP_NAME_LENGTH = 60; +export const MAX_TAB_REFS = 512; + +export type TabRefKind = 'session' | 'webview'; + +export interface TabRef { + kind: TabRefKind; + id: string; + placement?: 'manual'; +} + +export interface TabGroup { + id: string; + name: string; + refs: TabRef[]; +} + +export interface TabLayout { + version: number; + groups: TabGroup[]; + ungrouped: TabRef[]; + updatedAt: string; +} + +/** Owner and lineage facts supplied by the server or browser integration. */ +export interface TabRefMetadata { + kind: TabRefKind; + id: string; + /** False for missing, foreign-owned, or otherwise invalid refs. */ + ownerValid: boolean; + /** False when the owner is not permitted to see/store this ref. */ + visible: boolean; + /** Stable creation/sibling order. Ties fall back to kind and id. */ + order: number; + /** Session-only lineage hint. Ignored for webviews. */ + parentSessionId?: string; +} + +export interface TabMoveTarget { + /** Null denotes the real ungrouped container. */ + groupId: string | null; + /** Zero-based insertion index after removing the moved block. */ + index: number; +} + +export interface CreateTabGroupInput { + id: string; + name: string; + index?: number; +} + +export interface VisibleTabProjectionOptions { + liveSessionIds: ReadonlySet; + openWebviewIds: ReadonlySet; + collapsedGroupIds?: ReadonlySet; + highlighted?: TabRef; +} + +export class TabLayoutValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'TabLayoutValidationError'; + } +} + +const keyOf = (ref: Pick): string => `${ref.kind}\u0000${ref.id}`; + +function assertRecord(value: unknown, label: string): asserts value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TabLayoutValidationError(`${label} must be an object`); + } +} + +function parseNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new TabLayoutValidationError(`${label} must be a non-empty string`); + } + return value; +} + +function parseName(value: unknown, label: string): string { + if (typeof value !== 'string') throw new TabLayoutValidationError(`${label} must be a string`); + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > MAX_TAB_GROUP_NAME_LENGTH) { + throw new TabLayoutValidationError(`${label} must be 1-${MAX_TAB_GROUP_NAME_LENGTH} trimmed characters`); + } + return trimmed; +} + +function parseRef(value: unknown, label: string): TabRef { + assertRecord(value, label); + if (value.kind !== 'session' && value.kind !== 'webview') { + throw new TabLayoutValidationError(`${label}.kind must be session or webview`); + } + const id = parseNonEmptyString(value.id, `${label}.id`); + if (value.placement !== undefined && value.placement !== 'manual') { + throw new TabLayoutValidationError(`${label}.placement must be manual when present`); + } + return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id }; +} + +function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout { + assertRecord(input, 'layout'); + if (!Number.isSafeInteger(input.version) || (input.version as number) < 0) { + throw new TabLayoutValidationError('layout.version must be a non-negative safe integer'); + } + if (!Array.isArray(input.groups)) throw new TabLayoutValidationError('layout.groups must be an array'); + if (input.groups.length > MAX_TAB_GROUPS) { + throw new TabLayoutValidationError(`layout.groups cannot exceed ${MAX_TAB_GROUPS}`); + } + if (!Array.isArray(input.ungrouped)) throw new TabLayoutValidationError('layout.ungrouped must be an array'); + const updatedAt = parseNonEmptyString(input.updatedAt, 'layout.updatedAt'); + const groupIds = new Set(); + const refKeys = new Set(); + let refCount = input.ungrouped.length; + const parseStoredRef = (entry: unknown, label: string): TabRef => { + const ref = parseRef(entry, label); + const key = keyOf(ref); + if (!repairDuplicates && refKeys.has(key)) { + throw new TabLayoutValidationError(`duplicate ref: ${ref.kind}:${ref.id}`); + } + refKeys.add(key); + return ref; + }; + const groups = input.groups.map((rawGroup, groupIndex): TabGroup => { + const label = `layout.groups[${groupIndex}]`; + assertRecord(rawGroup, label); + const id = parseNonEmptyString(rawGroup.id, `${label}.id`); + if (groupIds.has(id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`); + groupIds.add(id); + if (!Array.isArray(rawGroup.refs)) throw new TabLayoutValidationError(`${label}.refs must be an array`); + refCount += rawGroup.refs.length; + return { + id, + name: parseName(rawGroup.name, `${label}.name`), + refs: rawGroup.refs.map((entry, refIndex) => parseStoredRef(entry, `${label}.refs[${refIndex}]`)), + }; + }); + if (refCount > MAX_TAB_REFS) { + throw new TabLayoutValidationError(`layout cannot contain more than ${MAX_TAB_REFS} refs`); + } + return { + version: input.version as number, + groups, + ungrouped: input.ungrouped.map((entry, index) => parseStoredRef(entry, `layout.ungrouped[${index}]`)), + updatedAt, + }; +} + +/** Validate and defensively clone a layout. Group names are normalized by trimming. */ +export function validateTabLayout(input: unknown): TabLayout { + return parseTabLayout(input, false); +} + +function validMetadata(metadata: readonly TabRefMetadata[]): TabRefMetadata[] { + const byKey = new Map(); + for (const item of metadata) { + if ((item.kind !== 'session' && item.kind !== 'webview') || typeof item.id !== 'string' || item.id.length === 0) { + throw new TabLayoutValidationError('metadata contains an invalid ref identity'); + } + if (!Number.isFinite(item.order)) throw new TabLayoutValidationError(`metadata order is invalid for ${item.id}`); + if (!item.ownerValid || !item.visible) continue; + const key = keyOf(item); + if (!byKey.has(key)) byKey.set(key, { ...item }); + } + const compareText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + const result = [...byKey.values()].sort( + (a, b) => a.order - b.order || compareText(a.kind, b.kind) || compareText(a.id, b.id) + ); + if (result.length > MAX_TAB_REFS) { + throw new TabLayoutValidationError(`owner layout cannot exceed ${MAX_TAB_REFS} refs`); + } + return result; +} + +interface LocatedRef { + ref: TabRef; + container: string | null; + position: number; +} + +function locations(layout: TabLayout): LocatedRef[] { + const result: LocatedRef[] = []; + let position = 0; + for (const group of layout.groups) { + for (const ref of group.refs) result.push({ ref, container: group.id, position: position++ }); + } + for (const ref of layout.ungrouped) result.push({ ref, container: null, position: position++ }); + return result; +} + +function withContainers(layout: TabLayout, refsByContainer: ReadonlyMap): TabLayout { + return { + ...layout, + groups: layout.groups.map((group) => ({ ...group, refs: [...(refsByContainer.get(group.id) ?? [])] })), + ungrouped: [...(refsByContainer.get(null) ?? [])], + }; +} + +/** + * Reconcile a layout against owner-valid metadata and session lineage. + * First stored occurrence wins; missing valid refs append to ungrouped. + */ +export function normalizeTabLayout(input: TabLayout, metadata: readonly TabRefMetadata[]): TabLayout { + const layout = parseTabLayout(input, true); + const valid = validMetadata(metadata); + const metadataByKey = new Map(valid.map((item) => [keyOf(item), item])); + const knownMetadataKeys = new Set(metadata.map((item) => keyOf(item))); + const seen = new Set(); + const dedupedByContainer = new Map(); + for (const group of layout.groups) dedupedByContainer.set(group.id, []); + dedupedByContainer.set(null, []); + + for (const located of locations(layout)) { + const key = keyOf(located.ref); + // Missing metadata is unknown rather than invalid (for example, during + // restoration). Preserve it until an explicit invalid/deletion fact arrives. + if ((knownMetadataKeys.has(key) && !metadataByKey.has(key)) || seen.has(key)) continue; + seen.add(key); + dedupedByContainer.get(located.container)!.push({ ...located.ref }); + } + for (const item of valid) { + const key = keyOf(item); + if (seen.has(key)) continue; + seen.add(key); + dedupedByContainer.get(null)!.push({ kind: item.kind, id: item.id }); + } + if (seen.size > MAX_TAB_REFS) { + throw new TabLayoutValidationError(`normalized layout cannot exceed ${MAX_TAB_REFS} refs`); + } + + let working = withContainers(layout, dedupedByContainer); + const located = locations(working); + const refByKey = new Map(located.map((item) => [keyOf(item.ref), item.ref])); + const sessionById = new Map(valid.filter((item) => item.kind === 'session').map((item) => [item.id, item])); + const manualCycleEdges = new Set(); + const state = new Map(); + + const visit = (id: string): void => { + if (state.get(id) === 'done') return; + state.set(id, 'visiting'); + const item = sessionById.get(id); + const stored = refByKey.get(keyOf({ kind: 'session', id })); + if (item?.parentSessionId && stored?.placement !== 'manual') { + const parent = sessionById.get(item.parentSessionId); + const parentStored = refByKey.get(keyOf({ kind: 'session', id: item.parentSessionId })); + if (parent && parentStored) { + if (state.get(parent.id) === 'visiting') manualCycleEdges.add(id); + else visit(parent.id); + } + } + state.set(id, 'done'); + }; + for (const item of located) + if (item.ref.kind === 'session' && state.get(item.ref.id) === undefined) visit(item.ref.id); + + if (manualCycleEdges.size > 0) { + working = { + ...working, + groups: working.groups.map((group) => ({ + ...group, + refs: group.refs.map((ref) => + ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref + ), + })), + ungrouped: working.ungrouped.map((ref) => + ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref + ), + }; + } + + const ordered = locations(working); + const updatedRefByKey = new Map(ordered.map((item) => [keyOf(item.ref), item.ref])); + const parentOf = new Map(); + const children = new Map(); + for (const item of ordered) { + if (item.ref.kind !== 'session' || item.ref.placement === 'manual') continue; + const info = sessionById.get(item.ref.id); + const parentId = info?.parentSessionId; + if (!parentId || !sessionById.has(parentId) || !updatedRefByKey.has(keyOf({ kind: 'session', id: parentId }))) + continue; + parentOf.set(item.ref.id, parentId); + const siblings = children.get(parentId) ?? []; + siblings.push(item.ref.id); + children.set(parentId, siblings); + } + + const emitted = new Set(); + const output = new Map(); + for (const group of working.groups) output.set(group.id, []); + output.set(null, []); + const emitSubtree = (root: TabRef, container: string | null): void => { + const rootKey = keyOf(root); + if (emitted.has(rootKey)) return; + emitted.add(rootKey); + output.get(container)!.push({ ...root }); + if (root.kind !== 'session') return; + for (const childId of children.get(root.id) ?? []) { + const child = updatedRefByKey.get(keyOf({ kind: 'session', id: childId })); + if (child) emitSubtree(child, container); + } + }; + for (const item of ordered) { + if (item.ref.kind === 'session' && parentOf.has(item.ref.id)) continue; + emitSubtree(item.ref, item.container); + } + return withContainers(working, output); +} + +function cloneForEdit(input: TabLayout): TabLayout { + return validateTabLayout(input); +} + +function boundedIndex(index: number, length: number, label: string): number { + if (!Number.isSafeInteger(index) || index < 0 || index > length) { + throw new TabLayoutValidationError(`${label} index must be between 0 and ${length}`); + } + return index; +} + +export function createGroup(input: TabLayout, group: CreateTabGroupInput): TabLayout { + const layout = cloneForEdit(input); + if (layout.groups.length >= MAX_TAB_GROUPS) + throw new TabLayoutValidationError(`cannot exceed ${MAX_TAB_GROUPS} groups`); + const id = parseNonEmptyString(group.id, 'group.id'); + if (layout.groups.some((entry) => entry.id === id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`); + const index = boundedIndex(group.index ?? layout.groups.length, layout.groups.length, 'group'); + const groups = [...layout.groups]; + groups.splice(index, 0, { id, name: parseName(group.name, 'group.name'), refs: [] }); + return { ...layout, groups }; +} + +export function renameGroup(input: TabLayout, groupId: string, name: string): TabLayout { + const layout = cloneForEdit(input); + if (!layout.groups.some((group) => group.id === groupId)) + throw new TabLayoutValidationError(`unknown group: ${groupId}`); + return { + ...layout, + groups: layout.groups.map((group) => + group.id === groupId ? { ...group, name: parseName(name, 'group.name') } : group + ), + }; +} + +export function deleteGroup(input: TabLayout, groupId: string): TabLayout { + const layout = cloneForEdit(input); + const group = layout.groups.find((entry) => entry.id === groupId); + if (!group) throw new TabLayoutValidationError(`unknown group: ${groupId}`); + return { + ...layout, + groups: layout.groups.filter((entry) => entry.id !== groupId), + ungrouped: [...layout.ungrouped, ...group.refs.map((ref) => ({ ...ref }))], + }; +} + +export function reorderGroup(input: TabLayout, groupId: string, index: number): TabLayout { + const layout = cloneForEdit(input); + const from = layout.groups.findIndex((group) => group.id === groupId); + if (from < 0) throw new TabLayoutValidationError(`unknown group: ${groupId}`); + const groups = [...layout.groups]; + const [group] = groups.splice(from, 1); + groups.splice(boundedIndex(index, groups.length, 'group'), 0, group); + return { ...layout, groups }; +} + +function mapRef(input: TabLayout, target: TabRef, transform: (ref: TabRef) => TabRef): TabLayout { + const layout = cloneForEdit(input); + let found = false; + const apply = (ref: TabRef): TabRef => { + if (keyOf(ref) !== keyOf(target)) return ref; + found = true; + return transform(ref); + }; + const result = { + ...layout, + groups: layout.groups.map((group) => ({ ...group, refs: group.refs.map(apply) })), + ungrouped: layout.ungrouped.map(apply), + }; + if (!found) throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`); + return result; +} + +export function setManualPlacement(input: TabLayout, target: TabRef, manual: boolean): TabLayout { + if (!manual) { + throw new TabLayoutValidationError('manual placement can only be cleared through followParent'); + } + return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' })); +} + +export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout { + const normalized = normalizeTabLayout(input, metadata); + if (target.kind !== 'session') { + throw new TabLayoutValidationError('only a session ref can follow a parent'); + } + + const valid = validMetadata(metadata); + const targetMetadata = valid.find((item) => item.kind === 'session' && item.id === target.id); + if (!targetMetadata?.parentSessionId) { + throw new TabLayoutValidationError(`session has no owner-valid parent: ${target.id}`); + } + const parentMetadata = valid.find((item) => item.kind === 'session' && item.id === targetMetadata.parentSessionId); + if (!parentMetadata) { + throw new TabLayoutValidationError(`session parent is not owner-valid: ${targetMetadata.parentSessionId}`); + } + + const storedKeys = new Set(locations(normalized).map((item) => keyOf(item.ref))); + if (!storedKeys.has(keyOf(target))) { + throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`); + } + const parentRef: TabRef = { kind: 'session', id: targetMetadata.parentSessionId }; + if (!storedKeys.has(keyOf(parentRef))) { + throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`); + } + + const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id })); + return normalizeTabLayout(cleared, metadata); +} + +function descendantKeys(root: TabRef, layout: TabLayout, metadata: readonly TabRefMetadata[]): Set { + const valid = validMetadata(metadata); + const stored = new Map(locations(layout).map((item) => [keyOf(item.ref), item.ref])); + const children = new Map(); + for (const item of valid) { + if (item.kind !== 'session' || !item.parentSessionId) continue; + const child = stored.get(keyOf(item)); + if (!child || child.placement === 'manual' || !stored.has(keyOf({ kind: 'session', id: item.parentSessionId }))) + continue; + const siblings = children.get(item.parentSessionId) ?? []; + siblings.push(item.id); + children.set(item.parentSessionId, siblings); + } + const result = new Set(); + const add = (ref: TabRef): void => { + const key = keyOf(ref); + if (result.has(key)) return; + result.add(key); + if (ref.kind !== 'session') return; + for (const childId of children.get(ref.id) ?? []) add({ kind: 'session', id: childId }); + }; + add(root); + return result; +} + +export function moveRef( + input: TabLayout, + target: TabRef, + destination: TabMoveTarget, + metadata: readonly TabRefMetadata[] +): TabLayout { + let layout = normalizeTabLayout(input, metadata); + const targetKey = keyOf(target); + if (!locations(layout).some((item) => keyOf(item.ref) === targetKey)) { + throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`); + } + if (destination.groupId !== null && !layout.groups.some((group) => group.id === destination.groupId)) { + throw new TabLayoutValidationError(`unknown group: ${destination.groupId}`); + } + + const blockKeys = descendantKeys(target, layout, metadata); + const block = locations(layout) + .filter((item) => blockKeys.has(keyOf(item.ref))) + .map((item) => ({ ...item.ref })); + const metadataItem = validMetadata(metadata).find((item) => keyOf(item) === targetKey); + if (target.kind === 'session' && metadataItem?.parentSessionId) block[0] = { ...block[0], placement: 'manual' }; + + const remaining = new Map(); + for (const group of layout.groups) + remaining.set( + group.id, + group.refs.filter((ref) => !blockKeys.has(keyOf(ref))) + ); + remaining.set( + null, + layout.ungrouped.filter((ref) => !blockKeys.has(keyOf(ref))) + ); + const destinationRefs = remaining.get(destination.groupId)!; + const index = boundedIndex(destination.index, destinationRefs.length, 'destination'); + destinationRefs.splice(index, 0, ...block); + layout = withContainers(layout, remaining); + return normalizeTabLayout(layout, metadata); +} + +/** + * Remove explicitly deleted session parents and pin their direct inherited + * children at their current stored positions so a later reused ID cannot adopt them. + */ +export function materializeOrphans( + input: TabLayout, + removedParentIds: readonly string[], + metadata: readonly TabRefMetadata[] +): TabLayout { + const layout = cloneForEdit(input); + const removed = new Set(removedParentIds); + const directChildren = new Set( + validMetadata(metadata) + .filter((item) => item.kind === 'session' && item.parentSessionId && removed.has(item.parentSessionId)) + .map((item) => item.id) + ); + const transform = (refs: readonly TabRef[]): TabRef[] => + refs + .filter((ref) => ref.kind !== 'session' || !removed.has(ref.id)) + .map((ref) => + ref.kind === 'session' && directChildren.has(ref.id) && ref.placement !== 'manual' + ? { ...ref, placement: 'manual' } + : { ...ref } + ); + return { + ...layout, + groups: layout.groups.map((group) => ({ ...group, refs: transform(group.refs) })), + ungrouped: transform(layout.ungrouped), + }; +} + +/** Session-only compatibility order; collapse and webviews do not affect it. */ +export function flattenOwnerSessionOrder(input: TabLayout): string[] { + return locations(validateTabLayout(input)) + .map((item) => item.ref) + .filter((ref): ref is TabRef & { kind: 'session' } => ref.kind === 'session') + .map((ref) => ref.id); +} + +/** Locally renderable order used by tab painting and Alt-number consumers. */ +export function flattenVisibleRefs(input: TabLayout, options: VisibleTabProjectionOptions): TabRef[] { + const layout = validateTabLayout(input); + const collapsed = options.collapsedGroupIds ?? new Set(); + const renderable = (ref: TabRef): boolean => + ref.kind === 'session' ? options.liveSessionIds.has(ref.id) : options.openWebviewIds.has(ref.id); + const highlightedKey = options.highlighted ? keyOf(options.highlighted) : undefined; + const result: TabRef[] = []; + for (const group of layout.groups) { + for (const ref of group.refs) { + if (!renderable(ref)) continue; + if (collapsed.has(group.id) && keyOf(ref) !== highlightedKey) continue; + result.push({ ...ref }); + } + } + for (const ref of layout.ungrouped) if (renderable(ref)) result.push({ ...ref }); + return result; +} diff --git a/src/types/app-state.ts b/src/types/app-state.ts index 4d16ddcd..4382573a 100644 --- a/src/types/app-state.ts +++ b/src/types/app-state.ts @@ -24,6 +24,7 @@ import type { TaskState } from './task.js'; import type { RalphLoopState } from './ralph.js'; import type { RespawnConfig } from './respawn.js'; import type { CronJob, CronJobRun } from './cron.js'; +import type { TabLayout } from '../tab-layout.js'; // ========== Global Stats Types ========== @@ -118,6 +119,8 @@ export interface AppState { cronJobRuns?: Record; /** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */ sessionOrder?: string[]; + /** Owner-scoped authoritative grouped tab layouts. */ + tabLayouts?: Record; } // ========== Default Configuration ========== diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index 18cbe603..c75b8c0d 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -14,3 +14,4 @@ export type { InfraPort, ScheduledRun } from './infra-port.js'; export type { AuthPort } from './auth-port.js'; export type { OrchestratorPort } from './orchestrator-port.js'; export type { CronPort } from './cron-port.js'; +export type { TabLayoutPort } from './tab-layout-port.js'; diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index 4fe3b96d..61e02be3 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -7,7 +7,7 @@ import type { Session } from '../../session.js'; export interface SessionPort { readonly sessions: ReadonlyMap; - addSession(session: Session): void; + addSession(session: Session): Promise; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise; setupSessionListeners(session: Session): Promise; persistSessionState(session: Session): void; diff --git a/src/web/ports/tab-layout-port.ts b/src/web/ports/tab-layout-port.ts new file mode 100644 index 00000000..ad42da88 --- /dev/null +++ b/src/web/ports/tab-layout-port.ts @@ -0,0 +1,8 @@ +/** @fileoverview Owner-scoped tab-layout capabilities exposed to route modules. */ +import type { TabLayoutService } from '../../tab-layout-service.js'; + +export type { LegacyOrderActor, LegacyOrderPutResult, SessionOrderProjectionChange } from '../../tab-layout-service.js'; + +export interface TabLayoutPort { + readonly tabLayouts: TabLayoutService; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index f32bddf6..df047e7f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -919,6 +919,8 @@ class CodemanApp { // Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder) // and then applies the sidebar variant on top — do not call both. this.applySessionListLayout(); + this.applyTabOrientation(); + this.initTabRailResize?.(); this.applyMonitorVisibility(); this.applyLineageLineSettings?.(); this._installLineageStripScrollListener?.(); @@ -3758,6 +3760,18 @@ class CodemanApp { return layout === 'sidebar' || layout === 'sidebar-rich' ? layout : 'header'; } + resolveSessionSidebarFontSize(value) { + const size = Number(value); + return Number.isInteger(size) && size >= 11 && size <= 18 ? size : 14; + } + + applySessionSidebarFontSize(settings = null) { + const resolvedSettings = settings ?? this.loadAppSettingsFromStorage(); + const size = this.resolveSessionSidebarFontSize(resolvedSettings?.sessionSidebarFontSize); + document.documentElement.style.setProperty('--session-sidebar-name-font-size', `${size}px`); + return size; + } + /** * Reads the APPLIED layout off , not the settings blob: this is called * per dragover event and per tab in render loops, and getSessionListLayout() @@ -3769,6 +3783,15 @@ class CodemanApp { return document.documentElement.dataset.sessionList === 'sidebar'; } + _tabOrientation() { + return document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal'; + } + + shouldInlineSessionActions() { + if (this.isSessionSidebarActive()) return !this.isSessionSidebarCollapsed(); + return this._tabOrientation() === 'vertical' && !document.documentElement.classList.contains('tab-rail-compact'); + } + /** * True when the sidebar is showing the DETAILED rows: the home screen's * per-session line ("created 3d ago · working 12m") plus a status pill. @@ -3861,17 +3884,22 @@ class CodemanApp { */ applySessionListLayout() { const mode = this.getSessionListLayout(); + this.applySessionSidebarFontSize(); // 'sidebar' and 'sidebar-rich' are the same column; only row detail differs. const sidebar = mode === 'sidebar' || mode === 'sidebar-rich'; const collapsed = this.isSessionSidebarCollapsed(); const prevMode = document.documentElement.dataset.sessionList; const prevDetail = document.documentElement.dataset.sidebarDetail; + const prevCollapsed = document.documentElement.dataset.sidebar; const tabsEl = document.getElementById('sessionTabs'); const headerHost = document.getElementById('sessionTabsHost'); const sidebarList = document.getElementById('sessionSidebarList'); if (!tabsEl || !headerHost || !sidebarList) return; - const host = sidebar ? sidebarList : headerHost; + const rail = document.getElementById('tabRail'); + const railOwnsTabs = + !sidebar && document.documentElement.getAttribute('data-tab-orientation') === 'vertical'; + const host = sidebar ? sidebarList : railOwnsTabs && rail ? rail : headerHost; if (tabsEl.parentElement !== host) host.appendChild(tabsEl); document.documentElement.dataset.sessionList = sidebar ? 'sidebar' : 'header'; @@ -3880,7 +3908,7 @@ class CodemanApp { // would let the sidebar CSS style a strip that has nothing to style. document.documentElement.dataset.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple'; document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded'; - tabsEl.setAttribute('aria-orientation', sidebar ? 'vertical' : 'horizontal'); + tabsEl.setAttribute('aria-orientation', host === headerHost ? 'horizontal' : 'vertical'); const btn = document.getElementById('sidebarToggleBtn'); if (btn) { @@ -3933,7 +3961,8 @@ class CodemanApp { const layoutChanged = prevMode !== document.documentElement.dataset.sessionList || prevDetail !== document.documentElement.dataset.sidebarDetail; - if (layoutChanged && prevTall === this._tallTabsEnabled) { + const collapseChanged = prevCollapsed !== document.documentElement.dataset.sidebar; + if ((layoutChanged || collapseChanged) && prevTall === this._tallTabsEnabled) { this._fullRenderSessionTabs(); } // tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode() @@ -4476,9 +4505,17 @@ class CodemanApp { const nameEl = tab.querySelector('.tab-name'); if (nameEl) { const _p = parseSessionPrefix(name); - const _label = _p && _p.suffix ? _p.suffix : name; - if (nameEl.textContent !== _label) { - nameEl.textContent = _label; + if (nameEl.dataset.fullName !== name) { + nameEl.replaceChildren(); + if (_p && _p.suffix) { + const prefix = document.createElement('span'); + prefix.className = 'tab-name-prefix'; + prefix.textContent = `${_p.prefix}: `; + nameEl.append(prefix, document.createTextNode(_p.suffix)); + } else { + nameEl.textContent = name; + } + nameEl.dataset.fullName = name; tab.title = _p && _p.suffix ? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix) : (session.workingDir || ''); @@ -4529,9 +4566,11 @@ class CodemanApp { // Need to add badge - insert before the action-icon overlay so the // badge stays a direct child of the tab (outside .tab-actions) const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents); - const actionsEl = tab.querySelector('.tab-actions'); + const actionsEl = tab.querySelector(':scope > .tab-actions'); if (actionsEl) { actionsEl.insertAdjacentHTML('beforebegin', badgeHtml); + } else { + tab.insertAdjacentHTML('beforeend', badgeHtml); } } else if (minimizedCount === 0 && subagentBadgeEl) { // Count went to 0 - remove badge @@ -4589,6 +4628,17 @@ class CodemanApp { const defaults = this.getDefaultSettings(); const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false; + const orientation = window.CodemanTabOverflow?.resolveTabOrientation + ? window.CodemanTabOverflow.resolveTabOrientation({ + deviceType, + setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal', + }) + : 'horizontal'; + if (orientation === 'vertical') { + container.classList.remove('tabs-auto-wrap'); + return; + } + if (manualTwoRows || deviceType !== 'desktop') { container.classList.remove('tabs-auto-wrap'); return; @@ -4629,6 +4679,7 @@ class CodemanApp { } _fullRenderSessionTabs() { + this.closeTabRailActionMenu?.(); if (this._inlineRenameActive) return; const container = this.$('sessionTabs'); @@ -4706,7 +4757,9 @@ class CodemanApp { // JUST the description on the tab; the generated w- id moves to the // tooltip and stays visible in the session settings modal. const parsedName = parseSessionPrefix(name); - const tabLabel = parsedName && parsedName.suffix ? parsedName.suffix : name; + const tabLabel = parsedName && parsedName.suffix + ? `${escapeHtml(parsedName.prefix)}: ${escapeHtml(parsedName.suffix)}` + : escapeHtml(name); const tabTooltip = parsedName && parsedName.suffix ? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix) : (session.workingDir || ''); @@ -4721,6 +4774,9 @@ class CodemanApp { ? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"` : ''; + const inlineSessionActions = this.shouldInlineSessionActions(); + const tabActionsHtml = `⚙⧉×`; + parts.push(``); _tabIdx++; } @@ -6027,6 +6084,7 @@ class CodemanApp { // Shared cleanup for all session data — called from both closeSession() and session:deleted handler _cleanupSessionData(sessionId) { + this.closeTabRailActionMenu?.(); // If the deleted session is currently being renamed, abort the rename // so the inline doesn't ghost as a stale tab on screen. if (this._activeRename?.sessionId === sessionId) { diff --git a/src/web/public/constants.js b/src/web/public/constants.js index e672be19..8fcc4c4b 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -10,7 +10,7 @@ * @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority) * @globals {function} getEventCoords - Unified mouse/touch coordinate extractor * @globals {function} escapeHtml - XSS-safe HTML escaping - * @globals {object} SSE_EVENTS - Centralized SSE event type constants (120 event types; must match backend src/web/sse-events.ts) + * @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts) * @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets * * @dependency None (first in load order) @@ -156,6 +156,43 @@ function shouldAutoWrapTabs(input) { return scrollWidth > clientWidth + 1; } +function resolveTabOrientation(input) { + if (!input || input.setting !== 'vertical') return 'horizontal'; + if (input.deviceType === 'mobile') return 'horizontal'; + return 'vertical'; +} + +const TAB_RAIL_MIN_WIDTH = 208; +const TAB_RAIL_DEFAULT_WIDTH = 256; +const TAB_RAIL_MAX_WIDTH = 360; + +function resolveTabRailWidth(input = {}) { + const viewportWidth = Number(input.viewportWidth); + const mainWidth = Number(input.mainWidth); + const minTerminalWidth = Number(input.minTerminalWidth); + const limits = [TAB_RAIL_MAX_WIDTH]; + if (Number.isFinite(viewportWidth) && viewportWidth > 0) limits.push(Math.floor(viewportWidth * 0.4)); + if (Number.isFinite(mainWidth) && mainWidth > 0 && Number.isFinite(minTerminalWidth) && minTerminalWidth > 0) { + limits.push(Math.floor(mainWidth - minTerminalWidth)); + } + const effectiveMax = Math.max(TAB_RAIL_MIN_WIDTH, Math.min(...limits)); + const requested = Number(input.width); + const width = Number.isFinite(requested) ? requested : TAB_RAIL_DEFAULT_WIDTH; + return Math.round(Math.min(effectiveMax, Math.max(TAB_RAIL_MIN_WIDTH, width))); +} + +function resolveTabRailKeyboardWidth(input = {}) { + let width; + if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH; + else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH; + else if (input.key === 'Enter') width = TAB_RAIL_DEFAULT_WIDTH; + else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') { + const direction = input.key === 'ArrowLeft' ? -1 : 1; + width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8); + } else return null; + return resolveTabRailWidth({ ...input, width }); +} + // Sliver of the neighbouring tab left visible when the strip scrolls a tab into // view. Landing a tab flush against the edge reads as "this is the last one"; // the gap is what tells the user there is more strip to swipe to. @@ -243,6 +280,9 @@ const LINEAGE_DIP_MAX_PX = 64; // apart bled into one thick band instead of reading as three separate lines. const LINEAGE_SIBLING_STEP_PX = 8; const LINEAGE_STRIP_TOLERANCE_PX = 4; +const LINEAGE_VERTICAL_TRACK_INSET_PX = 6; +const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3; +const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4; // Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled // (session-lineage.js). Every arc leaving one tab shares its colour however many // workers it spawns; a child that spawns in turn gets its own for the arcs below it. @@ -265,21 +305,44 @@ function computeLineagePath(input) { const ch = Number(child.height) || 0; if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null; - const px = Number(parent.left) + pw / 2; - const cx = Number(child.left) + cw / 2; - if (!Number.isFinite(px) || !Number.isFinite(cx)) return null; - + const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal'; const strip = input?.strip; + const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0)); + const pLeft = Number(parent.left); + const cLeft = Number(child.left); + const pTop = Number(parent.top); + const cTop = Number(child.top); + if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null; + + if (orientation === 'vertical') { + const py = pTop + ph / 2; + const cy = cTop + ch / 2; + if (strip && Number(strip.height) > 0) { + const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX; + const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX; + if (py < min || py > max || cy < min || cy > max) return null; + } + + const stripLeft = + strip && Number.isFinite(Number(strip.left)) + ? Number(strip.left) + : Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2; + const requestedTrack = + stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX; + const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX); + const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`; + return { d, endX: cLeft, endY: cy, sameRow: false }; + } + + const px = pLeft + pw / 2; + const cx = cLeft + cw / 2; if (strip && Number(strip.width) > 0) { const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX; const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX; if (px < min || px > max || cx < min || cx > max) return null; } - const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0)); - const pTop = Number(parent.top); const pBottom = pTop + ph; - const cTop = Number(child.top); const cBottom = cTop + ch; const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2; @@ -617,9 +680,17 @@ if (typeof window !== 'undefined') { window.shouldSkipWebGL = shouldSkipWebGL; window.CodemanTabOverflow = { shouldAutoWrapTabs, + resolveTabOrientation, computeTabScrollLeft, TAB_SCROLL_REVEAL_PX, }; + window.CodemanTabRail = { + DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH, + MIN_WIDTH: TAB_RAIL_MIN_WIDTH, + MAX_WIDTH: TAB_RAIL_MAX_WIDTH, + resolveWidth: resolveTabRailWidth, + resolveKeyboardWidth: resolveTabRailKeyboardWidth, + }; window.CodemanWsReconnect = { plan: planWsReconnect, }; @@ -628,6 +699,8 @@ if (typeof window !== 'undefined') { DIP_MIN_PX: LINEAGE_DIP_MIN_PX, DIP_MAX_PX: LINEAGE_DIP_MAX_PX, SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX, + VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX, + VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX, COLORS: LINEAGE_COLORS, }; window.CodemanConnectionLoss = { @@ -969,6 +1042,7 @@ const SSE_EVENTS = { // Web tabs (dashboard URLs) WEBVIEW_CHANGED: 'webview:changed', + TAB_LAYOUT_CHANGED: 'tab:layoutChanged', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index e23bfb45..840cf209 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -88,12 +88,14 @@ Object.assign(CodemanApp.prototype, { /** * Width-driven, like every other layout decision in the app. Explicitly yields - * to the phone overview: that surface already lists the same sessions, and two - * lists of the same thing on one screen is worse than none. + * to the phone overview and persistent vertical tab rail: those surfaces already + * list the same sessions, and two lists of the same thing on one screen is worse + * than none. */ shouldShowHomeSessions() { if (this.isSoloWindow) return false; if (this.shouldUseMobileOverview?.()) return false; + if (document.documentElement.getAttribute('data-tab-orientation') === 'vertical') return false; // The sidebar layout already docks the full session list flush left at full // height — the rail would render the same list right next to it (and z-wise // UNDER it: sidebar 11, welcome overlay 10, rail inside the overlay). diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 87a67728..a948dc58 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -233,6 +233,8 @@ 'Redraw Terminal Button': '重绘终端按钮', 'Tab Bar': '标签栏', 'Session List Layout': '会话列表布局', + 'Session Name Font Size': '会话名称字体大小', + 'Adjust only session names in the vertical sidebar.': '仅调整垂直侧边栏中的会话名称。', 'Header tab strip': '顶栏标签条', 'Left sidebar': '左侧边栏', 'Left sidebar simple': '左侧边栏(简洁)', diff --git a/src/web/public/index.html b/src/web/public/index.html index a3f7a3ee..a5a4250d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -65,7 +65,7 @@ app.js, NOT the handheld storage-key test `m`. Use a different predicate here and boot will contradict this value, animating the drawer open by itself on every load between 768 and 1023px. --> - + + + ${sessionOptionsMarkup()}`); + await page.evaluate(() => { + document.getElementById('sessionOptionsModal')!.classList.add('active'); + document + .querySelectorAll('#sessionOptionsModal .set-section') + .forEach((section) => section.classList.add('hidden')); + document.getElementById('context-tab')!.classList.remove('hidden'); + }); + } + + async function metrics() { + return page.evaluate(() => { + const modal = document.querySelector('#sessionOptionsModal .modal-content')!; + const doc = document.getElementById('sessionOptionsDoc')!; + const panel = document.getElementById('context-tab')!; + const header = panel.querySelector(':scope > .set-section-head')!; + const blurb = panel.querySelector(':scope > .set-section-blurb')!; + return { + modalWidth: modal.getBoundingClientRect().width, + docFits: doc.scrollWidth === doc.clientWidth, + panelFits: panel.scrollWidth === panel.clientWidth, + tracks: getComputedStyle(panel).gridTemplateColumns.split(' '), + headerGridColumn: getComputedStyle(header).gridColumn, + blurbGridColumn: getComputedStyle(blurb).gridColumn, + }; + }); + } + + it('uses one fitting column at the tablet-width desktop viewport', async () => { + await renderAt(974); + expect(await metrics()).toMatchObject({ docFits: true, panelFits: true, tracks: [expect.any(String)] }); + }); + + it('uses two fitting columns with a full-width introduction on wide screens', async () => { + await renderAt(1440); + const layout = await metrics(); + expect(layout.modalWidth).toBeGreaterThan(1000); + expect(layout.tracks).toHaveLength(2); + expect(layout.headerGridColumn).toBe('1 / -1'); + expect(layout.blurbGridColumn).toBe('1 / -1'); + expect(layout.docFits).toBe(true); + expect(layout.panelFits).toBe(true); + }); +}); diff --git a/test/session-options-structure.test.ts b/test/session-options-structure.test.ts index f2cf715b..4df59cdf 100644 --- a/test/session-options-structure.test.ts +++ b/test/session-options-structure.test.ts @@ -16,6 +16,7 @@ import { resolve } from 'node:path'; const publicDir = resolve(import.meta.dirname, '../src/web/public'); const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8'); +const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8'); /** The Session Options markup, so assertions can't be satisfied elsewhere. */ function optionsModal(): string { @@ -94,4 +95,12 @@ describe('Session Options modal structure', () => { expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {'); expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {'); }); + + it('uses document-safe context columns and widens only at the desktop breakpoint', () => { + expect(styles).toMatch(/#sessionOptionsModal #context-tab\s*\{[^}]*minmax\(0, 1fr\)/s); + expect(styles).toMatch( + /@media \(min-width: 1200px\)[\s\S]*#sessionOptionsModal #context-tab[^}]*repeat\(2, minmax\(0, 1fr\)\)/ + ); + expect(styles).not.toMatch(/@media \(min-width: 680px\)[\s\S]{0,1200}#sessionOptionsModal #context-tab/); + }); }); diff --git a/test/session-order-sse.test.ts b/test/session-order-sse.test.ts new file mode 100644 index 00000000..f3ff0db2 --- /dev/null +++ b/test/session-order-sse.test.ts @@ -0,0 +1,244 @@ +/** @fileoverview Trusted recipient selection for legacy session-order SSE compatibility. */ +import type { FastifyReply } from 'fastify'; +import { describe, expect, it } from 'vitest'; +import type { SessionOrderProjectionChange } from '../src/tab-layout-service.js'; +import { CleanupManager } from '../src/utils/index.js'; +import { sessionOrderPayloadFor } from '../src/web/session-order-sse.js'; +import { SseStreamManager } from '../src/web/sse-stream-manager.js'; + +function changeWith( + changedOwnerOrders: Record, + globalOrder: string[], + globalChanged: boolean +): SessionOrderProjectionChange { + return { changedOwnerOrders, globalOrder, globalChanged }; +} + +function client() { + const writes: string[] = []; + return { + writes, + reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply, + }; +} + +function backpressuredClient(options: { throwAfterBackpressure?: boolean } = {}) { + const writes: string[] = []; + let firstWrite = true; + let onDrain: (() => void) | undefined; + const raw = { + write(chunk: string) { + if (!firstWrite && options.throwAfterBackpressure) throw new Error('client disconnected'); + writes.push(chunk); + if (firstWrite) { + firstWrite = false; + return false; + } + return true; + }, + once(event: string, callback: () => void) { + if (event === 'drain') onDrain = callback; + return raw; + }, + }; + return { + writes, + reply: { raw } as unknown as FastifyReply, + drain: () => { + const callback = onDrain; + onDrain = undefined; + callback?.(); + }, + }; +} + +describe('legacy session-order SSE routing', () => { + it('selects an owner slice for the matching regular user and nothing for another user', () => { + const change = changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true); + expect(sessionOrderPayloadFor({ username: 'alice', role: 'user' }, change)).toEqual({ order: ['a2', 'a1'] }); + expect(sessionOrderPayloadFor({ username: 'bob', role: 'user' }, change)).toBeUndefined(); + }); + + it('does not treat inherited object properties as changed owner slices', () => { + const change = changeWith({ alice: ['a1'] }, ['a1'], true); + expect(sessionOrderPayloadFor({ username: 'constructor', role: 'user' }, change)).toBeUndefined(); + }); + + it('selects the global projection for admins even when only interleaving changed', () => { + const change = changeWith({}, ['b1', 'a1'], true); + expect(sessionOrderPayloadFor({ username: 'admin', role: 'admin' }, change)).toEqual({ order: ['b1', 'a1'] }); + }); + + it('uses the global projection for identity-less single-user clients', () => { + const change = changeWith({ '@single': ['s2', 's1'] }, ['s2', 's1'], true); + expect(sessionOrderPayloadFor(undefined, change)).toEqual({ order: ['s2', 's1'] }); + }); + + it('delivers owner slices to every matching device, the global order to admins, and nothing to other users', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alicePhone = client(); + const aliceDesktop = client(); + const bob = client(); + const admin = client(); + manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true)); + + const ownerFrame = 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n'; + expect(alicePhone.writes).toEqual([ownerFrame]); + expect(aliceDesktop.writes).toEqual([ownerFrame]); + expect(bob.writes).toEqual([]); + expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n']); + cleanup.dispose(); + }); + + it('delivers a global-only interleaving change to admins only', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = client(); + const admin = client(); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcastSessionOrder(changeWith({}, ['b1', 'a1'], true)); + + expect(alice.writes).toEqual([]); + expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["b1","a1"]}\n\n']); + cleanup.dispose(); + }); + + it.each([ + { + label: 'order-only repair', + change: changeWith({ alice: ['a', 'b'] }, ['a', 'bob-1', 'b'], true), + ownerOrder: ['a', 'b'], + globalOrder: ['a', 'bob-1', 'b'], + }, + { + label: 'legacy-only deletion', + change: changeWith({ alice: [] }, ['bob-1'], true), + ownerOrder: [], + globalOrder: ['bob-1'], + }, + ])('delivers an $label correction to same-owner devices and admins only', ({ change, ownerOrder, globalOrder }) => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alicePhone = client(); + const aliceDesktop = client(); + const bob = client(); + const admin = client(); + manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcastSessionOrder(change); + + const ownerFrame = `event: session:orderChanged\ndata: ${JSON.stringify({ order: ownerOrder })}\n\n`; + expect(alicePhone.writes).toEqual([ownerFrame]); + expect(aliceDesktop.writes).toEqual([ownerFrame]); + expect(bob.writes).toEqual([]); + expect(admin.writes).toEqual([`event: session:orderChanged\ndata: ${JSON.stringify({ order: globalOrder })}\n\n`]); + cleanup.dispose(); + }); + + it('skips an inherited-key username without starving later matching and admin recipients', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const constructorUser = client(); + const alice = client(); + const admin = client(); + manager.addClient(constructorUser.reply, null, false, undefined, { username: 'constructor', role: 'user' }); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + expect(() => manager.broadcastSessionOrder(changeWith({ alice: ['a1'] }, ['a1'], true))).not.toThrow(); + + expect(constructorUser.writes).toEqual([]); + expect(alice.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']); + expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']); + cleanup.dispose(); + }); + + it('coalesces the latest filtered owner order while backpressured and flushes it on drain', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const blockedAlice = backpressuredClient(); + const liveAlice = client(); + const bob = client(); + const admin = client(); + manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + for (const writes of [blockedAlice.writes, liveAlice.writes, bob.writes, admin.writes]) writes.length = 0; + + manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true)); + manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['b1', 'a1', 'a2'], true)); + + expect(blockedAlice.writes).toEqual([]); + expect(liveAlice.writes).toEqual([ + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + 'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n', + ]); + expect(bob.writes).toEqual([]); + expect(admin.writes).toEqual([ + 'event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n', + 'event: session:orderChanged\ndata: {"order":["b1","a1","a2"]}\n\n', + ]); + + blockedAlice.drain(); + + expect(blockedAlice.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('clears a queued owner order when a backpressured client disconnects', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = backpressuredClient(); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + alice.writes.length = 0; + manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true)); + + manager.removeClient(alice.reply); + alice.drain(); + + expect(alice.writes).toEqual([]); + cleanup.dispose(); + }); + + it('isolates a drain write failure from later healthy recipients', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const brokenAlice = backpressuredClient({ throwAfterBackpressure: true }); + const liveAlice = client(); + const admin = client(); + manager.addClient(brokenAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + brokenAlice.writes.length = 0; + liveAlice.writes.length = 0; + admin.writes.length = 0; + manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true)); + + expect(() => brokenAlice.drain()).not.toThrow(); + expect(manager.clientCount).toBe(2); + + manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['a1', 'a2'], true)); + expect(liveAlice.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n'); + expect(admin.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n'); + cleanup.dispose(); + }); +}); diff --git a/test/session-sidebar-ux.browser.test.ts b/test/session-sidebar-ux.browser.test.ts new file mode 100644 index 00000000..2ac3f0ff --- /dev/null +++ b/test/session-sidebar-ux.browser.test.ts @@ -0,0 +1,106 @@ +/** Real Chromium visibility and click coverage for nested vertical session actions. */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const styles = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8'); +const controller = readFileSync(resolve(import.meta.dirname, '../src/web/public/tab-rail-resize.js'), 'utf8'); + +function fixture(surface: 'sidebar' | 'rail', active = false) { + const root = + surface === 'sidebar' + ? 'data-session-list="sidebar" data-sidebar="expanded" data-tab-orientation="horizontal"' + : 'data-session-list="header" data-sidebar="expanded" data-tab-orientation="vertical"'; + const hostClass = surface === 'sidebar' ? 'session-sidebar' : 'tab-rail'; + return ` +
+
+ session + + +
+
+ `; +} + +async function installActionMenuController(page: Page) { + await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' }); + await page.addScriptTag({ content: controller }); + await page.evaluate(() => { + const app = new (window as any).CodemanApp(); + app.loadAppSettingsFromStorage = () => ({ showTabDetachButton: false }); + app.openSessionOptions = () => undefined; + app.requestCloseSession = () => undefined; + document + .querySelector('.tab-more') + ?.addEventListener('click', (event) => app.openTabRailActionMenu(event, 'session-1')); + (window as any).app = app; + }); +} + +async function actionState(page: Page) { + return page.locator('.tab-more').evaluate((node) => { + const style = getComputedStyle(node); + return { visibility: style.visibility, pointerEvents: style.pointerEvents }; + }); +} + +describe('expanded vertical session actions in Chromium', () => { + let browser: Browser; + let page: Page; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + }); + + afterAll(async () => { + await browser.close(); + }); + + for (const surface of ['sidebar', 'rail'] as const) { + it(`${surface} hides inactive actions, reveals them contextually, and opens the menu`, async () => { + await page.setContent(fixture(surface)); + await installActionMenuController(page); + await page.mouse.move(1200, 760); + const tab = page.locator('.session-tab'); + expect(await actionState(page)).toEqual({ visibility: 'hidden', pointerEvents: 'none' }); + await tab.hover(); + expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' }); + await page.locator('.tab-more').click(); + expect(await page.locator('.tab-rail-action-menu').count()).toBe(1); + + await page.setContent(fixture(surface)); + await installActionMenuController(page); + await page.locator('.session-tab').focus(); + expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' }); + + await page.setContent(fixture(surface, true)); + await installActionMenuController(page); + expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' }); + }); + } + + it('keeps nested actions reachable for a coarse pointer', async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, hasTouch: true }); + try { + const touchPage = await context.newPage(); + for (const surface of ['sidebar', 'rail'] as const) { + await touchPage.setContent(fixture(surface)); + expect(await actionState(touchPage)).toEqual({ visibility: 'visible', pointerEvents: 'auto' }); + } + } finally { + await context.close(); + } + }); + + it('closes the real controller menu when viewport geometry changes', async () => { + await page.setContent(fixture('rail', true)); + await installActionMenuController(page); + await page.locator('.tab-more').click(); + expect(await page.locator('.tab-rail-action-menu').count()).toBe(1); + await page.evaluate(() => window.dispatchEvent(new Event('resize'))); + expect(await page.locator('.tab-rail-action-menu').count()).toBe(0); + }); +}); diff --git a/test/session-sidebar-ux.test.ts b/test/session-sidebar-ux.test.ts new file mode 100644 index 00000000..3bca17cf --- /dev/null +++ b/test/session-sidebar-ux.test.ts @@ -0,0 +1,73 @@ +/** Structural and schema coverage for vertical session navigation density and actions. */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const app = readFileSync(resolve(publicDir, 'app.js'), 'utf8'); +const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); +const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8'); +const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8'); +const i18n = readFileSync(resolve(publicDir, 'i18n.js'), 'utf8'); +const railController = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8'); + +describe('vertical session navigation UX contract', () => { + it('accepts only integer session-name sizes from 11 through 18 pixels', () => { + for (const value of [11, 14, 18]) { + expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(true); + } + for (const value of [10, 19, 14.5, '14']) { + expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(false); + } + }); + + it('renders one existing action cluster through a shared placement resolver', () => { + expect(app).toContain('shouldInlineSessionActions()'); + expect(app).toContain('const inlineSessionActions = this.shouldInlineSessionActions();'); + expect(app).toContain('const tabActionsHtml ='); + expect(app).toContain("${inlineSessionActions ? tabActionsHtml : ''}"); + expect(app).toContain("${inlineSessionActions ? '' : tabActionsHtml}"); + expect(app.match(/class="tab-actions"/g)).toHaveLength(1); + expect(app).toContain("tab.querySelector(':scope > .tab-actions')"); + }); + + it('limits inline actions to expanded sidebar and expanded non-compact rail', () => { + expect(app).toMatch(/isSessionSidebarActive\(\)[\s\S]{0,100}!this\.isSessionSidebarCollapsed\(\)/); + expect(app).toMatch(/_tabOrientation\(\) === 'vertical'[\s\S]{0,120}tab-rail-compact/); + expect(styles).toContain("html[data-session-list='sidebar'][data-sidebar='expanded']"); + expect(styles).toContain("html[data-tab-orientation='vertical']:not(.tab-rail-compact)"); + }); + + it('opens only the existing session actions from the overflow trigger', () => { + expect(railController).toContain('openTabRailActionMenu(event, sessionId)'); + expect(railController).toContain("label: 'Session options'"); + expect(railController).toContain("label: 'Open in a new window'"); + expect(railController).toContain("label: 'Close session'"); + expect(railController).not.toContain('Move to group'); + }); + + it('wires the name-only size through first paint, settings, defaults, and both vertical surfaces', () => { + expect(html).toMatch(/id="appSettingsSessionSidebarFontSize"[^>]*min="11"[^>]*max="18"[^>]*step="1"/); + expect(html).toContain('aria-labelledby="appSettingsSessionSidebarFontSizeLabel"'); + expect(html).toContain('--session-sidebar-name-font-size'); + expect(settingsUi).toContain('sessionSidebarFontSize: this.resolveSessionSidebarFontSize('); + expect(settingsUi).toContain('sessionSidebarFontSize: 14,'); + expect(settingsUi).toContain("'sessionSidebarFontSize'"); + expect(app).toContain('resolveSessionSidebarFontSize(value)'); + expect(app).toContain('applySessionSidebarFontSize(settings = null)'); + expect(styles).toMatch( + /\.session-sidebar \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s + ); + expect(styles).toMatch( + /\.tab-rail \.session-tab \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s + ); + }); + + it('labels and translates the name-only scope', () => { + expect(html).toContain('Session Name Font Size'); + expect(html).toContain('Adjust only session names in the vertical sidebar.'); + expect(i18n).toContain("'Session Name Font Size':"); + expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':"); + }); +}); diff --git a/test/state-store-tab-layout.test.ts b/test/state-store-tab-layout.test.ts new file mode 100644 index 00000000..2b5b5562 --- /dev/null +++ b/test/state-store-tab-layout.test.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview Atomic StateStore publication tests for tab layouts and their legacy session-order projection. + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { StateStore } from '../src/state-store.js'; +import { recomposeGlobalSessionOrder } from '../src/tab-layout-legacy-order.js'; +import type { TabLayout } from '../src/tab-layout.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function createStore(seed: { sessionOrder?: string[]; tabLayouts?: Record } = {}): StateStore { + const dir = mkdtempSync(join(tmpdir(), 'codeman-state-tab-layout-')); + tempDirs.push(dir); + const file = join(dir, 'state.json'); + writeFileSync(file, JSON.stringify(seed)); + return new StateStore(file); +} + +const ownerLayout = (ids: readonly string[], version = 1): TabLayout => ({ + version, + groups: [], + ungrouped: ids.map((id) => ({ kind: 'session', id })), + updatedAt: `2026-08-23T12:00:0${version}.000Z`, +}); + +describe('StateStore.commitTabLayoutProjection', () => { + it('returns a defensive snapshot of all stored owner layouts for trusted owner discovery', () => { + const alice = ownerLayout(['a1'], 1); + const store = createStore({ tabLayouts: { alice, constructor: ownerLayout(['c1'], 2) } }); + + const layouts = store.getTabLayouts(); + + expect(Object.keys(layouts)).toEqual(['alice', 'constructor']); + layouts.alice.ungrouped[0].id = 'caller-mutated'; + expect(store.getTabLayout('alice')).toEqual(alice); + }); + + it('validates all layouts and publishes them with a latest-state projection using one save schedule', () => { + const originalAlice = ownerLayout(['a1'], 1); + const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: originalAlice } }); + const save = vi.spyOn(store, 'save').mockImplementation(() => undefined); + const alice = ownerLayout(['a2', 'a1'], 2); + const constructorOwner = ownerLayout(['constructor'], 1); + const projected = ['a2', 'a1', 'constructor', 'a2']; + + const result = store.commitTabLayoutProjection({ alice, constructor: constructorOwner }, (latest) => { + expect(latest).toEqual(['a1']); + expect(store.getSessionOrder()).toEqual(['a1']); + expect(store.getTabLayout('alice')).toEqual(originalAlice); + (latest as string[]).push('projector-local-mutation'); + return projected; + }); + + expect(store.getTabLayout('alice')).toEqual(alice); + expect(store.getTabLayout('constructor')).toEqual(constructorOwner); + expect(store.getSessionOrder()).toEqual(['a2', 'a1', 'constructor']); + expect(result).toEqual({ + layouts: { alice, constructor: constructorOwner }, + sessionOrder: ['a2', 'a1', 'constructor'], + }); + expect(save).toHaveBeenCalledTimes(1); + + alice.ungrouped[0].id = 'caller-mutated'; + constructorOwner.ungrouped[0].id = 'caller-mutated'; + projected[0] = 'caller-mutated'; + result.layouts.alice.ungrouped[0].id = 'return-mutated'; + result.layouts.constructor.ungrouped[0].id = 'return-mutated'; + result.sessionOrder[0] = 'return-mutated'; + + expect(store.getTabLayout('alice')?.ungrouped[0].id).toBe('a2'); + expect(store.getTabLayout('constructor')?.ungrouped[0].id).toBe('constructor'); + expect(store.getSessionOrder()).toEqual(['a2', 'a1', 'constructor']); + }); + + it('changes neither representation and does not project or save when any layout is invalid', () => { + const original = ownerLayout(['a1'], 1); + const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: original } }); + const save = vi.spyOn(store, 'save').mockImplementation(() => undefined); + const project = vi.fn(() => ['a2']); + const invalid = { ...ownerLayout(['b1'], 2), version: -1 }; + + expect(() => store.commitTabLayoutProjection({ bob: ownerLayout(['b1'], 2), alice: invalid }, project)).toThrow( + /version/ + ); + + expect(project).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + expect(store.getTabLayout('alice')).toEqual(original); + expect(store.getTabLayout('bob')).toBeNull(); + expect(store.getSessionOrder()).toEqual(['a1']); + }); + + it('changes neither representation and does not save when projection throws', () => { + const original = ownerLayout(['a1'], 1); + const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: original } }); + const save = vi.spyOn(store, 'save').mockImplementation(() => undefined); + + expect(() => + store.commitTabLayoutProjection({ alice: ownerLayout(['a2'], 2) }, () => { + throw new Error('projection rejected'); + }) + ).toThrow('projection rejected'); + + expect(save).not.toHaveBeenCalled(); + expect(store.getTabLayout('alice')).toEqual(original); + expect(store.getSessionOrder()).toEqual(['a1']); + }); + + it('derives sequential owner projections from the latest committed order', () => { + const store = createStore({ sessionOrder: ['a1', 'b1', 'a2', 'b2'] }); + const save = vi.spyOn(store, 'save').mockImplementation(() => undefined); + + store.commitTabLayoutProjection({ alice: ownerLayout(['a2', 'a1'], 2) }, (latest) => + recomposeGlobalSessionOrder(latest, [{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }]) + ); + store.commitTabLayoutProjection({ bob: ownerLayout(['b2', 'b1'], 2) }, (latest) => + recomposeGlobalSessionOrder(latest, [{ owner: 'bob', ownedIds: ['b1', 'b2'], order: ['b2', 'b1'] }]) + ); + + expect(store.getSessionOrder()).toEqual(['a2', 'b2', 'a1', 'b1']); + expect(store.getTabLayout('alice')).toEqual(ownerLayout(['a2', 'a1'], 2)); + expect(store.getTabLayout('bob')).toEqual(ownerLayout(['b2', 'b1'], 2)); + expect(save).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/state-store.test.ts b/test/state-store.test.ts index 56b522df..c351af3a 100644 --- a/test/state-store.test.ts +++ b/test/state-store.test.ts @@ -199,6 +199,20 @@ describe('StateStore', () => { expect(store.getSession('pinned-1')).not.toBeNull(); expect(store.getSession('plain-1')).toBeNull(); }); + + it('cleans only requested unpinned session ids', () => { + const store = new StateStore(testFilePath); + store.setSession('requested', createMockSessionState('requested')); + store.setSession('pinned', { ...createMockSessionState('pinned'), pinned: true, pinnedAt: Date.now() }); + store.setSession('unrequested', createMockSessionState('unrequested')); + + const result = store.cleanupSessionsByIds(new Set(['requested', 'pinned', 'missing'])); + + expect(result.cleaned.map((session) => session.id)).toEqual(['requested']); + expect(store.getSession('requested')).toBeNull(); + expect(store.getSession('pinned')).not.toBeNull(); + expect(store.getSession('unrequested')).not.toBeNull(); + }); }); describe('task operations', () => { diff --git a/test/tab-layout-legacy-order.test.ts b/test/tab-layout-legacy-order.test.ts new file mode 100644 index 00000000..f0fb5229 --- /dev/null +++ b/test/tab-layout-legacy-order.test.ts @@ -0,0 +1,180 @@ +/** + * @fileoverview Pure compatibility translation between legacy session order and owner tab layouts. + */ + +import { describe, expect, it } from 'vitest'; +import { + applyLegacySessionRank, + recomposeGlobalSessionOrder, + type OwnerOrderProjection, +} from '../src/tab-layout-legacy-order.js'; +import { normalizeTabLayout, validateTabLayout, type TabLayout, type TabRefMetadata } from '../src/tab-layout.js'; + +const session = (id: string, placement?: 'manual') => + placement ? { kind: 'session' as const, id, placement } : { kind: 'session' as const, id }; +const webview = (id: string) => ({ kind: 'webview' as const, id }); +const metadata = (id: string, overrides: Partial = {}): TabRefMetadata => ({ + kind: 'session', + id, + ownerValid: true, + visible: true, + order: 0, + ...overrides, +}); +const layout = (overrides: Partial = {}): TabLayout => ({ + version: 1, + groups: [], + ungrouped: [], + updatedAt: '2026-08-23T12:00:00.000Z', + ...overrides, +}); + +describe('applyLegacySessionRank', () => { + it('ranks only authoritative sessions while anchoring webviews and refs absent from metadata', () => { + const input = layout({ + ungrouped: [session('a'), webview('w1'), session('unknown'), session('b')], + }); + + expect(applyLegacySessionRank(input, ['b', 'a'], [metadata('a'), metadata('b')])).toEqual( + layout({ + ungrouped: [session('b'), webview('w1'), session('unknown'), session('a')], + }) + ); + }); + + it('ranks sessions only inside their current containers without changing groups or webview slots', () => { + const input = layout({ + groups: [ + { id: 'first', name: 'First', refs: [session('a'), webview('w1'), session('d')] }, + { id: 'second', name: 'Second', refs: [session('b'), webview('w2'), session('c')] }, + ], + ungrouped: [session('e'), webview('w3'), session('f')], + }); + const facts = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, order) => metadata(id, { order })); + + const result = applyLegacySessionRank(input, ['f', 'c', 'd', 'e', 'b', 'a'], facts); + + expect(result.groups).toEqual([ + { id: 'first', name: 'First', refs: [session('d'), webview('w1'), session('a')] }, + { id: 'second', name: 'Second', refs: [session('c'), webview('w2'), session('b')] }, + ]); + expect(result.ungrouped).toEqual([session('f'), webview('w3'), session('e')]); + }); + + it('materializes ranked children with represented owner-valid parents before normalization', () => { + const input = layout({ + groups: [ + { + id: 'family', + name: 'Family', + refs: [session('parent'), session('child'), session('other')], + }, + ], + }); + const facts = [ + metadata('parent', { order: 0 }), + metadata('child', { order: 1, parentSessionId: 'parent' }), + metadata('other', { order: 2 }), + ]; + + const result = applyLegacySessionRank(input, ['child', 'other', 'parent'], facts); + + expect(result.groups[0].refs).toEqual([session('child', 'manual'), session('other'), session('parent')]); + expect(normalizeTabLayout(result, facts)).toEqual(result); + }); + + it('keeps a ranked child manual in its group when normalization materializes its missing parent', () => { + const input = layout({ + groups: [{ id: 'child-group', name: 'Child', refs: [session('child')] }], + }); + const facts = [metadata('parent', { order: 0 }), metadata('child', { order: 1, parentSessionId: 'parent' })]; + + const result = applyLegacySessionRank(input, ['child', 'parent'], facts); + + expect(result.groups[0].refs).toEqual([session('child', 'manual')]); + expect(result.ungrouped).toEqual([session('parent')]); + expect(normalizeTabLayout(result, facts)).toEqual(result); + }); + + it('does not mutate inputs and returns a validated canonical deep clone', () => { + const input = layout({ + groups: [{ id: 'g', name: ' Work ', refs: [session('a'), webview('w')] }], + ungrouped: [session('b')], + }); + const requested = ['b', 'a']; + const facts = [metadata('a', { order: 0 }), metadata('b', { order: 1 })]; + const beforeInput = structuredClone(input); + const beforeRequested = [...requested]; + const beforeFacts = structuredClone(facts); + + const result = applyLegacySessionRank(input, requested, facts); + + expect(input).toEqual(beforeInput); + expect(requested).toEqual(beforeRequested); + expect(facts).toEqual(beforeFacts); + expect(result.groups[0].name).toBe('Work'); + expect(validateTabLayout(result)).toEqual(result); + expect(result).not.toBe(input); + expect(result.groups[0]).not.toBe(input.groups[0]); + expect(result.groups[0].refs[0]).not.toBe(input.groups[0].refs[0]); + }); +}); + +describe('recomposeGlobalSessionOrder', () => { + it('replaces only owner slots, preserves interleaving, appends new IDs once, and deduplicates', () => { + const current = ['a1', 'foreign', 'b1', 'a2', 'unmapped', 'a1']; + const projections: OwnerOrderProjection[] = [ + { + owner: 'alice', + ownedIds: ['a1', 'a2', 'a3', 'a3'], + order: ['a3', 'a2', 'a2', 'a1', 'foreign'], + }, + ]; + const beforeCurrent = [...current]; + const beforeProjections = structuredClone(projections); + + const result = recomposeGlobalSessionOrder(current, projections); + + expect(result).toEqual(['a3', 'foreign', 'b1', 'a2', 'unmapped', 'a1']); + expect(current).toEqual(beforeCurrent); + expect(projections).toEqual(beforeProjections); + }); + + it('starts from merged preferred order when preserving admin cross-owner intent', () => { + expect( + recomposeGlobalSessionOrder( + ['a1', 'b1', 'a2', 'server-only'], + [{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }], + ['b1', 'a1', 'a2'] + ) + ).toEqual(['b1', 'a2', 'a1', 'server-only']); + }); + + it('derives sequential owner projections from the latest order without a lost update', () => { + const initial = ['a1', 'b1', 'a2', 'b2']; + const afterAlice = recomposeGlobalSessionOrder(initial, [ + { owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }, + ]); + const afterBob = recomposeGlobalSessionOrder(afterAlice, [ + { owner: 'bob', ownedIds: ['b1', 'b2'], order: ['b2', 'b1'] }, + ]); + + expect(afterAlice).toEqual(['a2', 'b1', 'a1', 'b2']); + expect(afterBob).toEqual(['a2', 'b2', 'a1', 'b1']); + }); + + it('handles prototype-like owner names and IDs without corrupting membership', () => { + expect( + recomposeGlobalSessionOrder( + ['__proto__', 'foreign', 'constructor'], + [ + { + owner: 'constructor', + ownedIds: ['__proto__', 'constructor', 'toString'], + order: ['toString', 'constructor', '__proto__'], + }, + ] + ) + ).toEqual(['toString', 'foreign', 'constructor', '__proto__']); + }); +}); diff --git a/test/tab-layout-persistence.test.ts b/test/tab-layout-persistence.test.ts new file mode 100644 index 00000000..f070e9ba --- /dev/null +++ b/test/tab-layout-persistence.test.ts @@ -0,0 +1,264 @@ +/** + * @fileoverview Owner-scoped tab-layout persistence and legacy migration tests. + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { StateStore } from '../src/state-store.js'; +import { + SINGLE_USER_LAYOUT_OWNER, + normalizeOrMigrateOwnerTabLayout, + ownerLayoutKey, + type TabLayoutMigrationInput, +} from '../src/tab-layout-persistence.js'; +import { MAX_TAB_REFS, TabLayoutValidationError, type TabLayout } from '../src/tab-layout.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const baseInput = (overrides: Partial = {}): TabLayoutMigrationInput => ({ + owner: SINGLE_USER_LAYOUT_OWNER, + layouts: {}, + sessionOrder: [], + persistedSessions: [], + liveSessions: [], + webviews: [], + updatedAt: '2026-08-16T12:00:00.000Z', + ...overrides, +}); + +describe('owner layout key', () => { + it('uses the reserved single-user key or authenticated username', () => { + expect(ownerLayoutKey()).toBe('@single'); + expect(ownerLayoutKey('alice')).toBe('alice'); + }); +}); + +describe('normalizeOrMigrateOwnerTabLayout', () => { + it('filters global sessionOrder by owner and appends remaining sessions deterministically', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + owner: 'alice', + sessionOrder: ['foreign', 'late', 'ordered', 'missing'], + persistedSessions: [ + { id: 'foreign', owner: 'bob', createdAt: 1 }, + { id: 'late', owner: 'alice', createdAt: 30 }, + { id: 'ordered', owner: 'alice', createdAt: 20 }, + { id: 'tie-b', owner: 'alice', createdAt: 10 }, + { id: 'tie-a', owner: 'alice', createdAt: 10 }, + ], + }) + ); + + expect(result.created).toBe(true); + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'late' }, + { kind: 'session', id: 'ordered' }, + { kind: 'session', id: 'tie-a' }, + { kind: 'session', id: 'tie-b' }, + ]); + }); + + it('includes persisted stopped/pinned sessions and de-duplicates live records', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + persistedSessions: [ + { id: 'pinned', createdAt: 1 }, + { id: 'shared', createdAt: 5 }, + ], + liveSessions: [{ id: 'shared', createdAt: 2 }], + }) + ); + + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'pinned' }, + { kind: 'session', id: 'shared' }, + ]); + }); + + it('treats an ownerless live record as authoritative single-user ownership', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + persistedSessions: [{ id: 'moved', owner: 'alice', createdAt: 1 }], + liveSessions: [{ id: 'moved', createdAt: 2 }], + }) + ); + + expect(result.layout.ungrouped).toEqual([{ kind: 'session', id: 'moved' }]); + }); + + it('treats an absent live parentSessionId as an authoritative root', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + sessionOrder: ['child', 'former-parent'], + persistedSessions: [ + { id: 'child', parentSessionId: 'former-parent', createdAt: 1 }, + { id: 'former-parent', createdAt: 2 }, + ], + liveSessions: [ + { id: 'child', createdAt: 1 }, + { id: 'former-parent', createdAt: 2 }, + ], + }) + ); + + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'child' }, + { kind: 'session', id: 'former-parent' }, + ]); + }); + + it('marks migrated children manual when their parent is live and same-owner', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + owner: 'alice', + sessionOrder: ['child', 'parent', 'foreign-child'], + persistedSessions: [ + { id: 'child', owner: 'alice', parentSessionId: 'parent', createdAt: 2 }, + { id: 'parent', owner: 'alice', createdAt: 1 }, + { id: 'foreign-child', owner: 'alice', parentSessionId: 'foreign-parent', createdAt: 3 }, + ], + liveSessions: [ + { id: 'parent', owner: 'alice', createdAt: 1 }, + { id: 'foreign-parent', owner: 'bob', createdAt: 1 }, + ], + }) + ); + + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'child', placement: 'manual' }, + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'foreign-child' }, + ]); + }); + + it('appends owner webviews in server store order after sessions', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + owner: 'alice', + persistedSessions: [{ id: 'session', owner: 'alice', createdAt: 1 }], + webviews: [ + { id: 'second', owner: 'alice' }, + { id: 'foreign', owner: 'bob' }, + { id: 'first', owner: 'alice' }, + ], + }) + ); + + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'session' }, + { kind: 'webview', id: 'second' }, + { kind: 'webview', id: 'first' }, + ]); + }); + + it('normalizes an existing layout idempotently without pruning unknown refs', () => { + const existing: TabLayout = { + version: 7, + groups: [ + { + id: 'g', + name: ' Work ', + refs: [ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'foreign' }, + ], + }, + ], + ungrouped: [{ kind: 'session', id: 'known' }], + updatedAt: 'old', + }; + const input = baseInput({ + layouts: { '@single': existing }, + persistedSessions: [ + { id: 'known', createdAt: 1 }, + { id: 'foreign', owner: 'alice', createdAt: 2 }, + ], + }); + + const once = normalizeOrMigrateOwnerTabLayout(input); + const twice = normalizeOrMigrateOwnerTabLayout({ ...input, layouts: once.layouts }); + + expect(once.created).toBe(false); + expect(once.layout.groups).toEqual([{ id: 'g', name: 'Work', refs: [{ kind: 'session', id: 'unknown' }] }]); + expect(twice.layout).toEqual(once.layout); + }); + + it('migrates an owner named constructor instead of reading the inherited prototype key', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + owner: 'constructor', + layouts: { + alice: { + version: 1, + groups: [], + ungrouped: [], + updatedAt: 'old', + }, + }, + }) + ); + + expect(result.created).toBe(true); + expect(result.layouts.constructor).toBeDefined(); + expect(result.layout.version).toBe(0); + }); + + it('accepts exactly 512 refs and rejects 513 atomically without truncation', () => { + const sessions = Array.from({ length: MAX_TAB_REFS }, (_, i) => ({ id: `s-${i}`, createdAt: i })); + const accepted = normalizeOrMigrateOwnerTabLayout(baseInput({ persistedSessions: sessions })); + expect(accepted.layout.ungrouped).toHaveLength(MAX_TAB_REFS); + + const layouts = { untouched: accepted.layout }; + expect(() => + normalizeOrMigrateOwnerTabLayout( + baseInput({ layouts, persistedSessions: [...sessions, { id: 'overflow', createdAt: MAX_TAB_REFS }] }) + ) + ).toThrow(TabLayoutValidationError); + expect(layouts).toEqual({ untouched: accepted.layout }); + }); +}); + +describe('StateStore tabLayouts allowlist', () => { + it('persists and reloads full owner layouts across a restart', () => { + const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-layout-')); + tempDirs.push(dir); + const file = join(dir, 'state.json'); + const layout: TabLayout = { + version: 3, + groups: [{ id: 'g', name: 'Work', refs: [{ kind: 'session', id: 's', placement: 'manual' }] }], + ungrouped: [{ kind: 'webview', id: 'w' }], + updatedAt: '2026-08-16T12:00:00.000Z', + }; + + const store = new StateStore(file); + store.setTabLayout('alice', layout); + store.saveNow(); + + expect(JSON.parse(readFileSync(file, 'utf8')).tabLayouts).toEqual({ alice: layout }); + expect(new StateStore(file).getTabLayout('alice')).toEqual(layout); + }); + + it('does not treat an inherited constructor property as a persisted owner layout', () => { + const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-layout-')); + tempDirs.push(dir); + const file = join(dir, 'state.json'); + const layout: TabLayout = { + version: 3, + groups: [], + ungrouped: [], + updatedAt: '2026-08-16T12:00:00.000Z', + }; + + const store = new StateStore(file); + store.setTabLayout('alice', layout); + store.saveNow(); + + expect(store.getTabLayout('constructor')).toBeNull(); + }); +}); diff --git a/test/tab-layout-restore-gate.test.ts b/test/tab-layout-restore-gate.test.ts new file mode 100644 index 00000000..117e29e3 --- /dev/null +++ b/test/tab-layout-restore-gate.test.ts @@ -0,0 +1,62 @@ +/** @fileoverview Startup restoration must gate destructive tab-layout reconciliation. */ +import { describe, expect, it, vi } from 'vitest'; +import { WebServer } from '../src/web/server.js'; + +describe('tab layout restore gate', () => { + it('does not unlock pruning, cleanup stale state, or reconcile after a failed restore', async () => { + const markRestorationComplete = vi.fn(); + const markRestorationFailed = vi.fn(); + const reconcileAfterRestoration = vi.fn(async () => {}); + const cleanupStaleSessions = vi.fn(async () => 0); + const server = Object.create(WebServer.prototype) as { + tabLayouts: { + markRestorationComplete: typeof markRestorationComplete; + markRestorationFailed: typeof markRestorationFailed; + reconcileAfterRestoration: typeof reconcileAfterRestoration; + }; + cleanupStaleSessions: typeof cleanupStaleSessions; + mux: { reconcileSessions(): Promise }; + restoreMuxSessions(): Promise; + finalizeRestoredState(restored: boolean): Promise; + }; + server.tabLayouts = { markRestorationComplete, markRestorationFailed, reconcileAfterRestoration }; + server.cleanupStaleSessions = cleanupStaleSessions; + server.mux = { reconcileSessions: vi.fn(async () => Promise.reject(new Error('mux unavailable'))) }; + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const restored = await server.restoreMuxSessions(); + await server.finalizeRestoredState(restored); + + expect(restored).toBe(false); + expect(markRestorationComplete).not.toHaveBeenCalled(); + expect(markRestorationFailed).toHaveBeenCalledTimes(1); + expect(cleanupStaleSessions).not.toHaveBeenCalled(); + expect(reconcileAfterRestoration).not.toHaveBeenCalled(); + expect(errorLog).toHaveBeenCalledWith('[Server] Failed to restore mux sessions:', expect.any(Error)); + }); + + it('unlocks cleanup and reconciliation only after successful restoration', async () => { + const order: string[] = []; + const server = Object.create(WebServer.prototype) as { + tabLayouts: { + markRestorationComplete(): void; + markRestorationFailed(): void; + reconcileAfterRestoration(): Promise; + }; + cleanupStaleSessions(): Promise; + finalizeRestoredState(restored: boolean): Promise; + }; + server.tabLayouts = { + markRestorationComplete: () => order.push('complete'), + markRestorationFailed: () => order.push('failed'), + reconcileAfterRestoration: async () => { + order.push('reconcile'); + }, + }; + server.cleanupStaleSessions = async () => (order.push('cleanup'), 0); + + await server.finalizeRestoredState(true); + + expect(order).toEqual(['complete', 'cleanup', 'reconcile']); + }); +}); diff --git a/test/tab-layout-service.test.ts b/test/tab-layout-service.test.ts new file mode 100644 index 00000000..753e2517 --- /dev/null +++ b/test/tab-layout-service.test.ts @@ -0,0 +1,1213 @@ +/** + * @fileoverview Tab-layout server coordinator lifecycle, ownership, and version tests. + */ +import { describe, expect, it, vi } from 'vitest'; +import { TabLayoutService } from '../src/tab-layout-service.js'; +import { SseEvent } from '../src/web/sse-events.js'; +import type { TabLayout } from '../src/tab-layout.js'; + +type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string; pinned?: boolean }; + +function createHarness( + options: { + layouts?: Record; + order?: string[]; + persisted?: SessionFact[]; + live?: SessionFact[]; + webviews?: Array<{ id: string; owner?: string }>; + now?: () => string; + } = {} +) { + const layouts = { ...(options.layouts ?? {}) }; + const persisted = Object.fromEntries((options.persisted ?? []).map((session) => [session.id, session])); + const live = new Map((options.live ?? []).map((session) => [session.id, session])); + const order = [...(options.order ?? [])]; + const readWebviews = vi.fn(async () => options.webviews ?? []); + const store = { + getTabLayout: vi.fn((owner: string) => layouts[owner] ?? null), + getTabLayouts: vi.fn(() => ({ ...layouts })), + getSessions: vi.fn(() => persisted), + getSessionOrder: vi.fn(() => [...order]), + commitTabLayoutProjection: vi.fn( + (updates: Readonly>, project: (latest: readonly string[]) => readonly string[]) => { + const projected = [...project([...order])]; + Object.assign(layouts, structuredClone(updates)); + order.splice(0, order.length, ...projected); + return { layouts: structuredClone(updates), sessionOrder: [...order] }; + } + ), + }; + const broadcast = vi.fn(); + const broadcastSessionOrder = vi.fn(); + const service = new TabLayoutService({ + store: store as never, + sessions: live as never, + readWebviews, + broadcast, + broadcastSessionOrder, + now: options.now ?? (() => '2026-08-16T12:00:00.000Z'), + }); + return { service, store, broadcast, broadcastSessionOrder, layouts, order, live, persisted, readWebviews }; +} + +const existing = (refs: TabLayout['ungrouped'], version = 7): TabLayout => ({ + version, + groups: [], + ungrouped: refs, + updatedAt: '2026-08-15T00:00:00.000Z', +}); + +describe('TabLayoutService', () => { + it('guards destructive transactions by restoration state while allowing skipped test mode', async () => { + const h = createHarness({ layouts: { '@single': existing([{ kind: 'session', id: 'mine' }]) } }); + const action = vi.fn(async () => 'removed'); + + await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).rejects.toThrow(/restoration.*pending/i); + expect(action).not.toHaveBeenCalled(); + + h.service.markRestorationFailed(); + await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).rejects.toThrow(/restoration.*failed/i); + expect(action).not.toHaveBeenCalled(); + + h.service.markRestorationSkipped(); + await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).resolves.toBe('removed'); + expect(action).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('prepares and commits a complete-state session deletion around the resource action', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const action = vi.fn(async () => { + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + h.live.delete('mine'); + return 'removed'; + }); + + await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).resolves.toBe('removed'); + + expect(h.layouts.alice.ungrouped).toEqual([]); + expect(h.layouts.alice.version).toBe(8); + }); + + it('leaves stale records, layouts, versions, and events intact when bulk preparation fails', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'stale' }]) }, + persisted: [{ id: 'stale', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const cleanup = vi.fn(() => { + delete h.persisted.stale; + return { count: 1, cleaned: [{ id: 'stale', owner: 'alice' }] }; + }); + h.store.getSessions + .mockImplementationOnce(() => h.persisted) + .mockImplementationOnce(() => { + throw new Error('malformed persisted state'); + }); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).rejects.toThrow('malformed persisted state'); + + expect(cleanup).not.toHaveBeenCalled(); + expect(h.persisted.stale).toBeDefined(); + expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'stale' }])); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('bulk-cleans multiple owners once each while preserving pinned stale records', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'alice-stale' }, + { kind: 'session', id: 'alice-pinned' }, + ]), + bob: existing([{ kind: 'session', id: 'bob-stale' }]), + }, + persisted: [ + { id: 'bob-stale', owner: 'bob', createdAt: 1 }, + { id: 'alice-stale', owner: 'alice', createdAt: 2 }, + { id: 'alice-pinned', owner: 'alice', createdAt: 3, pinned: true }, + ], + }); + h.service.markRestorationComplete(); + const cleanup = vi.fn(() => { + delete h.persisted['alice-stale']; + delete h.persisted['bob-stale']; + return { count: 2 }; + }); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual({ count: 2 }); + + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'alice-pinned' }]); + expect(h.layouts.bob.ungrouped).toEqual([]); + expect(h.layouts.alice.version).toBe(8); + expect(h.layouts.bob.version).toBe(8); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(Object.keys(h.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']); + expect(h.broadcast).toHaveBeenCalledTimes(2); + }); + + it('does not commit a prepared stale layout when asynchronous cleanup rejects', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'alice-stale' }]) }, + persisted: [{ id: 'alice-stale', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const cleanup = vi.fn(async () => { + throw new Error('state removal failed'); + }); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).rejects.toThrow('state removal failed'); + + expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'alice-stale' }])); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + }); + + it('binds stale cleanup to revalidated original candidates after an owner queue wait', async () => { + const h = createHarness({ + layouts: { + alice: existing([{ kind: 'session', id: 'alice-stale' }]), + bob: existing([{ kind: 'session', id: 'bob-new-stale' }]), + }, + order: ['alice-stale'], + persisted: [{ id: 'alice-stale', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + let releaseFacts!: () => void; + const factsBlocked = new Promise((resolve) => { + releaseFacts = resolve; + }); + h.readWebviews.mockImplementationOnce(async () => { + await factsBlocked; + return []; + }); + + const occupiedOwnerQueue = h.service.get('alice'); + await vi.waitFor(() => expect(h.readWebviews).toHaveBeenCalledOnce()); + const cleanup = vi.fn((ids: ReadonlySet) => ({ ids: [...ids] })); + const pending = h.service.runStaleSessionCleanup(new Set(), cleanup); + + h.persisted['alice-stale'].pinned = true; + h.persisted['bob-new-stale'] = { id: 'bob-new-stale', owner: 'bob', createdAt: 2 }; + releaseFacts(); + + await occupiedOwnerQueue; + await expect(pending).resolves.toEqual({ ids: [] }); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledWith(new Set()); + expect(h.persisted['alice-stale']).toBeDefined(); + expect(h.persisted['bob-new-stale']).toBeDefined(); + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'alice-stale' }]); + expect(h.layouts.bob.ungrouped).toEqual([{ kind: 'session', id: 'bob-new-stale' }]); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + }); + + it('allows skipped-mode stale cleanup without pruning or versioning layouts', async () => { + const h = createHarness({ + layouts: { '@single': existing([{ kind: 'session', id: 'stale' }]) }, + persisted: [{ id: 'stale', createdAt: 1 }], + }); + h.service.markRestorationSkipped(); + const cleanup = vi.fn(() => ({ count: 1 })); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual({ count: 1 }); + + expect(cleanup).toHaveBeenCalledOnce(); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + }); + + it('repairs missing owner refs once while preserving unknown refs', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const first = await h.service.get('alice'); + expect(first.ungrouped).toEqual([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'mine' }, + ]); + expect(first.version).toBe(8); + await h.service.get('alice'); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.broadcast).toHaveBeenCalledWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }); + }); + + it('repairs only the legacy projection on GET when the stored layout is already canonical', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['b', 'a'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + const first = await h.service.get('alice'); + const second = await h.service.get('alice'); + + expect(first).toEqual( + existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]) + ); + expect(second).toEqual(first); + expect(h.order).toEqual(['a', 'b']); + expect(h.layouts.alice.version).toBe(7); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({}); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['a', 'b'] }, + globalOrder: ['a', 'b'], + globalChanged: true, + }); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['a', 'b'] }, + globalOrder: ['a', 'b'], + globalChanged: true, + }); + }); + + it('isolates owner metadata and filters saved webviews by exact owner', async () => { + const h = createHarness({ + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'bob', createdAt: 2 }, + ], + webviews: [ + { id: 'wa', owner: 'alice' }, + { id: 'wb', owner: 'bob' }, + ], + }); + expect((await h.service.get('alice')).ungrouped.map((ref) => ref.id)).toEqual(['a', 'wa']); + expect((await h.service.get('bob')).ungrouped.map((ref) => ref.id)).toEqual(['b', 'wb']); + }); + + it('rejects foreign and unknown refs atomically even for an admin-owned layout', async () => { + const h = createHarness({ + layouts: { admin: existing([{ kind: 'session', id: 'mine' }]) }, + live: [ + { id: 'mine', owner: 'admin', createdAt: 1 }, + { id: 'foreign', owner: 'bob', createdAt: 2 }, + ], + }); + const desired = existing([{ kind: 'session', id: 'foreign' }]); + await expect(h.service.put('admin', desired, 7)).rejects.toThrow(/not owned/); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + }); + + it.each([6, 8])('conflicts on every non-exact base version (%s)', async (baseVersion) => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + expect(await h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), baseVersion)).toEqual({ + status: 'conflict', + layout: existing([{ kind: 'session', id: 'mine' }]), + }); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('returns an unpersisted prepared layout for a stale first write without broadcasting', async () => { + const h = createHarness({ + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + + await expect(h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), 7)).resolves.toEqual({ + status: 'conflict', + layout: { + version: 0, + groups: [], + ungrouped: [{ kind: 'session', id: 'mine' }], + updatedAt: '2026-08-16T12:00:00.000Z', + }, + }); + expect(h.layouts.alice).toBeUndefined(); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('returns a normalized prepared layout for a stale write without persisting the reconciliation', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + + await expect(h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), 6)).resolves.toEqual({ + status: 'conflict', + layout: existing([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'mine' }, + ]), + }); + expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'unknown' }])); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('folds first-write initialization and the requested update into one commit', async () => { + const h = createHarness({ + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const desired = { + ...existing([]), + groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }], + }; + + const result = await h.service.put('alice', desired, 0); + + expect(result).toEqual({ + status: 'updated', + layout: { + version: 1, + groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }], + ungrouped: [], + updatedAt: '2026-08-16T12:00:00.000Z', + }, + }); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({ alice: result.layout }); + expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 1 }); + }); + + it('uses the prepared authoritative ordering for a matching folded first write with tied session timestamps', async () => { + const h = createHarness({ + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'B', owner: 'alice', createdAt: 1 }, + ], + }); + + const result = await h.service.put('alice', existing([]), 0); + + expect(result).toEqual({ + status: 'updated', + layout: { + version: 1, + groups: [], + ungrouped: [ + { kind: 'session', id: 'B' }, + { kind: 'session', id: 'a' }, + ], + updatedAt: '2026-08-16T12:00:00.000Z', + }, + }); + }); + + it('folds normalization and a matching requested update into one commit', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const desired = { + ...existing([]), + groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }], + }; + + const result = await h.service.put('alice', desired, 7); + + expect(result).toEqual({ + status: 'updated', + layout: { + version: 8, + groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }], + ungrouped: [], + updatedAt: '2026-08-16T12:00:00.000Z', + }, + }); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({ alice: result.layout }); + expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }); + }); + + it('stores a matching write with one fresh version and minimal event payload', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const result = await h.service.put( + 'alice', + { ...existing([{ kind: 'session', id: 'mine' }]), groups: [{ id: 'g', name: ' Group ', refs: [] }] }, + 7 + ); + expect(result.status).toBe('updated'); + expect(result.layout).toMatchObject({ version: 8, updatedAt: '2026-08-16T12:00:00.000Z' }); + expect(result.layout.groups[0].name).toBe('Group'); + expect(h.broadcast).toHaveBeenLastCalledWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }); + }); + + it('serializes concurrent writes so only one exact base version can win', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const desired = existing([{ kind: 'session', id: 'mine' }]); + const results = await Promise.all([h.service.put('alice', desired, 7), h.service.put('alice', desired, 7)]); + expect(results.map((result) => result.status).sort()).toEqual(['conflict', 'updated']); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.layouts.alice.version).toBe(8); + }); + + it('places a root at the end and a child after its parent subtree', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'tail' }, + ]), + }, + live: [ + { id: 'parent', owner: 'alice', createdAt: 1 }, + { id: 'tail', owner: 'alice', createdAt: 2 }, + ], + }); + h.live.set('root', { id: 'root', owner: 'alice', createdAt: 3 } as never); + await h.service.sessionCreated('alice'); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['parent', 'tail', 'root']); + h.live.set('child', { id: 'child', owner: 'alice', createdAt: 4, parentSessionId: 'parent' } as never); + await h.service.sessionCreated('alice'); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['parent', 'child', 'tail', 'root']); + expect(h.layouts.alice.version).toBe(9); + }); + + it('appends a newly saved owner webview and rejects over-limit writes atomically', async () => { + const webviews: Array<{ id: string; owner?: string }> = []; + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + webviews, + }); + webviews.push({ id: 'dashboard', owner: 'alice' }); + await h.service.webviewCreated('alice'); + expect(h.layouts.alice.ungrouped.at(-1)).toEqual({ kind: 'webview', id: 'dashboard' }); + + const tooMany = Array.from({ length: 513 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` })); + await expect(h.service.put('alice', existing(tooMany, 8), 8)).rejects.toThrow(/512/); + expect(h.layouts.alice.version).toBe(8); + }); + + it('materializes children and removes an explicitly deleted parent in one mutation', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'child' }, + ]), + }, + live: [{ id: 'child', owner: 'alice', createdAt: 2, parentSessionId: 'parent' }], + }); + h.service.markRestorationComplete(); + await h.service.sessionsRemoved([{ id: 'parent', owner: 'alice' }]); + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'child', placement: 'manual' }]); + expect(h.layouts.alice.version).toBe(8); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + }); + + it('publishes multi-owner post-removal repairs in one combined transaction', async () => { + const h = createHarness({ + layouts: { + alice: existing([{ kind: 'session', id: 'a-stale' }]), + bob: existing([{ kind: 'session', id: 'b-stale' }]), + }, + order: ['a-stale', 'b-stale'], + }); + h.service.markRestorationComplete(); + + await h.service.sessionsRemoved([ + { id: 'a-stale', owner: 'alice' }, + { id: 'b-stale', owner: 'bob' }, + ]); + + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(Object.keys(h.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']); + expect(h.layouts.alice.ungrouped).toEqual([]); + expect(h.layouts.bob.ungrouped).toEqual([]); + expect(h.order).toEqual([]); + }); + + it('does not publish any owner when later multi-owner post-removal preparation fails', async () => { + const h = createHarness({ + layouts: { + alice: existing([{ kind: 'session', id: 'a-stale' }]), + bob: existing([{ kind: 'session', id: 'b-stale' }]), + }, + order: ['a-stale', 'b-stale'], + }); + h.service.markRestorationComplete(); + h.readWebviews.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('bob facts failed')); + + await expect( + h.service.sessionsRemoved([ + { id: 'a-stale', owner: 'alice' }, + { id: 'b-stale', owner: 'bob' }, + ]) + ).rejects.toThrow('bob facts failed'); + + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a-stale']); + expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b-stale']); + }); + + it('prepares deletion version metadata before running the irreversible action', async () => { + const action = vi.fn(async () => 'removed'); + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + now: () => { + throw new Error('clock failed'); + }, + }); + h.service.markRestorationComplete(); + + await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).rejects.toThrow( + 'clock failed' + ); + + expect(action).not.toHaveBeenCalled(); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + }); + + it('removes a legacy-only ID when deleting before owner layout migration', async () => { + const h = createHarness({ + order: ['mine'], + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const action = vi.fn(async () => { + h.live.delete('mine'); + return 'removed'; + }); + + await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).resolves.toBe('removed'); + + expect(h.layouts.alice).toBeUndefined(); + expect(h.order).toEqual([]); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledExactlyOnceWith({}, expect.any(Function)); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: [] }, + globalOrder: [], + globalChanged: true, + }); + }); + + it('removes a deleted legacy ID absent from an existing layout without bumping its version', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'keep' }]) }, + order: ['gone', 'keep'], + live: [ + { id: 'gone', owner: 'alice', createdAt: 1 }, + { id: 'keep', owner: 'alice', createdAt: 2 }, + ], + }); + h.service.markRestorationComplete(); + + await h.service.runSessionDeletion([{ id: 'gone', owner: 'alice' }], async () => { + h.live.delete('gone'); + }); + + expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'keep' }])); + expect(h.order).toEqual(['keep']); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({}); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['keep'] }, + globalOrder: ['keep'], + globalChanged: true, + }); + }); + + it('removes legacy-only IDs from post-removal repair with no migrated layout or layout delta', async () => { + const h = createHarness({ + layouts: { bob: existing([{ kind: 'session', id: 'keep' }]) }, + order: ['alice-gone', 'bob-gone', 'keep'], + live: [{ id: 'keep', owner: 'bob', createdAt: 3 }], + }); + h.service.markRestorationComplete(); + + await h.service.sessionsRemoved([ + { id: 'alice-gone', owner: 'alice' }, + { id: 'bob-gone', owner: 'bob' }, + ]); + + expect(h.layouts.alice).toBeUndefined(); + expect(h.layouts.bob).toEqual(existing([{ kind: 'session', id: 'keep' }])); + expect(h.order).toEqual(['keep']); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({}); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: [], bob: ['keep'] }, + globalOrder: ['keep'], + globalChanged: true, + }); + }); + + it('removes a confirmed stale legacy-only ID even before owner layout migration', async () => { + const h = createHarness({ + order: ['stale'], + persisted: [{ id: 'stale', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const cleanup = vi.fn((ids: ReadonlySet) => { + delete h.persisted.stale; + return [...ids]; + }); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual(['stale']); + + expect(h.layouts.alice).toBeUndefined(); + expect(h.order).toEqual([]); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledExactlyOnceWith({}, expect.any(Function)); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: [] }, + globalOrder: [], + globalChanged: true, + }); + }); + + it('does not prune or bump when restoration was skipped', async () => { + const h = createHarness({ layouts: { '@single': existing([{ kind: 'session', id: 'gone' }]) } }); + h.service.markRestorationSkipped(); + await h.service.sessionsRemoved([{ id: 'gone' }]); + await h.service.webviewDeleted('@single', 'also-gone'); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.layouts['@single'].version).toBe(7); + }); + + it('retains pinned stopped sessions and prunes saved webviews after restoration', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'pinned' }, + { kind: 'webview', id: 'gone-web' }, + ]), + }, + persisted: [{ id: 'pinned', owner: 'alice', createdAt: 1, pinned: true }], + }); + h.service.markRestorationComplete(); + await h.service.webviewDeleted('alice', 'gone-web'); + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'pinned' }]); + expect(h.layouts.alice.version).toBe(8); + }); + + it('bridges a regular legacy reorder into the owner layout and canonical global projection', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['a', 'b'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + await expect(h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a'])).resolves.toEqual({ + order: ['b', 'a'], + changedOwnerOrders: { alice: ['b', 'a'] }, + globalOrder: ['b', 'a'], + globalChanged: true, + }); + expect((await h.service.get('alice')).ungrouped.map((ref) => ref.id)).toEqual(['b', 'a']); + expect(h.layouts.alice.version).toBe(8); + expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['b', 'a'] }, + globalOrder: ['b', 'a'], + globalChanged: true, + }); + }); + + it('preserves an unmapped global slot while a legacy PUT reorders authoritative owner sessions', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['unknown', 'a', 'b'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a']); + + expect(h.layouts.alice.ungrouped).toEqual([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'b' }, + { kind: 'session', id: 'a' }, + ]); + expect(result.globalOrder).toEqual(['unknown', 'b', 'a']); + expect(h.order).toEqual(['unknown', 'b', 'a']); + expect(result.order).toEqual(['b', 'a']); + }); + + it('preserves an unmapped global slot during a GET order repair', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'b' }, + { kind: 'session', id: 'a' }, + ]), + }, + order: ['unknown', 'a', 'b'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + await h.service.get('alice'); + + expect(h.order).toEqual(['unknown', 'b', 'a']); + expect(h.broadcastSessionOrder).toHaveBeenCalledWith({ + changedOwnerOrders: { alice: ['b', 'a'] }, + globalOrder: ['unknown', 'b', 'a'], + globalChanged: true, + }); + }); + + it('removes an unmapped global id only when a trusted deletion explicitly excludes it', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'a' }, + ]), + }, + order: ['unknown', 'a'], + live: [{ id: 'a', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + + await h.service.runSessionDeletion([{ id: 'unknown', owner: 'alice' }], async () => undefined); + + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'a' }]); + expect(h.order).toEqual(['a']); + }); + + it('reports an owner layout-order change even when the legacy global slice already matches', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['b', 'a'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a']); + + expect(result.changedOwnerOrders).toEqual({ alice: ['b', 'a'] }); + expect(result.globalChanged).toBe(false); + expect(h.broadcastSessionOrder).toHaveBeenCalledTimes(1); + }); + + it('reports an authoritative owner-order correction when a placement-only PUT repairs stale global order', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['b', 'a'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + const desired = { + ...existing([], 7), + groups: [ + { + id: 'g', + name: 'Same order', + refs: [ + { kind: 'session' as const, id: 'a' }, + { kind: 'session' as const, id: 'b' }, + ], + }, + ], + }; + + const result = await h.service.put('alice', desired, 7); + + expect(result.status).toBe('updated'); + expect(h.order).toEqual(['a', 'b']); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['a', 'b'] }, + globalOrder: ['a', 'b'], + globalChanged: true, + }); + }); + + it.each([ + ['foreign owner', ['b', 'a']], + ['unknown stored ref', ['unknown', 'a']], + ])('rejects a regular legacy request containing a %s without mutation or events', async (_label, requested) => { + const original = existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'unknown' }, + ]); + const h = createHarness({ + layouts: { alice: original }, + order: ['a', 'b', 'unknown'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'bob', createdAt: 2 }, + ], + }); + + await expect(h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, requested)).rejects.toThrow( + /not owned by layout owner/i + ); + expect(h.layouts.alice).toEqual(original); + expect(h.order).toEqual(['a', 'b', 'unknown']); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('keeps containers and anchored slots fixed, materializes ranked children, and merges missing known sessions', async () => { + const h = createHarness({ + layouts: { + alice: { + ...existing([ + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'child' }, + { kind: 'session', id: 'c' }, + ]), + groups: [ + { + id: 'g', + name: 'Work', + refs: [ + { kind: 'session', id: 'a' }, + { kind: 'webview', id: 'w' }, + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'b' }, + ], + }, + ], + }, + }, + order: ['a', 'b', 'parent', 'child', 'c'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + { id: 'parent', owner: 'alice', createdAt: 3 }, + { id: 'child', owner: 'alice', createdAt: 4, parentSessionId: 'parent' }, + { id: 'c', owner: 'alice', createdAt: 5 }, + ], + webviews: [{ id: 'w', owner: 'alice' }], + }); + + const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a', 'child', 'parent']); + + expect(result.order).toEqual(['b', 'a', 'child', 'parent', 'c']); + expect(h.layouts.alice.groups).toEqual([ + { + id: 'g', + name: 'Work', + refs: [ + { kind: 'session', id: 'b' }, + { kind: 'webview', id: 'w' }, + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'a' }, + ], + }, + ]); + expect(h.layouts.alice.ungrouped).toEqual([ + { kind: 'session', id: 'child', placement: 'manual' }, + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'c' }, + ]); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['b', 'a', 'child', 'parent', 'c'] }, + globalOrder: ['b', 'a', 'child', 'parent', 'c'], + globalChanged: true, + }); + expect(await h.service.get('alice')).toEqual(h.layouts.alice); + }); + + it('does not claim or disturb a known foreign session found in a stale owner layout', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a1' }, + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'a2' }, + ]), + bob: existing([ + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'b2' }, + ]), + }, + order: ['a1', 'b1', 'a2', 'b2'], + live: [ + { id: 'a1', owner: 'alice', createdAt: 1 }, + { id: 'b1', owner: 'bob', createdAt: 2 }, + { id: 'a2', owner: 'alice', createdAt: 3 }, + { id: 'b2', owner: 'bob', createdAt: 4 }, + ], + }); + + const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['a2', 'a1']); + + expect(result).toMatchObject({ + order: ['a2', 'a1'], + changedOwnerOrders: { alice: ['a2', 'a1'] }, + globalOrder: ['a2', 'b1', 'a1', 'b2'], + globalChanged: true, + }); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a2', 'a1']); + expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b1', 'b2']); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['a2', 'a1'] }, + globalOrder: ['a2', 'b1', 'a1', 'b2'], + globalChanged: true, + }); + }); + + it('publishes layout and order together, suppresses duplicate order events, and rolls back store rejection', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['a', 'b'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + const desired = { + ...existing([], 7), + groups: [ + { + id: 'g', + name: 'Grouped', + refs: [ + { kind: 'session' as const, id: 'a' }, + { kind: 'session' as const, id: 'b' }, + ], + }, + ], + }; + + await h.service.put('alice', desired, 7); + expect(h.order).toEqual(['a', 'b']); + expect(h.broadcast).toHaveBeenCalledTimes(1); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + await h.service.get('alice'); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + + const beforeLayout = structuredClone(h.layouts.alice); + const beforeOrder = [...h.order]; + h.store.commitTabLayoutProjection.mockImplementationOnce(() => { + throw new Error('publication failed'); + }); + h.broadcast.mockClear(); + await expect(h.service.put('alice', { ...desired, groups: [] }, 8)).rejects.toThrow('publication failed'); + expect(h.layouts.alice).toEqual(beforeLayout); + expect(h.order).toEqual(beforeOrder); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('uses latest-state synchronous publication for overlapping owner preparations without a lost update', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a1' }, + { kind: 'session', id: 'a2' }, + ]), + bob: existing([ + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'b2' }, + ]), + }, + order: ['a1', 'b1', 'a2', 'b2'], + live: [ + { id: 'a1', owner: 'alice', createdAt: 1 }, + { id: 'b1', owner: 'bob', createdAt: 2 }, + { id: 'a2', owner: 'alice', createdAt: 3 }, + { id: 'b2', owner: 'bob', createdAt: 4 }, + ], + }); + + await Promise.all([ + h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['a2', 'a1']), + h.service.putLegacyOrder({ owner: 'bob', isAdmin: false }, ['b2', 'b1']), + ]); + + expect(h.order).toEqual(['a2', 'b2', 'a1', 'b1']); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a2', 'a1']); + expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b2', 'b1']); + }); + + it('admin publishes all changed owner ranks atomically and can change only cross-owner interleaving', async () => { + const makeHarness = () => + createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a1' }, + { kind: 'session', id: 'a2' }, + ]), + bob: existing([ + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'b2' }, + ]), + }, + order: ['a1', 'b1', 'a2', 'b2'], + live: [ + { id: 'a1', owner: 'alice', createdAt: 1 }, + { id: 'b1', owner: 'bob', createdAt: 2 }, + { id: 'a2', owner: 'alice', createdAt: 3 }, + { id: 'b2', owner: 'bob', createdAt: 4 }, + ], + }); + const ranked = makeHarness(); + + await expect( + ranked.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['a2', 'b2', 'a1', 'b1']) + ).resolves.toEqual({ + order: ['a2', 'b2', 'a1', 'b1'], + changedOwnerOrders: { alice: ['a2', 'a1'], bob: ['b2', 'b1'] }, + globalOrder: ['a2', 'b2', 'a1', 'b1'], + globalChanged: true, + }); + expect(Object.keys(ranked.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']); + expect(ranked.layouts.alice.version).toBe(8); + expect(ranked.layouts.bob.version).toBe(8); + + const interleaved = makeHarness(); + await expect( + interleaved.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b1', 'a1', 'b2', 'a2']) + ).resolves.toEqual({ + order: ['b1', 'a1', 'b2', 'a2'], + changedOwnerOrders: {}, + globalOrder: ['b1', 'a1', 'b2', 'a2'], + globalChanged: true, + }); + expect(interleaved.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({}); + expect(interleaved.layouts.alice.version).toBe(7); + expect(interleaved.layouts.bob.version).toBe(7); + }); + + it('admin preserves requested owner slots while canonical grouped owner order wins', async () => { + const h = createHarness({ + layouts: { + alice: { + ...existing([{ kind: 'session', id: 'a2' }]), + groups: [{ id: 'fixed', name: 'Fixed', refs: [{ kind: 'session', id: 'a1' }] }], + }, + bob: existing([ + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'b2' }, + ]), + }, + order: ['a1', 'b1', 'a2', 'b2'], + live: [ + { id: 'a1', owner: 'alice', createdAt: 1 }, + { id: 'b1', owner: 'bob', createdAt: 2 }, + { id: 'a2', owner: 'alice', createdAt: 3 }, + { id: 'b2', owner: 'bob', createdAt: 4 }, + ], + }); + + const result = await h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b2', 'a2', 'b1', 'a1']); + + expect(result.globalOrder).toEqual(['b2', 'a1', 'b1', 'a2']); + expect(result.order).toEqual(result.globalOrder); + expect(result.changedOwnerOrders).toEqual({ bob: ['b2', 'b1'] }); + expect(h.layouts.alice.version).toBe(7); + expect(h.layouts.bob.version).toBe(8); + }); + + it('admin publishes prototype-like owner keys as own layout entries', async () => { + const owner = '__proto__'; + const h = createHarness({ + layouts: { + [owner]: existing([ + { kind: 'session', id: 'p1' }, + { kind: 'session', id: 'p2' }, + ]), + }, + order: ['p1', 'p2'], + live: [ + { id: 'p1', owner, createdAt: 1 }, + { id: 'p2', owner, createdAt: 2 }, + ], + }); + + await h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['p2', 'p1']); + + const updates = h.store.commitTabLayoutProjection.mock.calls[0][0]; + expect(Object.hasOwn(updates, owner)).toBe(true); + expect(h.layouts[owner].version).toBe(8); + expect(h.layouts[owner].ungrouped.map((ref) => ref.id)).toEqual(['p2', 'p1']); + }); + + it('admin retries owner discovery when a new trusted owner appears while queues are pending', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'a1' }]) }, + order: ['a1'], + live: [{ id: 'a1', owner: 'alice', createdAt: 1 }], + }); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + h.readWebviews.mockImplementationOnce(async () => { + await blocked; + return []; + }); + const occupied = h.service.get('alice'); + await vi.waitFor(() => expect(h.readWebviews).toHaveBeenCalledOnce()); + + const admin = h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b1', 'a1']); + h.live.set('b1', { id: 'b1', owner: 'bob', createdAt: 2 } as never); + release(); + + await occupied; + await expect(admin).resolves.toMatchObject({ globalOrder: ['b1', 'a1'] }); + expect(h.layouts.bob).toMatchObject({ version: 0, ungrouped: [{ kind: 'session', id: 'b1' }] }); + expect(Object.hasOwn(h.store.commitTabLayoutProjection.mock.calls.at(-1)![0], 'bob')).toBe(true); + }); +}); diff --git a/test/tab-layout-sse.test.ts b/test/tab-layout-sse.test.ts new file mode 100644 index 00000000..bef50067 --- /dev/null +++ b/test/tab-layout-sse.test.ts @@ -0,0 +1,305 @@ +/** @fileoverview Minimal tab-layout SSE payload routing to owner plus admins. */ +import type { FastifyReply } from 'fastify'; +import { describe, expect, it } from 'vitest'; +import type { SessionOrderProjectionChange } from '../src/tab-layout-service.js'; +import { CleanupManager } from '../src/utils/index.js'; +import { SseStreamManager } from '../src/web/sse-stream-manager.js'; +import { deriveTabLayoutSseHint } from '../src/web/tab-layout-sse.js'; + +function client() { + const writes: string[] = []; + return { + writes, + reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply, + }; +} + +function backpressuredClient(options: { throwAfterBackpressure?: boolean } = {}) { + const writes: string[] = []; + let firstWrite = true; + let onDrain: (() => void) | undefined; + const raw = { + write(chunk: string) { + if (!firstWrite && options.throwAfterBackpressure) throw new Error('client disconnected'); + writes.push(chunk); + if (firstWrite) { + firstWrite = false; + return false; + } + return true; + }, + once(event: string, callback: () => void) { + if (event === 'drain') onDrain = callback; + return raw; + }, + }; + return { + writes, + reply: { raw } as unknown as FastifyReply, + drain: () => { + const callback = onDrain; + onDrain = undefined; + callback?.(); + }, + }; +} + +function scriptedBackpressuredClient(outcomes: Array) { + const writes: string[] = []; + let onDrain: (() => void) | undefined; + const raw = { + write(chunk: string) { + const outcome = outcomes.shift() ?? true; + if (outcome instanceof Error) throw outcome; + writes.push(chunk); + return outcome; + }, + once(event: string, callback: () => void) { + if (event === 'drain') onDrain = callback; + return raw; + }, + }; + return { + writes, + reply: { raw } as unknown as FastifyReply, + drain: () => { + const callback = onDrain; + onDrain = undefined; + callback?.(); + }, + }; +} + +const orderChange = (order: string[]): SessionOrderProjectionChange => ({ + changedOwnerOrders: { alice: order }, + globalOrder: order, + globalChanged: true, +}); + +describe('tab layout SSE routing', () => { + it('derives an exact owner hint with fail-closed session scoping', () => { + expect(deriveTabLayoutSseHint({ owner: 'alice', version: 4 })).toEqual({ + username: 'alice', + sessionScoped: true, + }); + }); + + it('delivers the minimal event to the owner and admins, but not another user', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = client(); + const bob = client(); + const admin = client(); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + const payload = { owner: 'alice', version: 4 }; + manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload)); + + expect(alice.writes).toEqual(['event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n']); + expect(admin.writes).toEqual(alice.writes); + expect(bob.writes).toEqual([]); + cleanup.dispose(); + }); + + it('coalesces repeated layout invalidations and drains refresh, layout, then legacy order', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const blockedAlice = backpressuredClient(); + const liveAlice = client(); + const bob = client(); + const admin = client(); + manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + for (const target of [blockedAlice.writes, liveAlice.writes, bob.writes, admin.writes]) target.length = 0; + + const first = { owner: 'alice', version: 4 }; + const latest = { owner: 'alice', version: 5 }; + manager.broadcast('tab:layoutChanged', first, deriveTabLayoutSseHint(first)); + manager.broadcast('tab:layoutChanged', latest, deriveTabLayoutSseHint(latest)); + manager.broadcastSessionOrder(orderChange(['a2', 'a1'])); + + expect(blockedAlice.writes).toEqual([]); + expect(liveAlice.writes).toEqual([ + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + expect(bob.writes).toEqual([]); + expect(admin.writes).toEqual(liveAlice.writes); + + blockedAlice.drain(); + + expect(blockedAlice.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('retains the latest invalidation for every admin-visible owner while isolating regular users', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const blockedAlice = backpressuredClient(); + const blockedAdmin = backpressuredClient(); + const liveAlice = client(); + const liveAdmin = client(); + const bob = client(); + manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(blockedAdmin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(liveAdmin.reply, null, false, undefined, { username: 'ops', role: 'admin' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + + manager.broadcast('test:prime-admin', {}, { adminOnly: true }); + manager.broadcast('test:prime-alice', {}, { username: 'alice' }); + for (const target of [blockedAlice.writes, blockedAdmin.writes, liveAlice.writes, liveAdmin.writes, bob.writes]) { + target.length = 0; + } + + const alice4 = { owner: 'alice', version: 4 }; + const bob8 = { owner: 'bob', version: 8 }; + const alice5 = { owner: 'alice', version: 5 }; + const prototype9 = { owner: 'constructor', version: 9 }; + for (const payload of [alice4, bob8, alice5, prototype9]) { + manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload)); + } + const change = orderChange(['a2', 'a1']); + manager.broadcastSessionOrder(change); + + blockedAdmin.drain(); + blockedAlice.drain(); + + expect(blockedAdmin.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"constructor","version":9}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + expect(blockedAlice.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + expect(liveAlice.writes).toEqual([ + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + expect(bob.writes).toEqual(['event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n']); + expect(liveAdmin.writes).toEqual([ + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"constructor","version":9}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('retains later owners and the final order when a layout recovery write re-enters backpressure', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const admin = scriptedBackpressuredClient([false, true, false, true, true, true]); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + manager.broadcast('test:prime-backpressure', {}, { adminOnly: true }); + admin.writes.length = 0; + + const alice = { owner: 'alice', version: 5 }; + const bob = { owner: 'bob', version: 8 }; + manager.broadcast('tab:layoutChanged', alice, deriveTabLayoutSseHint(alice)); + manager.broadcast('tab:layoutChanged', bob, deriveTabLayoutSseHint(bob)); + manager.broadcastSessionOrder(orderChange(['a2', 'a1'])); + + admin.drain(); + expect(admin.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + ]); + + admin.drain(); + expect(admin.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('drops remaining recovery state after a partial layout write failure without affecting healthy clients', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const brokenAdmin = scriptedBackpressuredClient([false, true, true, new Error('client disconnected')]); + const liveAdmin = client(); + manager.addClient(brokenAdmin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + manager.addClient(liveAdmin.reply, null, false, undefined, { username: 'ops', role: 'admin' }); + manager.broadcast('test:prime-backpressure', {}, { adminOnly: true }); + brokenAdmin.writes.length = 0; + liveAdmin.writes.length = 0; + + const alice = { owner: 'alice', version: 5 }; + const bob = { owner: 'bob', version: 8 }; + manager.broadcast('tab:layoutChanged', alice, deriveTabLayoutSseHint(alice)); + manager.broadcast('tab:layoutChanged', bob, deriveTabLayoutSseHint(bob)); + manager.broadcastSessionOrder(orderChange(['a2', 'a1'])); + + expect(() => brokenAdmin.drain()).not.toThrow(); + + expect(brokenAdmin.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + ]); + expect(manager.clientCount).toBe(1); + expect(liveAdmin.writes).toEqual([ + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('clears a queued layout invalidation on disconnect and ignores its stale drain callback', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = backpressuredClient(); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + alice.writes.length = 0; + + const payload = { owner: 'alice', version: 4 }; + manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload)); + manager.removeClient(alice.reply); + alice.drain(); + + expect(alice.writes).toEqual([]); + expect(manager.clientCount).toBe(0); + cleanup.dispose(); + }); + + it('clears a queued layout invalidation when drain recovery fails', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = backpressuredClient({ throwAfterBackpressure: true }); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + alice.writes.length = 0; + + const payload = { owner: 'alice', version: 4 }; + manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload)); + expect(() => alice.drain()).not.toThrow(); + alice.drain(); + + expect(alice.writes).toEqual([]); + expect(manager.clientCount).toBe(0); + cleanup.dispose(); + }); +}); diff --git a/test/tab-layout.test.ts b/test/tab-layout.test.ts new file mode 100644 index 00000000..25b2a70a --- /dev/null +++ b/test/tab-layout.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_TAB_GROUP_NAME_LENGTH, + MAX_TAB_GROUPS, + MAX_TAB_REFS, + createGroup, + deleteGroup, + flattenOwnerSessionOrder, + flattenVisibleRefs, + followParent, + materializeOrphans, + moveRef, + normalizeTabLayout, + renameGroup, + reorderGroup, + setManualPlacement, + validateTabLayout, + type TabLayout, + type TabRefMetadata, +} from '../src/tab-layout.js'; + +const now = '2026-08-16T12:00:00.000Z'; +const ref = (id: string, kind: 'session' | 'webview' = 'session') => ({ kind, id }) as const; +const session = (id: string, order: number, parentSessionId?: string): TabRefMetadata => ({ + kind: 'session', + id, + order, + parentSessionId, + ownerValid: true, + visible: true, +}); +const webview = (id: string, order: number): TabRefMetadata => ({ + kind: 'webview', + id, + order, + ownerValid: true, + visible: true, +}); +const layout = (groups: TabLayout['groups'] = [], ungrouped: TabLayout['ungrouped'] = []): TabLayout => ({ + version: 3, + groups, + ungrouped, + updatedAt: now, +}); + +describe('tab layout validation and normalization', () => { + it('exports and enforces group, name, and raw ref ceilings at their boundaries', () => { + expect(MAX_TAB_GROUPS).toBe(32); + expect(MAX_TAB_GROUP_NAME_LENGTH).toBe(60); + expect(MAX_TAB_REFS).toBe(512); + + const groups = Array.from({ length: MAX_TAB_GROUPS }, (_, index) => ({ id: `g${index}`, name: 'x', refs: [] })); + expect(validateTabLayout(layout(groups)).groups).toHaveLength(MAX_TAB_GROUPS); + expect(() => validateTabLayout(layout([...groups, { id: 'overflow', name: 'x', refs: [] }]))).toThrow(/32/); + expect( + validateTabLayout(layout([{ id: 'g', name: ` ${'x'.repeat(60)} `, refs: [] }])).groups[0].name + ).toHaveLength(60); + expect(() => validateTabLayout(layout([{ id: 'g', name: 'x'.repeat(61), refs: [] }]))).toThrow(/60/); + + const refs = Array.from({ length: MAX_TAB_REFS }, (_, index) => ref(`s${index}`)); + expect(validateTabLayout(layout([], refs)).ungrouped).toHaveLength(MAX_TAB_REFS); + expect(() => validateTabLayout(layout([], [...refs, ref('overflow')]))).toThrow(/512/); + }); + + it('rejects malformed input deterministically and trims names', () => { + expect(() => validateTabLayout({ ...layout(), version: -1 })).toThrow(/version/); + expect(() => validateTabLayout(layout([{ id: 'g', name: ' ', refs: [] }]))).toThrow(/name/); + expect(() => + validateTabLayout( + layout([ + { id: 'g', name: 'x', refs: [] }, + { id: 'g', name: 'y', refs: [] }, + ]) + ) + ).toThrow(/duplicate group/); + expect(() => validateTabLayout(layout([], [{ kind: 'other', id: 'x' } as never]))).toThrow(/kind/); + expect(() => + validateTabLayout(layout([{ id: 'g', name: 'G', refs: [ref('duplicate')] }], [ref('duplicate')])) + ).toThrow(/duplicate ref/); + expect( + validateTabLayout(layout([{ id: 'g', name: 'G', refs: [ref('same')] }], [ref('same', 'webview')])).ungrouped + ).toEqual([ref('same', 'webview')]); + expect(renameGroup(layout([{ id: 'g', name: ' Before ', refs: [] }]), 'g', ' After ').groups[0].name).toBe( + 'After' + ); + }); + + it('deduplicates first occurrence, namespaces kinds, and repairs missing valid refs in stable order', () => { + const input = layout( + [{ id: 'g', name: 'G', refs: [ref('same'), ref('same'), ref('same', 'webview')] }], + [ref('same'), ref('later')] + ); + const normalized = normalizeTabLayout(input, [ + session('same', 1), + webview('same', 2), + session('later', 3), + session('new', 4), + ]); + expect(normalized.groups[0].refs).toEqual([ref('same'), ref('same', 'webview')]); + expect(normalized.ungrouped).toEqual([ref('later'), ref('new')]); + }); + + it('appends new roots and webviews and ignores metadata that is not owner-valid or visible', () => { + const metadata: TabRefMetadata[] = [ + session('root', 2), + webview('dash', 3), + { ...session('foreign', 0), ownerValid: false }, + { ...session('hidden-by-owner', 1), visible: false }, + ]; + expect(normalizeTabLayout(layout(), metadata).ungrouped).toEqual([ref('root'), ref('dash', 'webview')]); + }); + + it('preserves metadata-unknown stored refs while removing explicitly invalid refs', () => { + const input = layout([], [ref('unknown'), ref('unknown-web', 'webview'), ref('foreign')]); + const metadata: TabRefMetadata[] = [{ ...session('foreign', 0), ownerValid: false }, session('new', 1)]; + expect(normalizeTabLayout(input, metadata).ungrouped).toEqual([ + ref('unknown'), + ref('unknown-web', 'webview'), + ref('new'), + ]); + }); + + it('rejects a missing valid ref above the 512-ref ceiling without truncating the stored layout', () => { + const storedRefs = Array.from({ length: MAX_TAB_REFS }, (_, index) => ref(`s${index}`)); + const input = layout([], storedRefs); + const snapshot = structuredClone(input); + const metadata = [ + ...storedRefs.map((stored, index) => session(stored.id, index)), + session('missing-overflow', MAX_TAB_REFS), + ]; + + expect(() => normalizeTabLayout(input, metadata)).toThrow(/512/); + expect(input).toEqual(snapshot); + expect(input.ungrouped).toHaveLength(MAX_TAB_REFS); + }); + + it('does not mutate caller input', () => { + const input = layout([{ id: 'g', name: ' G ', refs: [ref('child')] }], [ref('parent')]); + const snapshot = structuredClone(input); + normalizeTabLayout(input, [session('parent', 0), session('child', 1, 'parent')]); + moveRef(input, ref('parent'), { groupId: 'g', index: 0 }, [session('parent', 0), session('child', 1, 'parent')]); + expect(input).toEqual(snapshot); + }); +}); + +describe('tab group operations', () => { + it('creates, renames, reorders, and deletes groups without deleting refs', () => { + let value = layout([{ id: 'a', name: 'A', refs: [ref('one')] }], [ref('loose')]); + value = createGroup(value, { id: 'b', name: ' Bee ', index: 0 }); + expect(value.groups.map((group) => [group.id, group.name])).toEqual([ + ['b', 'Bee'], + ['a', 'A'], + ]); + value = renameGroup(value, 'b', ' B '); + value = reorderGroup(value, 'a', 0); + expect(value.groups.map((group) => group.id)).toEqual(['a', 'b']); + value = deleteGroup(value, 'a'); + expect(value.groups.map((group) => group.id)).toEqual(['b']); + expect(value.ungrouped).toEqual([ref('loose'), ref('one')]); + }); + + it('moves refs within, between, and into ungrouped', () => { + const metadata = [session('a', 0), session('b', 1), session('c', 2)]; + let value = layout([{ id: 'g', name: 'G', refs: [ref('a'), ref('b')] }], [ref('c')]); + value = moveRef(value, ref('b'), { groupId: 'g', index: 0 }, metadata); + expect(value.groups[0].refs).toEqual([ref('b'), ref('a')]); + value = moveRef(value, ref('a'), { groupId: null, index: 1 }, metadata); + expect(value.ungrouped).toEqual([ref('c'), ref('a')]); + value = moveRef(value, ref('c'), { groupId: 'g', index: 1 }, metadata); + expect(value.groups[0].refs).toEqual([ref('b'), ref('c')]); + }); +}); + +describe('session lineage', () => { + it('normalizes nested lineage into preorder and preserves stable sibling order', () => { + const metadata = [ + session('root', 0), + session('child-b', 1, 'root'), + session('grandchild', 2, 'child-b'), + session('child-a', 3, 'root'), + ]; + const value = normalizeTabLayout( + layout([{ id: 'g', name: 'G', refs: [ref('root')] }], [ref('child-b'), ref('grandchild'), ref('child-a')]), + metadata + ); + expect(value.groups[0].refs).toEqual([ref('root'), ref('child-b'), ref('grandchild'), ref('child-a')]); + }); + + it('inserts a missing new child after the existing descendant subtree', () => { + const value = normalizeTabLayout(layout([], [ref('root'), ref('child'), ref('grandchild')]), [ + session('root', 0), + session('child', 1, 'root'), + session('grandchild', 2, 'child'), + session('new-child', 3, 'root'), + ]); + expect(value.ungrouped).toEqual([ref('root'), ref('child'), ref('grandchild'), ref('new-child')]); + }); + + it('moves a parent as one preorder block', () => { + const metadata = [ + session('root', 0), + session('child', 1, 'root'), + session('grandchild', 2, 'child'), + session('other', 3), + ]; + const input = layout( + [{ id: 'g', name: 'G', refs: [ref('other')] }], + [ref('root'), ref('child'), ref('grandchild')] + ); + const value = moveRef(input, ref('root'), { groupId: 'g', index: 0 }, metadata); + expect(value.groups[0].refs).toEqual([ref('root'), ref('child'), ref('grandchild'), ref('other')]); + expect(value.ungrouped).toEqual([]); + }); + + it('moves a parent subtree within one container without disturbing surrounding sibling order', () => { + const metadata = [ + session('before', 0), + session('root', 1), + session('child', 2, 'root'), + session('grandchild', 3, 'child'), + session('after', 4), + ]; + const input = layout([ + { + id: 'g', + name: 'G', + refs: [ref('before'), ref('root'), ref('child'), ref('grandchild'), ref('after')], + }, + ]); + + const value = moveRef(input, ref('root'), { groupId: 'g', index: 2 }, metadata); + expect(value.groups[0].refs).toEqual([ref('before'), ref('after'), ref('root'), ref('child'), ref('grandchild')]); + }); + + it('makes an independently moved child manual and followParent restores inheritance', () => { + const metadata = [session('root', 0), session('child', 1, 'root'), session('grandchild', 2, 'child')]; + let value = layout([{ id: 'g', name: 'G', refs: [ref('root'), ref('child'), ref('grandchild')] }]); + value = moveRef(value, ref('child'), { groupId: null, index: 0 }, metadata); + expect(value.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('grandchild')]); + value = followParent(value, ref('child'), metadata); + expect(value.groups[0].refs).toEqual([ref('root'), ref('child'), ref('grandchild')]); + expect(value.ungrouped).toEqual([]); + }); + + it('sets manual placement but rejects direct clearing outside followParent', () => { + const input = layout([], [ref('a')]); + expect(setManualPlacement(input, ref('a'), true).ungrouped[0]).toEqual({ ...ref('a'), placement: 'manual' }); + expect(() => setManualPlacement(setManualPlacement(input, ref('a'), true), ref('a'), false)).toThrow( + /followParent/ + ); + }); + + it('rejects stale follow-parent replay and keeps the child manual across later parent ID reuse', () => { + const materialized = layout([], [{ ...ref('child'), placement: 'manual' }]); + const staleMetadata = [session('child', 1, 'deleted-parent')]; + + expect(() => followParent(materialized, ref('child'), staleMetadata)).toThrow(/parent/); + expect(materialized.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }]); + + const afterIdReuse = normalizeTabLayout(materialized, [ + session('deleted-parent', 2), + session('child', 1, 'deleted-parent'), + ]); + expect(afterIdReuse.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('deleted-parent')]); + }); + + it('rejects follow-parent for roots and webviews', () => { + expect(() => + followParent(layout([], [{ ...ref('root'), placement: 'manual' }]), ref('root'), [session('root', 0)]) + ).toThrow(/parent/); + expect(() => + followParent(layout([], [{ ...ref('dash', 'webview'), placement: 'manual' }]), ref('dash', 'webview'), [ + webview('dash', 0), + ]) + ).toThrow(/session/); + }); + + it.each([ + { label: 'invalid', parent: { ...session('parent', 0), ownerValid: false } }, + { label: 'invisible', parent: { ...session('parent', 0), visible: false } }, + ])('rejects follow-parent when the parent is $label', ({ parent }) => { + const input = layout([], [ref('parent'), { ...ref('child'), placement: 'manual' }]); + expect(() => followParent(input, ref('child'), [parent, session('child', 1, 'parent')])).toThrow(/parent/); + }); + + it('breaks the repeated cycle edge deterministically at its current location', () => { + const value = normalizeTabLayout(layout([], [ref('a'), ref('b')]), [session('a', 0, 'b'), session('b', 1, 'a')]); + expect(value.ungrouped).toEqual([{ ...ref('b'), placement: 'manual' }, ref('a')]); + }); + + it('breaks a multi-node cross-container cycle deterministically', () => { + const metadata = [session('a', 0, 'b'), session('b', 1, 'c'), session('c', 2, 'a'), session('tail', 3)]; + const input = layout( + [ + { id: 'one', name: 'One', refs: [ref('a')] }, + { id: 'two', name: 'Two', refs: [ref('b')] }, + ], + [ref('c'), ref('tail')] + ); + + const first = normalizeTabLayout(input, metadata); + expect(first.groups.map((group) => group.refs)).toEqual([[], []]); + expect(first.ungrouped).toEqual([{ ...ref('c'), placement: 'manual' }, ref('b'), ref('a'), ref('tail')]); + expect(normalizeTabLayout(first, metadata)).toEqual(first); + }); + + it('materializes direct orphans and prevents a restored ID from re-adopting them', () => { + const metadata = [session('parent', 0), session('child', 1, 'parent'), session('grandchild', 2, 'child')]; + let value = layout([], [ref('parent'), ref('child'), ref('grandchild')]); + value = materializeOrphans(value, ['parent'], metadata); + value = normalizeTabLayout(value, metadata); + expect(value.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('grandchild'), ref('parent')]); + }); + + it('materializes a direct orphan in its grouped location amid surrounding refs', () => { + const metadata = [ + session('before', 0), + session('parent', 1), + session('child', 2, 'parent'), + session('grandchild', 3, 'child'), + session('after', 4), + ]; + const input = layout([ + { + id: 'g', + name: 'G', + refs: [ref('before'), ref('parent'), ref('child'), ref('grandchild'), ref('after')], + }, + ]); + + const materialized = materializeOrphans(input, ['parent'], metadata); + expect(materialized.groups[0].refs).toEqual([ + ref('before'), + { ...ref('child'), placement: 'manual' }, + ref('grandchild'), + ref('after'), + ]); + const restored = normalizeTabLayout(materialized, metadata); + expect(restored.groups[0].refs).toEqual([ + ref('before'), + { ...ref('child'), placement: 'manual' }, + ref('grandchild'), + ref('after'), + ]); + expect(restored.ungrouped).toEqual([ref('parent')]); + }); +}); + +describe('flatten projections', () => { + const value = layout( + [ + { id: 'collapsed', name: 'Collapsed', refs: [ref('s1'), ref('closed', 'webview'), ref('s2')] }, + { id: 'open', name: 'Open', refs: [ref('s3'), ref('dash', 'webview')] }, + ], + [ref('s4')] + ); + + it('projects every owner session in layout order, including collapse-hidden members', () => { + expect(flattenOwnerSessionOrder(value)).toEqual(['s1', 's2', 's3', 's4']); + }); + + it('projects renderable refs, omits unopened webviews, and keeps a collapsed highlight visible', () => { + expect( + flattenVisibleRefs(value, { + liveSessionIds: new Set(['s1', 's2', 's3', 's4']), + openWebviewIds: new Set(['dash']), + collapsedGroupIds: new Set(['collapsed']), + highlighted: ref('s2'), + }) + ).toEqual([ref('s2'), ref('s3'), ref('dash', 'webview'), ref('s4')]); + }); + + it('keeps a highlighted open webview visible inside a collapsed group', () => { + expect( + flattenVisibleRefs(value, { + liveSessionIds: new Set(['s1', 's2', 's3', 's4']), + openWebviewIds: new Set(['closed', 'dash']), + collapsedGroupIds: new Set(['collapsed']), + highlighted: ref('closed', 'webview'), + }) + ).toEqual([ref('closed', 'webview'), ref('s3'), ref('dash', 'webview'), ref('s4')]); + }); +}); diff --git a/test/tab-orientation.test.ts b/test/tab-orientation.test.ts new file mode 100644 index 00000000..7ac05234 --- /dev/null +++ b/test/tab-orientation.test.ts @@ -0,0 +1,73 @@ +/** @fileoverview COD-358 vertical session rail orientation policy and wiring. */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8'); +const indexHtml = read('src/web/public/index.html'); +const stylesCss = read('src/web/public/styles.css'); +const settingsJs = read('src/web/public/settings-ui.js'); +const appJs = read('src/web/public/app.js'); +const schemasTs = read('src/web/schemas.ts'); + +function loadPolicy() { + const context = vm.createContext({ window: {}, globalThis: {} }); + vm.runInContext(read('src/web/public/constants.js'), context, { filename: 'constants.js' }); + return ( + context.window as { + CodemanTabOverflow: { resolveTabOrientation: (input: unknown) => string }; + } + ).CodemanTabOverflow; +} + +describe('vertical tab orientation policy', () => { + it('honors vertical on desktop and tablet but forces phones horizontal', () => { + const policy = loadPolicy(); + expect(policy.resolveTabOrientation({ deviceType: 'desktop', setting: 'vertical' })).toBe('vertical'); + expect(policy.resolveTabOrientation({ deviceType: 'tablet', setting: 'vertical' })).toBe('vertical'); + expect(policy.resolveTabOrientation({ deviceType: 'mobile', setting: 'vertical' })).toBe('horizontal'); + }); + + it('fails closed to horizontal for absent and unknown values', () => { + const policy = loadPolicy(); + expect(policy.resolveTabOrientation(undefined)).toBe('horizontal'); + expect(policy.resolveTabOrientation({})).toBe('horizontal'); + expect(policy.resolveTabOrientation({ deviceType: 'desktop', setting: 'sideways' })).toBe('horizontal'); + }); +}); + +describe('vertical tab rail wiring', () => { + it('ships one rail before the terminal and one orientation control', () => { + expect(indexHtml).toContain('id="tabRail"'); + expect(indexHtml.indexOf('id="tabRail"')).toBeLessThan(indexHtml.indexOf('id="terminalContainer"')); + expect(indexHtml).toContain('id="appSettingsTabOrientation"'); + expect(indexHtml).toContain('