mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge remote-tracking branch 'origin/master' into worktree-grok-mode
# Conflicts: # src/web/public/app.js
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 ==========
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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">⚙</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">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</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">⚙</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">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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': '左侧边栏(简洁)',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.':");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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__']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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?.()');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user