Merge remote-tracking branch 'origin/master' into worktree-grok-mode

# Conflicts:
#	src/web/public/app.js
This commit is contained in:
Codeman maintainer
2026-08-24 00:32:25 +02:00
70 changed files with 7091 additions and 182 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---
Add the owner-scoped tab-layout model, persistence, API, lifecycle repair, and synchronized legacy ordering foundation.
@@ -0,0 +1,5 @@
---
'aicodeman': minor
---
Add an optional resizable vertical session rail with responsive layout, complete labels, accessible controls, and stable inline rename.
+1 -1
View File
@@ -338,7 +338,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry ### 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 ### API Routes
+3
View File
@@ -19,6 +19,9 @@
* why these are a runnable suite (`npm run test:browser`) rather than skipped. * why these are a runnable suite (`npm run test:browser`) rather than skipped.
*/ */
export const BROWSER_TEST_GLOBS = [ 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/inline-rename.test.ts',
'test/opencode-resize.test.ts', 'test/opencode-resize.test.ts',
'test/webgl-fallback.test.ts', 'test/webgl-fallback.test.ts',
+1 -1
View File
@@ -119,7 +119,7 @@ Shell and external CLI sessions accept `idle`, `working`, and `exit`.
## SSE ## 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. client with a test that fails on drift.
The heartbeat is a **named** `sse:heartbeat` event rather than an SSE comment, because The heartbeat is a **named** `sse:heartbeat` event rather than an SSE comment, because
+2
View File
@@ -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 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 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 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 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 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'); 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', 'input-cjk.js',
'sanitize-html.js', 'sanitize-html.js',
'app.js', 'app.js',
'tab-rail-resize.js',
'terminal-ui.js', 'terminal-ui.js',
'respawn-ui.js', 'respawn-ui.js',
'ralph-panel.js', 'ralph-panel.js',
+1 -1
View File
@@ -426,7 +426,7 @@ export class CronService {
piConfig, piConfig,
owner: job.owner, owner: job.owner,
}); });
this.deps.addSession(session); await this.deps.addSession(session);
this.store.incrementSessionsCreated(); this.store.incrementSessionsCreated();
this.deps.persistSessionState(session); this.deps.persistSessionState(session);
await this.deps.setupSessionListeners(session); await this.deps.setupSessionListeners(session);
+60 -14
View File
@@ -40,6 +40,8 @@ import {
} from './types.js'; } from './types.js';
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js'; import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
import { dataPath, CODEMAN_INSTANCE } from './config/instance.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) */ /** Debounce delay for batching state writes (ms) */
const SAVE_DEBOUNCE_MS = 500; const SAVE_DEBOUNCE_MS = 500;
@@ -281,6 +283,9 @@ export class StateStore {
if (this.state.sessionOrder) { if (this.state.sessionOrder) {
parts.push(`"sessionOrder":${JSON.stringify(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(',')}}`; return `{${parts.join(',')}}`;
} }
@@ -514,22 +519,28 @@ export class StateStore {
*/ */
cleanupStaleSessions(activeSessionIds: Set<string>): { cleanupStaleSessions(activeSessionIds: Set<string>): {
count: number; count: number;
cleaned: Array<{ id: string; name?: string }>; cleaned: Array<{ id: string; name?: string; owner?: string }>;
} { } {
const allSessionIds = Object.keys(this.state.sessions); const staleIds = new Set(Object.keys(this.state.sessions).filter((sessionId) => !activeSessionIds.has(sessionId)));
const cleaned: Array<{ id: string; name?: string }> = []; return this.cleanupSessionsByIds(staleIds);
}
for (const sessionId of allSessionIds) { /** Deletes only confirmed stale session IDs, retaining records pinned after confirmation. */
if (!activeSessionIds.has(sessionId)) { cleanupSessionsByIds(sessionIds: ReadonlySet<string>): {
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session count: number;
const name = this.state.sessions[sessionId]?.name; cleaned: Array<{ id: string; name?: string; owner?: string }>;
cleaned.push({ id: sessionId, name }); } {
delete this.state.sessions[sessionId]; const cleaned: Array<{ id: string; name?: string; owner?: string }> = [];
this.cachedSessionJsons.delete(sessionId);
this.dirtySessions.delete(sessionId); for (const sessionId of sessionIds) {
// Also clean up Ralph state for this session const session = this.state.sessions[sessionId];
this.ralphStates.delete(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) { if (cleaned.length > 0) {
@@ -664,6 +675,41 @@ export class StateStore {
this.save(); 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<string, TabLayout> {
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<Record<string, TabLayout>>,
projectOrder: (latest: readonly string[]) => readonly string[]
): { layouts: Record<string, TabLayout>; 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. */ /** Resets all state to initial values and saves immediately. */
reset(): void { reset(): void {
this.state = createInitialState(); this.state = createInitialState();
+81
View File
@@ -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<string, TabRefMetadata>();
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;
}
+144
View File
@@ -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<Record<string, TabLayout>>;
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<string, TabLayout>;
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<string, TabLayoutSessionRecord> {
const sessions = new Map<string, TabLayoutSessionRecord>();
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<string, TabLayoutSessionRecord>
): 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<string>();
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 };
}
+663
View File
@@ -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<string, TabLayoutSessionLike>;
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
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<string, string[]>;
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<string>;
}
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<TabRef, 'kind' | 'id'>): 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<string, Promise<void>>();
constructor(private readonly deps: TabLayoutServiceDeps) {}
private async withOwner<T>(owner: string, task: () => Promise<T>): Promise<T> {
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<T>(owners: readonly string[], task: () => Promise<T>, index = 0): Promise<T> {
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<void> {
if (this.restorationState !== 'complete') return;
const { persisted, live } = this.sessionRecords();
const webviews = await this.deps.readWebviews();
const owners = new Set<string>();
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<string, TabLayoutSessionRecord>();
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<string>();
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<Record<string, TabLayout>>,
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<PreparedOwnerLayout> {
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<TabLayout> {
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<TabLayout> {
return this.withOwner(owner, () => this.getUnlocked(owner));
}
async put(owner: string, desired: unknown, baseVersion: number): Promise<TabLayoutPutResult> {
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<LegacyOrderPutResult> {
return actor.isAdmin ? this.putAdminLegacyOrder(requested) : this.putOwnerLegacyOrder(actor.owner, requested);
}
private async putOwnerLegacyOrder(owner: string, requested: readonly string[]): Promise<LegacyOrderPutResult> {
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<LegacyOrderPutResult> {
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<LegacyOrderPutResult | null> => {
if (!sameOrder(owners, discoverOwners())) return null;
const normalized = normalizeSessionOrder(requested);
const knownOwners = new Map<string, string>();
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<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
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<TabLayout> {
return this.get(owner);
}
/** Reconcile one completed saved-webview creation into one versioned mutation. */
async webviewCreated(owner: string): Promise<TabLayout> {
return this.get(owner);
}
async sessionsRemoved(removed: readonly RemovedTabLayoutSession[]): Promise<void> {
if (this.restorationState !== 'complete' || removed.length === 0) return;
const byOwner = new Map<string, string[]>();
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<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
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<T>(removed: readonly RemovedTabLayoutSession[], action: () => Promise<T>): Promise<T> {
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<T>(
activeSessionIds: ReadonlySet<string>,
action: (ids: ReadonlySet<string>) => T | Promise<T>
): Promise<T> {
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<string, string[]>();
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<string, string[]>();
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<string>;
}> = [];
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<string, TabLayoutSessionRecord>();
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<void> {
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);
});
}
}
+547
View File
@@ -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<string>;
openWebviewIds: ReadonlySet<string>;
collapsedGroupIds?: ReadonlySet<string>;
highlighted?: TabRef;
}
export class TabLayoutValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'TabLayoutValidationError';
}
}
const keyOf = (ref: Pick<TabRef, 'kind' | 'id'>): string => `${ref.kind}\u0000${ref.id}`;
function assertRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
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<string>();
const refKeys = new Set<string>();
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<string, TabRefMetadata>();
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<string | null, TabRef[]>): 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<string>();
const dedupedByContainer = new Map<string | null, TabRef[]>();
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<string>();
const state = new Map<string, 'visiting' | 'done'>();
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<string, string>();
const children = new Map<string, string[]>();
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<string>();
const output = new Map<string | null, TabRef[]>();
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<string> {
const valid = validMetadata(metadata);
const stored = new Map(locations(layout).map((item) => [keyOf(item.ref), item.ref]));
const children = new Map<string, string[]>();
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<string>();
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<string | null, TabRef[]>();
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<string>();
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;
}
+3
View File
@@ -24,6 +24,7 @@ import type { TaskState } from './task.js';
import type { RalphLoopState } from './ralph.js'; import type { RalphLoopState } from './ralph.js';
import type { RespawnConfig } from './respawn.js'; import type { RespawnConfig } from './respawn.js';
import type { CronJob, CronJobRun } from './cron.js'; import type { CronJob, CronJobRun } from './cron.js';
import type { TabLayout } from '../tab-layout.js';
// ========== Global Stats Types ========== // ========== Global Stats Types ==========
@@ -118,6 +119,8 @@ export interface AppState {
cronJobRuns?: Record<string, CronJobRun>; cronJobRuns?: Record<string, CronJobRun>;
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */ /** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
sessionOrder?: string[]; sessionOrder?: string[];
/** Owner-scoped authoritative grouped tab layouts. */
tabLayouts?: Record<string, TabLayout>;
} }
// ========== Default Configuration ========== // ========== Default Configuration ==========
+1
View File
@@ -14,3 +14,4 @@ export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort } from './auth-port.js'; export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js'; export type { OrchestratorPort } from './orchestrator-port.js';
export type { CronPort } from './cron-port.js'; export type { CronPort } from './cron-port.js';
export type { TabLayoutPort } from './tab-layout-port.js';
+1 -1
View File
@@ -7,7 +7,7 @@ import type { Session } from '../../session.js';
export interface SessionPort { export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>; readonly sessions: ReadonlyMap<string, Session>;
addSession(session: Session): void; addSession(session: Session): Promise<void>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
setupSessionListeners(session: Session): Promise<void>; setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void; persistSessionState(session: Session): void;
+8
View File
@@ -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;
}
+68 -10
View File
@@ -919,6 +919,8 @@ class CodemanApp {
// Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder) // Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder)
// and then applies the sidebar variant on top — do not call both. // and then applies the sidebar variant on top — do not call both.
this.applySessionListLayout(); this.applySessionListLayout();
this.applyTabOrientation();
this.initTabRailResize?.();
this.applyMonitorVisibility(); this.applyMonitorVisibility();
this.applyLineageLineSettings?.(); this.applyLineageLineSettings?.();
this._installLineageStripScrollListener?.(); this._installLineageStripScrollListener?.();
@@ -3758,6 +3760,18 @@ class CodemanApp {
return layout === 'sidebar' || layout === 'sidebar-rich' ? layout : 'header'; 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 <html>, not the settings blob: this is called * Reads the APPLIED layout off <html>, not the settings blob: this is called
* per dragover event and per tab in render loops, and getSessionListLayout() * per dragover event and per tab in render loops, and getSessionListLayout()
@@ -3769,6 +3783,15 @@ class CodemanApp {
return document.documentElement.dataset.sessionList === 'sidebar'; 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 * 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. * per-session line ("created 3d ago · working 12m") plus a status pill.
@@ -3861,17 +3884,22 @@ class CodemanApp {
*/ */
applySessionListLayout() { applySessionListLayout() {
const mode = this.getSessionListLayout(); const mode = this.getSessionListLayout();
this.applySessionSidebarFontSize();
// 'sidebar' and 'sidebar-rich' are the same column; only row detail differs. // 'sidebar' and 'sidebar-rich' are the same column; only row detail differs.
const sidebar = mode === 'sidebar' || mode === 'sidebar-rich'; const sidebar = mode === 'sidebar' || mode === 'sidebar-rich';
const collapsed = this.isSessionSidebarCollapsed(); const collapsed = this.isSessionSidebarCollapsed();
const prevMode = document.documentElement.dataset.sessionList; const prevMode = document.documentElement.dataset.sessionList;
const prevDetail = document.documentElement.dataset.sidebarDetail; const prevDetail = document.documentElement.dataset.sidebarDetail;
const prevCollapsed = document.documentElement.dataset.sidebar;
const tabsEl = document.getElementById('sessionTabs'); const tabsEl = document.getElementById('sessionTabs');
const headerHost = document.getElementById('sessionTabsHost'); const headerHost = document.getElementById('sessionTabsHost');
const sidebarList = document.getElementById('sessionSidebarList'); const sidebarList = document.getElementById('sessionSidebarList');
if (!tabsEl || !headerHost || !sidebarList) return; 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); if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
document.documentElement.dataset.sessionList = sidebar ? 'sidebar' : 'header'; 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. // 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.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple';
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded'; 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'); const btn = document.getElementById('sidebarToggleBtn');
if (btn) { if (btn) {
@@ -3933,7 +3961,8 @@ class CodemanApp {
const layoutChanged = const layoutChanged =
prevMode !== document.documentElement.dataset.sessionList || prevMode !== document.documentElement.dataset.sessionList ||
prevDetail !== document.documentElement.dataset.sidebarDetail; 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(); this._fullRenderSessionTabs();
} }
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode() // tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
@@ -4476,9 +4505,17 @@ class CodemanApp {
const nameEl = tab.querySelector('.tab-name'); const nameEl = tab.querySelector('.tab-name');
if (nameEl) { if (nameEl) {
const _p = parseSessionPrefix(name); const _p = parseSessionPrefix(name);
const _label = _p && _p.suffix ? _p.suffix : name; if (nameEl.dataset.fullName !== name) {
if (nameEl.textContent !== _label) { nameEl.replaceChildren();
nameEl.textContent = _label; 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 tab.title = _p && _p.suffix
? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix) ? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix)
: (session.workingDir || ''); : (session.workingDir || '');
@@ -4529,9 +4566,11 @@ class CodemanApp {
// Need to add badge - insert before the action-icon overlay so the // Need to add badge - insert before the action-icon overlay so the
// badge stays a direct child of the tab (outside .tab-actions) // badge stays a direct child of the tab (outside .tab-actions)
const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents); const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
const actionsEl = tab.querySelector('.tab-actions'); const actionsEl = tab.querySelector(':scope > .tab-actions');
if (actionsEl) { if (actionsEl) {
actionsEl.insertAdjacentHTML('beforebegin', badgeHtml); actionsEl.insertAdjacentHTML('beforebegin', badgeHtml);
} else {
tab.insertAdjacentHTML('beforeend', badgeHtml);
} }
} else if (minimizedCount === 0 && subagentBadgeEl) { } else if (minimizedCount === 0 && subagentBadgeEl) {
// Count went to 0 - remove badge // Count went to 0 - remove badge
@@ -4589,6 +4628,17 @@ class CodemanApp {
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false; 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') { if (manualTwoRows || deviceType !== 'desktop') {
container.classList.remove('tabs-auto-wrap'); container.classList.remove('tabs-auto-wrap');
return; return;
@@ -4629,6 +4679,7 @@ class CodemanApp {
} }
_fullRenderSessionTabs() { _fullRenderSessionTabs() {
this.closeTabRailActionMenu?.();
if (this._inlineRenameActive) return; if (this._inlineRenameActive) return;
const container = this.$('sessionTabs'); const container = this.$('sessionTabs');
@@ -4706,7 +4757,9 @@ class CodemanApp {
// JUST the description on the tab; the generated w<n>-<case> id moves to the // JUST the description on the tab; the generated w<n>-<case> id moves to the
// tooltip and stays visible in the session settings modal. // tooltip and stays visible in the session settings modal.
const parsedName = parseSessionPrefix(name); const parsedName = parseSessionPrefix(name);
const tabLabel = parsedName && parsedName.suffix ? parsedName.suffix : name; const tabLabel = parsedName && parsedName.suffix
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
: escapeHtml(name);
const tabTooltip = parsedName && parsedName.suffix const tabTooltip = parsedName && parsedName.suffix
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix) ? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || ''); : (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}"` ? ` 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 = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}> parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''} ${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''} ${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
@@ -4728,7 +4784,8 @@ class CodemanApp {
<span class="tab-info"> <span class="tab-info">
<span class="tab-name-row"> <span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : ''} ${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : ''}
<span class="tab-name" data-session-id="${id}">${escapeHtml(tabLabel)}</span> <span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span> <span class="tab-detached-badge" aria-hidden="true">detached</span>
</span> </span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''} ${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
@@ -4737,7 +4794,7 @@ class CodemanApp {
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''} ${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge} ${subagentBadge}
${ultracodeBadge} ${ultracodeBadge}
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span></span> ${inlineSessionActions ? '' : tabActionsHtml}
</div>`); </div>`);
_tabIdx++; _tabIdx++;
} }
@@ -6027,6 +6084,7 @@ class CodemanApp {
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler // Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) { _cleanupSessionData(sessionId) {
this.closeTabRailActionMenu?.();
// If the deleted session is currently being renamed, abort the rename // If the deleted session is currently being renamed, abort the rename
// so the inline <input> doesn't ghost as a stale tab on screen. // so the inline <input> doesn't ghost as a stale tab on screen.
if (this._activeRename?.sessionId === sessionId) { if (this._activeRename?.sessionId === sessionId) {
+82 -8
View File
@@ -10,7 +10,7 @@
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority) * @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor * @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
* @globals {function} escapeHtml - XSS-safe HTML escaping * @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 * @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
* *
* @dependency None (first in load order) * @dependency None (first in load order)
@@ -156,6 +156,43 @@ function shouldAutoWrapTabs(input) {
return scrollWidth > clientWidth + 1; 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 // 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"; // 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. // 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. // apart bled into one thick band instead of reading as three separate lines.
const LINEAGE_SIBLING_STEP_PX = 8; const LINEAGE_SIBLING_STEP_PX = 8;
const LINEAGE_STRIP_TOLERANCE_PX = 4; 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 // 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 // (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. // 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; const ch = Number(child.height) || 0;
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null; if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
const px = Number(parent.left) + pw / 2; const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
const cx = Number(child.left) + cw / 2;
if (!Number.isFinite(px) || !Number.isFinite(cx)) return null;
const strip = input?.strip; 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) { if (strip && Number(strip.width) > 0) {
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX; const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.left) + Number(strip.width) + 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; 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 pBottom = pTop + ph;
const cTop = Number(child.top);
const cBottom = cTop + ch; const cBottom = cTop + ch;
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2; 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.shouldSkipWebGL = shouldSkipWebGL;
window.CodemanTabOverflow = { window.CodemanTabOverflow = {
shouldAutoWrapTabs, shouldAutoWrapTabs,
resolveTabOrientation,
computeTabScrollLeft, computeTabScrollLeft,
TAB_SCROLL_REVEAL_PX, 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 = { window.CodemanWsReconnect = {
plan: planWsReconnect, plan: planWsReconnect,
}; };
@@ -628,6 +699,8 @@ if (typeof window !== 'undefined') {
DIP_MIN_PX: LINEAGE_DIP_MIN_PX, DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
DIP_MAX_PX: LINEAGE_DIP_MAX_PX, DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_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, COLORS: LINEAGE_COLORS,
}; };
window.CodemanConnectionLoss = { window.CodemanConnectionLoss = {
@@ -969,6 +1042,7 @@ const SSE_EVENTS = {
// Web tabs (dashboard URLs) // Web tabs (dashboard URLs)
WEBVIEW_CHANGED: 'webview:changed', WEBVIEW_CHANGED: 'webview:changed',
TAB_LAYOUT_CHANGED: 'tab:layoutChanged',
}; };
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
+4 -2
View File
@@ -88,12 +88,14 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Width-driven, like every other layout decision in the app. Explicitly yields * 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 * to the phone overview and persistent vertical tab rail: those surfaces already
* lists of the same thing on one screen is worse than none. * list the same sessions, and two lists of the same thing on one screen is worse
* than none.
*/ */
shouldShowHomeSessions() { shouldShowHomeSessions() {
if (this.isSoloWindow) return false; if (this.isSoloWindow) return false;
if (this.shouldUseMobileOverview?.()) 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 // 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 // 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). // UNDER it: sidebar 11, welcome overlay 10, rail inside the overlay).
+2
View File
@@ -233,6 +233,8 @@
'Redraw Terminal Button': '重绘终端按钮', 'Redraw Terminal Button': '重绘终端按钮',
'Tab Bar': '标签栏', 'Tab Bar': '标签栏',
'Session List Layout': '会话列表布局', 'Session List Layout': '会话列表布局',
'Session Name Font Size': '会话名称字体大小',
'Adjust only session names in the vertical sidebar.': '仅调整垂直侧边栏中的会话名称。',
'Header tab strip': '顶栏标签条', 'Header tab strip': '顶栏标签条',
'Left sidebar': '左侧边栏', 'Left sidebar': '左侧边栏',
'Left sidebar simple': '左侧边栏(简洁)', 'Left sidebar simple': '左侧边栏(简洁)',
+52 -1
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate 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 here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. --> itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var L=JSON.parse(localStorage.getItem(k)||'{}').sessionListLayout;var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';}</script> <script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) --> <!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style> <style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)} .loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -358,6 +358,20 @@
<!-- Main Terminal Area --> <!-- Main Terminal Area -->
<main class="main"> <main class="main">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
<div
id="tabRailResizeHandle"
class="tab-rail-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize session rail"
aria-valuemin="208"
aria-valuemax="360"
aria-valuenow="256"
tabindex="0"
></div>
</aside>
<!-- Collapsible session sidebar (opt-in layout). Deliberately EMPTY in <!-- Collapsible session sidebar (opt-in layout). Deliberately EMPTY in
markup: applySessionListLayout() moves #sessionTabs in here, so the markup: applySessionListLayout() moves #sessionTabs in here, so the
vertical list is the exact same DOM node as the header strip and every vertical list is the exact same DOM node as the header strip and every
@@ -513,6 +527,7 @@
desktop (which never loads mobile.css) can never render it. --> desktop (which never loads mobile.css) can never render it. -->
<div class="mobile-overview" id="mobileOverview" hidden></div> <div class="mobile-overview" id="mobileOverview" hidden></div>
</main> </main>
<div class="tab-rail-resize-shield" id="tabRailResizeShield" aria-hidden="true" hidden></div>
<!-- Project Insights Panel (shows file-viewing Bash commands) --> <!-- Project Insights Panel (shows file-viewing Bash commands) -->
<div class="project-insights-panel" id="projectInsightsPanel"> <div class="project-insights-panel" id="projectInsightsPanel">
@@ -1877,6 +1892,29 @@
<div class="set-group"> <div class="set-group">
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div> <div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
<div class="set-group-body"> <div class="set-group-body">
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
<div class="set-row-text">
<span class="set-row-label">Tab Orientation</span>
<span class="set-row-desc">Keep tabs in the header or place them beside the terminal. Phones stay horizontal.</span>
</div>
<select id="appSettingsTabOrientation" class="set-select">
<option value="horizontal">Horizontal (top)</option>
<option value="vertical">Vertical (side rail)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Width</span>
<span class="set-row-desc">Set the preferred rail width for this device.</span>
</div>
<select id="appSettingsTabRailWidth" class="set-select">
<option value="208">Compact (208px)</option>
<option value="256">Default (256px)</option>
<option value="320">Wide (320px)</option>
<option value="360">Maximum (360px)</option>
<option value="custom" disabled>Custom</option>
</select>
</div>
<div class="set-row has-field" data-search="session list layout sidebar tab strip vertical"> <div class="set-row has-field" data-search="session list layout sidebar tab strip vertical">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Session List Layout</span> <span class="set-row-label">Session List Layout</span>
@@ -1888,6 +1926,18 @@
<option value="sidebar-rich">Left sidebar</option> <option value="sidebar-rich">Left sidebar</option>
</select> </select>
</div> </div>
<div class="set-row has-field" data-search="session sidebar name font size text">
<div class="set-row-text">
<span class="set-row-label" id="appSettingsSessionSidebarFontSizeLabel">Session Name Font Size</span>
<span class="set-row-desc">Adjust only session names in the vertical sidebar.</span>
</div>
<label class="set-range-field" for="appSettingsSessionSidebarFontSize">
<input type="range" id="appSettingsSessionSidebarFontSize" min="11" max="18" step="1" value="14"
aria-labelledby="appSettingsSessionSidebarFontSizeLabel"
oninput="document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent=this.value+' px'">
<output id="appSettingsSessionSidebarFontSizeValue" for="appSettingsSessionSidebarFontSize">14 px</output>
</label>
</div>
<div class="set-row" data-search="tall tabs folder name two rows"> <div class="set-row" data-search="tall tabs folder name two rows">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Tall Tabs</span> <span class="set-row-label">Tall Tabs</span>
@@ -3332,6 +3382,7 @@
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. --> <!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script> <script defer src="sanitize-html.js"></script>
<script defer src="app.js"></script> <script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script> <script defer src="terminal-ui.js"></script>
<script defer src="respawn-ui.js"></script> <script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script> <script defer src="ralph-panel.js"></script>
+9 -1
View File
@@ -160,6 +160,8 @@ Object.assign(CodemanApp.prototype, {
const strip = document.getElementById('sessionTabs'); const strip = document.getElementById('sessionTabs');
if (!strip) return; if (!strip) return;
const stripRect = strip.getBoundingClientRect(); const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
for (const edge of edges) { for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) { for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id; const key = 'tab:' + id;
@@ -175,7 +177,13 @@ Object.assign(CodemanApp.prototype, {
const childRect = rects.get('tab:' + edge.childId); const childRect = rects.get('tab:' + edge.childId);
if (!parentRect || !childRect) continue; if (!parentRect || !childRect) continue;
const geom = compute({ parent: parentRect, child: childRect, strip: stripRect, depth: edge.depth }); const geom = compute({
parent: parentRect,
child: childRect,
strip: stripRect,
depth: edge.depth,
orientation,
});
if (!geom) continue; // scrolled out of the strip, or a degenerate rect if (!geom) continue; // scrolled out of the strip, or a degenerate rect
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+49 -9
View File
@@ -1851,15 +1851,22 @@ Object.assign(CodemanApp.prototype, {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return; if (!session) return;
this._activeRename?.cancel();
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`); const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return; if (!tabName) return;
// Prevent tab re-renders from destroying the input while renaming // Prevent tab re-renders from destroying the input while renaming
this._inlineRenameActive = true; this._inlineRenameActive = true;
tabName.classList.add('tab-name-renaming');
const currentName = this.getSessionName(session); const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name); const parsed = parseSessionPrefix(session.name);
const originalContent = tabName.textContent; const originalContent = tabName.textContent;
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
const restoreOriginalChildren = () => {
tabName.replaceChildren(...originalChildren.map((node) => node.cloneNode(true)));
};
// Clear existing content to make room for the input element // Clear existing content to make room for the input element
tabName.textContent = ''; tabName.textContent = '';
while (tabName.firstChild) tabName.removeChild(tabName.firstChild); while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
@@ -1867,6 +1874,7 @@ Object.assign(CodemanApp.prototype, {
// If prefix detected, show it as non-editable label // If prefix detected, show it as non-editable label
if (parsed) { if (parsed) {
const prefixLabel = document.createElement('span'); const prefixLabel = document.createElement('span');
prefixLabel.className = 'tab-rename-prefix';
prefixLabel.textContent = parsed.prefix + ': '; prefixLabel.textContent = parsed.prefix + ': ';
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;'; prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
tabName.appendChild(prefixLabel); tabName.appendChild(prefixLabel);
@@ -1879,36 +1887,67 @@ Object.assign(CodemanApp.prototype, {
input.className = 'tab-rename-input'; input.className = 'tab-rename-input';
// 80px is tuned for the narrow header tab; a full-width sidebar row can and // 80px is tuned for the narrow header tab; a full-width sidebar row can and
// should give the whole line to the input. // should give the whole line to the input.
const renameWidth = this.isSessionSidebarActive?.() ? '100%' : '80px'; const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`; input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
tabName.appendChild(input); tabName.appendChild(input);
input.focus(); input.focus();
input.select(); input.select();
const finishRename = async ({ commit }) => { let editSettled = false;
if (!this._inlineRenameActive) return; // prevent double-fire let invalidated = false;
let completed = false;
const releaseRenderGuard = () => {
if (this._activeRename !== renameHandle) return;
this._inlineRenameActive = false; this._inlineRenameActive = false;
};
const completeCurrentRename = () => {
if (this._activeRename !== renameHandle) return;
completed = true;
releaseRenderGuard();
this._activeRename = null; this._activeRename = null;
this.renderSessionTabs();
};
const cancelRename = () => {
if (invalidated || completed) return;
invalidated = true;
editSettled = true;
tabName.classList.remove('tab-name-renaming');
restoreOriginalChildren();
completeCurrentRename();
};
const finishRename = async ({ commit }) => {
if (editSettled || invalidated) return;
editSettled = true;
tabName.classList.remove('tab-name-renaming');
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render // Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
// so any ghost DOM is replaced with the canonical tab list, and skip the // so any ghost DOM is replaced with the canonical tab list, and skip the
// API call — a cancel must not fire a stale rename PUT. // API call — a cancel must not fire a stale rename PUT.
if (!commit) { if (!commit) {
this.renderSessionTabs(); cancelRename();
return; return;
} }
if (this._activeRename !== renameHandle) return;
releaseRenderGuard();
const suffix = input.value.trim(); const suffix = input.value.trim();
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix; const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
tabName.textContent = fullName || originalContent; if (fullName === session.name) restoreOriginalChildren();
else tabName.textContent = fullName || originalContent;
// Skip the API call if the session vanished between focus and blur. // Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId); const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) { if (stillExists && fullName !== session.name) {
const confirmed = await this._putSessionName(sessionId, fullName); const confirmed = await this._putSessionName(sessionId, fullName);
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
if (confirmed === null) { if (confirmed === null) {
tabName.textContent = originalContent; restoreOriginalChildren();
this.showToast('Failed to rename', 'error'); this.showToast('Failed to rename', 'error');
} else { } else {
// The re-render below repaints from this.sessions, so the new name has // The re-render below repaints from this.sessions, so the new name has
@@ -1917,14 +1956,15 @@ Object.assign(CodemanApp.prototype, {
} }
} }
// Re-render tabs to restore full tab structure // Re-render tabs to restore full tab structure
this.renderSessionTabs(); completeCurrentRename();
}; };
// Register only after the input is wired so a throw above can't strand state. // Register only after the input is wired so a throw above can't strand state.
this._activeRename = { const renameHandle = {
sessionId, sessionId,
cancel: () => finishRename({ commit: false }), cancel: cancelRename,
}; };
this._activeRename = renameHandle;
input.addEventListener('blur', () => finishRename({ commit: true })); input.addEventListener('blur', () => finishRename({ commit: true }));
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
+63 -1
View File
@@ -400,9 +400,20 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false; document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
document.getElementById('appSettingsTabOrientation').value =
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
width: settings.tabRailWidth ?? defaults.tabRailWidth ?? 256,
}) ?? 256;
this.syncTabRailWidthSetting?.(tabRailWidth);
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value = document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
settings.sessionSidebarFontSize ?? defaults.sessionSidebarFontSize
);
document.getElementById('appSettingsSessionSidebarFontSize').value = String(sessionSidebarFontSize);
document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent = `${sessionSidebarFontSize} px`;
// Claude CLI settings // Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
const allowedToolsRow = document.getElementById('allowedToolsRow'); const allowedToolsRow = document.getElementById('allowedToolsRow');
@@ -2028,8 +2039,13 @@ Object.assign(CodemanApp.prototype, {
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked, webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked, extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked, showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value, sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
document.getElementById('appSettingsSessionSidebarFontSize').value
),
skin: document.getElementById('appSettingsSkin').value, skin: document.getElementById('appSettingsSkin').value,
// Claude CLI settings // Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value, claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -2179,6 +2195,7 @@ Object.assign(CodemanApp.prototype, {
// Re-parents #sessionTabs between header host and sidebar if the layout // Re-parents #sessionTabs between header host and sidebar if the layout
// changed, then calls applyTabWrapSettings() itself — do not call both. // changed, then calls applyTabWrapSettings() itself — do not call both.
this.applySessionListLayout(); this.applySessionListLayout();
this.applyTabOrientation({ settleRailWidth: true });
this.applyLineageLineSettings?.(); this.applyLineageLineSettings?.();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change) this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility(); this.applyMonitorVisibility();
@@ -2424,7 +2441,10 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: false, imageWatcherEnabled: false,
ralphTrackerEnabled: false, ralphTrackerEnabled: false,
tabTwoRows: false, tabTwoRows: false,
tabOrientation: 'horizontal',
tabRailWidth: 256,
sessionListLayout: 'header', sessionListLayout: 'header',
sessionSidebarFontSize: 14,
cjkInputEnabled: false, cjkInputEnabled: false,
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
webglRendererEnabled: false, // mobile always uses the DOM renderer webglRendererEnabled: false, // mobile always uses the DOM renderer
@@ -2665,6 +2685,48 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
applyTabOrientation(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const sidebarOwnsTabs = this.isSessionSidebarActive?.() === true;
const orientation =
!this.isSoloWindow && !sidebarOwnsTabs && window.CodemanTabOverflow?.resolveTabOrientation
? window.CodemanTabOverflow.resolveTabOrientation({
deviceType: MobileDetection.getDeviceType(),
setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal',
})
: 'horizontal';
const root = document.documentElement;
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
root.setAttribute('data-tab-orientation', orientation);
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
if (!sidebarOwnsTabs && tabsEl && rail && headerHost) {
if (orientation === 'vertical') {
if (tabsEl.parentElement !== rail) rail.appendChild(tabsEl);
} else if (tabsEl.parentElement !== headerHost) {
headerHost.appendChild(tabsEl);
}
}
if (tabsEl) {
tabsEl.setAttribute('aria-orientation', sidebarOwnsTabs || orientation === 'vertical' ? 'vertical' : 'horizontal');
}
const settleRailWidth =
options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation);
this.applyTabRailWidth?.({ settle: settleRailWidth });
if (previous !== orientation) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.fitAddon?.fit();
this._fullRenderSessionTabs?.();
this._updateConnectionLinesImmediate?.();
this._refreshHomeSessionsIfVisible?.();
}
},
applyTabWrapSettings() { applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
@@ -2898,7 +2960,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontFamily',
'language', 'language',
+293
View File
@@ -62,6 +62,7 @@
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55); --ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
--header-height: 36px; --header-height: 36px;
--toolbar-height: 42px; --toolbar-height: 42px;
--tab-rail-width: 256px;
--sidebar-width: 260px; --sidebar-width: 260px;
--sidebar-width-rich: 300px; /* detailed rows carry a stamps line as well */ --sidebar-width-rich: 300px; /* detailed rows carry a stamps line as well */
--sidebar-width-collapsed: 44px; /* == --touch-target-min */ --sidebar-width-collapsed: 44px; /* == --touch-target-min */
@@ -575,6 +576,99 @@ body {
background: var(--border-light); background: var(--border-light);
} }
.tab-rail {
display: none;
flex: 0 0 var(--tab-rail-width);
width: var(--tab-rail-width);
min-width: 0;
overflow: hidden;
background: var(--glass-bg);
border-right: 1px solid var(--glass-border);
position: relative;
z-index: 11;
}
html[data-tab-orientation='vertical'] .tab-rail {
display: flex;
flex-direction: column;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tabs {
--lineage-vertical-gutter: 24px;
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
gap: 2px;
padding: 0.35rem;
padding-left: calc(0.35rem + var(--lineage-vertical-gutter));
overflow-x: hidden;
overflow-y: auto;
max-height: none;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab {
width: 100%;
max-width: none;
justify-content: flex-start;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab > * {
flex-shrink: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
flex: 1 1 auto;
min-width: 0;
}
html[data-tab-orientation='vertical'] .header-right {
margin-left: auto;
}
.tab-rail-resize-handle {
position: absolute;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
width: 16px;
cursor: ew-resize;
touch-action: none;
transition: background-color 0.15s ease;
}
.tab-rail-resize-handle:hover,
.tab-rail-resize-handle:focus-visible {
background: color-mix(in srgb, var(--accent) 24%, transparent);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
html:not([data-tab-orientation='vertical']) .tab-rail-resize-handle {
display: none;
}
.tab-rail-resize-shield:not([hidden]) {
display: block;
position: fixed;
inset: 0;
z-index: 10000;
cursor: ew-resize;
}
body.tab-rail-resizing,
body.tab-rail-resizing * {
cursor: ew-resize !important;
user-select: none !important;
}
@media (prefers-reduced-motion: reduce) {
.tab-rail,
.tab-rail-resize-handle {
transition: none !important;
}
}
.session-tab { .session-tab {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1422,6 +1516,44 @@ html[data-line-anim="packet"] .connection-line.line-enter {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.session-tab .tab-name-prefix {
display: none;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
white-space: normal;
line-height: 1.25;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
display: inline;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name.tab-name-renaming {
display: flex;
align-items: center;
-webkit-box-orient: initial;
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
}
html[data-tab-orientation='vertical'] .tab-name-renaming .tab-rename-prefix {
flex: 0 0 auto;
}
html[data-tab-orientation='vertical'] .tab-name-renaming .tab-rename-input {
flex: 1 1 0;
width: auto;
min-width: 0;
}
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */ /* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
.session-tab .tab-folder { .session-tab .tab-folder {
font-size: 0.6rem; font-size: 0.6rem;
@@ -2153,6 +2285,123 @@ html[data-line-anim="packet"] .connection-line.line-enter {
align-items: center; align-items: center;
} }
.session-tab .tab-more {
display: none;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.5rem;
padding: 0;
visibility: hidden;
pointer-events: none;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font: 700 1rem/1 monospace;
cursor: pointer;
}
html[data-session-list='sidebar'][data-sidebar='expanded'] .session-sidebar .tab-name-row > .tab-actions > .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail .tab-name-row > .tab-actions > .tab-more,
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab.active > .tab-actions > .tab-more {
display: inline-flex;
}
.session-tab:hover > .tab-actions > .tab-more,
.session-tab:focus-within > .tab-actions > .tab-more,
.session-tab.active > .tab-actions > .tab-more,
html[data-session-list='sidebar'][data-sidebar='expanded'] .session-sidebar
:is(.session-tab:hover, .session-tab:focus-within, .session-tab.active)
.tab-name-row
> .tab-actions
> .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail
:is(.session-tab:hover, .session-tab:focus-within, .session-tab.active)
.tab-name-row
> .tab-actions
> .tab-more {
visibility: visible;
pointer-events: auto;
}
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) {
display: none;
}
@media (pointer: coarse) {
.session-tab > .tab-actions > .tab-more,
html[data-session-list='sidebar'][data-sidebar='expanded']
.session-sidebar
.session-tab
.tab-name-row
> .tab-actions
> .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact)
.tab-rail
.session-tab
.tab-name-row
> .tab-actions
> .tab-more {
visibility: visible;
pointer-events: auto;
}
}
.tab-rail-action-menu {
position: fixed;
z-index: 2000;
display: grid;
min-width: 180px;
padding: 0.3rem;
border: 1px solid var(--glass-border);
border-radius: var(--btn-radius);
background: var(--floating-bg);
box-shadow: var(--elevated-shadow);
}
.tab-rail-action-menu button {
padding: 0.45rem 0.6rem;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.tab-rail-action-menu button:hover,
.tab-rail-action-menu button:focus-visible {
background: var(--control-bg-hover);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-rail-action-menu .danger {
color: var(--red);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
display: grid;
grid-template-columns: minmax(112px, 1fr) 44px;
align-items: center;
gap: 8px;
width: min(220px, 44vw);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field input {
width: 100%;
accent-color: var(--accent);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field output {
color: var(--text);
font-family: var(--font-mono);
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device). /* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>. settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
A tab that is ALREADY detached keeps its icon regardless: it is the A tab that is ALREADY detached keeps its icon regardless: it is the
@@ -14806,6 +15055,10 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
display: none; display: none;
} }
html[data-tab-orientation='vertical'] .home-sessions {
display: none !important;
}
/* Belt and braces with shouldShowHomeSessions(): a resize that outruns the /* Belt and braces with shouldShowHomeSessions(): a resize that outruns the
matchMedia listener must never leave the column overlapping the content. */ matchMedia listener must never leave the column overlapping the content. */
@media (max-width: 1179px) { @media (max-width: 1179px) {
@@ -16582,6 +16835,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
padding: 0 22px 22px; padding: 0 22px 22px;
} }
#sessionOptionsModal #context-tab {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.25rem 1rem;
align-items: start;
}
#sessionOptionsModal .set-section { #sessionOptionsModal .set-section {
padding-top: 18px; padding-top: 18px;
} }
@@ -16599,6 +16859,22 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
font-size: 0.75rem; font-size: 0.75rem;
} }
@media (min-width: 1200px) {
#sessionOptionsModal .modal-content.modal-lg {
width: min(1120px, 96vw);
max-width: min(1120px, 96vw);
}
#sessionOptionsModal #context-tab {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#sessionOptionsModal #context-tab > .set-section-head,
#sessionOptionsModal #context-tab > .set-section-blurb {
grid-column: 1 / -1;
}
}
#sessionOptionsModal .set-group + .set-group { #sessionOptionsModal .set-group + .set-group {
margin-top: 16px; margin-top: 16px;
} }
@@ -16888,10 +17164,27 @@ html[data-session-list="sidebar"] .session-sidebar .tab-info {
min-width: 0; min-width: 0;
} }
html[data-session-list="sidebar"] .session-sidebar .tab-name-row,
html[data-session-list="sidebar"] .session-sidebar .tab-name { html[data-session-list="sidebar"] .session-sidebar .tab-name {
min-width: 0;
max-width: none; max-width: none;
} }
html[data-session-list='sidebar'] .session-sidebar .tab-name {
flex: 0 1 auto;
white-space: nowrap;
font-size: var(--session-sidebar-name-font-size, 14px);
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
font-size: var(--session-sidebar-name-font-size, 14px);
}
html[data-session-list='sidebar'] .session-sidebar .tab-actions,
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .tab-actions {
flex-shrink: 0;
}
/* Reveal-on-hover reads badly on a 40px-tall full-width row, so keep the row /* Reveal-on-hover reads badly on a 40px-tall full-width row, so keep the row
actions permanently visible on the active session — no layout jitter when actions permanently visible on the active session — no layout jitter when
the pointer crosses the list. */ the pointer crosses the list. */
+316
View File
@@ -0,0 +1,316 @@
/** @fileoverview Accessible, device-local vertical session-rail sizing. */
Object.assign(CodemanApp.prototype, {
_getTabRailMinimumTerminalWidth() {
const cellWidth = this.terminal?._core?._renderService?.dimensions?.css?.cell?.width;
if (Number.isFinite(cellWidth) && cellWidth > 0) return Math.ceil(cellWidth * 40 + 24);
return 420;
},
_getTabRailBounds() {
const main = document.querySelector('.main');
return {
viewportWidth: window.innerWidth,
mainWidth: main?.clientWidth || window.innerWidth,
minTerminalWidth: this._getTabRailMinimumTerminalWidth(),
};
},
_getCurrentTabRailWidth() {
const fromCss = Number.parseFloat(document.documentElement.style.getPropertyValue('--tab-rail-width'));
if (Number.isFinite(fromCss)) return fromCss;
const measured = document.getElementById('tabRail')?.getBoundingClientRect?.().width;
return Number.isFinite(measured) && measured > 0 ? measured : window.CodemanTabRail?.DEFAULT_WIDTH || 256;
},
readTabRailWidthSetting() {
const select = document.getElementById('appSettingsTabRailWidth');
if (!select) return this._getCurrentTabRailWidth();
if (select.value === 'custom') return Number(select.dataset.currentWidth) || this._getCurrentTabRailWidth();
return Number(select.value) || window.CodemanTabRail?.DEFAULT_WIDTH || 256;
},
syncTabRailWidthSetting(width) {
const select = document.getElementById('appSettingsTabRailWidth');
if (!select) return;
const rounded = Math.round(width);
select.dataset.currentWidth = String(rounded);
const preset = select.querySelector(`option[value="${rounded}"]`);
if (preset) {
select.value = String(rounded);
return;
}
const custom = select.querySelector('option[value="custom"]');
if (custom) custom.textContent = `Custom (${rounded}px)`;
select.value = 'custom';
},
_setTabRailWidth(width) {
const policy = window.CodemanTabRail;
if (!policy) return 256;
const preferred = policy.resolveWidth({ width });
const bounds = this._getTabRailBounds();
const resolved = policy.resolveWidth({ width: preferred, ...bounds });
const effectiveMax = policy.resolveWidth({ width: policy.MAX_WIDTH, ...bounds });
const root = document.documentElement;
root.style.setProperty('--tab-rail-width', `${resolved}px`);
const wasCompact = root.classList.contains('tab-rail-compact');
const compact = resolved < 240;
root.classList.toggle('tab-rail-compact', compact);
if (wasCompact !== compact) this._fullRenderSessionTabs?.();
const handle = document.getElementById('tabRailResizeHandle');
if (handle) {
handle.setAttribute('aria-valuemax', String(effectiveMax));
handle.setAttribute('aria-valuenow', String(resolved));
}
this.syncTabRailWidthSetting(preferred);
return resolved;
},
_persistTabRailWidth(width) {
const settings = this.loadAppSettingsFromStorage();
if (settings.tabRailWidth === width) return;
settings.tabRailWidth = width;
this.saveAppSettingsToStorage(settings);
},
_claimTabRailResize() {
this._tabRailResizeOwnsObserver = true;
clearTimeout(this._tabRailResizeWatchdog);
clearTimeout(this._tabRailReleaseTimer);
if (this._tabRailReleaseRaf) cancelAnimationFrame(this._tabRailReleaseRaf);
this._armTabRailResizeWatchdog();
if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf);
if (this._resizeTimeout) clearTimeout(this._resizeTimeout);
this._resizeRaf = null;
this._resizeTimeout = null;
},
_armTabRailResizeWatchdog() {
this._tabRailResizeWatchdog = setTimeout(() => {
this._tabRailResizeWatchdog = null;
if (document.body.classList.contains('tab-rail-resizing')) {
this._armTabRailResizeWatchdog();
return;
}
this._tabRailResizeOwnsObserver = false;
}, 1000);
},
_releaseTabRailResize() {
clearTimeout(this._tabRailResizeWatchdog);
this._tabRailResizeWatchdog = null;
const release = () => {
clearTimeout(this._tabRailReleaseTimer);
this._tabRailReleaseTimer = null;
this._tabRailReleaseRaf = null;
this._tabRailResizeOwnsObserver = false;
};
if (typeof requestAnimationFrame === 'function') {
this._tabRailReleaseRaf = requestAnimationFrame(() => {
this._tabRailReleaseRaf = requestAnimationFrame(release);
});
this._tabRailReleaseTimer = setTimeout(release, 250);
} else release();
},
_scheduleTabRailSettle(effective, preferred = effective) {
clearTimeout(this._tabRailSettleTimer);
this._tabRailSettleTimer = setTimeout(async () => {
this._tabRailSettleTimer = null;
this._persistTabRailWidth(preferred);
try {
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
else this.fitAddon?.fit();
this._updateConnectionLinesImmediate?.();
} catch (error) {
console.warn('Failed to resize terminal after rail resize:', error);
} finally {
this._releaseTabRailResize();
}
}, 150);
},
applyTabRailWidth(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const requested = settings.tabRailWidth ?? window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
if (options.settle) this._claimTabRailResize();
const resolved = this._setTabRailWidth(preferred);
if (options.persist !== false && requested !== preferred) this._persistTabRailWidth(preferred);
if (options.settle) this._scheduleTabRailSettle(resolved, preferred);
return resolved;
},
_applyTabRailPointerWidth(clientX) {
const main = document.querySelector('.main');
if (!main) return this._getCurrentTabRailWidth();
const previous = this._getCurrentTabRailWidth();
const width = this._setTabRailWidth(clientX - main.getBoundingClientRect().left);
let proposed = null;
try {
proposed = this.fitAddon?.proposeDimensions?.();
} catch {}
return proposed && proposed.cols < 40 ? this._setTabRailWidth(previous) : width;
},
_queueTabRailPointerWidth(clientX) {
this._tabRailPendingClientX = clientX;
if (this._tabRailPointerRaf) return;
this._tabRailPointerRaf = requestAnimationFrame(() => {
this._tabRailPointerRaf = null;
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
});
},
_finishTabRailDrag(handle, pointerId) {
if (!document.body.classList.contains('tab-rail-resizing')) return;
if (this._tabRailPointerRaf) {
cancelAnimationFrame(this._tabRailPointerRaf);
this._tabRailPointerRaf = null;
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
}
document.body.classList.remove('tab-rail-resizing');
const shield = document.getElementById('tabRailResizeShield');
if (shield) shield.hidden = true;
try {
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
} catch {}
const preferred = window.CodemanTabRail?.resolveWidth({ width: this._tabRailDragWidth }) ?? this._tabRailDragWidth;
this._scheduleTabRailSettle(this._tabRailDragWidth || this._getCurrentTabRailWidth(), preferred);
},
_onTabRailKeyDown(event) {
const width = window.CodemanTabRail?.resolveKeyboardWidth({
key: event.key,
shiftKey: event.shiftKey,
currentWidth: this._getCurrentTabRailWidth(),
...this._getTabRailBounds(),
});
if (width === null || width === undefined) return;
event.preventDefault();
this._claimTabRailResize();
const effective = this._setTabRailWidth(width);
this._scheduleTabRailSettle(effective, window.CodemanTabRail.resolveWidth({ width }));
},
initTabRailResize() {
const handle = document.getElementById('tabRailResizeHandle');
if (!handle || handle.dataset.ready === '1') return;
handle.dataset.ready = '1';
this.applyTabRailWidth();
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
this.closeTabRailActionMenu();
this._claimTabRailResize();
this._tabRailDragWidth = this._getCurrentTabRailWidth();
this._tabRailPendingClientX = event.clientX;
document.body.classList.add('tab-rail-resizing');
const shield = document.getElementById('tabRailResizeShield');
if (shield) shield.hidden = false;
try {
handle.setPointerCapture(event.pointerId);
} catch {
document.body.classList.remove('tab-rail-resizing');
if (shield) shield.hidden = true;
this._releaseTabRailResize();
}
});
handle.addEventListener('pointermove', (event) => {
if (handle.hasPointerCapture?.(event.pointerId)) this._queueTabRailPointerWidth(event.clientX);
});
handle.addEventListener('pointerup', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('pointercancel', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('lostpointercapture', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('keydown', (event) => this._onTabRailKeyDown(event));
handle.addEventListener('dblclick', (event) => {
event.preventDefault();
this._claimTabRailResize();
const preferred = window.CodemanTabRail?.DEFAULT_WIDTH || 256;
const effective = this._setTabRailWidth(preferred);
this._scheduleTabRailSettle(effective, preferred);
});
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
this.applyTabOrientation?.();
this.applyTabRailWidth({ persist: false });
}, 100);
});
},
closeTabRailActionMenu(options = {}) {
const menu = document.querySelector('.tab-rail-action-menu');
const trigger = this._tabRailActionMenuTrigger;
menu?.remove();
if (this._tabRailActionMenuOutside) {
document.removeEventListener('pointerdown', this._tabRailActionMenuOutside, true);
this._tabRailActionMenuOutside = null;
}
if (this._tabRailActionMenuViewport) {
window.removeEventListener('resize', this._tabRailActionMenuViewport);
this._tabRailActionMenuViewport = null;
}
this._tabRailActionMenuTrigger = null;
if (options.restoreFocus) trigger?.focus?.();
},
openTabRailActionMenu(event, sessionId) {
event.preventDefault();
event.stopPropagation();
this.closeTabRailActionMenu();
const trigger = event.currentTarget;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Session actions');
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
];
for (const action of actions) {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.className) button.className = action.className;
button.addEventListener('click', () => {
this.closeTabRailActionMenu();
action.run();
});
menu.appendChild(button);
}
document.body.appendChild(menu);
const rect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(rect.right - menuRect.width, window.innerWidth - menuRect.width - 8))}px`;
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`;
this._tabRailActionMenuTrigger = trigger;
this._tabRailActionMenuOutside = (pointerEvent) => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== trigger) this.closeTabRailActionMenu();
};
document.addEventListener('pointerdown', this._tabRailActionMenuOutside, true);
this._tabRailActionMenuViewport = () => this.closeTabRailActionMenu();
window.addEventListener('resize', this._tabRailActionMenuViewport, { once: true });
menu.addEventListener('keydown', (keyEvent) => {
const buttons = [...menu.querySelectorAll('button')];
const index = buttons.indexOf(document.activeElement);
if (keyEvent.key === 'Escape') {
keyEvent.preventDefault();
this.closeTabRailActionMenu({ restoreFocus: true });
} else if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
keyEvent.preventDefault();
const direction = keyEvent.key === 'ArrowDown' ? 1 : -1;
buttons[(index + direction + buttons.length) % buttons.length]?.focus();
}
});
menu.querySelector('button')?.focus();
},
});
+1
View File
@@ -901,6 +901,7 @@ Object.assign(CodemanApp.prototype, {
const MIN_ROWS = 10; const MIN_ROWS = 10;
const throttledResize = () => { const throttledResize = () => {
if (this._tabRailResizeOwnsObserver) return;
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
// once after the user stops resizing. During active resize, the terminal // once after the user stops resizing. During active resize, the terminal
// stays at its old dimensions for up to 300ms. // stays at its old dimensions for up to 300ms.
+1
View File
@@ -26,3 +26,4 @@ export { registerAdminRoutes } from './admin-routes.js';
export { registerWsRoutes } from './ws-routes.js'; export { registerWsRoutes } from './ws-routes.js';
export { registerVoiceRoutes } from './voice-routes.js'; export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js'; export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
+1 -1
View File
@@ -411,7 +411,7 @@ export function registerRalphRoutes(
writeFileSync(promptPath, fullPrompt, 'utf-8'); writeFileSync(promptPath, fullPrompt, 'utf-8');
// Register session // Register session
ctx.addSession(session); await ctx.addSession(session);
ctx.store.incrementSessionsCreated(); ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session); ctx.persistSessionState(session);
await ctx.setupSessionListeners(session); await ctx.setupSessionListeners(session);
+23 -15
View File
@@ -48,7 +48,8 @@ import {
SessionWaitQuerySchema, SessionWaitQuerySchema,
SessionWaitOutputQuerySchema, SessionWaitOutputQuerySchema,
} from '../schemas.js'; } from '../schemas.js';
import { mergeSessionOrder } from '../../session-order.js'; import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import { import {
sessionWaits, sessionWaits,
resolveWaitSignals, resolveWaitSignals,
@@ -108,7 +109,7 @@ import {
setHistoryIndexRefresher, setHistoryIndexRefresher,
setHistorySessionIndex, setHistorySessionIndex,
} from '../session-history-index.js'; } from '../session-history-index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { RunSummaryTracker } from '../../run-summary.js'; import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js'; import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
@@ -650,7 +651,7 @@ async function injectAgentSkill(casePath: string): Promise<void> {
export function registerSessionRoutes( export function registerSessionRoutes(
app: FastifyInstance, app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
): void { ): void {
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Auth // Auth
@@ -682,16 +683,23 @@ export function registerSessionRoutes(
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner)); return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
}); });
// ========== Session Tab Order (global sync, COD-131) ========== // ========== Legacy Session Tab Order (temporary synchronized compatibility bridge) ==========
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => { app.put('/api/session-order', async (req, reply): Promise<ApiResponse<{ order: string[] }>> => {
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order'); try {
// Server is authoritative but never drops ids it knows about that the const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder). const user = getAuthUser(req);
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder()); const result = await ctx.tabLayouts.putLegacyOrder(
ctx.store.setSessionOrder(merged); { owner: ownerLayoutKey(ownerFor(req)), isAdmin: user.role === 'admin' },
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged }); order
return { success: true, data: { order: merged } }; );
return { success: true, data: { order: result.order } };
} catch (error) {
if (error instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, error.message));
}
throw error;
}
}); });
// ========== Session Creation ========== // ========== Session Creation ==========
@@ -951,7 +959,7 @@ export function registerSessionRoutes(
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
}); });
ctx.addSession(session); await ctx.addSession(session);
ctx.store.incrementSessionsCreated(); ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session); ctx.persistSessionState(session);
await ctx.setupSessionListeners(session); await ctx.setupSessionListeners(session);
@@ -2665,7 +2673,7 @@ export function registerSessionRoutes(
allowedTools: runClaudeModeConfig.allowedTools, allowedTools: runClaudeModeConfig.allowedTools,
owner: runOwner, owner: runOwner,
}); });
ctx.addSession(session); await ctx.addSession(session);
ctx.store.incrementSessionsCreated(); ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session); ctx.persistSessionState(session);
await ctx.setupSessionListeners(session); await ctx.setupSessionListeners(session);
@@ -3102,7 +3110,7 @@ export function registerSessionRoutes(
} }
} }
ctx.addSession(session); await ctx.addSession(session);
ctx.store.incrementSessionsCreated(); ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session); ctx.persistSessionState(session);
await ctx.setupSessionListeners(session); await ctx.setupSessionListeners(session);
+5 -3
View File
@@ -49,7 +49,7 @@ import {
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js'; import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
@@ -129,7 +129,7 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
export function registerSystemRoutes( export function registerSystemRoutes(
app: FastifyInstance, app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
): void { ): void {
const windowStatesPath = dataPath('subagent-window-states.json'); const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('subagent-parents.json'); const parentMapPath = dataPath('subagent-parents.json');
@@ -469,7 +469,9 @@ export function registerSystemRoutes(
app.post('/api/cleanup-state', async () => { app.post('/api/cleanup-state', async () => {
const activeSessionIds = new Set(ctx.sessions.keys()); const activeSessionIds = new Set(ctx.sessions.keys());
const result = ctx.store.cleanupStaleSessions(activeSessionIds); const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
ctx.store.cleanupSessionsByIds(ids)
);
const lifecycleLog = getLifecycleLog(); const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) { for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
+50
View File
@@ -0,0 +1,50 @@
/** @fileoverview Authenticated owner-scoped tab-layout read/write API. */
import type { FastifyInstance } from 'fastify';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { ownerFor } from '../route-helpers.js';
import type { TabLayoutPort } from '../ports/index.js';
export const TAB_LAYOUT_BODY_LIMIT = 128 * 1024;
function parseWriteBody(body: unknown): { baseVersion: number; layout: unknown } {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
throw new TabLayoutValidationError('body must be an object');
}
const keys = Object.keys(body);
if (keys.length !== 2 || !Object.hasOwn(body, 'baseVersion') || !Object.hasOwn(body, 'layout')) {
throw new TabLayoutValidationError('body must contain exactly baseVersion and layout');
}
const input = body as { baseVersion?: unknown; layout?: unknown };
if (!Number.isSafeInteger(input.baseVersion) || (input.baseVersion as number) < 0 || input.layout === undefined) {
throw new TabLayoutValidationError('baseVersion must be a non-negative safe integer and layout is required');
}
return { baseVersion: input.baseVersion as number, layout: input.layout };
}
export function registerTabLayoutRoutes(app: FastifyInstance, ctx: TabLayoutPort): void {
app.get('/api/tab-layout', async (req) => ({
success: true,
data: { layout: await ctx.tabLayouts.get(ownerLayoutKey(ownerFor(req))) },
}));
app.put('/api/tab-layout', { bodyLimit: TAB_LAYOUT_BODY_LIMIT }, async (req, reply) => {
try {
const { baseVersion, layout } = parseWriteBody(req.body);
const result = await ctx.tabLayouts.put(ownerLayoutKey(ownerFor(req)), layout, baseVersion);
if (result.status === 'conflict') {
return reply.code(409).send({
...createErrorResponse(ApiErrorCode.CONFLICT, 'Tab layout version conflict'),
data: { layout: result.layout },
});
}
return { success: true, data: { layout: result.layout } };
} catch (error) {
if (error instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, error.message));
}
throw error;
}
});
}
+30 -4
View File
@@ -53,7 +53,8 @@ import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js'; import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js'; import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import type { EventPort } from '../ports/index.js'; import type { EventPort, TabLayoutPort } from '../ports/index.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { import {
buildDownstreamResponseHeaders, buildDownstreamResponseHeaders,
buildProxyCorsHeaders, buildProxyCorsHeaders,
@@ -98,14 +99,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
return next; return next;
} }
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void { export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
registerCrudRoutes(app, ctx); registerCrudRoutes(app, ctx);
registerProxyRoutes(app); registerProxyRoutes(app);
} }
// ───────────────────────────── CRUD ───────────────────────────── // ───────────────────────────── CRUD ─────────────────────────────
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void { function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
app.get('/api/webviews', async (req) => { app.get('/api/webviews', async (req) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const all = await readWebviews(configDir()); const all = await readWebviews(configDir());
@@ -145,6 +146,21 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`)); .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
} }
try {
await ctx.tabLayouts.webviewCreated(ownerLayoutKey(created.owner));
} catch (error) {
// The saved webview and its layout ref are one logical creation. If the
// layout rejects the new ref (for example at MAX_TAB_REFS), roll back the
// already-written JSON record and publish neither creation event.
await withWebviews(async (list) => {
const index = list.findIndex((webview) => webview.id === created.id);
if (index >= 0) {
list.splice(index, 1);
await writeWebviews(configDir(), list);
}
});
throw error;
}
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id }); ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
return { success: true, data: created }; return { success: true, data: created };
}); });
@@ -187,9 +203,19 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
const index = list.findIndex((w) => w.id === id); const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const; if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const; if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
const removed = list[index];
list.splice(index, 1); list.splice(index, 1);
await writeWebviews(configDir(), list); await writeWebviews(configDir(), list);
return 'deleted' as const; try {
await ctx.tabLayouts.webviewDeleted(ownerLayoutKey(removed.owner), id);
} catch (error) {
// Still inside withWebviews' mutex: restore the exact record at its
// original position without overwriting any concurrent mutation.
list.splice(index, 0, removed);
await writeWebviews(configDir(), list);
throw error;
}
return { status: 'deleted' as const, owner: removed.owner };
}); });
if (result === 'not-found') { if (result === 'not-found') {
+4
View File
@@ -995,6 +995,8 @@ export const SettingsUpdateSchema = z
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting. // CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(), acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(), tabTwoRows: z.boolean().optional(),
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
/** /**
* Session list layout. Display key (per-device). * Session list layout. Display key (per-device).
* 'header' = horizontal tab strip * 'header' = horizontal tab strip
@@ -1006,6 +1008,8 @@ export const SettingsUpdateSchema = z
* on data-sidebar-detail. See applySessionListLayout() in app.js. * on data-sidebar-detail. See applySessionListLayout() in app.js.
*/ */
sessionListLayout: z.enum(['header', 'sidebar', 'sidebar-rich']).optional(), sessionListLayout: z.enum(['header', 'sidebar', 'sidebar-rich']).optional(),
/** Session-name text size in vertical navigation. Display key (per-device). */
sessionSidebarFontSize: z.number().int().min(11).max(18).optional(),
agentTeamsEnabled: z.boolean().optional(), agentTeamsEnabled: z.boolean().optional(),
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */ /** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
claudeModel: z.string().max(50).optional(), claudeModel: z.string().max(50).optional(),
+88 -25
View File
@@ -50,6 +50,8 @@ import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js'; import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js'; import { createMultiplexer } from '../mux-factory.js';
import { getStore } from '../state-store.js'; import { getStore } from '../state-store.js';
import { TabLayoutService } from '../tab-layout-service.js';
import { readWebviews } from '../webview-store.js';
import { extractCompletionPhrase } from '../ralph-config.js'; import { extractCompletionPhrase } from '../ralph-config.js';
import { fileStreamManager } from '../file-stream-manager.js'; import { fileStreamManager } from '../file-stream-manager.js';
import { import {
@@ -81,6 +83,7 @@ import { applyWorkspaceHooks } from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js'; import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push'; import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js'; import { SseStreamManager } from './sse-stream-manager.js';
import { deriveTabLayoutSseHint } from './tab-layout-sse.js';
import { import {
type SessionListenerRefs, type SessionListenerRefs,
createSessionListeners, createSessionListeners,
@@ -170,6 +173,7 @@ import {
registerWsRoutes, registerWsRoutes,
registerVoiceRoutes, registerVoiceRoutes,
registerWebviewRoutes, registerWebviewRoutes,
registerTabLayoutRoutes,
tryWebviewRefererFallback, tryWebviewRefererFallback,
} from './routes/index.js'; } from './routes/index.js';
import { CronService } from '../cron/cron-service.js'; import { CronService } from '../cron/cron-service.js';
@@ -247,6 +251,7 @@ export class WebServer extends EventEmitter {
private cronService!: CronService; private cronService!: CronService;
private sse: SseStreamManager; private sse: SseStreamManager;
private store = getStore(); private store = getStore();
private tabLayouts!: TabLayoutService;
private port: number; private port: number;
private host: string; private host: string;
private https: boolean; private https: boolean;
@@ -350,6 +355,17 @@ export class WebServer extends EventEmitter {
}, },
this.cleanup this.cleanup
); );
this.tabLayouts = new TabLayoutService({
store: this.store,
sessions: this.sessions,
readWebviews: () => readWebviews(getDataDir()),
broadcast: this.broadcast.bind(this),
broadcastSessionOrder: (change) => {
this.cachedLightState = null;
this.sse.broadcastSessionOrder(change);
},
});
if (this.testMode) this.tabLayouts.markRestorationSkipped();
// Approvals Inbox → SSE. The singleton has no server reference; these // Approvals Inbox → SSE. The singleton has no server reference; these
// callbacks are its only way out. Broadcasts carry sessionId, so the // callbacks are its only way out. Broadcasts carry sessionId, so the
@@ -595,6 +611,17 @@ export class WebServer extends EventEmitter {
} }
} }
/** Add a tentative session only after its owner layout accepts the creation. */
private async registerSessionWithLayout(session: Session): Promise<void> {
this.sessions.set(session.id, session);
try {
await this.tabLayouts.sessionCreated(session.owner ?? '@single');
} catch (error) {
this.sessions.delete(session.id);
throw error;
}
}
/** /**
* Build a route context object satisfying all 5 port interfaces. * Build a route context object satisfying all 5 port interfaces.
* Single object with zero runtime cost — ISP enforced at the type level. * Single object with zero runtime cost — ISP enforced at the type level.
@@ -605,9 +632,8 @@ export class WebServer extends EventEmitter {
return { return {
// SessionPort // SessionPort
sessions: this.sessions as ReadonlyMap<string, Session>, sessions: this.sessions as ReadonlyMap<string, Session>,
addSession: (session: Session) => { addSession: this.registerSessionWithLayout.bind(this),
this.sessions.set(session.id, session); tabLayouts: this.tabLayouts,
},
cleanupSession: this.cleanupSession.bind(this), cleanupSession: this.cleanupSession.bind(this),
setupSessionListeners: this.setupSessionListeners.bind(this), setupSessionListeners: this.setupSessionListeners.bind(this),
persistSessionState: this.persistSessionState.bind(this), persistSessionState: this.persistSessionState.bind(this),
@@ -991,6 +1017,7 @@ export class WebServer extends EventEmitter {
registerAdminRoutes(this.app, ctx); registerAdminRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx); registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx); registerWebviewRoutes(this.app, ctx);
registerTabLayoutRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute // Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes. // due times for any persisted jobs, then expose it to its routes.
@@ -1154,8 +1181,19 @@ export class WebServer extends EventEmitter {
} }
} }
private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void> { private async _doCleanupSession(
sessionId: string,
killMux: boolean,
reason?: string,
coordinateLayout = true
): Promise<void> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
if (coordinateLayout && session && killMux && !pinned) {
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
this._doCleanupSession(sessionId, killMux, reason, false)
);
}
const lifecycleLog = getLifecycleLog(); const lifecycleLog = getLifecycleLog();
lifecycleLog.log({ lifecycleLog.log({
event: killMux ? 'deleted' : 'detached', event: killMux ? 'deleted' : 'detached',
@@ -1860,7 +1898,7 @@ export class WebServer extends EventEmitter {
// mode) so the flag-off path stays byte-identical. // mode) so the flag-off path stays byte-identical.
session = new Session({ workingDir: run.workingDir }); session = new Session({ workingDir: run.workingDir });
} }
this.sessions.set(session.id, session); await this.registerSessionWithLayout(session);
this.store.incrementSessionsCreated(); this.store.incrementSessionsCreated();
this.persistSessionState(session); this.persistSessionState(session);
await this.setupSessionListeners(session); await this.setupSessionListeners(session);
@@ -1990,9 +2028,11 @@ export class WebServer extends EventEmitter {
* Called on startup and can be called via API endpoint. * Called on startup and can be called via API endpoint.
* @returns Number of sessions cleaned up * @returns Number of sessions cleaned up
*/ */
private cleanupStaleSessions(): number { private async cleanupStaleSessions(): Promise<number> {
const activeSessionIds = new Set(this.sessions.keys()); const activeSessionIds = new Set(this.sessions.keys());
const result = this.store.cleanupStaleSessions(activeSessionIds); const result = await this.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
this.store.cleanupSessionsByIds(ids)
);
const lifecycleLog = getLifecycleLog(); const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) { for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
@@ -2016,11 +2056,13 @@ export class WebServer extends EventEmitter {
/** Shallow-filter the light-state blob to what a non-admin user may see. */ /** Shallow-filter the light-state blob to what a non-admin user may see. */
private filterLightStateForUser(base: Record<string, unknown>, username: string): Record<string, unknown> { private filterLightStateForUser(base: Record<string, unknown>, username: string): Record<string, unknown> {
const ownedIds = new Set<string>(); const authoritativeOwners = new Map<string, string | undefined>();
for (const [id, session] of Object.entries(this.store.getSessions())) authoritativeOwners.set(id, session.owner);
for (const [id, session] of this.sessions) authoritativeOwners.set(id, session.owner);
const ownedIds = new Set([...authoritativeOwners].filter(([, owner]) => owner === username).map(([id]) => id));
const ownedClaudeIds = new Set<string>(); const ownedClaudeIds = new Set<string>();
for (const [id, s] of this.sessions) { for (const s of this.sessions.values()) {
if (s.owner === username) { if (s.owner === username) {
ownedIds.add(id);
if (s.claudeSessionId) ownedClaudeIds.add(s.claudeSessionId); if (s.claudeSessionId) ownedClaudeIds.add(s.claudeSessionId);
} }
} }
@@ -2038,6 +2080,9 @@ export class WebServer extends EventEmitter {
const filtered: Record<string, unknown> = { const filtered: Record<string, unknown> = {
...base, ...base,
sessions, sessions,
sessionOrder: Array.isArray(base.sessionOrder)
? (base.sessionOrder as string[]).filter((id) => ownedIds.has(id))
: [],
respawnStatus, respawnStatus,
scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only
subagents: bySession(base.subagents, 'sessionId'), subagents: bySession(base.subagents, 'sessionId'),
@@ -2074,6 +2119,7 @@ export class WebServer extends EventEmitter {
const result = { const result = {
version: APP_VERSION, version: APP_VERSION,
sessions: this.getLightSessionsState(), sessions: this.getLightSessionsState(),
sessionOrder: this.store.getSessionOrder(),
scheduledRuns: Array.from(this.scheduledRuns.values()), scheduledRuns: Array.from(this.scheduledRuns.values()),
respawnStatus, respawnStatus,
globalStats: this.store.getAggregateStats(activeSessionTokens), globalStats: this.store.getAggregateStats(activeSessionTokens),
@@ -2082,7 +2128,6 @@ export class WebServer extends EventEmitter {
timestamp: now, timestamp: now,
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON', inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
}; };
this.cachedLightState = { data: result, timestamp: now }; this.cachedLightState = { data: result, timestamp: now };
@@ -2121,6 +2166,11 @@ export class WebServer extends EventEmitter {
) { ) {
return { adminOnly: true }; return { adminOnly: true };
} }
// Layout payloads contain only trusted routing metadata. Route them to that
// exact owner plus admins, never by resolving a client-supplied ref.
if (event.startsWith('tab:')) {
return deriveTabLayoutSseHint(data);
}
// Session-scoped families: resolve the owner from the payload's session id. // Session-scoped families: resolve the owner from the payload's session id.
const SESSION_PREFIXES = [ const SESSION_PREFIXES = [
'session:', 'session:',
@@ -2366,26 +2416,26 @@ export class WebServer extends EventEmitter {
// This prevents race conditions where clients connect before state is ready // This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions // CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if (!this.testMode) { if (!this.testMode) {
await this.restoreMuxSessions(); const restored = await this.restoreMuxSessions();
await this.finalizeRestoredState(restored);
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of // Instance-scoped reaper: after restore, `docker rm -f` managed containers of
// THIS instance whose case is gone from docker-cases.json (best-effort, never // THIS instance whose case is gone from docker-cases.json (best-effort, never
// touches another instance's containers). Runs after restore so containers // touches another instance's containers). Runs after restore so containers
// still referenced by a restored session are preserved. // still referenced by a restored session are preserved.
void import('../docker-hosts.js') if (restored) {
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE)) void import('../docker-hosts.js')
.then((reaped) => { .then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
if (reaped.length > 0) .then((reaped) => {
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`); if (reaped.length > 0)
}) console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
.catch(() => { })
/* best-effort — daemon may be absent */ .catch(() => {
}); /* best-effort — daemon may be absent */
});
}
} }
// Clean up stale sessions from state file that don't have active mux sessions
this.cleanupStaleSessions();
// Bound disk use under heavy paste-image traffic: delete `paste-*` files // Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly. // older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) { if (!this.testMode) {
@@ -2621,7 +2671,7 @@ export class WebServer extends EventEmitter {
return false; return false;
} }
private async restoreMuxSessions(): Promise<void> { private async restoreMuxSessions(): Promise<boolean> {
try { try {
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones) // Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
const { alive, dead, discovered } = await this.mux.reconcileSessions(); const { alive, dead, discovered } = await this.mux.reconcileSessions();
@@ -2901,11 +2951,24 @@ export class WebServer extends EventEmitter {
if (dead.length > 0) { if (dead.length > 0) {
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`); console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
} }
return true;
} catch (err) { } catch (err) {
console.error('[Server] Failed to restore mux sessions:', err); console.error('[Server] Failed to restore mux sessions:', err);
return false;
} }
} }
/** Unlock destructive reconciliation only after mux restoration fully succeeds. */
private async finalizeRestoredState(restored: boolean): Promise<void> {
if (!restored) {
this.tabLayouts.markRestorationFailed();
return;
}
this.tabLayouts.markRestorationComplete();
await this.cleanupStaleSessions();
await this.tabLayouts.reconcileAfterRestoration();
}
/** /**
* Install Codeman's hooks into the workspaces of the sessions just recovered. * Install Codeman's hooks into the workspaces of the sessions just recovered.
* *
+15
View File
@@ -0,0 +1,15 @@
/** @fileoverview Trusted per-recipient payload selection for legacy session-order invalidations. */
import type { SessionOrderProjectionChange } from '../tab-layout-service.js';
import type { AuthUser } from '../types.js';
export function sessionOrderPayloadFor(
identity: AuthUser | undefined,
change: SessionOrderProjectionChange
): { order: string[] } | undefined {
if (!identity || identity.role === 'admin') {
return change.globalChanged ? { order: [...change.globalOrder] } : undefined;
}
if (!Object.hasOwn(change.changedOwnerOrders, identity.username)) return undefined;
const order = change.changedOwnerOrders[identity.username];
return Array.isArray(order) ? { order: [...order] } : undefined;
}
+6 -3
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`). * and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync. * Both files MUST be kept in sync.
* *
* 155 event constants organized by category: * 156 event constants organized by category:
* - **Core** (1): init * - **Core** (1): init
* - **Transport** (1): sse:heartbeat * - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ... * - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
@@ -25,14 +25,14 @@
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled * - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed * - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected * - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed * - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed, suppressed
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox) * - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error * - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write * - **Clipboard** (1): write
* - **Cases** (4): created, linked, deleted, order-changed * - **Cases** (4): created, linked, deleted, order-changed
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated * - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged * - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
* - **Web tabs** (1): webview:changed * - **Web tabs** (2): webview:changed, tab:layoutChanged
* *
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`) * Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
* *
@@ -449,6 +449,8 @@ export const SessionOrderChanged = 'session:orderChanged' as const;
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client * Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
* re-fetches the list rather than patching from the payload. */ * re-fetches the list rather than patching from the payload. */
export const WebviewChanged = 'webview:changed' as const; export const WebviewChanged = 'webview:changed' as const;
/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */
export const TabLayoutChanged = 'tab:layoutChanged' as const;
// ─── Namespace Re-export ───────────────────────────────────────────────────── // ─── Namespace Re-export ─────────────────────────────────────────────────────
@@ -665,4 +667,5 @@ export const SseEvent = {
// Web tabs (dashboard URLs) // Web tabs (dashboard URLs)
WebviewChanged, WebviewChanged,
TabLayoutChanged,
} as const; } as const;
+85 -26
View File
@@ -17,9 +17,11 @@
import type { FastifyReply } from 'fastify'; import type { FastifyReply } from 'fastify';
import type { BackgroundTask } from '../session.js'; import type { BackgroundTask } from '../session.js';
import type { SessionOrderProjectionChange } from '../tab-layout-service.js';
import type { AuthUser } from '../types.js'; import type { AuthUser } from '../types.js';
import { CleanupManager, StaleExpirationMap } from '../utils/index.js'; import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
import { SseEvent } from './sse-events.js'; import { SseEvent } from './sse-events.js';
import { sessionOrderPayloadFor } from './session-order-sse.js';
import { import {
TERMINAL_BATCH_INTERVAL, TERMINAL_BATCH_INTERVAL,
TASK_UPDATE_BATCH_INTERVAL, TASK_UPDATE_BATCH_INTERVAL,
@@ -34,6 +36,7 @@ import {
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush. // Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
// Pre-computed once at startup to avoid repeated string allocation. // Pre-computed once at startup to avoid repeated string allocation.
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n'; const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
const UNROUTED_TAB_LAYOUT = Symbol('unrouted-tab-layout');
/** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */ /** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */
interface SseStreamManagerDeps { interface SseStreamManagerDeps {
@@ -77,6 +80,10 @@ export class SseStreamManager {
private remoteSseClients: Set<FastifyReply> = new Set(); private remoteSseClients: Set<FastifyReply> = new Set();
/** Clients with backpressure — skip writes until 'drain' fires */ /** Clients with backpressure — skip writes until 'drain' fires */
private backpressuredClients: Set<FastifyReply> = new Set(); private backpressuredClients: Set<FastifyReply> = new Set();
/** Latest already recipient-filtered legacy order frame awaiting a client's drain. */
private pendingSessionOrderFrames: Map<FastifyReply, string> = new Map();
/** Latest owner-filtered tab-layout invalidation per affected owner awaiting a client's drain. */
private pendingTabLayoutFrames: Map<FastifyReply, Map<string | symbol, string>> = new Map();
// ─── Tunnel State ─────────────────────────────────────── // ─── Tunnel State ───────────────────────────────────────
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */ /** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
@@ -144,10 +151,7 @@ export class SseStreamManager {
// If a previous reply registered the same id (reconnect), drop the old one. // If a previous reply registered the same id (reconnect), drop the old one.
const prev = this.sseClientsById.get(clientId); const prev = this.sseClientsById.get(clientId);
if (prev && prev !== reply) { if (prev && prev !== reply) {
this.sseClients.delete(prev); this.removeClient(prev);
this.remoteSseClients.delete(prev);
this.backpressuredClients.delete(prev);
this.sseClientIdentity.delete(prev);
} }
this.sseClientsById.set(clientId, reply); this.sseClientsById.set(clientId, reply);
} }
@@ -157,6 +161,8 @@ export class SseStreamManager {
this.sseClients.delete(reply); this.sseClients.delete(reply);
this.remoteSseClients.delete(reply); this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply); this.backpressuredClients.delete(reply);
this.pendingSessionOrderFrames.delete(reply);
this.pendingTabLayoutFrames.delete(reply);
this.sseClientIdentity.delete(reply); this.sseClientIdentity.delete(reply);
// Clear any clientId mappings pointing at this reply // Clear any clientId mappings pointing at this reply
for (const [id, r] of this.sseClientsById) { for (const [id, r] of this.sseClientsById) {
@@ -199,8 +205,7 @@ export class SseStreamManager {
try { try {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch { } catch {
this.sseClients.delete(reply); this.removeClient(reply);
this.remoteSseClients.delete(reply);
} }
} }
@@ -210,7 +215,44 @@ export class SseStreamManager {
try { try {
reply.raw.write(SSE_PADDING); reply.raw.write(SSE_PADDING);
} catch { } catch {
/* client gone */ this.removeClient(reply);
}
}
private markBackpressured(reply: FastifyReply): void {
this.backpressuredClients.add(reply);
reply.raw.once('drain', () => this.flushBackpressuredClient(reply));
}
private flushBackpressuredClient(reply: FastifyReply): void {
if (!this.sseClients.has(reply)) return;
this.backpressuredClients.delete(reply);
try {
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
const recovered = reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
if (!recovered) {
this.markBackpressured(reply);
return;
}
const pendingLayouts = this.pendingTabLayoutFrames.get(reply);
if (pendingLayouts) {
for (const [owner, pendingLayout] of pendingLayouts) {
pendingLayouts.delete(owner);
this.sendSSEPreformatted(reply, pendingLayout);
if (!this.sseClients.has(reply)) return;
if (this.backpressuredClients.has(reply)) {
if (pendingLayouts.size === 0) this.pendingTabLayoutFrames.delete(reply);
return;
}
}
this.pendingTabLayoutFrames.delete(reply);
}
const pendingOrder = this.pendingSessionOrderFrames.get(reply);
if (!pendingOrder) return;
this.pendingSessionOrderFrames.delete(reply);
this.sendSSEPreformatted(reply, pendingOrder);
} catch {
this.removeClient(reply);
} }
} }
@@ -224,24 +266,11 @@ export class SseStreamManager {
try { try {
const ok = reply.raw.write(message); const ok = reply.raw.write(message);
if (!ok) { if (!ok) {
// Buffer is full — mark as backpressured, resume on drain // Buffer is full — mark as backpressured, resume on drain.
this.backpressuredClients.add(reply); this.markBackpressured(reply);
reply.raw.once('drain', () => {
this.backpressuredClients.delete(reply);
// Client may have missed terminal data during backpressure.
// Tell it to reload the active session's buffer to recover.
try {
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
} catch {
/* client gone */
}
});
} }
} catch { } catch {
this.sseClients.delete(reply); this.removeClient(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
} }
} }
@@ -276,6 +305,36 @@ export class SseStreamManager {
for (const [client] of this.sseClients) { for (const [client] of this.sseClients) {
// Multi-user ownership routing (no-op for identity-less single-user clients). // Multi-user ownership routing (no-op for identity-less single-user clients).
if (!this.canDeliver(client, hint)) continue; if (!this.canDeliver(client, hint)) continue;
if (event === SseEvent.TabLayoutChanged && this.backpressuredClients.has(client)) {
const owner =
data !== null &&
typeof data === 'object' &&
Object.hasOwn(data, 'owner') &&
typeof (data as { owner?: unknown }).owner === 'string'
? (data as { owner: string }).owner
: (hint?.username ?? hint?.owner ?? UNROUTED_TAB_LAYOUT);
let pending = this.pendingTabLayoutFrames.get(client);
if (!pending) {
pending = new Map();
this.pendingTabLayoutFrames.set(client, pending);
}
pending.set(owner, message);
continue;
}
this.sendSSEPreformatted(client, message);
}
}
/** Dispatch the legacy order projection selected from each trusted client identity. */
broadcastSessionOrder(change: SessionOrderProjectionChange): void {
for (const [client] of this.sseClients) {
const payload = sessionOrderPayloadFor(this.sseClientIdentity.get(client), change);
if (!payload) continue;
const message = `event: ${SseEvent.SessionOrderChanged}\ndata: ${JSON.stringify(payload)}\n\n`;
if (this.backpressuredClients.has(client)) {
this.pendingSessionOrderFrames.set(client, message);
continue;
}
this.sendSSEPreformatted(client, message); this.sendSSEPreformatted(client, message);
} }
} }
@@ -504,9 +563,7 @@ export class SseStreamManager {
// Remove dead clients // Remove dead clients
for (const client of deadClients) { for (const client of deadClients) {
this.sseClients.delete(client); this.removeClient(client);
this.remoteSseClients.delete(client);
this.backpressuredClients.delete(client);
} }
if (deadClients.length > 0) { if (deadClients.length > 0) {
@@ -553,6 +610,8 @@ export class SseStreamManager {
this.sseClients.clear(); this.sseClients.clear();
this.remoteSseClients.clear(); this.remoteSseClients.clear();
this.backpressuredClients.clear(); this.backpressuredClients.clear();
this.pendingSessionOrderFrames.clear();
this.pendingTabLayoutFrames.clear();
// Clear per-session batch timers // Clear per-session batch timers
for (const timer of this.terminalBatchTimers.values()) { for (const timer of this.terminalBatchTimers.values()) {
+6
View File
@@ -0,0 +1,6 @@
/** @fileoverview Trusted owner routing metadata for tab-layout invalidations. */
import type { SseRoutingHint } from './sse-stream-manager.js';
export function deriveTabLayoutSseHint(data: unknown): SseRoutingHint {
return { username: (data as { owner?: string }).owner, sessionScoped: true };
}
+40
View File
@@ -434,6 +434,46 @@ describe('CronService', () => {
}); });
describe('runNow', () => { describe('runNow', () => {
it('awaits layout insertion and stops lifecycle work when registration rejects', async () => {
const store = makeStore();
const sessions = new Map<string, FakeSession>();
let rejectRegistration!: (error: Error) => void;
const addSession = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectRegistration = reject;
})
);
const persistSessionState = vi.fn();
const setupSessionListeners = vi.fn(async () => {});
const service = new CronService({
store,
sessions,
addSession,
persistSessionState,
setupSessionListeners,
broadcast: vi.fn(),
getGlobalNiceConfig: vi.fn(async () => undefined),
getModelConfig: vi.fn(async () => null),
getClaudeModeConfig: vi.fn(async () => ({})),
getCheckpointDefaultEnabled: vi.fn(async () => true),
mux: { backend: 'tmux' },
} as unknown as CronDeps);
const job = service.createJob(mkInput({ enabled: false }));
const pending = service.runNow(job.id);
await vi.waitFor(() => expect(addSession).toHaveBeenCalledTimes(1));
expect(persistSessionState).not.toHaveBeenCalled();
expect(setupSessionListeners).not.toHaveBeenCalled();
rejectRegistration(new Error('layout capacity exceeded'));
const run = await pending;
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/layout capacity exceeded/);
expect(persistSessionState).not.toHaveBeenCalled();
expect(setupSessionListeners).not.toHaveBeenCalled();
});
it('launches regardless of enabled/schedule state', async () => { it('launches regardless of enabled/schedule state', async () => {
const job = svc.service.createJob(mkInput({ enabled: false })); const job = svc.service.createJob(mkInput({ enabled: false }));
const run = await svc.service.runNow(job.id); const run = await svc.service.runNow(job.id);
+15 -1
View File
@@ -40,13 +40,16 @@ function fakeElement(): any {
* point: if that reuse ever breaks, these tests stop loading rather than * point: if that reuse ever breaks, these tests stop loading rather than
* quietly testing a divergent copy. * quietly testing a divergent copy.
*/ */
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) { function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512, tabOrientation = 'horizontal') {
const CodemanApp = function CodemanApp(this: any) {}; const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({ const context = vm.createContext({
CodemanApp, CodemanApp,
console, console,
window: { innerWidth }, window: { innerWidth },
document: { document: {
documentElement: {
getAttribute: (name: string) => (name === 'data-tab-orientation' ? tabOrientation : null),
},
getElementById: () => null, getElementById: () => null,
createElement: () => fakeElement(), createElement: () => fakeElement(),
createElementNS: () => fakeElement(), createElementNS: () => fakeElement(),
@@ -198,6 +201,11 @@ describe('home sessions column: gate', () => {
expect(app.shouldShowHomeSessions()).toBe(true); expect(app.shouldShowHomeSessions()).toBe(true);
}); });
it('yields to the persistent rail when the effective tab orientation is vertical', () => {
expect(loadHomeSessionsApp({}, 1512, 'vertical').shouldShowHomeSessions()).toBe(false);
expect(loadHomeSessionsApp({}, 1512, 'horizontal').shouldShowHomeSessions()).toBe(true);
});
it('stays out of a window too narrow to hold it beside the centered content', () => { it('stays out of a window too narrow to hold it beside the centered content', () => {
// Absolutely positioned: below the gate it would overlap the search panel // Absolutely positioned: below the gate it would overlap the search panel
// rather than push it aside. // rather than push it aside.
@@ -238,6 +246,12 @@ describe('home sessions column: wiring', () => {
expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/); expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/);
}); });
it('has a CSS backstop that suppresses the homepage rail in vertical mode', () => {
expect(css).toMatch(
/html\[data-tab-orientation='vertical'\]\s+\.home-sessions\s*\{\s*display:\s*none\s*!important;/
);
});
it('reuses the tab-load spinner rather than declaring a second one', () => { it('reuses the tab-load spinner rather than declaring a second one', () => {
// The working ring is the same motion a tab shows while it loads, on both // The working ring is the same motion a tab shows while it loads, on both
// home screens. Re-declaring the keyframes here is how they drift apart. // home screens. Re-declaring the keyframes here is how they drift apart.
+180 -1
View File
@@ -6,8 +6,10 @@
* These behaviors live in server.ts (preSerialization hook, setNotFoundHandler), * These behaviors live in server.ts (preSerialization hook, setNotFoundHandler),
* which the route-test harness does not install — so they need a real WebServer. * which the route-test harness does not install — so they need a real WebServer.
*/ */
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js';
import { WebServer } from '../src/web/server.js'; import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
const PORT = 3168; const PORT = 3168;
@@ -33,6 +35,183 @@ describe('Stable HTTP contract (live server)', () => {
expect(body.data.version).toBeDefined(); expect(body.data.version).toBeDefined();
}); });
it('preserves the legacy global session order in single-user light state without exposing layouts', async () => {
const res = await fetch(`${base}/api/status`);
const body = await res.json();
expect(body.data.sessionOrder).toEqual([]);
expect(body.data).not.toHaveProperty('tabLayouts');
});
it('filters status order for regular users while admins and single-user mode retain the global projection', () => {
type FakeSession = {
id: string;
owner: string;
inputTokens: number;
outputTokens: number;
totalCost: number;
toLightDetailedState(): { id: string; owner: string };
};
type StatusInternals = {
sessions: Map<string, FakeSession>;
store: { getSessionOrder(): string[]; setSessionOrder(order: string[]): void };
cachedLightState: unknown;
cachedSessionsList: unknown;
getLightState(identity?: { username: string; role: 'admin' | 'user' }): Record<string, unknown>;
};
const internals = server as unknown as StatusInternals;
const previousOrder = internals.store.getSessionOrder();
const previousSessions = new Map(internals.sessions);
const fakeSession = (id: string, owner: string): FakeSession => ({
id,
owner,
inputTokens: 0,
outputTokens: 0,
totalCost: 0,
toLightDetailedState: () => ({ id, owner }),
});
try {
internals.sessions.clear();
internals.sessions.set('a1', fakeSession('a1', 'alice'));
internals.sessions.set('b1', fakeSession('b1', 'bob'));
internals.sessions.set('a2', fakeSession('a2', 'alice'));
internals.store.setSessionOrder(['b1', 'a1', 'a2']);
internals.cachedLightState = null;
internals.cachedSessionsList = null;
vi.stubEnv('CODEMAN_MULTIUSER', '1');
expect(internals.getLightState({ username: 'alice', role: 'user' }).sessionOrder).toEqual(['a1', 'a2']);
expect(internals.getLightState({ username: 'root', role: 'admin' }).sessionOrder).toEqual(['b1', 'a1', 'a2']);
vi.stubEnv('CODEMAN_MULTIUSER', '0');
expect(internals.getLightState().sessionOrder).toEqual(['b1', 'a1', 'a2']);
} finally {
vi.unstubAllEnvs();
internals.sessions.clear();
for (const [id, session] of previousSessions) internals.sessions.set(id, session);
internals.store.setSessionOrder(previousOrder);
internals.cachedLightState = null;
internals.cachedSessionsList = null;
}
});
it('keeps the tab layout foundation smoke contract atomic and writable', async () => {
type TabLayoutInternals = {
sessions: Map<string, { id: string; createdAt: number; owner?: string }>;
store: {
getState(): { sessionOrder?: string[]; tabLayouts?: Record<string, TabLayout> };
getSessionOrder(): string[];
getTabLayout(owner: string): TabLayout | null;
getTabLayouts(): Record<string, TabLayout>;
commitTabLayoutProjection: (...args: unknown[]) => unknown;
save(): void;
};
sse: {
addClient(reply: unknown, sessionFilter: Set<string> | null, isRemote: boolean): void;
removeClient(reply: unknown): void;
broadcast: (...args: unknown[]) => void;
broadcastSessionOrder: (...args: unknown[]) => void;
};
};
const internals = server as unknown as TabLayoutInternals;
const commit = vi.spyOn(internals.store, 'commitTabLayoutProjection');
const layoutEvent = vi.spyOn(internals.sse, 'broadcast');
const orderEvent = vi.spyOn(internals.sse, 'broadcastSessionOrder');
const previousSessions = new Map(internals.sessions);
const storeState = internals.store.getState();
const previousSessionOrder = storeState.sessionOrder ? [...storeState.sessionOrder] : undefined;
const previousTabLayouts = storeState.tabLayouts ? structuredClone(storeState.tabLayouts) : undefined;
const recipientWrites: string[] = [];
const recipient = { raw: { write: (chunk: string) => (recipientWrites.push(chunk), true) } };
internals.sse.addClient(recipient, null, false);
const requested = {
version: 0,
groups: [],
ungrouped: [],
updatedAt: '2026-08-23T00:00:00.000Z',
};
try {
expect(internals.store.getTabLayout('@single')).toBeNull();
const direct = await fetch(`${base}/api/tab-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: 0, layout: requested }),
});
expect(direct.status).toBe(200);
expect((await direct.json()).data.layout.version).toBe(1);
expect(commit).toHaveBeenCalledTimes(1);
expect(layoutEvent).toHaveBeenCalledTimes(1);
expect(layoutEvent).toHaveBeenCalledWith(SseEvent.TabLayoutChanged, { owner: '@single', version: 1 }, undefined);
expect(orderEvent).not.toHaveBeenCalled();
expect(recipientWrites).toEqual(['event: tab:layoutChanged\ndata: {"owner":"@single","version":1}\n\n']);
recipientWrites.length = 0;
const layoutBeforeConflict = internals.store.getTabLayout('@single');
const orderBeforeConflict = internals.store.getSessionOrder();
const writesBeforeConflict = commit.mock.calls.length;
const layoutEventsBeforeConflict = layoutEvent.mock.calls.length;
const orderEventsBeforeConflict = orderEvent.mock.calls.length;
const stale = await fetch(`${base}/api/tab-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: 0, layout: requested }),
});
expect(stale.status).toBe(409);
expect((await stale.json()).errorCode).toBe('CONFLICT');
expect(internals.store.getTabLayout('@single')).toEqual(layoutBeforeConflict);
expect(internals.store.getSessionOrder()).toEqual(orderBeforeConflict);
expect(commit).toHaveBeenCalledTimes(writesBeforeConflict);
expect(layoutEvent).toHaveBeenCalledTimes(layoutEventsBeforeConflict);
expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeConflict);
expect(recipientWrites).toEqual([]);
const ownerGet = await fetch(`${base}/api/tab-layout`);
expect(ownerGet.status).toBe(200);
expect((await ownerGet.json()).data.layout.version).toBe(1);
const firstId = 'tab-layout-smoke-a';
const secondId = 'tab-layout-smoke-b';
internals.sessions.set(firstId, { id: firstId, createdAt: 1 });
internals.sessions.set(secondId, { id: secondId, createdAt: 2 });
const orderEventsBeforeLegacy = orderEvent.mock.calls.length;
const legacyPut = await fetch(`${base}/api/session-order`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: [secondId, firstId] }),
});
expect(legacyPut.status).toBe(200);
expect((await legacyPut.json()).data.order).toEqual([secondId, firstId]);
expect(internals.store.getSessionOrder()).toEqual([secondId, firstId]);
expect(flattenOwnerSessionOrder(internals.store.getTabLayout('@single')!)).toEqual([secondId, firstId]);
expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeLegacy + 1);
expect(orderEvent).toHaveBeenLastCalledWith({
changedOwnerOrders: { '@single': [secondId, firstId] },
globalOrder: [secondId, firstId],
globalChanged: true,
});
expect(recipientWrites.at(-1)).toBe(
`event: session:orderChanged\ndata: {"order":["${secondId}","${firstId}"]}\n\n`
);
} finally {
internals.sse.removeClient(recipient);
internals.sessions.clear();
for (const [id, session] of previousSessions) internals.sessions.set(id, session);
if (previousSessionOrder) storeState.sessionOrder = [...previousSessionOrder];
else delete storeState.sessionOrder;
if (previousTabLayouts) storeState.tabLayouts = structuredClone(previousTabLayouts);
else delete storeState.tabLayouts;
internals.store.save();
commit.mockRestore();
layoutEvent.mockRestore();
orderEvent.mockRestore();
}
expect(internals.store.getSessionOrder()).toEqual(previousSessionOrder ?? []);
expect(internals.store.getTabLayouts()).toEqual(previousTabLayouts ?? {});
});
it('serves the same envelope on the /api/v1 alias', async () => { it('serves the same envelope on the /api/v1 alias', async () => {
const res = await fetch(`${base}/api/v1/status`); const res = await fetch(`${base}/api/v1/status`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
+86 -2
View File
@@ -186,6 +186,9 @@ describe('Inline rename input', () => {
renameActiveAfter: !!app._activeRename, renameActiveAfter: !!app._activeRename,
sessionGone: !app.sessions.has('ghost-id'), sessionGone: !app.sessions.has('ghost-id'),
fetchFired, fetchFired,
renameClassActive:
document.querySelector('.tab-name[data-session-id="ghost-id"]')?.classList.contains('tab-name-renaming') ??
false,
}; };
}); });
@@ -194,6 +197,7 @@ describe('Inline rename input', () => {
expect(result.sessionGone).toBe(true); expect(result.sessionGone).toBe(true);
// Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT. // Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT.
expect(result.fetchFired).toBe(false); expect(result.fetchFired).toBe(false);
expect(result.renameClassActive).toBe(false);
}); });
it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => { it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => {
@@ -386,10 +390,16 @@ describe('Inline rename input', () => {
await new Promise((r) => setTimeout(r, 60)); await new Promise((r) => setTimeout(r, 60));
window.fetch = origFetch; window.fetch = origFetch;
return { mapName: app.sessions.get('no-sse')?.name ?? null }; return {
mapName: app.sessions.get('no-sse')?.name ?? null,
renameClassActive:
document.querySelector('.tab-name[data-session-id="no-sse"]')?.classList.contains('tab-name-renaming') ??
false,
};
}); });
expect(result.mapName).toBe('w9-case: fresh'); expect(result.mapName).toBe('w9-case: fresh');
expect(result.renameClassActive).toBe(false);
}); });
it('A rejected rename restores the old label and leaves app.sessions untouched', async () => { it('A rejected rename restores the old label and leaves app.sessions untouched', async () => {
@@ -426,12 +436,16 @@ describe('Inline rename input', () => {
return { return {
mapName: app.sessions.get('rename-500')?.name ?? null, mapName: app.sessions.get('rename-500')?.name ?? null,
label: document.querySelector('.tab-name[data-session-id="rename-500"]')?.textContent ?? null, label: document.querySelector('.tab-name[data-session-id="rename-500"]')?.textContent ?? null,
renameClassActive:
document.querySelector('.tab-name[data-session-id="rename-500"]')?.classList.contains('tab-name-renaming') ??
false,
toasts, toasts,
}; };
}); });
expect(result.mapName).toBe('w9-case'); expect(result.mapName).toBe('w9-case');
expect(result.label).toBe('w9-case'); expect(result.label).toBe('w9-case');
expect(result.renameClassActive).toBe(false);
expect(result.toasts).toContain('Failed to rename'); expect(result.toasts).toContain('Failed to rename');
}); });
@@ -445,6 +459,7 @@ describe('Inline rename input', () => {
app: { app: {
_activeRename: { sessionId: string } | null; _activeRename: { sessionId: string } | null;
sessions: Map<string, { id: string; name: string }>; sessions: Map<string, { id: string; name: string }>;
renderSessionTabs: () => void;
startInlineRename: (id: string) => void; startInlineRename: (id: string) => void;
}; };
} }
@@ -461,11 +476,80 @@ describe('Inline rename input', () => {
tabName.textContent = 'Second'; tabName.textContent = 'Second';
wrap.appendChild(tabName); wrap.appendChild(tabName);
document.body.appendChild(wrap); document.body.appendChild(wrap);
// Cancelling the first rename is allowed to repaint the tab list. Model
// that synchronously so a target captured before cancel() becomes stale.
const originalRenderSessionTabs = app.renderSessionTabs;
app.renderSessionTabs = () => {
const current = document.querySelector('.tab-name[data-session-id="second-id"]');
current?.replaceWith(current.cloneNode(true));
};
app.startInlineRename('second-id'); app.startInlineRename('second-id');
return { firstActive, secondActive: app._activeRename?.sessionId }; app.renderSessionTabs = originalRenderSessionTabs;
return {
firstActive,
secondActive: app._activeRename?.sessionId,
secondInputVisible: !!document.querySelector('.tab-name[data-session-id="second-id"] input.tab-rename-input'),
firstRenameClassActive:
document.querySelector('.tab-name[data-session-id="first-id"]')?.classList.contains('tab-name-renaming') ??
false,
};
}); });
expect(result.firstActive).toBe('first-id'); expect(result.firstActive).toBe('first-id');
expect(result.secondActive).toBe('second-id'); expect(result.secondActive).toBe('second-id');
expect(result.secondInputVisible).toBe(true);
expect(result.firstRenameClassActive).toBe(false);
});
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
await resetState();
const id = 'vertical-live-input';
await page.evaluate((sessionId) => {
const app = (
window as unknown as {
app: {
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
};
}
).app;
document.documentElement.dataset.tabOrientation = 'vertical';
const rail = document.getElementById('tabRail') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.innerHTML =
`<span class="tab-name" data-session-id="${sessionId}">` +
'<span class="tab-name-prefix">w9-case: </span>old</span>';
rail.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
app.startInlineRename(sessionId);
}, id);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.type('edited title');
expect(await input.inputValue()).toBe('edited title');
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
const settled = await page.evaluate((sessionId) => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
app._activeRename?.cancel();
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
return {
classActive: label.classList.contains('tab-name-renaming'),
inputPresent: !!label.querySelector('input.tab-rename-input'),
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
};
}, id);
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
}); });
}); });
+23 -1
View File
@@ -33,9 +33,30 @@ export function createMockRouteContext(options?: {
return { return {
// -- SessionPort -- // -- SessionPort --
sessions, sessions,
addSession: vi.fn((s: MockSession) => { addSession: vi.fn(async (s: MockSession) => {
sessions.set(s.id, s); sessions.set(s.id, s);
}), }),
tabLayouts: {
get: vi.fn(),
put: vi.fn(),
putLegacyOrder: vi.fn(async (_actor: unknown, order: readonly string[]) => ({
order: [...order],
changedOwnerOrders: {},
globalOrder: [...order],
globalChanged: false,
})),
sessionCreated: vi.fn(),
webviewCreated: vi.fn(),
sessionsRemoved: vi.fn(async () => {}),
webviewDeleted: vi.fn(async () => {}),
markRestorationComplete: vi.fn(),
markRestorationFailed: vi.fn(),
markRestorationSkipped: vi.fn(),
assertDeletionReady: vi.fn(),
runSessionDeletion: vi.fn(async (_removed, action) => action()),
runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())),
reconcileAfterRestoration: vi.fn(async () => {}),
},
cleanupSession: vi.fn(async () => {}), cleanupSession: vi.fn(async () => {}),
setupSessionListeners: vi.fn(async () => {}), setupSessionListeners: vi.fn(async () => {}),
persistSessionState: vi.fn(), persistSessionState: vi.fn(),
@@ -82,6 +103,7 @@ export function createMockRouteContext(options?: {
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })), getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
getDailyStats: vi.fn(() => []), getDailyStats: vi.fn(() => []),
cleanupStaleSessions: vi.fn(() => ({ count: 0, cleaned: [] })), cleanupStaleSessions: vi.fn(() => ({ count: 0, cleaned: [] })),
cleanupSessionsByIds: vi.fn(() => ({ count: 0, cleaned: [] })),
}, },
port: 3000, port: 3000,
https: false, https: false,
+26
View File
@@ -461,6 +461,32 @@ describe('ralph-routes', () => {
// ========== POST /api/ralph-loop/start ========== // ========== POST /api/ralph-loop/start ==========
describe('POST /api/ralph-loop/start', () => { describe('POST /api/ralph-loop/start', () => {
it('awaits layout insertion and stops lifecycle work when registration rejects', async () => {
let rejectRegistration!: (error: Error) => void;
harness.ctx.addSession.mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
rejectRegistration = reject;
})
);
const pending = harness.app.inject({
method: 'POST',
url: '/api/ralph-loop/start',
payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' },
});
await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1));
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
rejectRegistration(new Error('layout capacity exceeded'));
const response = await pending;
expect(response.statusCode).toBe(500);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
expect(harness.ctx.broadcast).not.toHaveBeenCalledWith('session:created', expect.anything());
});
it('rejects invalid request body', async () => { it('rejects invalid request body', async () => {
const res = await harness.app.inject({ const res = await harness.app.inject({
method: 'POST', method: 'POST',
+49 -34
View File
@@ -1,11 +1,11 @@
/** /**
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131). * @fileoverview Tests for the synchronized legacy PUT /api/session-order endpoint.
* *
* Uses app.inject() — no real HTTP ports needed. * Uses app.inject() — no real HTTP ports needed.
* Asserts the uniform envelope contract: * Asserts the uniform envelope contract:
* SUCCESS -> 2xx, { success: true, data: { order } } * SUCCESS -> 2xx, { success: true, data: { order } }
* ERROR -> 4xx/5xx, { success: false, error, errorCode } * ERROR -> 4xx/5xx, { success: false, error, errorCode }
* and that the order is persisted to the (mock) StateStore + broadcast over SSE. * Legacy callers are routed through the authenticated owner-scoped tab-layout service.
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { TabLayoutValidationError } from '../../src/tab-layout.js';
// registerSessionRoutes pulls in session.js which can shell out; stub the bits // registerSessionRoutes pulls in session.js which can shell out; stub the bits
// that would touch the OS at import/registration time. None are needed by the // that would touch the OS at import/registration time. None are needed by the
@@ -29,11 +30,22 @@ interface LocalHarness {
ctx: MockRouteContext; ctx: MockRouteContext;
} }
async function buildHarness(): Promise<LocalHarness> { async function buildHarness(authUser = { username: 'alice', role: 'user' as const }): Promise<LocalHarness> {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
await app.register(fastifyCookie); await app.register(fastifyCookie);
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
const ctx = createMockRouteContext(); const ctx = createMockRouteContext();
Object.assign(ctx.tabLayouts, {
putLegacyOrder: vi.fn(async (_actor: unknown, order: string[]) => ({
order: [...order],
changedOwnerOrders: {},
globalOrder: [...order],
globalChanged: true,
})),
});
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]); registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
// Mirror production's uniform-envelope preSerialization hook (server.ts). // Mirror production's uniform-envelope preSerialization hook (server.ts).
@@ -60,35 +72,22 @@ describe('PUT /api/session-order', () => {
let harness: LocalHarness; let harness: LocalHarness;
beforeEach(async () => { beforeEach(async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
harness = await buildHarness(); harness = await buildHarness();
}); });
afterEach(async () => { afterEach(async () => {
await harness.app.close(); await harness.app.close();
vi.unstubAllEnvs();
}); });
it('persists the order and returns it in the envelope', async () => { it('routes a regular legacy PUT through the authenticated owner layout service', async () => {
const res = await harness.app.inject({ vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockResolvedValueOnce({
method: 'PUT', order: ['a', 'b'],
url: '/api/session-order', changedOwnerOrders: { alice: ['a', 'b'] },
payload: { order: ['a', 'b', 'c'] }, globalOrder: ['a', 'b'],
globalChanged: true,
}); });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
// Persisted to the store.
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
// Broadcast over SSE.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
});
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
// Seed the store with an order containing a server-only id "z".
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
const res = await harness.app.inject({ const res = await harness.app.inject({
method: 'PUT', method: 'PUT',
url: '/api/session-order', url: '/api/session-order',
@@ -96,25 +95,24 @@ describe('PUT /api/session-order', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = res.json(); expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'alice', isAdmin: false }, ['b', 'a']);
// Incoming order wins, server-only "z" falls to the end. expect(res.json().data.order).toEqual(['a', 'b']);
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
}); });
it('normalizes junk input (dedup + drop empties) before persisting', async () => { it('uses the machine-wide admin bridge for an admin caller', async () => {
await harness.app.close();
harness = await buildHarness({ username: 'root', role: 'admin' });
const res = await harness.app.inject({ const res = await harness.app.inject({
method: 'PUT', method: 'PUT',
url: '/api/session-order', url: '/api/session-order',
payload: { order: ['a', 'a', '', 'b'] }, payload: { order: ['b', 'a'] },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } }); expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'root', isAdmin: true }, ['b', 'a']);
}); });
it('rejects a non-array order with a 4xx envelope', async () => { it('rejects malformed bodies before invoking the service', async () => {
const res = await harness.app.inject({ const res = await harness.app.inject({
method: 'PUT', method: 'PUT',
url: '/api/session-order', url: '/api/session-order',
@@ -124,6 +122,23 @@ describe('PUT /api/session-order', () => {
expect(res.statusCode).toBeGreaterThanOrEqual(400); expect(res.statusCode).toBeGreaterThanOrEqual(400);
const body = res.json(); const body = res.json();
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled(); expect(harness.ctx.tabLayouts.putLegacyOrder).not.toHaveBeenCalled();
});
it('maps owner-boundary validation failures to INVALID_INPUT', async () => {
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockRejectedValueOnce(
new TabLayoutValidationError('session is not owned by layout owner: foreign')
);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['foreign'] },
});
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({
success: false,
errorCode: ApiErrorCode.INVALID_INPUT,
});
}); });
}); });
+6 -1
View File
@@ -332,11 +332,16 @@ describe('system-routes', () => {
describe('POST /api/cleanup-state', () => { describe('POST /api/cleanup-state', () => {
it('cleans up stale session state', async () => { it('cleans up stale session state', async () => {
const runStaleSessionCleanup = vi.fn(
async (_activeIds: Set<string>, action: (ids: ReadonlySet<string>) => unknown) => action(new Set())
);
harness.ctx.tabLayouts.runStaleSessionCleanup = runStaleSessionCleanup;
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' }); const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.cleanedSessions).toBe(0); expect(body.cleanedSessions).toBe(0);
expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled(); expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set());
expect(runStaleSessionCleanup).toHaveBeenCalledOnce();
}); });
}); });
+106
View File
@@ -0,0 +1,106 @@
/**
* @fileoverview Owner-scoped tab-layout HTTP concurrency and validation contract.
*/
import Fastify from 'fastify';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerTabLayoutRoutes } from '../../src/web/routes/tab-layout-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import type { TabLayout } from '../../src/tab-layout.js';
const layout = (version = 3): TabLayout => ({
version,
groups: [],
ungrouped: [{ kind: 'session', id: 'mine' }],
updatedAt: '2026-08-16T00:00:00.000Z',
});
async function harness(username?: string, role: 'admin' | 'user' = 'user') {
const app = Fastify({ logger: false });
if (username) {
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: { username: string; role: 'admin' | 'user' } }).authUser = { username, role };
});
}
const service = {
get: vi.fn(async () => layout()),
put: vi.fn(async (_owner: string, desired: unknown, baseVersion: number) => ({
status: 'updated' as const,
layout: { ...(desired as TabLayout), version: baseVersion + 1 },
})),
};
registerTabLayoutRoutes(app, { tabLayouts: service } as never);
installRouteErrorHandler(app);
await app.ready();
return { app, service };
}
afterEach(() => vi.unstubAllEnvs());
describe('tab layout routes', () => {
it('maps single-user requests to @single and never accepts an owner override', async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '0');
const { app, service } = await harness();
const response = await app.inject({ method: 'GET', url: '/api/tab-layout?owner=foreign' });
expect(response.statusCode).toBe(200);
expect(service.get).toHaveBeenCalledWith('@single');
await app.close();
});
it('uses the authenticated username in multi-user mode, including for admins', async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
const { app, service } = await harness('admin-a', 'admin');
await app.inject({ method: 'GET', url: '/api/tab-layout?owner=someone-else' });
expect(service.get).toHaveBeenCalledWith('admin-a');
await app.close();
});
it.each([2, 4])('returns 409 with the authoritative prepared layout when baseVersion=%s', async (baseVersion) => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
const { app, service } = await harness('alice');
service.put.mockResolvedValueOnce({ status: 'conflict', layout: layout(3) });
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion, layout: layout(baseVersion) },
});
expect(response.statusCode).toBe(409);
expect(response.json().success).toBe(false);
expect(response.json().errorCode).toBe('CONFLICT');
expect(response.json().data.layout).toEqual(layout(3));
expect(service.put).toHaveBeenCalledWith('alice', layout(baseVersion), baseVersion);
await app.close();
});
it('rejects malformed writes before invoking the service', async () => {
const { app, service } = await harness();
const response = await app.inject({ method: 'PUT', url: '/api/tab-layout', payload: { baseVersion: -1 } });
expect(response.statusCode).toBe(400);
expect(service.put).not.toHaveBeenCalled();
await app.close();
});
it('rejects extra write keys before invoking the service', async () => {
const { app, service } = await harness();
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion: 3, layout: layout(), owner: 'foreign' },
});
expect(response.statusCode).toBe(400);
expect(response.json().errorCode).toBe('INVALID_INPUT');
expect(service.put).not.toHaveBeenCalled();
await app.close();
});
it('has an explicit conservative body limit', async () => {
const { app } = await harness();
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion: 3, layout: layout(), padding: 'x'.repeat(140 * 1024) },
});
expect(response.statusCode).toBe(413);
await app.close();
});
});
+73 -1
View File
@@ -5,7 +5,7 @@
* the developer's real ~/.codeman/webviews.json. * the developer's real ~/.codeman/webviews.json.
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
import fastifyWebsocket from '@fastify/websocket'; import fastifyWebsocket from '@fastify/websocket';
@@ -16,17 +16,23 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { webviewCapabilities } from '../../src/webview-capabilities.js'; import { webviewCapabilities } from '../../src/webview-capabilities.js';
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js'; import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
import { TabLayoutService } from '../../src/tab-layout-service.js';
import type { TabLayout } from '../../src/tab-layout.js';
let app: FastifyInstance; let app: FastifyInstance;
let tmpDir: string; let tmpDir: string;
let savedDataDir: string | undefined; let savedDataDir: string | undefined;
const broadcasts: Array<{ event: string; data: unknown }> = []; const broadcasts: Array<{ event: string; data: unknown }> = [];
const webviewCreated = vi.fn(async () => {});
const webviewDeleted = vi.fn(async () => {});
beforeEach(async () => { beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-'));
savedDataDir = process.env.CODEMAN_DATA_DIR; savedDataDir = process.env.CODEMAN_DATA_DIR;
process.env.CODEMAN_DATA_DIR = tmpDir; process.env.CODEMAN_DATA_DIR = tmpDir;
broadcasts.length = 0; broadcasts.length = 0;
webviewCreated.mockClear();
webviewDeleted.mockClear();
app = Fastify({ logger: false }); app = Fastify({ logger: false });
await app.register(fastifyCookie); await app.register(fastifyCookie);
@@ -34,6 +40,7 @@ beforeEach(async () => {
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
registerWebviewRoutes(app, { registerWebviewRoutes(app, {
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }), broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
tabLayouts: { webviewCreated, webviewDeleted },
} as never); } as never);
installRouteErrorHandler(app); installRouteErrorHandler(app);
await app.ready(); await app.ready();
@@ -90,6 +97,58 @@ describe('POST /api/webviews', () => {
} }
}); });
it('rolls back webview persistence and emits nothing when layout capacity rejects insertion', async () => {
const refs = Array.from({ length: 512 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` }));
const original: TabLayout = {
version: 9,
groups: [],
ungrouped: refs,
updatedAt: '2026-08-16T00:00:00.000Z',
};
let stored = original;
const live = new Map(refs.map((ref, index) => [ref.id, { id: ref.id, createdAt: index }]));
const atomicBroadcast = vi.fn();
const service = new TabLayoutService({
store: {
getTabLayout: () => stored,
setTabLayout: (_owner, layout) => {
stored = layout;
},
getSessions: () => ({}),
getSessionOrder: () => [],
} as never,
sessions: live,
readWebviews: async () =>
(await import('../../src/webview-store.js')).readWebviews(tmpDir) as Promise<
Array<{ id: string; owner?: string }>
>,
broadcast: atomicBroadcast,
broadcastSessionOrder: vi.fn(),
});
const atomicApp = Fastify({ logger: false });
await atomicApp.register(fastifyCookie);
await atomicApp.register(fastifyWebsocket);
registerWebviewRoutes(atomicApp, {
broadcast: atomicBroadcast,
tabLayouts: service,
} as never);
installRouteErrorHandler(atomicApp);
await atomicApp.ready();
const response = await atomicApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'overflow', url: 'https://example.test/' },
});
const list = (await atomicApp.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
expect(response.statusCode).toBe(500);
expect(list).toEqual([]);
expect(stored).toEqual(original);
expect(atomicBroadcast).not.toHaveBeenCalled();
await atomicApp.close();
});
it('rejects URLs carrying embedded credentials', async () => { it('rejects URLs carrying embedded credentials', async () => {
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' }); const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
@@ -142,6 +201,19 @@ describe('DELETE /api/webviews/:id', () => {
expect(webviewCapabilities.resolve(cap)).toBeUndefined(); expect(webviewCapabilities.resolve(cap)).toBeUndefined();
}); });
it('keeps the exact saved record and emits nothing when layout deletion fails', async () => {
const created = (await create({ name: 'Keep me', url: 'https://keep.example/' })).json().data;
broadcasts.length = 0;
webviewDeleted.mockRejectedValueOnce(new Error('tab layout restoration failed'));
const response = await app.inject({ method: 'DELETE', url: `/api/webviews/${created.id}` });
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
expect(response.statusCode).toBe(500);
expect(list).toEqual([created]);
expect(broadcasts).toEqual([]);
});
it('404s an unknown id', async () => { it('404s an unknown id', async () => {
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404); expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
}); });
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { WebServer } from '../src/web/server.js';
describe('scheduled session layout registration', () => {
it('registers the layout before lifecycle work and rolls back a rejected tentative session', async () => {
const sessions = new Map<string, { id: string; owner?: string }>();
const session = { id: 'scheduled-session', owner: 'alice' };
const sessionCreated = vi.fn(async () => {
throw new Error('layout unavailable');
});
const server = Object.create(WebServer.prototype) as {
sessions: typeof sessions;
tabLayouts: { sessionCreated: typeof sessionCreated };
registerSessionWithLayout(session: typeof session): Promise<void>;
};
server.sessions = sessions;
server.tabLayouts = { sessionCreated };
await expect(server.registerSessionWithLayout(session)).rejects.toThrow('layout unavailable');
expect(sessionCreated).toHaveBeenCalledWith('alice');
expect(sessions.has(session.id)).toBe(false);
});
it('uses the shared registration helper in the scheduled loop before persistence and listeners', () => {
const source = readFileSync(new URL('../src/web/server.ts', import.meta.url), 'utf8');
const loop = source.slice(
source.indexOf('private async runScheduledLoop'),
source.indexOf('private async stopScheduledRun')
);
expect(loop).toContain('await this.registerSessionWithLayout(session);');
expect(loop.indexOf('await this.registerSessionWithLayout(session);')).toBeLessThan(
loop.indexOf('this.store.incrementSessionsCreated();')
);
expect(loop.indexOf('await this.registerSessionWithLayout(session);')).toBeLessThan(
loop.indexOf('await this.setupSessionListeners(session);')
);
});
});
+31 -2
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js'; import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs'; import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir } from 'node:os'; import { homedir, tmpdir } from 'node:os';
const TEST_PORT = 3120; const TEST_PORT = 3120;
const CASES_DIR = join(homedir(), 'codeman-cases'); const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -15,10 +15,12 @@ const CASES_DIR = join(homedir(), 'codeman-cases');
describe('Session Cleanup', () => { describe('Session Cleanup', () => {
let server: WebServer; let server: WebServer;
let baseUrl: string; let baseUrl: string;
let testWorkingDir: string;
const createdCases: string[] = []; const createdCases: string[] = [];
const createdSessions: string[] = []; const createdSessions: string[] = [];
beforeAll(async () => { beforeAll(async () => {
testWorkingDir = mkdtempSync(join(tmpdir(), 'codeman-cleanup-test-'));
server = new WebServer(TEST_PORT, false, true); server = new WebServer(TEST_PORT, false, true);
await server.start(); await server.start();
baseUrl = `http://localhost:${TEST_PORT}`; baseUrl = `http://localhost:${TEST_PORT}`;
@@ -43,9 +45,36 @@ describe('Session Cleanup', () => {
} catch {} } catch {}
} }
await server.stop(); await server.stop();
rmSync(testWorkingDir, { recursive: true, force: true });
}, 60000); }, 60000);
describe('Session Deletion', () => { describe('Session Deletion', () => {
it('rejects before stopping the session when layout deletion preparation fails', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: testWorkingDir }),
});
const created = await createRes.json();
const id = created.data.session.id;
createdSessions.push(id);
const internals = server as unknown as {
tabLayouts: { runSessionDeletion: (...args: unknown[]) => Promise<unknown> };
};
const deletionSpy = vi
.spyOn(internals.tabLayouts, 'runSessionDeletion')
.mockRejectedValueOnce(new Error('layout prune unavailable'));
try {
const deleteRes = await fetch(`${baseUrl}/api/sessions/${id}`, { method: 'DELETE' });
const getRes = await fetch(`${baseUrl}/api/sessions/${id}`);
expect(deleteRes.status).toBe(500);
expect(getRes.status).toBe(200);
} finally {
deletionSpy.mockRestore();
}
});
it('should properly stop and cleanup interactive session', async () => { it('should properly stop and cleanup interactive session', async () => {
const caseName = `cleanup-test-${Date.now()}`; const caseName = `cleanup-test-${Date.now()}`;
createdCases.push(caseName); createdCases.push(caseName);
+86 -2
View File
@@ -12,6 +12,10 @@ import { describe, expect, it } from 'vitest';
type Rect = { left: number; top: number; width: number; height: number }; type Rect = { left: number; top: number; width: number; height: number };
type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null; type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null;
type Orientation = 'horizontal' | 'vertical';
const lineageJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-lineage.js'), 'utf8');
const stylesCss = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
function loadLineageHelper() { function loadLineageHelper() {
const context = vm.createContext({ window: {}, globalThis: {} }); const context = vm.createContext({ window: {}, globalThis: {} });
@@ -20,7 +24,13 @@ function loadLineageHelper() {
return ( return (
context.window as { context.window as {
CodemanLineage: { CodemanLineage: {
computePath: (input: { parent: Rect | null; child: Rect | null; strip?: Rect; depth?: number }) => LineagePath; computePath: (input: {
parent: Rect | null;
child: Rect | null;
strip?: Rect;
depth?: number;
orientation?: Orientation;
}) => LineagePath;
DIP_MIN_PX: number; DIP_MIN_PX: number;
DIP_MAX_PX: number; DIP_MAX_PX: number;
SIBLING_STEP_PX: number; SIBLING_STEP_PX: number;
@@ -48,7 +58,11 @@ function loadLineageApp(): { app: LineageApp; sandbox: LineageSandbox } {
globalThis: {}, globalThis: {},
CodemanApp, CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop' }, MobileDetection: { getDeviceType: () => 'desktop' },
document: { getElementById: () => null, createElementNS: () => null }, document: {
documentElement: { getAttribute: () => 'horizontal' },
getElementById: () => null,
createElementNS: () => null,
},
}; };
const context = vm.createContext(sandbox); const context = vm.createContext(sandbox);
for (const file of ['constants.js', 'session-lineage.js']) { for (const file of ['constants.js', 'session-lineage.js']) {
@@ -258,6 +272,76 @@ describe('lineage line geometry', () => {
).toBeNull(); ).toBeNull();
}); });
it('routes vertical tabs through the empty left gutter instead of their shared centerline', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(geom).not.toBeNull();
expect(nums).toHaveLength(5);
expect(nums[0]).toBe(parent.left);
expect(nums[1]).toBe(parent.top + parent.height / 2);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect(nums[3]).toBe(child.top + child.height / 2);
expect(nums[4]).toBe(child.left);
expect(geom.endX).toBe(child.left);
expect(geom.endY).toBe(child.top + child.height / 2);
});
it('offsets vertical sibling tracks without moving either tab endpoint', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const first = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 0 })!;
const second = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 1 })!;
const numbers = (d: string) => d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(numbers(second.d)[2]).toBeGreaterThan(numbers(first.d)[2]);
expect([second.endX, second.endY]).toEqual([first.endX, first.endY]);
});
it('keeps the same gutter shape when the child sits above its parent', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 220, width: 260, height: 40 };
const child: Rect = { left: 132, top: 60, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(nums).toEqual([parent.left, 240, expect.any(Number), 80, child.left]);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect([geom.endX, geom.endY]).toEqual([child.left, 80]);
});
it('clips vertical lineage by the visible Y range after rail scrolling', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 100, width: 320, height: 300 };
const visible: Rect = { left: 132, top: 160, width: 260, height: 40 };
const above: Rect = { left: 132, top: 20, width: 260, height: 40 };
const below: Rect = { left: 132, top: 460, width: 260, height: 40 };
expect(helper.computePath({ parent: above, child: visible, strip, orientation: 'vertical' })).toBeNull();
expect(helper.computePath({ parent: visible, child: below, strip, orientation: 'vertical' })).toBeNull();
expect(
helper.computePath({ parent: visible, child: { ...visible, top: 300 }, strip, orientation: 'vertical' })
).not.toBeNull();
});
it('passes the resolved DOM orientation into geometry and reserves a vertical gutter', () => {
expect(lineageJs).toContain("getAttribute('data-tab-orientation')");
expect(lineageJs).toMatch(/compute\(\{[\s\S]{0,180}orientation/);
const selector = "html[data-tab-orientation='vertical'] .tab-rail .session-tabs {";
const verticalRailBlock = stylesCss.slice(stylesCss.indexOf(selector), stylesCss.indexOf(selector) + 600);
expect(verticalRailBlock).toContain('--lineage-vertical-gutter');
expect(verticalRailBlock).toContain('padding-left');
});
it('still draws when no strip rect is supplied (clipping is opt-in)', () => { it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
const helper = loadLineageHelper(); const helper = loadLineageHelper();
const geom = helper.computePath({ parent: tab(0), child: tab(9000) }); const geom = helper.computePath({ parent: tab(0), child: tab(9000) });
+51
View File
@@ -34,6 +34,7 @@ const SCHEMAS = readFileSync(new URL('../src/web/schemas.ts', import.meta.url),
interface LayoutApp { interface LayoutApp {
soloSessionId: string | null; soloSessionId: string | null;
isSoloWindow: boolean;
sessions: Map<string, unknown>; sessions: Map<string, unknown>;
sessionOrder: string[]; sessionOrder: string[];
_tallTabsEnabled?: boolean; _tallTabsEnabled?: boolean;
@@ -44,6 +45,7 @@ interface LayoutApp {
isSessionSidebarActive(): boolean; isSessionSidebarActive(): boolean;
isSessionSidebarCollapsed(): boolean; isSessionSidebarCollapsed(): boolean;
applySessionListLayout(): void; applySessionListLayout(): void;
applyTabOrientation(): void;
toggleSessionSidebar(): void; toggleSessionSidebar(): void;
updateSidebarCount(): void; updateSidebarCount(): void;
closeSessionSidebarOnHandheld(): void; closeSessionSidebarOnHandheld(): void;
@@ -86,6 +88,7 @@ const SHELL = `
</div> </div>
</header> </header>
<main class="main"> <main class="main">
<div class="tab-rail" id="tabRail"></div>
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions"> <aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
<div class="session-sidebar-head"> <div class="session-sidebar-head">
<span class="session-sidebar-title">Sessions</span> <span class="session-sidebar-title">Sessions</span>
@@ -154,6 +157,7 @@ function boot(
// terminal stack. Only the layout surface is under test here. // terminal stack. Only the layout surface is under test here.
const app = Object.create(win.__CodemanApp.prototype) as LayoutApp; const app = Object.create(win.__CodemanApp.prototype) as LayoutApp;
app.soloSessionId = options.solo ?? null; app.soloSessionId = options.solo ?? null;
app.isSoloWindow = !!app.soloSessionId;
app.sessions = new Map(); app.sessions = new Map();
app.sessionOrder = []; app.sessionOrder = [];
app._elemCache = new Map(); app._elemCache = new Map();
@@ -177,6 +181,39 @@ describe('session list layout', () => {
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true); expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
}); });
it('preserves vertical rail ownership when the session-list layout reapplies', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
app.applySessionListLayout();
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
app.applySessionListLayout();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
});
it('keeps aria orientation synchronized when tab orientation moves hosts', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
app.applySessionListLayout();
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
win.localStorage.setItem(
'codeman-app-settings',
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'horizontal' })
);
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
});
it('re-parents the tab list into the sidebar and flips the a11y state', () => { it('re-parents the tab list into the sidebar and flips the a11y state', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } }); const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
expect(app.getSessionListLayout()).toBe('sidebar'); expect(app.getSessionListLayout()).toBe('sidebar');
@@ -225,6 +262,20 @@ describe('session list layout', () => {
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost'); expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
}); });
it('forces horizontal tabs in a solo window even when vertical orientation is preferred', () => {
const { win, app } = boot({
stored: { sessionListLayout: 'header', tabOrientation: 'vertical' },
solo: 'sess-1',
});
app.applySessionListLayout();
app.applyTabOrientation();
expect(win.document.documentElement.dataset.tabOrientation).toBe('horizontal');
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
});
it('round-trips the collapse state through its own storage key', () => { it('round-trips the collapse state through its own storage key', () => {
// Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that // Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that
// blob from the DOM controls, so a key without a control is wiped on Save. // blob from the DOM controls, so a key without a control is wiped on Save.
@@ -0,0 +1,77 @@
/** Real-browser responsive layout coverage for Session Options. */
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 publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8');
function sessionOptionsMarkup() {
const start = html.indexOf('<div class="modal" id="sessionOptionsModal">');
const end = html.indexOf('<!-- Close Session Confirmation Modal -->', start);
if (start < 0 || end < 0) throw new Error('Session Options markup not found');
return html.slice(start, end);
}
describe('Session Options responsive layout in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
});
afterAll(async () => browser.close());
async function renderAt(width: number) {
await page.setViewportSize({ width, height: 1000 });
await page.setContent(`<!doctype html><html><head><style>${styles}</style>
<style>@media (max-width: 1023px) { ${mobileStyles} }</style></head>
<body>${sessionOptionsMarkup()}</body></html>`);
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<HTMLElement>('#sessionOptionsModal .modal-content')!;
const doc = document.getElementById('sessionOptionsDoc')!;
const panel = document.getElementById('context-tab')!;
const header = panel.querySelector<HTMLElement>(':scope > .set-section-head')!;
const blurb = panel.querySelector<HTMLElement>(':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);
});
});
+9
View File
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
const publicDir = resolve(import.meta.dirname, '../src/web/public'); const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), '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. */ /** The Session Options markup, so assertions can't be satisfied elsewhere. */
function optionsModal(): string { 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(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {');
expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {'); 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/);
});
}); });
+244
View File
@@ -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<string, string[]>,
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();
});
});
+106
View File
@@ -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 `<!doctype html><html ${root}><head><style>${styles}</style></head><body>
<div class="${hostClass}"><div class="session-tabs">
<div class="session-tab${active ? ' active' : ''}" tabindex="0">
<span class="tab-info"><span class="tab-name-row"><span class="tab-name">session</span>
<span class="tab-actions"><button class="tab-more" type="button">…</button></span>
</span></span>
</div>
</div></div>
</body></html>`;
}
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);
});
});
+73
View File
@@ -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.':");
});
});
+134
View File
@@ -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<string, TabLayout> } = {}): 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);
});
});
+14
View File
@@ -199,6 +199,20 @@ describe('StateStore', () => {
expect(store.getSession('pinned-1')).not.toBeNull(); expect(store.getSession('pinned-1')).not.toBeNull();
expect(store.getSession('plain-1')).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', () => { describe('task operations', () => {
+180
View File
@@ -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> = {}): TabRefMetadata => ({
kind: 'session',
id,
ownerValid: true,
visible: true,
order: 0,
...overrides,
});
const layout = (overrides: Partial<TabLayout> = {}): 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__']);
});
});
+264
View File
@@ -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> = {}): 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();
});
});
+62
View File
@@ -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<never> };
restoreMuxSessions(): Promise<boolean>;
finalizeRestoredState(restored: boolean): Promise<void>;
};
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<void>;
};
cleanupStaleSessions(): Promise<number>;
finalizeRestoredState(restored: boolean): Promise<void>;
};
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']);
});
});
File diff suppressed because it is too large Load Diff
+305
View File
@@ -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<boolean | Error>) {
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();
});
});
+383
View File
@@ -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')]);
});
});
+73
View File
@@ -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('<option value="horizontal">');
expect(indexHtml).toContain('<option value="vertical">');
});
it('moves the existing list between the rail and its header host', () => {
expect(settingsJs).toMatch(/applyTabOrientation\(options = \{\}\)/);
expect(settingsJs).toContain('rail.appendChild(tabsEl)');
expect(settingsJs).toContain('headerHost.appendChild(tabsEl)');
expect(settingsJs).toContain("tabsEl.setAttribute('aria-orientation'");
});
it('applies orientation before horizontal wrapping and suppresses wrap vertically', () => {
expect(appJs.indexOf('this.applyTabOrientation()')).toBeLessThan(appJs.indexOf('this.applyTabWrapSettings()'));
const start = appJs.indexOf('updateTabOverflowMode() {');
const block = appJs.slice(start, start + 3500);
expect(block).toContain('resolveTabOrientation');
expect(block.indexOf('resolveTabOrientation')).toBeLessThan(block.indexOf('shouldAutoWrapTabs'));
});
it('round-trips a device-scoped strict setting and has vertical styles', () => {
expect(settingsJs).toMatch(/tabOrientation:\s*'horizontal'/);
expect(settingsJs).toMatch(/tabOrientation:\s*document\.getElementById\('appSettingsTabOrientation'\)\.value/);
const displayKeys = settingsJs.slice(settingsJs.indexOf('const displayKeys = new Set(['));
expect(displayKeys.slice(0, 1800)).toContain("'tabOrientation'");
expect(schemasTs).toMatch(/tabOrientation:\s*z\.enum\(\['horizontal',\s*'vertical'\]\)\.optional\(\)/);
expect(stylesCss).toContain("html[data-tab-orientation='vertical'] .tab-rail");
});
});
+115
View File
@@ -0,0 +1,115 @@
/** @fileoverview Real Chromium coverage for COD-358 rail resize and label geometry. */
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 publicDir = resolve(import.meta.dirname, '../src/web/public');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
const controller = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
describe('COD-358 vertical rail 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 } });
await page.setContent(`<!doctype html><html data-tab-orientation="vertical"><head><style>${styles}</style></head>
<body><header class="header"><div id="sessionTabsHost"></div></header><main class="main" style="width:100%;height:700px">
<aside class="tab-rail" id="tabRail"><div class="session-tabs" id="sessionTabs"><div class="session-tab active">
<span class="tab-info"><span class="tab-name-row"><span class="tab-name"><span class="tab-name-prefix">w2-codeman: </span>super-long-unbroken-session-name-for-wrapping</span></span></span>
</div></div><div id="tabRailResizeHandle" class="tab-rail-resize-handle" role="separator" tabindex="0" aria-valuemin="208" aria-valuemax="360" aria-valuenow="256"></div></aside>
<div class="terminal-wrap"><iframe title="terminal"></iframe></div></main>
<div id="tabRailResizeShield" class="tab-rail-resize-shield" hidden></div>
<select id="appSettingsTabRailWidth"><option value="custom">Custom</option><option value="208">Compact</option><option value="256">Default</option><option value="320">Wide</option><option value="360">Maximum</option></select>
</body></html>`);
await page.addScriptTag({ content: constants });
await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' });
await page.addScriptTag({ content: settingsUi });
await page.addScriptTag({ content: controller });
await page.evaluate(() => {
const app = new (window as any).CodemanApp();
const settings = { tabOrientation: 'vertical', tabRailWidth: 256 };
(window as any).__settings = settings;
(window as any).__deviceType = 'desktop';
(window as any).MobileDetection = { getDeviceType: () => (window as any).__deviceType };
app.activeSessionId = 'session-1';
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (next: Record<string, unknown>) => Object.assign(settings, next);
app.getDefaultSettings = () => ({});
app.isSessionSidebarActive = () => false;
app.updateTabOverflowMode = () => undefined;
app._fullRenderSessionTabs = () => undefined;
app._updateConnectionLinesImmediate = () => undefined;
app.fitAddon = { proposeDimensions: () => ({ cols: 80, rows: 24 }), fit: () => undefined };
app.sendResize = async () => ((window as any).__resizeCount = ((window as any).__resizeCount || 0) + 1);
app.initTabRailResize();
(window as any).app = app;
});
});
afterAll(async () => browser.close());
it('shows the complete label while clamping it to two lines', async () => {
const metrics = await page.locator('.tab-name').evaluate((element) => {
const style = getComputedStyle(element);
return {
text: element.textContent,
clamp: style.getPropertyValue('-webkit-line-clamp'),
whiteSpace: style.whiteSpace,
overflowWrap: style.overflowWrap,
height: element.getBoundingClientRect().height,
lineHeight: Number.parseFloat(style.lineHeight),
};
});
expect(metrics.text).toBe('w2-codeman: super-long-unbroken-session-name-for-wrapping');
expect(metrics.clamp).toBe('2');
expect(metrics.whiteSpace).toBe('normal');
expect(metrics.overflowWrap).toBe('anywhere');
expect(metrics.height).toBeLessThanOrEqual(metrics.lineHeight * 2 + 1);
});
it('resizes from the keyboard and settles once', async () => {
const handle = page.locator('#tabRailResizeHandle');
await handle.focus();
await handle.press('ArrowRight');
expect(await handle.getAttribute('aria-valuenow')).toBe('264');
await page.waitForTimeout(180);
expect(await page.evaluate(() => (window as any).__resizeCount)).toBe(1);
});
it('preserves 360px while a narrow viewport applies only an effective clamp', async () => {
await page.setViewportSize({ width: 700, height: 800 });
const narrow = await page.evaluate(() => {
(window as any).__settings.tabRailWidth = 360;
return { effective: (window as any).app.applyTabRailWidth(), preferred: (window as any).__settings.tabRailWidth };
});
expect(narrow).toEqual({ effective: 280, preferred: 360 });
await page.setViewportSize({ width: 1280, height: 800 });
expect(await page.evaluate(() => (window as any).app.applyTabRailWidth())).toBe(360);
});
it('reapplies phone fallback in both directions without changing preferences', async () => {
await page.evaluate(() => {
(window as any).__settings.tabRailWidth = 360;
(window as any).__deviceType = 'mobile';
});
await page.setViewportSize({ width: 390, height: 800 });
await page.waitForTimeout(130);
expect(await page.locator('#sessionTabs').evaluate((node) => node.parentElement?.id)).toBe('sessionTabsHost');
expect(await page.evaluate(() => (window as any).__settings)).toEqual({
tabOrientation: 'vertical',
tabRailWidth: 360,
});
await page.evaluate(() => ((window as any).__deviceType = 'desktop'));
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(130);
expect(await page.locator('#sessionTabs').evaluate((node) => node.parentElement?.id)).toBe('tabRail');
expect(await page.evaluate(() => (window as any).app._getCurrentTabRailWidth())).toBe(360);
});
});
+226
View File
@@ -0,0 +1,226 @@
/** @fileoverview COD-358 resizable vertical session rail policy and wiring. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const readPublic = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
const indexHtml = readPublic('index.html');
const stylesCss = readPublic('styles.css');
const settingsJs = readPublic('settings-ui.js');
const appJs = readPublic('app.js');
const terminalUiJs = readPublic('terminal-ui.js');
const buildJs = readFileSync(resolve(import.meta.dirname, '../scripts/build.mjs'), 'utf8');
type RailPolicy = {
DEFAULT_WIDTH: number;
MIN_WIDTH: number;
MAX_WIDTH: number;
resolveWidth: (input?: Record<string, unknown>) => number;
resolveKeyboardWidth: (input: Record<string, unknown>) => number | null;
};
function loadRailPolicy(): RailPolicy {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(readPublic('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanTabRail: RailPolicy }).CodemanTabRail;
}
afterEach(() => vi.useRealTimers());
describe('tab rail width policy', () => {
it('uses fixed storage bounds and separate viewport/terminal clamps', () => {
const policy = loadRailPolicy();
expect(policy.DEFAULT_WIDTH).toBe(256);
expect(policy.MIN_WIDTH).toBe(208);
expect(policy.MAX_WIDTH).toBe(360);
expect(policy.resolveWidth()).toBe(256);
expect(policy.resolveWidth({ width: 120 })).toBe(208);
expect(policy.resolveWidth({ width: 900 })).toBe(360);
expect(policy.resolveWidth({ width: 360, viewportWidth: 700 })).toBe(280);
expect(policy.resolveWidth({ width: 360 })).toBe(360);
expect(policy.resolveWidth({ width: 360, mainWidth: 700, minTerminalWidth: 400 })).toBe(300);
});
it('supports accessible keyboard steps and reset keys', () => {
const policy = loadRailPolicy();
const base = { currentWidth: 256, viewportWidth: 1600, mainWidth: 1200, minTerminalWidth: 400 };
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowLeft' })).toBe(248);
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowRight' })).toBe(264);
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowRight', shiftKey: true })).toBe(288);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Home' })).toBe(208);
expect(policy.resolveKeyboardWidth({ ...base, key: 'End' })).toBe(360);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter' })).toBe(256);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Escape' })).toBeNull();
});
});
describe('tab rail resize wiring', () => {
it('ships an accessible handle and width presets', () => {
expect(indexHtml).toContain('id="tabRailResizeHandle"');
expect(indexHtml).toContain('role="separator"');
expect(indexHtml).toContain('aria-orientation="vertical"');
expect(indexHtml).toContain('aria-valuemin="208"');
expect(indexHtml).toContain('aria-valuemax="360"');
expect(indexHtml).toContain('id="appSettingsTabRailWidth"');
expect(indexHtml).toContain('<option value="208">Compact');
expect(indexHtml).toContain('<option value="256">Default');
expect(indexHtml).toContain('<option value="320">Wide');
expect(indexHtml).toContain('<option value="360">Maximum');
});
it('keeps preferred width device-local and distinct from effective width', () => {
expect(settingsJs).toMatch(/tabRailWidth:\s*256/);
expect(settingsJs).toMatch(/tabRailWidth:\s*this\.readTabRailWidthSetting/);
const displayKeys = settingsJs.slice(settingsJs.indexOf('const displayKeys = new Set(['));
expect(displayKeys.slice(0, 1900)).toContain("'tabRailWidth'");
const controller = readPublic('tab-rail-resize.js');
expect(controller).toContain('_scheduleTabRailSettle(resolved, preferred)');
expect(controller).toContain('_scheduleTabRailSettle(effective, preferred)');
expect(controller).toContain('applyTabOrientation?.()');
expect(controller).toContain('applyTabRailWidth({ persist: false })');
});
it('installs the controller before terminal creation and owns generic observer churn', () => {
const controller = readPublic('tab-rail-resize.js');
expect(indexHtml).toContain('<script defer src="tab-rail-resize.js"></script>');
expect(appJs).toContain('this.initTabRailResize?.()');
expect(controller).toContain('setPointerCapture');
expect(controller).toContain("addEventListener('lostpointercapture'");
expect(controller).toContain('_tabRailResizeWatchdog');
expect(terminalUiJs).toMatch(/if \(this\._tabRailResizeOwnsObserver\) return/);
expect(buildJs).toContain("run('minify tab-rail-resize.js'");
expect(buildJs).toMatch(/HASHABLE[\s\S]{0,1000}'tab-rail-resize\.js'/);
});
it('coalesces effective changes while persisting the preferred width', async () => {
vi.useFakeTimers();
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: {},
document: { querySelector: () => null },
console,
clearTimeout,
setTimeout,
});
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app.activeSessionId = 'session-1';
app.sendResize = vi.fn(async () => true);
app._persistTabRailWidth = vi.fn();
app._tabRailResizeOwnsObserver = true;
app._scheduleTabRailSettle(280, 360);
app._scheduleTabRailSettle(300, 360);
await vi.advanceTimersByTimeAsync(150);
expect(app._persistTabRailWidth).toHaveBeenCalledOnce();
expect(app._persistTabRailWidth).toHaveBeenCalledWith(360);
expect(app.sendResize).toHaveBeenCalledOnce();
expect(app._tabRailResizeOwnsObserver).toBe(false);
});
it('keeps resize-observer ownership for pointer drags longer than the watchdog', async () => {
vi.useFakeTimers();
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
let resizing = true;
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: {},
document: {
body: { classList: { contains: (name: string) => name === 'tab-rail-resizing' && resizing } },
querySelector: () => null,
},
console,
clearTimeout,
setTimeout,
});
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app._claimTabRailResize();
await vi.advanceTimersByTimeAsync(1100);
expect(app._tabRailResizeOwnsObserver).toBe(true);
resizing = false;
await vi.advanceTimersByTimeAsync(1000);
expect(app._tabRailResizeOwnsObserver).toBe(false);
});
it('keeps a viewport-clamped effective width out of the preferred-width control', () => {
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
const values = new Map<string, string>();
const rootClasses = new Set<string>();
const custom = { textContent: 'Custom' };
const select = {
value: '360',
dataset: { currentWidth: '360' },
querySelector(selector: string) {
if (selector === 'option[value="360"]') return {};
if (selector === 'option[value="custom"]') return custom;
return null;
},
};
const handle = { setAttribute: vi.fn() };
const document = {
documentElement: {
style: {
getPropertyValue: (name: string) => values.get(name) ?? '',
setProperty: (name: string, value: string) => values.set(name, value),
},
classList: {
contains: (name: string) => rootClasses.has(name),
toggle: (name: string, force: boolean) => (force ? rootClasses.add(name) : rootClasses.delete(name)),
},
},
querySelector: (selector: string) => (selector === '.main' ? { clientWidth: 700 } : null),
getElementById: (id: string) => {
if (id === 'appSettingsTabRailWidth') return select;
if (id === 'tabRailResizeHandle') return handle;
return null;
},
};
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: { innerWidth: 700 },
document,
console,
clearTimeout,
setTimeout,
});
vm.runInContext(readPublic('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app.loadAppSettingsFromStorage = () => ({ tabRailWidth: 360 });
app.saveAppSettingsToStorage = vi.fn();
expect(app.applyTabRailWidth({ persist: false })).toBe(280);
expect(values.get('--tab-rail-width')).toBe('280px');
expect(select.value).toBe('360');
expect(select.dataset.currentWidth).toBe('360');
expect(app.readTabRailWidthSetting()).toBe(360);
});
it('retains complete labels and clamps only the settled vertical presentation', () => {
expect(appJs).toContain('class="tab-name-prefix"');
const start = stylesCss.indexOf("html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name");
const rule = stylesCss.slice(start, start + 800);
expect(rule).toContain('-webkit-line-clamp: 2');
expect(rule).toContain('overflow-wrap: anywhere');
expect(rule).toContain('white-space: normal');
});
it('closes detached rail menus before tab rebuilds and session cleanup', () => {
const fullRenderStart = appJs.indexOf(' _fullRenderSessionTabs() {');
const fullRender = appJs.slice(fullRenderStart, fullRenderStart + 250);
const cleanupStart = appJs.indexOf(' _cleanupSessionData(sessionId) {');
const cleanup = appJs.slice(cleanupStart, cleanupStart + 250);
expect(fullRender).toContain('this.closeTabRailActionMenu?.()');
expect(cleanup).toContain('this.closeTabRailActionMenu?.()');
});
});
+21 -10
View File
@@ -17,11 +17,22 @@ const PROJECT_HASH = '-home-arkon-default-claudeman';
const SESSION_UUID = '388113c8-cd01-4e80-93a8-3be66ab1519b'; const SESSION_UUID = '388113c8-cd01-4e80-93a8-3be66ab1519b';
const RUN_ID = 'wf_test1234-abc'; const RUN_ID = 'wf_test1234-abc';
/**
* The fixture's epochs are anchored to "now", never pinned, because the recency
* assertions below compare them against `Date.now()`. A frozen epoch plus a fixed
* window is a time bomb: the original fixture's newest activity sat at
* 2026-06-14T20:06:40Z, and `getRecentRunSummaries(100000)` — that argument is
* MINUTES, i.e. 69.4 days — stopped matching it on 2026-08-23T06:46:40Z, turning
* CI red on a suite nobody had touched. Offsets from the anchor are preserved
* verbatim, so every parsed duration and ordering assertion is unchanged.
*/
const RUN_ANCHOR = Date.now() - 601_000;
/** A run JSON shaped like a real (killed) run: all three agent states + the bloat fields. */ /** A run JSON shaped like a real (killed) run: all three agent states + the bloat fields. */
function sampleRunJson() { function sampleRunJson() {
return { return {
runId: RUN_ID, runId: RUN_ID,
timestamp: '2026-06-15T00:00:00.000Z', timestamp: new Date(RUN_ANCHOR).toISOString(),
taskId: 'task_abc', taskId: 'task_abc',
// --- bloat fields that MUST be stripped --- // --- bloat fields that MUST be stripped ---
script: 'export const meta = {};\n'.repeat(5000), // ~110KB script: 'export const meta = {};\n'.repeat(5000), // ~110KB
@@ -35,7 +46,7 @@ function sampleRunJson() {
workflowName: 'review-open-prs', workflowName: 'review-open-prs',
status: 'killed', status: 'killed',
error: 'user stopped the task', error: 'user stopped the task',
startTime: 1781466999000, startTime: RUN_ANCHOR,
defaultModel: 'claude-opus-4-8[1m]', defaultModel: 'claude-opus-4-8[1m]',
totalTokens: 109703, totalTokens: 109703,
totalToolCalls: 44, totalToolCalls: 44,
@@ -55,13 +66,13 @@ function sampleRunJson() {
agentId: 'a6c0e282c3f5ac0bf', agentId: 'a6c0e282c3f5ac0bf',
model: 'claude-opus-4-8[1m]', model: 'claude-opus-4-8[1m]',
state: 'done', state: 'done',
startedAt: 1781467000002, startedAt: RUN_ANCHOR + 1002,
queuedAt: 1781466999962, queuedAt: RUN_ANCHOR + 962,
attempt: 1, attempt: 1,
lastToolName: 'StructuredOutput', lastToolName: 'StructuredOutput',
lastToolSummary: 'Does the profile setting make the allowlist dead config', lastToolSummary: 'Does the profile setting make the allowlist dead config',
promptPreview: 'You are reviewing a pull request...', promptPreview: 'You are reviewing a pull request...',
lastProgressAt: 1781467524143, lastProgressAt: RUN_ANCHOR + 525_143,
tokens: 104703, tokens: 104703,
toolCalls: 41, toolCalls: 41,
durationMs: 524140, durationMs: 524140,
@@ -76,12 +87,12 @@ function sampleRunJson() {
agentId: 'a1234567890abcdef', agentId: 'a1234567890abcdef',
model: 'claude-opus-4-8[1m]', model: 'claude-opus-4-8[1m]',
state: 'progress', state: 'progress',
startedAt: 1781467010000, startedAt: RUN_ANCHOR + 11_000,
queuedAt: 1781466999970, queuedAt: RUN_ANCHOR + 970,
attempt: 1, attempt: 1,
lastToolName: 'Read', lastToolName: 'Read',
promptPreview: 'Review PR 127...', promptPreview: 'Review PR 127...',
lastProgressAt: 1781467600000, lastProgressAt: RUN_ANCHOR + 601_000,
tokens: 5000, tokens: 5000,
toolCalls: 3, toolCalls: 3,
}, },
@@ -93,9 +104,9 @@ function sampleRunJson() {
phaseTitle: 'Verify', phaseTitle: 'Verify',
model: 'claude-opus-4-8[1m]', model: 'claude-opus-4-8[1m]',
state: 'start', state: 'start',
queuedAt: 1781466999980, queuedAt: RUN_ANCHOR + 980,
promptPreview: 'Verify finding x...', promptPreview: 'Verify finding x...',
lastProgressAt: 1781466999980, lastProgressAt: RUN_ANCHOR + 980,
}, },
], ],
}; };