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
12 changes: 11 additions & 1 deletion packages/client/src/connection/session-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RecordedSession, SessionSummary, LiveSessionSummary, RecordedTimelineEvent, SessionComparison } from '@agent-move/shared';
import type { RecordedSession, SessionSummary, LiveSessionSummary, RecordedTimelineEvent, ReplayTimelineEvent, SessionComparison } from '@agent-move/shared';

/** Derive the base URL from current page location (same origin as WebSocket) */
function getBaseUrl(): string {
Expand Down Expand Up @@ -55,6 +55,16 @@ export async function updateSessionLabel(id: string, label: string | null): Prom
});
}

export async function fetchReplayEvents(id: string): Promise<{
events: ReplayTimelineEvent[];
session: RecordedSession;
hasReplayData: boolean;
}> {
const res = await fetch(`${getBaseUrl()}/api/sessions/${id}/replay-events`);
if (!res.ok) throw new Error(`Session ${id} not found`);
return res.json();
}

export async function recordCurrentSession(rootSessionId?: string): Promise<string> {
const res = await fetch(`${getBaseUrl()}/api/sessions/record-current`, {
method: 'POST',
Expand Down
35 changes: 35 additions & 0 deletions packages/client/src/connection/state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,46 @@ export class StateStore {
private _timeline: TimelineEvent[] = [];
private _pendingPermissions = new Map<string, PendingPermission>();
private _lastHookActivityAt: number | null = null;
private _isReplayMode = false;
private _savedTimeline: TimelineEvent[] = [];
private _savedAgents = new Map<string, AgentState>();

get connectionStatus(): ConnectionStatus {
return this._connectionStatus;
}

get isReplayMode(): boolean {
return this._isReplayMode;
}

/** Enter replay mode: save current state, clear agents, replace timeline with replay events */
enterReplayMode(events: TimelineEvent[]): void {
// Save current live state
this._savedTimeline = this._timeline;
this._savedAgents = new Map(this.agents);

// Clear agents and set replay timeline
this.agents.clear();
this._timeline = events;
this._isReplayMode = true;

this.emit('state:reset', this.agents);
this.emit('timeline:snapshot', this._timeline);
}

/** Exit replay mode: restore saved state */
exitReplayMode(): void {
// Restore saved state
this._timeline = this._savedTimeline;
this.agents = this._savedAgents;
this._savedTimeline = [];
this._savedAgents = new Map();
this._isReplayMode = false;

this.emit('state:reset', this.agents);
this.emit('timeline:snapshot', this._timeline);
}

setWsClient(client: WsClient): void {
this.wsClient = client;
}
Expand Down
21 changes: 21 additions & 0 deletions packages/client/src/connection/ws-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ export class WsClient {
this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
}

/** Pause the connection (for replay mode) — closes WS without marking as disposed */
pause(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// Set disposed to suppress auto-reconnect, but remember we're just paused
this.disposed = true;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}

/** Resume the connection (after replay mode) */
reconnect(): void {
this.disposed = false;
this.reconnectMs = MIN_RECONNECT_MS;
this.connect();
}

disconnect(): void {
this.disposed = true;
if (this.reconnectTimer) {
Expand Down
25 changes: 24 additions & 1 deletion packages/client/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { SessionHistoryPanel } from './ui/session-history-panel.js';
import { SessionComparisonPanel } from './ui/session-comparison-panel.js';
import { SessionDetailPanel } from './ui/session-detail-panel.js';
import { SettingsPanel } from './ui/settings-panel.js';
import { ReplayManager } from './replay/replay-manager.js';

async function main() {
const appEl = document.getElementById('app')!;
Expand Down Expand Up @@ -377,7 +378,10 @@ async function main() {
}
updateFocusIndicator();
break;
case 'exit-focus': if (focusModeActive) exitFocusMode(); break;
case 'exit-focus':
if (replayManager.isReplaying) { replayManager.exitReplay(); }
else if (focusModeActive) { exitFocusMode(); }
break;
case 'session-export': sessionExport.toggle(); break;
case 'toggle-trails': trails.toggle(); break;
case 'toggle-daynight': world.dayNight.toggle(); break;
Expand Down Expand Up @@ -412,6 +416,25 @@ async function main() {
const ws = new WsClient(store);
ws.connect();

// ── Replay Manager ──
const replayManager = new ReplayManager(store, ws, timeline);

// Wire replay from session history panel
sessionHistoryPanel.setReplayHandler(async (sessionId) => {
try {
await replayManager.startReplay(sessionId);
// Switch to monitor tab so the grid is visible
sidebar.setActiveTab('monitor');
} catch (err) {
console.error('Failed to start replay:', err);
}
});

// Wire exit replay handler on timeline
timeline.setExitReplayHandler(() => {
replayManager.exitReplay();
});

// Zoom controls
document.getElementById('zoom-in')!.addEventListener('click', () => world.camera.zoomIn());
document.getElementById('zoom-out')!.addEventListener('click', () => world.camera.zoomOut());
Expand Down
162 changes: 162 additions & 0 deletions packages/client/src/replay/replay-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { TimelineEvent, ReplayTimelineEvent, AgentState, ZoneId } from '@agent-move/shared';
import type { StateStore } from '../connection/state-store.js';
import type { WsClient } from '../connection/ws-client.js';
import type { Timeline } from '../ui/timeline.js';
import { fetchReplayEvents } from '../connection/session-api.js';

/**
* Orchestrates the enter/exit replay flow:
* - Fetches replay events from the server
* - Pauses the WebSocket connection
* - Injects events into StateStore replay mode
* - Coordinates with Timeline for replay UI
*/
export class ReplayManager {
private _isReplaying = false;

constructor(
private store: StateStore,
private wsClient: WsClient,
private timeline: Timeline,
) {}

get isReplaying(): boolean {
return this._isReplaying;
}

/** Start replaying a recorded session */
async startReplay(sessionId: string): Promise<void> {
if (this._isReplaying) {
this.exitReplay();
}

// 1. Fetch replay events from server
const { events, session, hasReplayData } = await fetchReplayEvents(sessionId);

if (!hasReplayData || events.length === 0) {
throw new Error('This session has no replay data');
}

// 2. Convert ReplayTimelineEvent[] → TimelineEvent[]
const timelineEvents = this.convertToTimelineEvents(events);

if (timelineEvents.length === 0) {
throw new Error('No replayable events found');
}

// 3. Pause WebSocket
this.wsClient.pause();

// 4. Enter replay mode in StateStore
this.store.enterReplayMode(timelineEvents);

// 5. Tell Timeline to enter recording replay
const label = session.label || session.projectName;
this.timeline.enterRecordingReplay(label);

this._isReplaying = true;
}

/** Exit replay and return to live mode */
exitReplay(): void {
if (!this._isReplaying) return;

// 1. Tell Timeline to exit recording replay
this.timeline.exitRecordingReplay();

// 2. Exit replay mode in StateStore (restores saved state)
this.store.exitReplayMode();

// 3. Reconnect WebSocket
this.wsClient.reconnect();

this._isReplaying = false;
}

/** Convert ReplayTimelineEvent[] (server format) → TimelineEvent[] (client format) */
private convertToTimelineEvents(events: ReplayTimelineEvent[]): TimelineEvent[] {
const result: TimelineEvent[] = [];
// Track last known state per agent for shutdown events
const lastKnownState = new Map<string, AgentState>();

for (const evt of events) {
let type: TimelineEvent['type'];
switch (evt.kind) {
case 'spawn': type = 'agent:spawn'; break;
case 'tool': type = 'agent:update'; break;
case 'zone-change': type = 'agent:update'; break;
case 'idle': type = 'agent:idle'; break;
case 'shutdown': type = 'agent:shutdown'; break;
case 'text': type = 'agent:update'; break;
case 'tokens': type = 'agent:update'; break;
default: continue;
}

// Use stored AgentState if available, otherwise build a minimal one
let agent: AgentState;
if (evt.agentState) {
agent = {
...evt.agentState,
// Ensure stripped fields have defaults
recentFiles: evt.agentState.recentFiles ?? [],
recentDiffs: evt.agentState.recentDiffs ?? [],
};
lastKnownState.set(evt.agentId, agent);
} else if (lastKnownState.has(evt.agentId)) {
// For shutdown or events without state, use last known
agent = { ...lastKnownState.get(evt.agentId)! };
if (evt.zone) agent.currentZone = evt.zone as ZoneId;
if (evt.tool) agent.currentTool = evt.tool;
} else {
// Minimal fallback — shouldn't happen for well-formed recordings
agent = this.buildMinimalAgent(evt);
}

result.push({ type, agent, timestamp: evt.timestamp });
}

return result;
}

/** Build a minimal AgentState from a ReplayTimelineEvent (fallback) */
private buildMinimalAgent(evt: ReplayTimelineEvent): AgentState {
return {
id: evt.agentId,
sessionId: evt.agentId,
agentType: 'claude',
rootSessionId: '',
projectPath: '',
projectName: '',
agentName: null,
role: 'main',
parentId: null,
teamName: null,
currentZone: (evt.zone as ZoneId) ?? 'thinking',
currentTool: evt.tool ?? null,
currentActivity: evt.toolArgs ?? null,
messageTarget: null,
taskDescription: null,
speechText: null,
lastActivityAt: evt.timestamp,
spawnedAt: evt.timestamp,
isIdle: evt.kind === 'idle',
isDone: false,
isPlanning: false,
isWaitingForUser: false,
phase: evt.kind === 'idle' ? 'idle' : 'running',
lastToolOutcome: null,
totalInputTokens: 0,
totalOutputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
contextTokens: 0,
contextCacheTokens: 0,
model: null,
colorIndex: 0,
toolUseCount: 0,
gitBranch: null,
recentFiles: [],
recentDiffs: [],
};
}
}
17 changes: 17 additions & 0 deletions packages/client/src/ui/session-history-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class SessionHistoryPanel {
private onCompare: ((idA: string, idB: string) => void) | null = null;
private onOpenSession: ((sessionId: string) => void) | null = null;
private onOpenLiveSession: ((live: LiveSessionSummary) => void) | null = null;
private onReplay: ((sessionId: string) => void) | null = null;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private shutdownRefreshTimer: ReturnType<typeof setTimeout> | null = null;
private store: StateStore | null = null;
Expand Down Expand Up @@ -63,6 +64,10 @@ export class SessionHistoryPanel {
this.onOpenLiveSession = handler;
}

setReplayHandler(handler: (sessionId: string) => void): void {
this.onReplay = handler;
}

show(): void {
this.isVisible = true;
this.contentEl.style.display = '';
Expand Down Expand Up @@ -199,6 +204,15 @@ export class SessionHistoryPanel {
});
});

// Replay handlers
this.contentEl.querySelectorAll<HTMLElement>('.sh-replay-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = btn.dataset.id!;
this.onReplay?.(id);
});
});

// Label edit handlers
this.contentEl.querySelectorAll<HTMLElement>('.sh-label-edit').forEach(btn => {
btn.addEventListener('click', async (e) => {
Expand Down Expand Up @@ -329,6 +343,9 @@ export class SessionHistoryPanel {
${detailHtml}
</div>
<div class="sh-row-actions">
<button class="sh-replay-btn" data-id="${s.id}" title="Replay session">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="sh-label-edit" data-id="${s.id}" data-label="${escapeHtml(s.label || '')}" title="Edit label">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
Expand Down
Loading