mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Merge pull request #157 from aakhter/cod-162-session-manager-polish
Session Manager: pinning, cross-device ordering, name/prompt retention
This commit is contained in:
@@ -28,9 +28,15 @@ export type UnifiedSessionItem = {
|
|||||||
lastActivityAt?: number;
|
lastActivityAt?: number;
|
||||||
claudeSessionId?: string;
|
claudeSessionId?: string;
|
||||||
firstPrompt?: string;
|
firstPrompt?: string;
|
||||||
|
/** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */
|
||||||
|
lastPrompt?: string;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
remote?: boolean;
|
remote?: boolean;
|
||||||
|
/** Pinned to the top of the session manager list (COD-139). */
|
||||||
|
pinned?: boolean;
|
||||||
|
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
|
||||||
|
pinnedAt?: number;
|
||||||
sources: string[];
|
sources: string[];
|
||||||
stats?: { memoryMB: number; cpuPercent: number };
|
stats?: { memoryMB: number; cpuPercent: number };
|
||||||
};
|
};
|
||||||
@@ -46,6 +52,8 @@ export type LiveSessionInput = {
|
|||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
lastActivityAt?: number;
|
lastActivityAt?: number;
|
||||||
claudeSessionId?: string;
|
claudeSessionId?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
pinnedAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Persisted session view (subset of `SessionState`). */
|
/** Persisted session view (subset of `SessionState`). */
|
||||||
@@ -59,6 +67,8 @@ export type PersistedSessionInput = {
|
|||||||
lastActivityAt?: number;
|
lastActivityAt?: number;
|
||||||
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
||||||
claudeSessionId?: string;
|
claudeSessionId?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
pinnedAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
||||||
@@ -77,6 +87,8 @@ export type HistoryInput = {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
lastModified: string;
|
lastModified: string;
|
||||||
firstPrompt?: string;
|
firstPrompt?: string;
|
||||||
|
/** Most recent user prompt from the transcript (COD-145). */
|
||||||
|
lastPrompt?: string;
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,6 +161,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
|||||||
overwrite(item, 'workingDir', h.workingDir);
|
overwrite(item, 'workingDir', h.workingDir);
|
||||||
overwrite(item, 'sizeBytes', h.sizeBytes);
|
overwrite(item, 'sizeBytes', h.sizeBytes);
|
||||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||||
|
overwrite(item, 'lastPrompt', h.lastPrompt);
|
||||||
overwrite(item, 'projectKey', h.projectKey);
|
overwrite(item, 'projectKey', h.projectKey);
|
||||||
const ms = Date.parse(h.lastModified);
|
const ms = Date.parse(h.lastModified);
|
||||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||||
@@ -175,6 +188,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
|||||||
overwrite(item, 'workingDir', p.workingDir);
|
overwrite(item, 'workingDir', p.workingDir);
|
||||||
overwrite(item, 'createdAt', p.createdAt);
|
overwrite(item, 'createdAt', p.createdAt);
|
||||||
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
||||||
|
overwrite(item, 'pinned', p.pinned);
|
||||||
|
overwrite(item, 'pinnedAt', p.pinnedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) live (highest precedence)
|
// 4) live (highest precedence)
|
||||||
@@ -189,6 +204,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
|||||||
overwrite(item, 'createdAt', v.createdAt);
|
overwrite(item, 'createdAt', v.createdAt);
|
||||||
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
||||||
overwrite(item, 'claudeSessionId', v.claudeSessionId);
|
overwrite(item, 'claudeSessionId', v.claudeSessionId);
|
||||||
|
overwrite(item, 'pinned', v.pinned);
|
||||||
|
overwrite(item, 'pinnedAt', v.pinnedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) mux stats + remote flag (create item if mux-only)
|
// 5) mux stats + remote flag (create item if mux-only)
|
||||||
@@ -200,6 +217,64 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
|||||||
if (m.remote !== undefined) item.remote = m.remote;
|
if (m.remote !== undefined) item.remote = m.remote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// firstPrompt backfill (COD-140): the only source that sets firstPrompt is the
|
||||||
|
// transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted
|
||||||
|
// row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an
|
||||||
|
// on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new
|
||||||
|
// uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows
|
||||||
|
// "(no prompt captured)" even though a real transcript for that working dir exists under a
|
||||||
|
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
|
||||||
|
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
|
||||||
|
// firstPrompt (so rows keyed to their own transcript are untouched).
|
||||||
|
const firstPromptByUuid = new Map<string, string>();
|
||||||
|
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||||
|
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
|
||||||
|
const lastPromptByUuid = new Map<string, string>();
|
||||||
|
const lastPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||||
|
for (const h of sources.history ?? []) {
|
||||||
|
const ms = Date.parse(h.lastModified);
|
||||||
|
const ts = Number.isNaN(ms) ? -Infinity : ms;
|
||||||
|
if (h.firstPrompt) {
|
||||||
|
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
|
||||||
|
if (h.workingDir) {
|
||||||
|
const existing = firstPromptByWorkingDir.get(h.workingDir);
|
||||||
|
if (!existing || ts > existing.ms) {
|
||||||
|
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (h.lastPrompt) {
|
||||||
|
lastPromptByUuid.set(h.sessionId, h.lastPrompt);
|
||||||
|
if (h.workingDir) {
|
||||||
|
const existing = lastPromptByWorkingDir.get(h.workingDir);
|
||||||
|
if (!existing || ts > existing.ms) {
|
||||||
|
lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of map.values()) {
|
||||||
|
if (!item.firstPrompt) {
|
||||||
|
// never overwrite an existing non-empty prompt
|
||||||
|
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||||
|
if (byUuid) {
|
||||||
|
item.firstPrompt = byUuid;
|
||||||
|
} else if (item.workingDir) {
|
||||||
|
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||||
|
if (byDir) item.firstPrompt = byDir.prompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!item.lastPrompt) {
|
||||||
|
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||||
|
if (byUuid) {
|
||||||
|
item.lastPrompt = byUuid;
|
||||||
|
} else if (item.workingDir) {
|
||||||
|
const byDir = lastPromptByWorkingDir.get(item.workingDir);
|
||||||
|
if (byDir) item.lastPrompt = byDir.prompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||||
const kept: UnifiedSessionItem[] = [];
|
const kept: UnifiedSessionItem[] = [];
|
||||||
for (const item of map.values()) {
|
for (const item of map.values()) {
|
||||||
@@ -211,8 +286,24 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
|||||||
if (isReal) kept.push(item);
|
if (isReal) kept.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc.
|
// Stable sort (COD-139): pinned group first (pinnedAt desc, most-recently-pinned
|
||||||
|
// first), then unpinned by lastActivityAt desc (undefined last), createdAt desc,
|
||||||
|
// sessionId asc.
|
||||||
kept.sort((a, b) => {
|
kept.sort((a, b) => {
|
||||||
|
const pa = a.pinned === true;
|
||||||
|
const pb = b.pinned === true;
|
||||||
|
if (pa !== pb) return pa ? -1 : 1; // pinned floats above unpinned
|
||||||
|
if (pa && pb) {
|
||||||
|
// Both pinned: most-recently-pinned first (undefined pinnedAt sorts last).
|
||||||
|
const ta = a.pinnedAt;
|
||||||
|
const tb = b.pinnedAt;
|
||||||
|
if (ta !== tb) {
|
||||||
|
if (ta === undefined) return 1;
|
||||||
|
if (tb === undefined) return -1;
|
||||||
|
return tb - ta;
|
||||||
|
}
|
||||||
|
// tie-break falls through to the activity/createdAt/id rules below.
|
||||||
|
}
|
||||||
const la = a.lastActivityAt;
|
const la = a.lastActivityAt;
|
||||||
const lb = b.lastActivityAt;
|
const lb = b.lastActivityAt;
|
||||||
if (la !== lb) {
|
if (la !== lb) {
|
||||||
@@ -234,7 +325,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId)
|
* Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId)
|
||||||
* with offset/limit paging. `total` is the filtered count BEFORE paging.
|
* with offset/limit paging. `total` is the filtered count BEFORE paging.
|
||||||
*/
|
*/
|
||||||
export function filterAndPaginate(
|
export function filterAndPaginate(
|
||||||
@@ -244,7 +335,7 @@ export function filterAndPaginate(
|
|||||||
const q = (opts.q ?? '').trim().toLowerCase();
|
const q = (opts.q ?? '').trim().toLowerCase();
|
||||||
const filtered = q
|
const filtered = q
|
||||||
? items.filter((it) => {
|
? items.filter((it) => {
|
||||||
const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId]
|
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
|
||||||
.filter((v): v is string => typeof v === 'string')
|
.filter((v): v is string => typeof v === 'string')
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Pure helpers for the global session tab-order (COD-131).
|
||||||
|
*
|
||||||
|
* Tab order (drag-and-drop reorder + Ctrl+Shift+{/}) is persisted server-side
|
||||||
|
* so it follows the user across devices. The server is authoritative; the
|
||||||
|
* browser's localStorage (`codeman-session-order`) is the offline fallback.
|
||||||
|
*
|
||||||
|
* These helpers are pure (no IO) so they can be unit-tested in isolation and
|
||||||
|
* reused by both the PUT /api/session-order route and the StateStore accessor.
|
||||||
|
*
|
||||||
|
* - `normalizeSessionOrder` coerces arbitrary input into a clean string[]
|
||||||
|
* (non-empty strings only, deduped with first occurrence winning).
|
||||||
|
* - `mergeSessionOrder` lets the pushing device's order win, while preserving
|
||||||
|
* any server-only ids the pushing device didn't know about — they fall to the
|
||||||
|
* END in their existing relative order, never dropped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce arbitrary input into a clean ordered list of session ids:
|
||||||
|
* keep only non-empty strings and dedup (first occurrence wins).
|
||||||
|
*
|
||||||
|
* @param order - unknown input (expected to be a string[], but defensive)
|
||||||
|
* @returns a normalized string[] (empty array for non-array / all-junk input)
|
||||||
|
*/
|
||||||
|
export function normalizeSessionOrder(order: unknown): string[] {
|
||||||
|
if (!Array.isArray(order)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const entry of order) {
|
||||||
|
if (typeof entry !== 'string' || entry.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(entry);
|
||||||
|
result.push(entry);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an incoming order from a pushing device with the existing server order.
|
||||||
|
*
|
||||||
|
* The incoming order wins; any ids present in `existing` but NOT in `incoming`
|
||||||
|
* are appended at the END, preserving their relative order. This is the
|
||||||
|
* "server-only ids the pushing device didn't know about fall to the end, never
|
||||||
|
* dropped" rule.
|
||||||
|
*
|
||||||
|
* Both arguments are normalized first, so callers may pass raw input safely.
|
||||||
|
*
|
||||||
|
* @param incoming - the order the pushing device wants
|
||||||
|
* @param existing - the current server-side order
|
||||||
|
* @returns the merged, normalized order
|
||||||
|
*/
|
||||||
|
export function mergeSessionOrder(incoming: string[], existing: string[]): string[] {
|
||||||
|
const normalizedIncoming = normalizeSessionOrder(incoming);
|
||||||
|
const incomingSet = new Set(normalizedIncoming);
|
||||||
|
const merged = [...normalizedIncoming];
|
||||||
|
for (const id of normalizeSessionOrder(existing)) {
|
||||||
|
if (!incomingSet.has(id)) {
|
||||||
|
merged.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -342,6 +342,11 @@ export class Session extends EventEmitter {
|
|||||||
// Image watcher setting (per-session toggle)
|
// Image watcher setting (per-session toggle)
|
||||||
private _imageWatcherEnabled: boolean = false;
|
private _imageWatcherEnabled: boolean = false;
|
||||||
|
|
||||||
|
// Pin state (COD-139) — pinned sessions float to the top of the session
|
||||||
|
// manager list, ordered by pinnedAt descending (most-recently-pinned first).
|
||||||
|
private _pinned: boolean = false;
|
||||||
|
private _pinnedAt: number | null = null;
|
||||||
|
|
||||||
// Flicker filter setting (per-session toggle, applied on frontend)
|
// Flicker filter setting (per-session toggle, applied on frontend)
|
||||||
private _flickerFilterEnabled: boolean = false;
|
private _flickerFilterEnabled: boolean = false;
|
||||||
|
|
||||||
@@ -992,6 +997,26 @@ export class Session extends EventEmitter {
|
|||||||
this._imageWatcherEnabled = enabled;
|
this._imageWatcherEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this session is pinned to the top of the session manager (COD-139). */
|
||||||
|
get pinned(): boolean {
|
||||||
|
return this._pinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When the session was pinned (epoch ms), or null when unpinned. */
|
||||||
|
get pinnedAt(): number | null {
|
||||||
|
return this._pinnedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set pin state (COD-139). Pinning stamps pinnedAt with now so the pinned
|
||||||
|
* group orders most-recently-pinned first; unpinning clears it. Idempotent:
|
||||||
|
* re-pinning an already-pinned session refreshes its pinnedAt.
|
||||||
|
*/
|
||||||
|
setPinned(pinned: boolean): void {
|
||||||
|
this._pinned = pinned;
|
||||||
|
this._pinnedAt = pinned ? Date.now() : null;
|
||||||
|
}
|
||||||
|
|
||||||
get flickerFilterEnabled(): boolean {
|
get flickerFilterEnabled(): boolean {
|
||||||
return this._flickerFilterEnabled;
|
return this._flickerFilterEnabled;
|
||||||
}
|
}
|
||||||
@@ -1058,6 +1083,8 @@ export class Session extends EventEmitter {
|
|||||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||||
|
pinned: this._pinned || undefined,
|
||||||
|
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||||
totalCost: this._totalCost,
|
totalCost: this._totalCost,
|
||||||
inputTokens: this._totalInputTokens,
|
inputTokens: this._totalInputTokens,
|
||||||
outputTokens: this._totalOutputTokens,
|
outputTokens: this._totalOutputTokens,
|
||||||
|
|||||||
@@ -278,6 +278,9 @@ export class StateStore {
|
|||||||
if (this.state.cronJobRuns) {
|
if (this.state.cronJobRuns) {
|
||||||
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
|
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
|
||||||
}
|
}
|
||||||
|
if (this.state.sessionOrder) {
|
||||||
|
parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`);
|
||||||
|
}
|
||||||
|
|
||||||
return `{${parts.join(',')}}`;
|
return `{${parts.join(',')}}`;
|
||||||
}
|
}
|
||||||
@@ -485,6 +488,25 @@ export class StateStore {
|
|||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COD-142: Remove a session's persisted record on kill UNLESS it is pinned.
|
||||||
|
* A pinned session is demoted to a lightweight `stopped` record (pin retained)
|
||||||
|
* so it stays visible in the session-manager pinned group and survives restart.
|
||||||
|
* Unpinned sessions are fully removed (unchanged behavior).
|
||||||
|
* @returns 'preserved' if demoted to stopped+pinned, 'removed' if deleted, 'absent' if no record existed.
|
||||||
|
*/
|
||||||
|
demoteOrRemoveSession(id: string): 'preserved' | 'removed' | 'absent' {
|
||||||
|
const existing = this.state.sessions[id];
|
||||||
|
if (!existing) return 'absent';
|
||||||
|
if (existing.pinned === true) {
|
||||||
|
// Demote in place: keep identity/resume fields + pin, mark stopped, clear live runtime.
|
||||||
|
this.setSession(id, { ...existing, status: 'stopped', pid: null });
|
||||||
|
return 'preserved';
|
||||||
|
}
|
||||||
|
this.removeSession(id);
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans up stale sessions from state that don't have corresponding active sessions.
|
* Cleans up stale sessions from state that don't have corresponding active sessions.
|
||||||
* @param activeSessionIds - Set of currently active session IDs
|
* @param activeSessionIds - Set of currently active session IDs
|
||||||
@@ -499,6 +521,7 @@ export class StateStore {
|
|||||||
|
|
||||||
for (const sessionId of allSessionIds) {
|
for (const sessionId of allSessionIds) {
|
||||||
if (!activeSessionIds.has(sessionId)) {
|
if (!activeSessionIds.has(sessionId)) {
|
||||||
|
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
|
||||||
const name = this.state.sessions[sessionId]?.name;
|
const name = this.state.sessions[sessionId]?.name;
|
||||||
cleaned.push({ id: sessionId, name });
|
cleaned.push({ id: sessionId, name });
|
||||||
delete this.state.sessions[sessionId];
|
delete this.state.sessions[sessionId];
|
||||||
@@ -630,6 +653,17 @@ export class StateStore {
|
|||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the global tab order (ordered sessionIds), [] if unset. COD-131. */
|
||||||
|
getSessionOrder(): string[] {
|
||||||
|
return this.state.sessionOrder ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists the global tab order (ordered sessionIds) and triggers a debounced save. COD-131. */
|
||||||
|
setSessionOrder(order: string[]): void {
|
||||||
|
this.state.sessionOrder = order;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
/** 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();
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ export interface AppState {
|
|||||||
cronJobs?: Record<string, CronJob>;
|
cronJobs?: Record<string, CronJob>;
|
||||||
/** Scheduled job run history, keyed by run ID. */
|
/** Scheduled job run history, keyed by run ID. */
|
||||||
cronJobRuns?: Record<string, CronJobRun>;
|
cronJobRuns?: Record<string, CronJobRun>;
|
||||||
|
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
|
||||||
|
sessionOrder?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Default Configuration ==========
|
// ========== Default Configuration ==========
|
||||||
|
|||||||
@@ -405,6 +405,10 @@ export interface SessionState {
|
|||||||
autoResumeEnabled?: boolean;
|
autoResumeEnabled?: boolean;
|
||||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||||
autoResumeAt?: number;
|
autoResumeAt?: number;
|
||||||
|
/** Pinned to the top of the session manager list (COD-139) */
|
||||||
|
pinned?: boolean;
|
||||||
|
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||||
|
pinnedAt?: number;
|
||||||
/** Image watcher enabled for this session */
|
/** Image watcher enabled for this session */
|
||||||
imageWatcherEnabled?: boolean;
|
imageWatcherEnabled?: boolean;
|
||||||
/** Total cost in USD */
|
/** Total cost in USD */
|
||||||
|
|||||||
+60
-1
@@ -291,6 +291,9 @@ const _SSE_HANDLER_MAP = [
|
|||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
||||||
|
|
||||||
|
// Session order (global tab order sync, COD-131)
|
||||||
|
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
@@ -1500,6 +1503,26 @@ class CodemanApp {
|
|||||||
console.error('[SSE] docker image build failed:', err);
|
console.error('[SSE] docker image build failed:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// COD-139: a session:pinned event updates the local live-session pin flag (so
|
||||||
|
// a subsequent render is consistent) and re-sorts the open session manager /
|
||||||
|
// welcome list so pinned sessions float to the top.
|
||||||
|
addListener(SSE_EVENTS.SESSION_PINNED, (e) => {
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(e.data);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed payload */
|
||||||
|
}
|
||||||
|
if (data && data.id) {
|
||||||
|
const live = this.sessions.get(data.id);
|
||||||
|
if (live) {
|
||||||
|
live.pinned = data.pinned === true;
|
||||||
|
live.pinnedAt = data.pinned ? data.pinnedAt : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._onSessionListMaybeChanged();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -3023,6 +3046,13 @@ class CodemanApp {
|
|||||||
// on another device).
|
// on another device).
|
||||||
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
|
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
|
||||||
|
|
||||||
|
// COD-131: server is authoritative for global tab order. Seed localStorage
|
||||||
|
// from the server snapshot (if present) so syncSessionOrder() reconciles
|
||||||
|
// against the cross-device order rather than this device's stale local copy.
|
||||||
|
if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) {
|
||||||
|
try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||||
this.syncSessionOrder();
|
this.syncSessionOrder();
|
||||||
|
|
||||||
@@ -3587,13 +3617,42 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save session order to localStorage
|
// Save session order to localStorage and (debounced) sync to the server so it
|
||||||
|
// follows the user across devices (COD-131). localStorage stays the offline
|
||||||
|
// fallback; the server is authoritative and echoes back via SSE.
|
||||||
saveSessionOrder() {
|
saveSessionOrder() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
|
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore storage errors
|
// Ignore storage errors
|
||||||
}
|
}
|
||||||
|
const order = [...this.sessionOrder];
|
||||||
|
this._debouncedCall('saveSessionOrderServer', () => {
|
||||||
|
fetch('/api/session-order', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ order }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// COD-131: another device (or our own debounced push) reordered tabs. Adopt the
|
||||||
|
// server order as the new base and reconcile to our currently-open sessions.
|
||||||
|
// Guard against no-op churn so an echo of our own push doesn't flicker the tabs.
|
||||||
|
_onSessionOrderChanged(data) {
|
||||||
|
if (!data || !Array.isArray(data.order)) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('codeman-session-order', JSON.stringify(data.order));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
const before = JSON.stringify(this.sessionOrder);
|
||||||
|
this.syncSessionOrder();
|
||||||
|
// Only re-render when the reconciled order actually changed (avoids flicker
|
||||||
|
// when the broadcast is just an echo of the order we already have).
|
||||||
|
if (JSON.stringify(this.sessionOrder) !== before) {
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up drag-and-drop handlers on tab elements
|
// Set up drag-and-drop handlers on tab elements
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ const SSE_EVENTS = {
|
|||||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||||||
SESSION_CLI_INFO: 'session:cliInfo',
|
SESSION_CLI_INFO: 'session:cliInfo',
|
||||||
|
SESSION_PINNED: 'session:pinned',
|
||||||
SESSION_MESSAGE: 'session:message',
|
SESSION_MESSAGE: 'session:message',
|
||||||
SESSION_INTERACTIVE: 'session:interactive',
|
SESSION_INTERACTIVE: 'session:interactive',
|
||||||
SESSION_RUNNING: 'session:running',
|
SESSION_RUNNING: 'session:running',
|
||||||
@@ -490,6 +491,9 @@ const SSE_EVENTS = {
|
|||||||
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
||||||
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
||||||
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
|
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
|
||||||
|
|
||||||
|
// Session order (global tab order sync)
|
||||||
|
SESSION_ORDER_CHANGED: 'session:orderChanged',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
btn.append(dirSpan, metaSpan);
|
btn.append(dirSpan, metaSpan);
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.resumeHistorySession(s.sessionId, s.workingDir);
|
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
||||||
});
|
});
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2794,6 +2794,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* COD-139: pinned sessions float to the top with a subtle accent highlight. */
|
||||||
|
.history-item.is-pinned {
|
||||||
|
border-color: rgba(245, 158, 11, 0.35);
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
box-shadow: inset 3px 0 0 rgba(245, 158, 11, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item.is-pinned:hover {
|
||||||
|
border-color: rgba(245, 158, 11, 0.5);
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-pin {
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
.history-item-main {
|
.history-item-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1239,8 +1239,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
|
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
|
||||||
|
|
||||||
|
const isPinned = s.pinned === true;
|
||||||
|
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'history-item';
|
item.className = 'history-item' + (isPinned ? ' is-pinned' : '');
|
||||||
item.title = s.workingDir || '';
|
item.title = s.workingDir || '';
|
||||||
|
|
||||||
// Main row: clickable surface. A caller-supplied onActivate wins (the
|
// Main row: clickable surface. A caller-supplied onActivate wins (the
|
||||||
@@ -1258,7 +1260,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (isLive && this.sessions.has(s.sessionId)) {
|
if (isLive && this.sessions.has(s.sessionId)) {
|
||||||
this.selectSession(s.sessionId);
|
this.selectSession(s.sessionId);
|
||||||
} else {
|
} else {
|
||||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
|
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1268,7 +1270,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const titleSpan = document.createElement('span');
|
const titleSpan = document.createElement('span');
|
||||||
titleSpan.className = 'history-item-title';
|
titleSpan.className = 'history-item-title';
|
||||||
titleSpan.textContent = s.name || s.firstPrompt || shortDir;
|
if (isPinned) {
|
||||||
|
// Filled pin glyph indicating the session is pinned to the top (COD-139).
|
||||||
|
const pin = document.createElement('span');
|
||||||
|
pin.className = 'history-item-pin';
|
||||||
|
pin.textContent = '📌';
|
||||||
|
pin.setAttribute('aria-label', 'Pinned');
|
||||||
|
pin.title = 'Pinned';
|
||||||
|
titleSpan.appendChild(pin);
|
||||||
|
}
|
||||||
|
titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir));
|
||||||
|
|
||||||
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
|
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
|
||||||
const badgeRow = document.createElement('div');
|
const badgeRow = document.createElement('div');
|
||||||
@@ -1327,6 +1338,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
promptText.textContent = s.firstPrompt || '(no prompt captured)';
|
promptText.textContent = s.firstPrompt || '(no prompt captured)';
|
||||||
promptRow.append(promptLabel, promptText);
|
promptRow.append(promptLabel, promptText);
|
||||||
|
|
||||||
|
// COD-145: show the most recent user prompt too, but collapse single-prompt
|
||||||
|
// sessions (omit when there's no last prompt or it duplicates the first).
|
||||||
|
let lastPromptRow = null;
|
||||||
|
if (s.lastPrompt && s.lastPrompt !== s.firstPrompt) {
|
||||||
|
lastPromptRow = document.createElement('div');
|
||||||
|
lastPromptRow.className = 'history-detail-row';
|
||||||
|
const lastPromptLabel = document.createElement('span');
|
||||||
|
lastPromptLabel.className = 'history-detail-label';
|
||||||
|
lastPromptLabel.textContent = 'Last prompt';
|
||||||
|
const lastPromptText = document.createElement('span');
|
||||||
|
lastPromptText.className = 'history-detail-value history-detail-prompt';
|
||||||
|
lastPromptText.textContent = s.lastPrompt;
|
||||||
|
lastPromptRow.append(lastPromptLabel, lastPromptText);
|
||||||
|
}
|
||||||
|
|
||||||
const pathRow = document.createElement('div');
|
const pathRow = document.createElement('div');
|
||||||
pathRow.className = 'history-detail-row';
|
pathRow.className = 'history-detail-row';
|
||||||
const pathLabel = document.createElement('span');
|
const pathLabel = document.createElement('span');
|
||||||
@@ -1345,7 +1371,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
metaParts.push(s.sessionId.slice(0, 8));
|
metaParts.push(s.sessionId.slice(0, 8));
|
||||||
metaRow.textContent = metaParts.join(' · ');
|
metaRow.textContent = metaParts.join(' · ');
|
||||||
|
|
||||||
detail.append(promptRow, pathRow, metaRow);
|
detail.append(promptRow);
|
||||||
|
if (lastPromptRow) detail.append(lastPromptRow);
|
||||||
|
detail.append(pathRow, metaRow);
|
||||||
|
|
||||||
if (showViewAll && s.projectKey) {
|
if (showViewAll && s.projectKey) {
|
||||||
const actionRow = document.createElement('div');
|
const actionRow = document.createElement('div');
|
||||||
@@ -1463,13 +1491,27 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
} else {
|
} else {
|
||||||
// Resume by the Claude conversation UUID when present (resumed sessions
|
// Resume by the Claude conversation UUID when present (resumed sessions
|
||||||
// carry theirs separately from their Codeman id).
|
// carry theirs separately from their Codeman id).
|
||||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
|
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
|
||||||
}
|
}
|
||||||
this.closeSessionManager?.();
|
this.closeSessionManager?.();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Pin / Unpin (COD-139) — floats the session to the top of the list.
|
||||||
|
const isPinned = s.pinned === true;
|
||||||
|
addItem(isPinned ? 'Unpin session' : 'Pin to top', async () => {
|
||||||
|
const ok = await this._setSessionPinned(s.sessionId, !isPinned);
|
||||||
|
if (ok) {
|
||||||
|
// Optimistic local flip so a re-render before the SSE event is consistent.
|
||||||
|
s.pinned = !isPinned;
|
||||||
|
this.showToast(!isPinned ? 'Pinned to top' : 'Unpinned', 'success');
|
||||||
|
} else {
|
||||||
|
this.showToast('Pin failed', 'error');
|
||||||
|
}
|
||||||
|
closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
// Open folder (only for a live+open session — file browser is session-scoped).
|
// Open folder (only for a live+open session — file browser is session-scoped).
|
||||||
if (isLiveOpen) {
|
if (isLiveOpen) {
|
||||||
addItem('Open folder', () => {
|
addItem('Open folder', () => {
|
||||||
@@ -1536,6 +1578,32 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._openRowMenuClose = closeMenu;
|
this._openRowMenuClose = closeMenu;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COD-139: Toggle a session's pin via POST /api/sessions/:id/pin.
|
||||||
|
* Pinned sessions float to the top of the session manager list. Returns true
|
||||||
|
* on success. The live re-sort happens when the session:pinned SSE event
|
||||||
|
* fires (handled in app.js), so callers don't need to re-render themselves.
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {boolean} pinned explicit desired pin state (idempotent)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async _setSessionPinned(sessionId, pinned) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pin`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ pinned }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.success === true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[_setSessionPinned]', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/** Number of history items shown before "Show More" */
|
/** Number of history items shown before "Show More" */
|
||||||
_HISTORY_INITIAL_COUNT: 4,
|
_HISTORY_INITIAL_COUNT: 4,
|
||||||
|
|
||||||
@@ -1721,7 +1789,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._folderHistoryState = null;
|
this._folderHistoryState = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async resumeHistorySession(sessionId, workingDir) {
|
// Choose the name for a resumed session: keep the session's own name when it
|
||||||
|
// has one, otherwise synthesize a fresh w<N>-<dir> name (next free w-number
|
||||||
|
// across open sessions). COD-143 — resume used to always generate a new name.
|
||||||
|
_resolveResumeName(existingName, workingDir) {
|
||||||
|
if (typeof existingName === 'string' && existingName.trim()) return existingName;
|
||||||
|
const dirName = (workingDir || '').split('/').pop() || 'session';
|
||||||
|
let startNumber = 1;
|
||||||
|
for (const [, session] of this.sessions) {
|
||||||
|
const match = session.name && session.name.match(/^w(\d+)-/);
|
||||||
|
if (match) {
|
||||||
|
const num = parseInt(match[1]);
|
||||||
|
if (num >= startNumber) startNumber = num + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `w${startNumber}-${dirName}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async resumeHistorySession(sessionId, workingDir, existingName) {
|
||||||
// Close the run mode menu if open
|
// Close the run mode menu if open
|
||||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||||
// Close folder history modal if open
|
// Close folder history modal if open
|
||||||
@@ -1730,17 +1815,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
||||||
|
|
||||||
// Generate a session name from the working dir
|
// Keep the session's own name when resuming; only synthesize a w<N>-<dir>
|
||||||
const dirName = workingDir.split('/').pop() || 'session';
|
// name when the source row had none (COD-143).
|
||||||
let startNumber = 1;
|
const name = this._resolveResumeName(existingName, workingDir);
|
||||||
for (const [, session] of this.sessions) {
|
|
||||||
const match = session.name && session.name.match(/^w(\d+)-/);
|
|
||||||
if (match) {
|
|
||||||
const num = parseInt(match[1]);
|
|
||||||
if (num >= startNumber) startNumber = num + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const name = `w${startNumber}-${dirName}`;
|
|
||||||
|
|
||||||
// Create session with resumeSessionId — include envOverrides so resumed
|
// Create session with resumeSessionId — include envOverrides so resumed
|
||||||
// conversations inherit current UI settings (effort, agent teams, etc.).
|
// conversations inherit current UI settings (effort, agent teams, etc.).
|
||||||
|
|||||||
@@ -32,12 +32,15 @@ import {
|
|||||||
AutoClearSchema,
|
AutoClearSchema,
|
||||||
AutoCompactSchema,
|
AutoCompactSchema,
|
||||||
AutoResumeSchema,
|
AutoResumeSchema,
|
||||||
|
PinSessionSchema,
|
||||||
ImageWatcherSchema,
|
ImageWatcherSchema,
|
||||||
FlickerFilterSchema,
|
FlickerFilterSchema,
|
||||||
QuickRunSchema,
|
QuickRunSchema,
|
||||||
QuickStartSchema,
|
QuickStartSchema,
|
||||||
InteractiveStartSchema,
|
InteractiveStartSchema,
|
||||||
|
SessionOrderUpdateSchema,
|
||||||
} from '../schemas.js';
|
} from '../schemas.js';
|
||||||
|
import { mergeSessionOrder } from '../../session-order.js';
|
||||||
import {
|
import {
|
||||||
autoConfigureRalph,
|
autoConfigureRalph,
|
||||||
canAccessOwned,
|
canAccessOwned,
|
||||||
@@ -334,6 +337,18 @@ 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) ==========
|
||||||
|
|
||||||
|
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
|
||||||
|
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
|
||||||
|
// Server is authoritative but never drops ids it knows about that the
|
||||||
|
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
|
||||||
|
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
|
||||||
|
ctx.store.setSessionOrder(merged);
|
||||||
|
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
|
||||||
|
return { success: true, data: { order: merged } };
|
||||||
|
});
|
||||||
|
|
||||||
// ========== Session Creation ==========
|
// ========== Session Creation ==========
|
||||||
|
|
||||||
app.post('/api/sessions', async (req) => {
|
app.post('/api/sessions', async (req) => {
|
||||||
@@ -1652,6 +1667,50 @@ export function registerSessionRoutes(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== Pin (float to top of the session manager list, COD-139) ==========
|
||||||
|
|
||||||
|
app.post('/api/sessions/:id/pin', async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = parseBody(PinSessionSchema, req.body, 'Invalid request body');
|
||||||
|
|
||||||
|
const session = ctx.sessions.get(id);
|
||||||
|
if (session) {
|
||||||
|
if (!canAccessOwned(getAuthUser(req), session.owner)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||||||
|
}
|
||||||
|
session.setPinned(body.pinned);
|
||||||
|
// Persist + broadcast session:updated (keeps tabs/state consistent), then a
|
||||||
|
// dedicated session:pinned event so the session manager list re-sorts live.
|
||||||
|
persistAndBroadcastSession(ctx, session);
|
||||||
|
ctx.broadcast(SseEvent.SessionPinned, {
|
||||||
|
id,
|
||||||
|
pinned: session.pinned,
|
||||||
|
pinnedAt: session.pinnedAt ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
pinned: session.pinned,
|
||||||
|
pinnedAt: session.pinnedAt ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// COD-142 keeps a pinned session's record after kill (demoteOrRemoveSession),
|
||||||
|
// so pin toggles must also work WITHOUT a live Session — otherwise a
|
||||||
|
// pinned-then-killed record could never be unpinned (cleanup skips pinned
|
||||||
|
// records, and the record has no live session to route through).
|
||||||
|
const persisted = ctx.store.getSession(id);
|
||||||
|
if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||||||
|
}
|
||||||
|
const pinnedAt = body.pinned ? Date.now() : undefined;
|
||||||
|
ctx.store.setSession(id, { ...persisted, pinned: body.pinned || undefined, pinnedAt });
|
||||||
|
ctx.broadcast(SseEvent.SessionPinned, { id, pinned: body.pinned, pinnedAt });
|
||||||
|
return { success: true, data: { pinned: body.pinned, pinnedAt } };
|
||||||
|
});
|
||||||
|
|
||||||
// ========== Image Watcher ==========
|
// ========== Image Watcher ==========
|
||||||
|
|
||||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||||
@@ -2249,6 +2308,59 @@ export function registerSessionRoutes(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the text of the LAST user message from a JSONL transcript chunk
|
||||||
|
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
|
||||||
|
* detection, same noise/secret/slash-command filters, same 120-char cap — but
|
||||||
|
* keeps the last qualifying match instead of returning on the first. Scan the
|
||||||
|
* file tail for this (the most recent prompt lives near the end).
|
||||||
|
*/
|
||||||
|
function extractLastUserPrompt(text: string): string | undefined {
|
||||||
|
const MAX_PROMPT_LEN = 120;
|
||||||
|
let result: string | undefined;
|
||||||
|
let start = 0;
|
||||||
|
while (start < text.length) {
|
||||||
|
const end = text.indexOf('\n', start);
|
||||||
|
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
||||||
|
start = end === -1 ? text.length : end + 1;
|
||||||
|
if (!line.includes('"type":"user"')) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type !== 'user' || !entry.message) continue;
|
||||||
|
const content = entry.message.content;
|
||||||
|
let msgText: string | undefined;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
msgText = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
const textBlock = content.find((b: { type: string }) => b.type === 'text');
|
||||||
|
if (textBlock) msgText = textBlock.text;
|
||||||
|
}
|
||||||
|
if (!msgText) continue;
|
||||||
|
msgText = msgText
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
if (!msgText) continue;
|
||||||
|
if (
|
||||||
|
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) ||
|
||||||
|
/^(Please )?(analyze|review) this codebase/i.test(msgText) ||
|
||||||
|
/^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) ||
|
||||||
|
/^\d+ vulnerabilit/i.test(msgText) ||
|
||||||
|
/\btoolu_/.test(msgText) ||
|
||||||
|
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) ||
|
||||||
|
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) ||
|
||||||
|
msgText.length < 8
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText;
|
||||||
|
} catch {
|
||||||
|
// Malformed line — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
|
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
|
||||||
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
||||||
@@ -2387,6 +2499,7 @@ export function registerSessionRoutes(
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
lastModified: string;
|
lastModified: string;
|
||||||
firstPrompt?: string;
|
firstPrompt?: string;
|
||||||
|
lastPrompt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scan a single project directory and return all valid history sessions in it.
|
// Scan a single project directory and return all valid history sessions in it.
|
||||||
@@ -2432,6 +2545,17 @@ export function registerSessionRoutes(
|
|||||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COD-145: last (most recent) user prompt lives near the END of the file, so
|
||||||
|
// prefer the tail. For large files where no tail was read yet, read one
|
||||||
|
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
|
||||||
|
// then contains the whole transcript — scan it for the last match instead.
|
||||||
|
if (!tail && fileStat.size > 65536) {
|
||||||
|
const tailBuf = Buffer.alloc(32768);
|
||||||
|
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||||
|
}
|
||||||
|
const lastPrompt =
|
||||||
|
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
|
||||||
|
|
||||||
out.push({
|
out.push({
|
||||||
sessionId,
|
sessionId,
|
||||||
workingDir,
|
workingDir,
|
||||||
@@ -2439,6 +2563,7 @@ export function registerSessionRoutes(
|
|||||||
sizeBytes: fileStat.size,
|
sizeBytes: fileStat.size,
|
||||||
lastModified: fileStat.mtime.toISOString(),
|
lastModified: fileStat.mtime.toISOString(),
|
||||||
firstPrompt,
|
firstPrompt,
|
||||||
|
lastPrompt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -2514,6 +2639,8 @@ export function registerSessionRoutes(
|
|||||||
createdAt: st.createdAt,
|
createdAt: st.createdAt,
|
||||||
lastActivityAt: st.lastActivityAt,
|
lastActivityAt: st.lastActivityAt,
|
||||||
claudeSessionId: s.claudeSessionId ?? undefined,
|
claudeSessionId: s.claudeSessionId ?? undefined,
|
||||||
|
pinned: st.pinned,
|
||||||
|
pinnedAt: st.pinnedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2529,6 +2656,8 @@ export function registerSessionRoutes(
|
|||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
lastActivityAt: p.lastActivityAt,
|
lastActivityAt: p.lastActivityAt,
|
||||||
claudeSessionId: p.resumeSessionId,
|
claudeSessionId: p.resumeSessionId,
|
||||||
|
pinned: p.pinned,
|
||||||
|
pinnedAt: p.pinnedAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Lifecycle audit log (newest-first, capped).
|
// Lifecycle audit log (newest-first, capped).
|
||||||
@@ -2562,6 +2691,7 @@ export function registerSessionRoutes(
|
|||||||
sizeBytes: h.sizeBytes,
|
sizeBytes: h.sizeBytes,
|
||||||
lastModified: h.lastModified,
|
lastModified: h.lastModified,
|
||||||
firstPrompt: h.firstPrompt,
|
firstPrompt: h.firstPrompt,
|
||||||
|
lastPrompt: h.lastPrompt,
|
||||||
projectKey: h.projectKey,
|
projectKey: h.projectKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -867,6 +867,11 @@ export const AutoResumeSchema = z.object({
|
|||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
|
||||||
|
export const PinSessionSchema = z.object({
|
||||||
|
pinned: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
/** POST /api/sessions/:id/image-watcher */
|
/** POST /api/sessions/:id/image-watcher */
|
||||||
export const ImageWatcherSchema = z.object({
|
export const ImageWatcherSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
@@ -969,6 +974,14 @@ export const CaseOrderSchema = z.object({
|
|||||||
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
|
||||||
|
export const SessionOrderUpdateSchema = z.object({
|
||||||
|
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
|
||||||
|
// its open-tab order (max sessions is 50) — 500 leaves ample headroom while
|
||||||
|
// keeping a hostile/buggy client from persisting megabytes into state.json.
|
||||||
|
order: z.array(z.string().max(100)).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
/** POST /api/auth/revoke */
|
/** POST /api/auth/revoke */
|
||||||
export const RevokeSessionSchema = z.object({
|
export const RevokeSessionSchema = z.object({
|
||||||
sessionToken: z.string().min(1).max(200).optional(),
|
sessionToken: z.string().min(1).max(200).optional(),
|
||||||
|
|||||||
+2
-1
@@ -1212,7 +1212,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// Only remove from state.json if we're also killing the mux session.
|
// Only remove from state.json if we're also killing the mux session.
|
||||||
// When killMux=false (server shutdown), preserve state for recovery.
|
// When killMux=false (server shutdown), preserve state for recovery.
|
||||||
if (killMux) {
|
if (killMux) {
|
||||||
this.store.removeSession(sessionId);
|
this.store.demoteOrRemoveSession(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1871,6 +1871,7 @@ 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 };
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as con
|
|||||||
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
||||||
/** CLI version/model info detected from session output. */
|
/** CLI version/model info detected from session output. */
|
||||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||||
|
/** Session pin state changed (COD-139): pinned/unpinned in the session manager list. */
|
||||||
|
export const SessionPinned = 'session:pinned' as const;
|
||||||
/** General session message (e.g. status text). */
|
/** General session message (e.g. status text). */
|
||||||
export const SessionMessage = 'session:message' as const;
|
export const SessionMessage = 'session:message' as const;
|
||||||
/** Session entered interactive mode (claude or shell). */
|
/** Session entered interactive mode (claude or shell). */
|
||||||
@@ -404,6 +406,9 @@ export const AdminUsersChanged = 'admin:usersChanged' as const;
|
|||||||
/** A user must change their password (targeted); the frontend shows the modal. */
|
/** A user must change their password (targeted); the frontend shows the modal. */
|
||||||
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
|
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
|
||||||
|
|
||||||
|
/** Global session tab order changed (synced across devices). COD-131. */
|
||||||
|
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||||
|
|
||||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,6 +438,7 @@ export const SseEvent = {
|
|||||||
SessionLimitResumeCancelled,
|
SessionLimitResumeCancelled,
|
||||||
SessionRespawnBreakerTripped,
|
SessionRespawnBreakerTripped,
|
||||||
SessionCliInfo,
|
SessionCliInfo,
|
||||||
|
SessionPinned,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
SessionInteractive,
|
SessionInteractive,
|
||||||
SessionRunning,
|
SessionRunning,
|
||||||
@@ -602,4 +608,7 @@ export const SseEvent = {
|
|||||||
AdminUsersChanged,
|
AdminUsersChanged,
|
||||||
AuthPasswordChangeRequired,
|
AuthPasswordChangeRequired,
|
||||||
DockerContainerRecreated,
|
DockerContainerRecreated,
|
||||||
|
|
||||||
|
// Session order (global tab order sync)
|
||||||
|
SessionOrderChanged,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
|||||||
const sessions = new Map<string, MockSession>();
|
const sessions = new Map<string, MockSession>();
|
||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
|
|
||||||
|
// Stateful backing for the global tab order (COD-131) so route tests can
|
||||||
|
// assert that setSessionOrder() actually persists what the handler computed.
|
||||||
|
let sessionOrder: string[] = [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// -- SessionPort --
|
// -- SessionPort --
|
||||||
sessions,
|
sessions,
|
||||||
@@ -65,6 +69,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
|||||||
load: vi.fn(),
|
load: vi.fn(),
|
||||||
incrementSessionsCreated: vi.fn(),
|
incrementSessionsCreated: vi.fn(),
|
||||||
setConfig: vi.fn(),
|
setConfig: vi.fn(),
|
||||||
|
getSessionOrder: vi.fn(() => sessionOrder),
|
||||||
|
setSessionOrder: vi.fn((order: string[]) => {
|
||||||
|
sessionOrder = order;
|
||||||
|
}),
|
||||||
getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })),
|
getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })),
|
||||||
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
|
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
|
||||||
getDailyStats: vi.fn(() => []),
|
getDailyStats: vi.fn(() => []),
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ export class MockSession extends EventEmitter {
|
|||||||
color: this.color,
|
color: this.color,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
muxName: this._muxName,
|
muxName: this._muxName,
|
||||||
|
pinned: this.pinned || undefined,
|
||||||
|
pinnedAt: this.pinned ? (this.pinnedAt ?? undefined) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +243,14 @@ export class MockSession extends EventEmitter {
|
|||||||
if (!enabled) this.autoResumeAt = null;
|
if (!enabled) this.autoResumeAt = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Pin state (COD-139) */
|
||||||
|
pinned: boolean = false;
|
||||||
|
pinnedAt: number | null = null;
|
||||||
|
setPinned = vi.fn((pinned: boolean) => {
|
||||||
|
this.pinned = pinned;
|
||||||
|
this.pinnedAt = pinned ? Date.now() : null;
|
||||||
|
});
|
||||||
|
|
||||||
/** Check if session is busy */
|
/** Check if session is busy */
|
||||||
isBusy = vi.fn(() => false);
|
isBusy = vi.fn(() => false);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview COD-143 — resuming a session from the Session Manager must retain its
|
||||||
|
* original tab name, not synthesize a fresh `w<N>-<dir>` name every time.
|
||||||
|
*
|
||||||
|
* Root cause: `resumeHistorySession(sessionId, workingDir)` ignored the row's `name` and
|
||||||
|
* always built `w<N>-<dir>` from the working dir. The fix threads the name through and
|
||||||
|
* extracts the choice into a pure `_resolveResumeName(existingName, workingDir)` helper:
|
||||||
|
* prefer a non-empty existing name; otherwise generate the next free `w<N>-<dir>` by
|
||||||
|
* scanning open sessions' names.
|
||||||
|
*
|
||||||
|
* This pins the helper's contract:
|
||||||
|
* 1. a non-empty existing name is returned verbatim (custom name retained),
|
||||||
|
* 2. a missing/empty/whitespace name falls back to `w<N>-<dir>`,
|
||||||
|
* 3. the generated number is the next free w-index across `this.sessions`,
|
||||||
|
* 4. the generated dir segment is the basename of workingDir (or `session` when empty).
|
||||||
|
*
|
||||||
|
* Loaded via `vm` against a stub `CodemanApp` (no jsdom — same harness as
|
||||||
|
* file-browser-reveal.test.ts / connection-indicator.test.ts). terminal-ui.js does
|
||||||
|
* `Object.assign(CodemanApp.prototype, {...})` at module-eval, so we capture the real
|
||||||
|
* `_resolveResumeName` off the prototype and invoke it against a minimal host whose
|
||||||
|
* `sessions` is a Map.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/** Eval the shipping terminal-ui.js into a vm with a stub CodemanApp, return its prototype. */
|
||||||
|
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
CodemanApp: class CodemanApp {},
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
document: { addEventListener: vi.fn(), getElementById: vi.fn() },
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
});
|
||||||
|
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||||
|
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = loadTerminalUiPrototype();
|
||||||
|
|
||||||
|
/** Minimal host carrying the real `_resolveResumeName` + a sessions Map. */
|
||||||
|
function makeApp(sessionNames: string[] = []) {
|
||||||
|
const sessions = new Map<string, { name: string }>();
|
||||||
|
sessionNames.forEach((name, i) => sessions.set(`s${i}`, { name }));
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
_resolveResumeName: proto._resolveResumeName as (existingName: unknown, workingDir: unknown) => string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('COD-143 _resolveResumeName', () => {
|
||||||
|
it('returns a non-empty existing name verbatim (custom name retained)', () => {
|
||||||
|
const app = makeApp(['w1-foo', 'w2-bar']);
|
||||||
|
expect(app._resolveResumeName.call(app, 'my-custom-tab', '/home/me/proj')).toBe('my-custom-tab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to w<N>-<dir> when no name is given', () => {
|
||||||
|
const app = makeApp([]);
|
||||||
|
expect(app._resolveResumeName.call(app, undefined, '/home/me/proj')).toBe('w1-proj');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats empty / whitespace names as no-name (falls back)', () => {
|
||||||
|
const app = makeApp([]);
|
||||||
|
expect(app._resolveResumeName.call(app, '', '/a/b/widgets')).toBe('w1-widgets');
|
||||||
|
expect(app._resolveResumeName.call(app, ' ', '/a/b/widgets')).toBe('w1-widgets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generated w-number is the next free index across open sessions', () => {
|
||||||
|
const app = makeApp(['w1-foo', 'w3-bar', 'plain-name']);
|
||||||
|
// highest w<N> is 3 → next is 4
|
||||||
|
expect(app._resolveResumeName.call(app, null, '/x/y/svc')).toBe('w4-svc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses "session" as the dir segment when workingDir is empty', () => {
|
||||||
|
const app = makeApp([]);
|
||||||
|
expect(app._resolveResumeName.call(app, '', '')).toBe('w1-session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a generated fallback clobber an explicit name even when sessions exist', () => {
|
||||||
|
const app = makeApp(['w1-foo', 'w2-bar']);
|
||||||
|
expect(app._resolveResumeName.call(app, 'keepme', '/p/q')).toBe('keepme');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
|
||||||
|
*
|
||||||
|
* Uses app.inject() — no real HTTP ports needed.
|
||||||
|
* Asserts the uniform envelope contract:
|
||||||
|
* SUCCESS -> 2xx, { success: true, data: { order } }
|
||||||
|
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
|
||||||
|
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||||
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
|
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// session-order handler itself, but the module imports them.
|
||||||
|
vi.mock('node:child_process', async (orig) => {
|
||||||
|
const actual = await orig<typeof import('node:child_process')>();
|
||||||
|
return { ...actual, execFile: vi.fn(), spawn: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
|
||||||
|
interface LocalHarness {
|
||||||
|
app: FastifyInstance;
|
||||||
|
ctx: MockRouteContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildHarness(): Promise<LocalHarness> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
|
||||||
|
const ctx = createMockRouteContext();
|
||||||
|
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
|
||||||
|
|
||||||
|
// Mirror production's uniform-envelope preSerialization hook (server.ts).
|
||||||
|
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||||
|
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||||
|
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||||
|
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||||
|
if (p.success === false) {
|
||||||
|
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||||
|
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||||
|
}
|
||||||
|
return done(null, payload);
|
||||||
|
}
|
||||||
|
if (p.success === true) return done(null, payload);
|
||||||
|
return done(null, { success: true, data: payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
installRouteErrorHandler(app);
|
||||||
|
await app.ready();
|
||||||
|
return { app, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PUT /api/session-order', () => {
|
||||||
|
let harness: LocalHarness;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await buildHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the order and returns it in the envelope', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/session-order',
|
||||||
|
payload: { order: ['a', 'b', 'c'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/session-order',
|
||||||
|
payload: { order: ['b', 'a'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
// Incoming order wins, server-only "z" falls to the end.
|
||||||
|
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 () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/session-order',
|
||||||
|
payload: { order: ['a', 'a', '', 'b'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-array order with a 4xx envelope', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/session-order',
|
||||||
|
payload: { order: 'nope' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Route tests for POST /api/sessions/:id/pin (COD-139).
|
||||||
|
*
|
||||||
|
* Pinning floats a session to the top of the unified session list. The route
|
||||||
|
* sets the session's pin flag, persists it, and broadcasts session:pinned.
|
||||||
|
* Uses app.inject() with the production-mirroring envelope harness.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||||
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
|
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||||
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
|
||||||
|
interface LocalHarness {
|
||||||
|
app: FastifyInstance;
|
||||||
|
ctx: MockRouteContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEnvelopeHarness(): Promise<LocalHarness> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
const ctx = createMockRouteContext();
|
||||||
|
registerSessionRoutes(app, ctx as never);
|
||||||
|
|
||||||
|
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||||
|
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||||
|
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||||
|
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||||
|
if (p.success === false) {
|
||||||
|
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||||
|
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||||
|
}
|
||||||
|
return done(null, payload);
|
||||||
|
}
|
||||||
|
if (p.success === true) return done(null, payload);
|
||||||
|
return done(null, { success: true, data: payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
installRouteErrorHandler(app);
|
||||||
|
await app.ready();
|
||||||
|
return { app, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/sessions/:id/pin', () => {
|
||||||
|
let harness: LocalHarness;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createEnvelopeHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins a session: sets state, persists, and broadcasts session:pinned', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/pin',
|
||||||
|
payload: { pinned: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.pinned).toBe(true);
|
||||||
|
expect(typeof body.data.pinnedAt).toBe('number');
|
||||||
|
|
||||||
|
expect(harness.ctx._session.pinned).toBe(true);
|
||||||
|
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||||
|
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
|
||||||
|
expect(broadcastCalls).toContain('session:pinned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unpins a session and clears pinnedAt', async () => {
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/pin',
|
||||||
|
payload: { pinned: true },
|
||||||
|
});
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/pin',
|
||||||
|
payload: { pinned: false },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data.pinned).toBe(false);
|
||||||
|
expect(body.data.pinnedAt).toBeUndefined();
|
||||||
|
expect(harness.ctx._session.pinned).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent for an explicit pinned value', async () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/pin',
|
||||||
|
payload: { pinned: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data.pinned).toBe(true);
|
||||||
|
}
|
||||||
|
expect(harness.ctx._session.pinned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for an unknown session', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/does-not-exist/pin',
|
||||||
|
payload: { pinned: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing/invalid body with 400', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/pin',
|
||||||
|
payload: { pinned: 'yes' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// COD-142 keeps a pinned session's persisted record after kill, so pin toggles
|
||||||
|
// must work WITHOUT a live Session — otherwise a pinned-then-killed record could
|
||||||
|
// never be unpinned (cleanupStaleSessions deliberately skips pinned records).
|
||||||
|
it('unpins a persisted-only (killed but pinned) record via the store fallback', async () => {
|
||||||
|
const persisted = { id: 'dead-1', name: 'Dead', status: 'stopped', pinned: true, pinnedAt: 123 };
|
||||||
|
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/dead-1/pin',
|
||||||
|
payload: { pinned: false },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data.pinned).toBe(false);
|
||||||
|
expect(body.data.pinnedAt).toBeUndefined();
|
||||||
|
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
|
||||||
|
'dead-1',
|
||||||
|
expect.objectContaining({ pinned: undefined, pinnedAt: undefined })
|
||||||
|
);
|
||||||
|
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
|
||||||
|
expect(broadcastCalls).toContain('session:pinned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-pins a persisted-only record via the store fallback', async () => {
|
||||||
|
const persisted = { id: 'dead-2', name: 'Dead2', status: 'stopped' };
|
||||||
|
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/dead-2/pin',
|
||||||
|
payload: { pinned: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data.pinned).toBe(true);
|
||||||
|
expect(typeof body.data.pinnedAt).toBe('number');
|
||||||
|
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
|
||||||
|
'dead-2',
|
||||||
|
expect.objectContaining({ pinned: true, pinnedAt: expect.any(Number) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -163,6 +163,211 @@ describe('mergeUnifiedSessions', () => {
|
|||||||
expect(merged).toHaveLength(1);
|
expect(merged).toHaveLength(1);
|
||||||
expect(merged[0].projectKey).toBe('-repo-alpha');
|
expect(merged[0].projectKey).toBe('-repo-alpha');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// COD-140: firstPrompt backfill — live sessions whose Codeman id does not match an
|
||||||
|
// on-disk transcript UUID still surface a first prompt (by claudeSessionId join, then
|
||||||
|
// by newest transcript in the same workingDir).
|
||||||
|
it('backfills firstPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'codeman-1', status: 'working', claudeSessionId: 'uuid-A', workingDir: '/w' }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
sessionId: 'uuid-A',
|
||||||
|
workingDir: '/w',
|
||||||
|
sizeBytes: 5000,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'fix the bug',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const live = merged.find((m) => m.sessionId === 'codeman-1');
|
||||||
|
expect(live).toBeDefined();
|
||||||
|
expect(live!.firstPrompt).toBe('fix the bug');
|
||||||
|
// The upstream unified-service alias map (COD-160/161) folds a history row keyed
|
||||||
|
// by the Claude conversation UUID into the owning live session (claudeSessionId
|
||||||
|
// join), so it does NOT surface as a separate item — the firstPrompt reaches the
|
||||||
|
// live row above rather than a duplicate uuid-A entry.
|
||||||
|
const hist = merged.find((m) => m.sessionId === 'uuid-A');
|
||||||
|
expect(hist).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the workingDir transcript when no uuid join exists (workingDir fallback)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'codeman-2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w2' }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
sessionId: 'uuid-other',
|
||||||
|
workingDir: '/w2',
|
||||||
|
sizeBytes: 5000,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'borrowed prompt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const live = merged.find((m) => m.sessionId === 'codeman-2');
|
||||||
|
expect(live).toBeDefined();
|
||||||
|
expect(live!.firstPrompt).toBe('borrowed prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the newest transcript per workingDir for the fallback (newest-wins)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'codeman-3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w3' }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
sessionId: 'uuid-old',
|
||||||
|
workingDir: '/w3',
|
||||||
|
sizeBytes: 5000,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'older prompt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 'uuid-new',
|
||||||
|
workingDir: '/w3',
|
||||||
|
sizeBytes: 6000,
|
||||||
|
lastModified: '2026-02-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'newer prompt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const live = merged.find((m) => m.sessionId === 'codeman-3');
|
||||||
|
expect(live).toBeDefined();
|
||||||
|
expect(live!.firstPrompt).toBe('newer prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never overwrites a firstPrompt that already merged from the session own transcript (no overwrite)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'self-uuid', status: 'working', claudeSessionId: 'self-uuid', workingDir: '/w4' }],
|
||||||
|
history: [
|
||||||
|
// the session's own transcript (keyed by its id) — provides the real prompt
|
||||||
|
{
|
||||||
|
sessionId: 'self-uuid',
|
||||||
|
workingDir: '/w4',
|
||||||
|
sizeBytes: 5000,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'own prompt',
|
||||||
|
},
|
||||||
|
// a newer sibling transcript in the same dir that must NOT clobber it
|
||||||
|
{
|
||||||
|
sessionId: 'sibling-uuid',
|
||||||
|
workingDir: '/w4',
|
||||||
|
sizeBytes: 6000,
|
||||||
|
lastModified: '2026-03-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'sibling prompt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const self = merged.find((m) => m.sessionId === 'self-uuid');
|
||||||
|
expect(self).toBeDefined();
|
||||||
|
expect(self!.firstPrompt).toBe('own prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves firstPrompt undefined when there is no transcript at all (no transcript)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'codeman-5', status: 'working', claudeSessionId: 'uuid-none', workingDir: '/empty' }],
|
||||||
|
});
|
||||||
|
const live = merged.find((m) => m.sessionId === 'codeman-5');
|
||||||
|
expect(live).toBeDefined();
|
||||||
|
expect(live!.firstPrompt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
|
||||||
|
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
|
||||||
|
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'codeman-l1', status: 'working', claudeSessionId: 'uuid-LA', workingDir: '/wl' }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
sessionId: 'uuid-LA',
|
||||||
|
workingDir: '/wl',
|
||||||
|
sizeBytes: 5000,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'fix the bug',
|
||||||
|
lastPrompt: 'now ship it',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const live = merged.find((m) => m.sessionId === 'codeman-l1');
|
||||||
|
expect(live).toBeDefined();
|
||||||
|
expect(live!.lastPrompt).toBe('now ship it');
|
||||||
|
// The upstream unified-service alias map (COD-160/161) folds the UUID-keyed
|
||||||
|
// history row into the owning live session, so lastPrompt reaches the live row
|
||||||
|
// above rather than surfacing as a separate uuid-LA entry.
|
||||||
|
const hist = merged.find((m) => m.sessionId === 'uuid-LA');
|
||||||
|
expect(hist).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the workingDir transcript for lastPrompt when no uuid join exists (workingDir fallback)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'codeman-l2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl2' }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
sessionId: 'uuid-other',
|
||||||
|
workingDir: '/wl2',
|
||||||
|
sizeBytes: 5000,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'borrowed first',
|
||||||
|
lastPrompt: 'borrowed last',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const live = merged.find((m) => m.sessionId === 'codeman-l2');
|
||||||
|
expect(live).toBeDefined();
|
||||||
|
expect(live!.lastPrompt).toBe('borrowed last');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the newest transcript per workingDir for the lastPrompt fallback (newest-wins)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'codeman-l3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl3' }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
sessionId: 'uuid-old',
|
||||||
|
workingDir: '/wl3',
|
||||||
|
sizeBytes: 5000,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'older first',
|
||||||
|
lastPrompt: 'older last',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 'uuid-new',
|
||||||
|
workingDir: '/wl3',
|
||||||
|
sizeBytes: 6000,
|
||||||
|
lastModified: '2026-02-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'newer first',
|
||||||
|
lastPrompt: 'newer last',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const live = merged.find((m) => m.sessionId === 'codeman-l3');
|
||||||
|
expect(live).toBeDefined();
|
||||||
|
expect(live!.lastPrompt).toBe('newer last');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never overwrites a lastPrompt that already merged from the session own transcript (no overwrite)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [{ id: 'self-luuid', status: 'working', claudeSessionId: 'self-luuid', workingDir: '/wl4' }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
sessionId: 'self-luuid',
|
||||||
|
workingDir: '/wl4',
|
||||||
|
sizeBytes: 5000,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'own first',
|
||||||
|
lastPrompt: 'own last',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 'sibling-uuid',
|
||||||
|
workingDir: '/wl4',
|
||||||
|
sizeBytes: 6000,
|
||||||
|
lastModified: '2026-03-01T00:00:00.000Z',
|
||||||
|
firstPrompt: 'sibling first',
|
||||||
|
lastPrompt: 'sibling last',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const self = merged.find((m) => m.sessionId === 'self-luuid');
|
||||||
|
expect(self).toBeDefined();
|
||||||
|
expect(self!.lastPrompt).toBe('own last');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterAndPaginate', () => {
|
describe('filterAndPaginate', () => {
|
||||||
@@ -170,6 +375,14 @@ describe('filterAndPaginate', () => {
|
|||||||
{ sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' },
|
{ sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' },
|
||||||
{ sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' },
|
{ sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' },
|
||||||
{ sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' },
|
{ sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' },
|
||||||
|
{
|
||||||
|
sessionId: 's4',
|
||||||
|
name: 'Delta',
|
||||||
|
firstPrompt: 'start the migration',
|
||||||
|
lastPrompt: 'roll back the migration',
|
||||||
|
sources: ['history'],
|
||||||
|
workingDir: '/repo/delta',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
it('filters by name (case-insensitive)', () => {
|
it('filters by name (case-insensitive)', () => {
|
||||||
@@ -183,18 +396,24 @@ describe('filterAndPaginate', () => {
|
|||||||
expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3');
|
expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters by lastPrompt (COD-145)', () => {
|
||||||
|
const r = filterAndPaginate(items, { q: 'roll back' });
|
||||||
|
expect(r.total).toBe(1);
|
||||||
|
expect(r.sessions[0].sessionId).toBe('s4');
|
||||||
|
});
|
||||||
|
|
||||||
it('reports total as the pre-page filtered count', () => {
|
it('reports total as the pre-page filtered count', () => {
|
||||||
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
|
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
|
||||||
// both s1 and s2 have /repo/ workingDir
|
// s1, s2, and s4 all have /repo/ workingDir
|
||||||
expect(r.total).toBe(2);
|
expect(r.total).toBe(3);
|
||||||
expect(r.sessions).toHaveLength(1);
|
expect(r.sessions).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps limit to a max of 500', () => {
|
it('clamps limit to a max of 500', () => {
|
||||||
const r = filterAndPaginate(items, { limit: 99999 });
|
const r = filterAndPaginate(items, { limit: 99999 });
|
||||||
expect(r.sessions).toHaveLength(items.length);
|
expect(r.sessions).toHaveLength(items.length);
|
||||||
// clamp does not throw and returns all 3 (< 500)
|
// clamp does not throw and returns all items (< 500)
|
||||||
expect(r.total).toBe(3);
|
expect(r.total).toBe(items.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps limit to a min of 1', () => {
|
it('clamps limit to a min of 1', () => {
|
||||||
@@ -206,7 +425,7 @@ describe('filterAndPaginate', () => {
|
|||||||
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
|
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
|
||||||
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
|
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
|
||||||
expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']);
|
expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']);
|
||||||
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3']);
|
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3', 's4']);
|
||||||
const overlap = page1.sessions
|
const overlap = page1.sessions
|
||||||
.map((s) => s.sessionId)
|
.map((s) => s.sessionId)
|
||||||
.filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id));
|
.filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id));
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for the pure session-order helpers
|
||||||
|
* (normalizeSessionOrder, mergeSessionOrder) used by global tab-order sync (COD-131).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { normalizeSessionOrder, mergeSessionOrder } from '../src/session-order.js';
|
||||||
|
|
||||||
|
describe('normalizeSessionOrder', () => {
|
||||||
|
it('keeps a clean array unchanged', () => {
|
||||||
|
expect(normalizeSessionOrder(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedups, first occurrence wins', () => {
|
||||||
|
expect(normalizeSessionOrder(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops empty strings', () => {
|
||||||
|
expect(normalizeSessionOrder(['a', '', 'b', ' '])).toEqual(['a', 'b', ' ']);
|
||||||
|
expect(normalizeSessionOrder([''])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops non-string entries', () => {
|
||||||
|
expect(normalizeSessionOrder(['a', 1, null, undefined, {}, 'b', true])).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for non-array input', () => {
|
||||||
|
expect(normalizeSessionOrder(undefined)).toEqual([]);
|
||||||
|
expect(normalizeSessionOrder(null)).toEqual([]);
|
||||||
|
expect(normalizeSessionOrder('abc')).toEqual([]);
|
||||||
|
expect(normalizeSessionOrder(42)).toEqual([]);
|
||||||
|
expect(normalizeSessionOrder({ 0: 'a' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for empty array', () => {
|
||||||
|
expect(normalizeSessionOrder([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeSessionOrder', () => {
|
||||||
|
it('incoming order wins', () => {
|
||||||
|
expect(mergeSessionOrder(['c', 'a', 'b'], ['a', 'b', 'c'])).toEqual(['c', 'a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends server-only ids (not in incoming) at the end, preserving their relative order', () => {
|
||||||
|
expect(mergeSessionOrder(['a', 'b'], ['x', 'a', 'y', 'b', 'z'])).toEqual(['a', 'b', 'x', 'y', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty incoming yields the existing order (normalized)', () => {
|
||||||
|
expect(mergeSessionOrder([], ['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty existing yields the incoming order (normalized)', () => {
|
||||||
|
expect(mergeSessionOrder(['a', 'b', 'c'], [])).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both empty yields empty', () => {
|
||||||
|
expect(mergeSessionOrder([], [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes both args (dedup + drop junk) before merging', () => {
|
||||||
|
expect(mergeSessionOrder(['a', 'a', '', 'b'], ['b', 'c', 'c', ''])).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate an id present in both', () => {
|
||||||
|
expect(mergeSessionOrder(['a', 'b'], ['b', 'a'])).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-array / junk inputs defensively', () => {
|
||||||
|
// @ts-expect-error testing runtime robustness against bad input
|
||||||
|
expect(mergeSessionOrder(null, ['a', 'b'])).toEqual(['a', 'b']);
|
||||||
|
// @ts-expect-error testing runtime robustness against bad input
|
||||||
|
expect(mergeSessionOrder(['a'], 'nope')).toEqual(['a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for session pinning (COD-139) — the pure
|
||||||
|
* merge/sort layer in unified-session-service.ts.
|
||||||
|
*
|
||||||
|
* Pinned sessions float to the top of the unified session list, ordered by
|
||||||
|
* pinnedAt descending (most-recently-pinned first). Unpinned sessions keep the
|
||||||
|
* existing lastActivityAt-desc ordering. Pin state flows through both the live
|
||||||
|
* and persisted inputs so it survives a reload (live → persisted-only on boot).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mergeUnifiedSessions } from '../src/services/unified-session-service.js';
|
||||||
|
|
||||||
|
describe('mergeUnifiedSessions — pinning (COD-139)', () => {
|
||||||
|
it('floats a pinned session above unpinned ones regardless of activity', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [
|
||||||
|
{ id: 'a', name: 'A', lastActivityAt: 100 },
|
||||||
|
{ id: 'b', name: 'B', lastActivityAt: 5000, pinned: true, pinnedAt: 200 },
|
||||||
|
{ id: 'c', name: 'C', lastActivityAt: 9000 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// b is pinned → first, even though c is the most-recently active.
|
||||||
|
expect(merged.map((m) => m.sessionId)).toEqual(['b', 'c', 'a']);
|
||||||
|
expect(merged[0].pinned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders multiple pinned sessions by pinnedAt descending (most recent first)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [
|
||||||
|
{ id: 'p1', name: 'P1', lastActivityAt: 1, pinned: true, pinnedAt: 100 },
|
||||||
|
{ id: 'p2', name: 'P2', lastActivityAt: 2, pinned: true, pinnedAt: 300 },
|
||||||
|
{ id: 'p3', name: 'P3', lastActivityAt: 3, pinned: true, pinnedAt: 200 },
|
||||||
|
{ id: 'u', name: 'U', lastActivityAt: 9999 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Pinned group sorted by pinnedAt desc: p2(300) p3(200) p1(100); then unpinned.
|
||||||
|
expect(merged.map((m) => m.sessionId)).toEqual(['p2', 'p3', 'p1', 'u']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the existing activity-desc order among unpinned sessions', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [
|
||||||
|
{ id: 'old', name: 'Old', lastActivityAt: 100 },
|
||||||
|
{ id: 'new', name: 'New', lastActivityAt: 900 },
|
||||||
|
{ id: 'mid', name: 'Mid', lastActivityAt: 500 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(merged.map((m) => m.sessionId)).toEqual(['new', 'mid', 'old']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces pin state from a persisted-only session (survives reload)', () => {
|
||||||
|
// On boot, a live session becomes persisted-only (status stopped). The pin
|
||||||
|
// flag must come through the persisted input so it still floats to the top.
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
persisted: [
|
||||||
|
{ id: 'fresh', name: 'Fresh', lastActivityAt: 5000 },
|
||||||
|
{ id: 'pinned', name: 'Pinned', lastActivityAt: 1, pinned: true, pinnedAt: 42 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(merged[0].sessionId).toBe('pinned');
|
||||||
|
expect(merged[0].pinned).toBe(true);
|
||||||
|
expect(merged[0].pinnedAt).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('live pin overrides a stale persisted unpinned value (live precedence)', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
persisted: [{ id: 's', name: 'S', lastActivityAt: 10 }],
|
||||||
|
live: [{ id: 's', name: 'S', lastActivityAt: 10, pinned: true, pinnedAt: 77 }],
|
||||||
|
});
|
||||||
|
const s = merged.find((m) => m.sessionId === 's');
|
||||||
|
expect(s?.pinned).toBe(true);
|
||||||
|
expect(s?.pinnedAt).toBe(77);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats pinned:false the same as unpinned', () => {
|
||||||
|
const merged = mergeUnifiedSessions({
|
||||||
|
live: [
|
||||||
|
{ id: 'x', name: 'X', lastActivityAt: 100, pinned: false },
|
||||||
|
{ id: 'y', name: 'Y', lastActivityAt: 900, pinned: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(merged.map((m) => m.sessionId)).toEqual(['y', 'x']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -141,6 +141,66 @@ describe('StateStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('demoteOrRemoveSession and pinned cleanup (COD-142)', () => {
|
||||||
|
it('should preserve a pinned session as a stopped record on kill', () => {
|
||||||
|
const store = new StateStore(testFilePath);
|
||||||
|
const pinnedAt = Date.now();
|
||||||
|
store.setSession('pinned-1', {
|
||||||
|
...createMockSessionState('pinned-1'),
|
||||||
|
name: 'My Pinned Session',
|
||||||
|
workingDir: '/tmp/pinned',
|
||||||
|
pinned: true,
|
||||||
|
pinnedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = store.demoteOrRemoveSession('pinned-1');
|
||||||
|
|
||||||
|
expect(result).toBe('preserved');
|
||||||
|
const preserved = store.getSession('pinned-1');
|
||||||
|
expect(preserved).not.toBeNull();
|
||||||
|
expect(preserved?.status).toBe('stopped');
|
||||||
|
expect(preserved?.pid).toBeNull();
|
||||||
|
expect(preserved?.pinned).toBe(true);
|
||||||
|
expect(preserved?.pinnedAt).toBe(pinnedAt);
|
||||||
|
expect(preserved?.name).toBe('My Pinned Session');
|
||||||
|
expect(preserved?.workingDir).toBe('/tmp/pinned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fully remove an unpinned session on kill', () => {
|
||||||
|
const store = new StateStore(testFilePath);
|
||||||
|
store.setSession('plain-1', createMockSessionState('plain-1'));
|
||||||
|
|
||||||
|
const result = store.demoteOrRemoveSession('plain-1');
|
||||||
|
|
||||||
|
expect(result).toBe('removed');
|
||||||
|
expect(store.getSession('plain-1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report absent for an unknown session id', () => {
|
||||||
|
const store = new StateStore(testFilePath);
|
||||||
|
|
||||||
|
expect(store.demoteOrRemoveSession('does-not-exist')).toBe('absent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep pinned records during cleanupStaleSessions but reap unpinned ones', () => {
|
||||||
|
const store = new StateStore(testFilePath);
|
||||||
|
store.setSession('pinned-1', {
|
||||||
|
...createMockSessionState('pinned-1'),
|
||||||
|
pinned: true,
|
||||||
|
pinnedAt: Date.now(),
|
||||||
|
});
|
||||||
|
store.setSession('plain-1', createMockSessionState('plain-1'));
|
||||||
|
|
||||||
|
const result = store.cleanupStaleSessions(new Set<string>());
|
||||||
|
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.cleaned.map((c) => c.id)).toEqual(['plain-1']);
|
||||||
|
expect(result.cleaned.some((c) => c.id === 'pinned-1')).toBe(false);
|
||||||
|
expect(store.getSession('pinned-1')).not.toBeNull();
|
||||||
|
expect(store.getSession('plain-1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('task operations', () => {
|
describe('task operations', () => {
|
||||||
it('should set and get tasks', () => {
|
it('should set and get tasks', () => {
|
||||||
const store = new StateStore(testFilePath);
|
const store = new StateStore(testFilePath);
|
||||||
|
|||||||
Reference in New Issue
Block a user