mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +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;
|
||||
claudeSessionId?: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */
|
||||
lastPrompt?: string;
|
||||
sizeBytes?: number;
|
||||
projectKey?: string;
|
||||
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[];
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
};
|
||||
@@ -46,6 +52,8 @@ export type LiveSessionInput = {
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
};
|
||||
|
||||
/** Persisted session view (subset of `SessionState`). */
|
||||
@@ -59,6 +67,8 @@ export type PersistedSessionInput = {
|
||||
lastActivityAt?: number;
|
||||
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
||||
claudeSessionId?: string;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
};
|
||||
|
||||
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
||||
@@ -77,6 +87,8 @@ export type HistoryInput = {
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145). */
|
||||
lastPrompt?: string;
|
||||
projectKey?: string;
|
||||
};
|
||||
|
||||
@@ -149,6 +161,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'workingDir', h.workingDir);
|
||||
overwrite(item, 'sizeBytes', h.sizeBytes);
|
||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||
overwrite(item, 'lastPrompt', h.lastPrompt);
|
||||
overwrite(item, 'projectKey', h.projectKey);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
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, 'createdAt', p.createdAt);
|
||||
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
||||
overwrite(item, 'pinned', p.pinned);
|
||||
overwrite(item, 'pinnedAt', p.pinnedAt);
|
||||
}
|
||||
|
||||
// 4) live (highest precedence)
|
||||
@@ -189,6 +204,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'createdAt', v.createdAt);
|
||||
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
||||
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)
|
||||
@@ -200,6 +217,64 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
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.
|
||||
const kept: UnifiedSessionItem[] = [];
|
||||
for (const item of map.values()) {
|
||||
@@ -211,8 +286,24 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
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) => {
|
||||
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 lb = b.lastActivityAt;
|
||||
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.
|
||||
*/
|
||||
export function filterAndPaginate(
|
||||
@@ -244,7 +335,7 @@ export function filterAndPaginate(
|
||||
const q = (opts.q ?? '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? 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')
|
||||
.join(' ')
|
||||
.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)
|
||||
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)
|
||||
private _flickerFilterEnabled: boolean = false;
|
||||
|
||||
@@ -992,6 +997,26 @@ export class Session extends EventEmitter {
|
||||
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 {
|
||||
return this._flickerFilterEnabled;
|
||||
}
|
||||
@@ -1058,6 +1083,8 @@ export class Session extends EventEmitter {
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
totalCost: this._totalCost,
|
||||
inputTokens: this._totalInputTokens,
|
||||
outputTokens: this._totalOutputTokens,
|
||||
|
||||
@@ -278,6 +278,9 @@ export class StateStore {
|
||||
if (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(',')}}`;
|
||||
}
|
||||
@@ -485,6 +488,25 @@ export class StateStore {
|
||||
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.
|
||||
* @param activeSessionIds - Set of currently active session IDs
|
||||
@@ -499,6 +521,7 @@ export class StateStore {
|
||||
|
||||
for (const sessionId of allSessionIds) {
|
||||
if (!activeSessionIds.has(sessionId)) {
|
||||
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
|
||||
const name = this.state.sessions[sessionId]?.name;
|
||||
cleaned.push({ id: sessionId, name });
|
||||
delete this.state.sessions[sessionId];
|
||||
@@ -630,6 +653,17 @@ export class StateStore {
|
||||
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. */
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface AppState {
|
||||
cronJobs?: Record<string, CronJob>;
|
||||
/** Scheduled job run history, keyed by run ID. */
|
||||
cronJobRuns?: Record<string, CronJobRun>;
|
||||
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
|
||||
sessionOrder?: string[];
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -405,6 +405,10 @@ export interface SessionState {
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
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 */
|
||||
imageWatcherEnabled?: boolean;
|
||||
/** Total cost in USD */
|
||||
|
||||
+60
-1
@@ -291,6 +291,9 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Clipboard
|
||||
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
];
|
||||
|
||||
|
||||
@@ -1500,6 +1503,26 @@ class CodemanApp {
|
||||
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).
|
||||
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
|
||||
|
||||
// COD-131: server is authoritative for global tab order. Seed localStorage
|
||||
// from the server snapshot (if present) so syncSessionOrder() reconciles
|
||||
// against the cross-device order rather than this device's stale local copy.
|
||||
if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) {
|
||||
try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {}
|
||||
}
|
||||
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
|
||||
@@ -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() {
|
||||
try {
|
||||
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
const order = [...this.sessionOrder];
|
||||
this._debouncedCall('saveSessionOrderServer', () => {
|
||||
fetch('/api/session-order', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
}).catch(() => {});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// COD-131: another device (or our own debounced push) reordered tabs. Adopt the
|
||||
// server order as the new base and reconcile to our currently-open sessions.
|
||||
// Guard against no-op churn so an echo of our own push doesn't flicker the tabs.
|
||||
_onSessionOrderChanged(data) {
|
||||
if (!data || !Array.isArray(data.order)) return;
|
||||
try {
|
||||
localStorage.setItem('codeman-session-order', JSON.stringify(data.order));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
const before = JSON.stringify(this.sessionOrder);
|
||||
this.syncSessionOrder();
|
||||
// Only re-render when the reconciled order actually changed (avoids flicker
|
||||
// when the broadcast is just an echo of the order we already have).
|
||||
if (JSON.stringify(this.sessionOrder) !== before) {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
|
||||
@@ -322,6 +322,7 @@ const SSE_EVENTS = {
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_PINNED: 'session:pinned',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
@@ -490,6 +491,9 @@ const SSE_EVENTS = {
|
||||
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
||||
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
||||
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.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir);
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
@@ -2794,6 +2794,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1239,8 +1239,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
|
||||
|
||||
const isPinned = s.pinned === true;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.className = 'history-item' + (isPinned ? ' is-pinned' : '');
|
||||
item.title = s.workingDir || '';
|
||||
|
||||
// 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)) {
|
||||
this.selectSession(s.sessionId);
|
||||
} 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');
|
||||
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.
|
||||
const badgeRow = document.createElement('div');
|
||||
@@ -1327,6 +1338,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
promptText.textContent = s.firstPrompt || '(no prompt captured)';
|
||||
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');
|
||||
pathRow.className = 'history-detail-row';
|
||||
const pathLabel = document.createElement('span');
|
||||
@@ -1345,7 +1371,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
metaParts.push(s.sessionId.slice(0, 8));
|
||||
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) {
|
||||
const actionRow = document.createElement('div');
|
||||
@@ -1463,13 +1491,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
// Resume by the Claude conversation UUID when present (resumed sessions
|
||||
// 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?.();
|
||||
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).
|
||||
if (isLiveOpen) {
|
||||
addItem('Open folder', () => {
|
||||
@@ -1536,6 +1578,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
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" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
|
||||
@@ -1721,7 +1789,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
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
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
@@ -1730,17 +1815,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
||||
|
||||
// Generate a session name from the working dir
|
||||
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;
|
||||
}
|
||||
}
|
||||
const name = `w${startNumber}-${dirName}`;
|
||||
// Keep the session's own name when resuming; only synthesize a w<N>-<dir>
|
||||
// name when the source row had none (COD-143).
|
||||
const name = this._resolveResumeName(existingName, workingDir);
|
||||
|
||||
// Create session with resumeSessionId — include envOverrides so resumed
|
||||
// conversations inherit current UI settings (effort, agent teams, etc.).
|
||||
|
||||
@@ -32,12 +32,15 @@ import {
|
||||
AutoClearSchema,
|
||||
AutoCompactSchema,
|
||||
AutoResumeSchema,
|
||||
PinSessionSchema,
|
||||
ImageWatcherSchema,
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
InteractiveStartSchema,
|
||||
SessionOrderUpdateSchema,
|
||||
} from '../schemas.js';
|
||||
import { mergeSessionOrder } from '../../session-order.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
@@ -334,6 +337,18 @@ export function registerSessionRoutes(
|
||||
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 ==========
|
||||
|
||||
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 ==========
|
||||
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
@@ -2249,6 +2308,59 @@ export function registerSessionRoutes(
|
||||
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")
|
||||
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
||||
@@ -2387,6 +2499,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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({
|
||||
sessionId,
|
||||
workingDir,
|
||||
@@ -2439,6 +2563,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
lastPrompt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -2514,6 +2639,8 @@ export function registerSessionRoutes(
|
||||
createdAt: st.createdAt,
|
||||
lastActivityAt: st.lastActivityAt,
|
||||
claudeSessionId: s.claudeSessionId ?? undefined,
|
||||
pinned: st.pinned,
|
||||
pinnedAt: st.pinnedAt,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2529,6 +2656,8 @@ export function registerSessionRoutes(
|
||||
createdAt: p.createdAt,
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
claudeSessionId: p.resumeSessionId,
|
||||
pinned: p.pinned,
|
||||
pinnedAt: p.pinnedAt,
|
||||
}));
|
||||
|
||||
// Lifecycle audit log (newest-first, capped).
|
||||
@@ -2562,6 +2691,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
projectKey: h.projectKey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -867,6 +867,11 @@ export const AutoResumeSchema = z.object({
|
||||
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 */
|
||||
export const ImageWatcherSchema = z.object({
|
||||
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')),
|
||||
});
|
||||
|
||||
/** 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 */
|
||||
export const RevokeSessionSchema = z.object({
|
||||
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.
|
||||
// When killMux=false (server shutdown), preserve state for recovery.
|
||||
if (killMux) {
|
||||
this.store.removeSession(sessionId);
|
||||
this.store.demoteOrRemoveSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1871,6 +1871,7 @@ export class WebServer extends EventEmitter {
|
||||
timestamp: now,
|
||||
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
|
||||
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
|
||||
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
|
||||
};
|
||||
|
||||
this.cachedLightState = { data: result, timestamp: now };
|
||||
|
||||
@@ -84,6 +84,8 @@ export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as con
|
||||
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
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). */
|
||||
export const SessionMessage = 'session:message' as const;
|
||||
/** 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. */
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -433,6 +438,7 @@ export const SseEvent = {
|
||||
SessionLimitResumeCancelled,
|
||||
SessionRespawnBreakerTripped,
|
||||
SessionCliInfo,
|
||||
SessionPinned,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
@@ -602,4 +608,7 @@ export const SseEvent = {
|
||||
AdminUsersChanged,
|
||||
AuthPasswordChangeRequired,
|
||||
DockerContainerRecreated,
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SessionOrderChanged,
|
||||
} as const;
|
||||
|
||||
@@ -21,6 +21,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
const sessions = new Map<string, MockSession>();
|
||||
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 {
|
||||
// -- SessionPort --
|
||||
sessions,
|
||||
@@ -65,6 +69,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
load: vi.fn(),
|
||||
incrementSessionsCreated: 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 })),
|
||||
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
|
||||
getDailyStats: vi.fn(() => []),
|
||||
|
||||
@@ -229,6 +229,8 @@ export class MockSession extends EventEmitter {
|
||||
color: this.color,
|
||||
mode: this.mode,
|
||||
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;
|
||||
});
|
||||
|
||||
/** 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 */
|
||||
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[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', () => {
|
||||
@@ -170,6 +375,14 @@ describe('filterAndPaginate', () => {
|
||||
{ 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: '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)', () => {
|
||||
@@ -183,18 +396,24 @@ describe('filterAndPaginate', () => {
|
||||
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', () => {
|
||||
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
|
||||
// both s1 and s2 have /repo/ workingDir
|
||||
expect(r.total).toBe(2);
|
||||
// s1, s2, and s4 all have /repo/ workingDir
|
||||
expect(r.total).toBe(3);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clamps limit to a max of 500', () => {
|
||||
const r = filterAndPaginate(items, { limit: 99999 });
|
||||
expect(r.sessions).toHaveLength(items.length);
|
||||
// clamp does not throw and returns all 3 (< 500)
|
||||
expect(r.total).toBe(3);
|
||||
// clamp does not throw and returns all items (< 500)
|
||||
expect(r.total).toBe(items.length);
|
||||
});
|
||||
|
||||
it('clamps limit to a min of 1', () => {
|
||||
@@ -206,7 +425,7 @@ describe('filterAndPaginate', () => {
|
||||
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
|
||||
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
|
||||
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
|
||||
.map((s) => s.sessionId)
|
||||
.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', () => {
|
||||
it('should set and get tasks', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
Reference in New Issue
Block a user