Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions src/services/unified-session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand All @@ -46,6 +52,8 @@ export type LiveSessionInput = {
createdAt?: number;
lastActivityAt?: number;
claudeSessionId?: string;
pinned?: boolean;
pinnedAt?: number;
};

/** Persisted session view (subset of `SessionState`). */
Expand All @@ -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). */
Expand All @@ -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;
};

Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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()) {
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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();
Expand Down
68 changes: 68 additions & 0 deletions src/session-order.ts
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 27 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,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;

Expand Down Expand Up @@ -957,6 +962,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;
}
Expand Down Expand Up @@ -1021,6 +1046,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,
Expand Down
34 changes: 34 additions & 0 deletions src/state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(',')}}`;
}
Expand Down Expand Up @@ -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
Expand All @@ -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];
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/types/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==========
Expand Down
4 changes: 4 additions & 0 deletions src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,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 */
Expand Down
Loading