diff --git a/extension/src/codex-session-watcher.ts b/extension/src/codex-session-watcher.ts index 0c227a4d..91da7602 100644 --- a/extension/src/codex-session-watcher.ts +++ b/extension/src/codex-session-watcher.ts @@ -52,6 +52,7 @@ interface WatchedCodexSession { sessionDetected: boolean sessionCompleted: boolean label: string + cwd?: string rolloutState: CodexRolloutState parser: CodexRolloutParser } @@ -167,6 +168,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { return Array.from(this.sessions.values()).map(s => ({ id: s.sessionId, label: s.label, + cwd: s.cwd, status: s.sessionCompleted ? 'completed' : 'active', startTime: s.sessionStartTime, lastActivityTime: s.lastActivityTime, @@ -177,7 +179,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { for (const [id, session] of this.sessions) { if (!session.sessionDetected) continue if (sessionIds && !sessionIds.includes(id)) continue - this._onSessionLifecycle.fire({ type: 'started', sessionId: id, label: session.label }) + this._onSessionLifecycle.fire({ type: 'started', sessionId: id, label: session.label, cwd: session.cwd }) } } @@ -232,9 +234,9 @@ export class CodexSessionWatcher implements AgentSessionWatcher { const ageS = (Date.now() - stat.mtimeMs) / 1000 if (ageS > ACTIVE_SESSION_AGE_S) continue + const cwd = readSessionCwd(filePath) // Workspace filter — only attach if cwd matches (or no workspace set) if (this.workspacePath) { - const cwd = readSessionCwd(filePath) if (cwd === null) continue const resolvedCwd = this.resolvePath(cwd) if (!resolvedCwd || !this.pathMatchesWorkspace(resolvedCwd)) { @@ -243,7 +245,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { } } - this.attachSession(filePath, stat) + this.attachSession(filePath, stat, cwd ?? undefined) } } @@ -280,7 +282,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { return candidate.startsWith(workspace + path.sep) } - private attachSession(filePath: string, stat: fs.Stats): void { + private attachSession(filePath: string, stat: fs.Stats, cwd?: string): void { const sessionId = this.sessionIdFor(filePath) const label = `Codex ${sessionId.slice(0, SESSION_ID_DISPLAY)}` @@ -296,7 +298,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { const s = this.sessions.get(sessionId) if (!s || !s.label.startsWith('Codex ')) return // only replace auto-label s.label = newLabel - this._onSessionLifecycle.fire({ type: 'updated', sessionId, label: newLabel }) + this._onSessionLifecycle.fire({ type: 'updated', sessionId, label: newLabel, cwd: s.cwd }) }, }) @@ -313,6 +315,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { sessionDetected: false, sessionCompleted: false, label, + cwd, rolloutState: createCodexRolloutState(), parser, } @@ -323,7 +326,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { session.sessionDetected = true this._onSessionDetected.fire(sessionId) - this._onSessionLifecycle.fire({ type: 'started', sessionId, label }) + this._onSessionLifecycle.fire({ type: 'started', sessionId, label, cwd }) try { session.fileWatcher = fs.watch(filePath, () => this.readNewLines(sessionId)) diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 563fb94a..dba9ede0 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -31,6 +31,8 @@ export interface AgentEvent { export interface SessionInfo { id: string label: string + /** Working directory of the session (used to disambiguate tabs across projects) */ + cwd?: string status: 'active' | 'completed' startTime: number lastActivityTime: number @@ -47,7 +49,7 @@ export type ExtensionToWebviewMessage = | { type: 'session-list'; sessions: SessionInfo[] } | { type: 'session-started'; session: SessionInfo } | { type: 'session-ended'; sessionId: string } - | { type: 'session-updated'; sessionId: string; label: string } + | { type: 'session-updated'; sessionId: string; label: string; cwd?: string } export interface VisualizerConfig { mode: 'live' | 'replay' @@ -71,6 +73,7 @@ export interface TranscriptEntry { sessionId: string type: string uuid?: string + cwd?: string message: { role: string model?: string @@ -188,6 +191,7 @@ export interface WatchedSession { subagentsDir: string | null label: string labelSet: boolean + cwd?: string model: string | null /** Maps agent names to their last emitted model ID — re-emits on model change */ modelDetectedAgents: Map diff --git a/extension/src/session-runtime.ts b/extension/src/session-runtime.ts index fb3bde13..a0314066 100644 --- a/extension/src/session-runtime.ts +++ b/extension/src/session-runtime.ts @@ -21,6 +21,7 @@ export interface SessionLifecycleEvent { type: 'started' | 'ended' | 'updated' sessionId: string label: string + cwd?: string } /** Interface every runtime's watcher implements. Uses portable typed-event @@ -100,13 +101,14 @@ export function wireWatcherToPanel( session: { id: lifecycle.sessionId, label: lifecycle.label, + cwd: lifecycle.cwd, status: 'active', startTime: Date.now(), lastActivityTime: Date.now(), }, }) } else if (lifecycle.type === 'updated') { - panel.postMessage({ type: 'session-updated', sessionId: lifecycle.sessionId, label: lifecycle.label }) + panel.postMessage({ type: 'session-updated', sessionId: lifecycle.sessionId, label: lifecycle.label, cwd: lifecycle.cwd }) } else { panel.postMessage({ type: 'session-ended', sessionId: lifecycle.sessionId }) } diff --git a/extension/src/session-watcher.ts b/extension/src/session-watcher.ts index ba7a7fb6..3b905d3d 100644 --- a/extension/src/session-watcher.ts +++ b/extension/src/session-watcher.ts @@ -94,6 +94,7 @@ export class SessionWatcher implements AgentSessionWatcher { return Array.from(this.sessions.values()).map(s => ({ id: s.sessionId, label: s.label, + cwd: s.cwd, status: s.sessionCompleted ? 'completed' : 'active', startTime: s.sessionStartTime, lastActivityTime: s.lastActivityTime, @@ -428,7 +429,7 @@ export class SessionWatcher implements AgentSessionWatcher { // Emit session start this._onSessionDetected.fire(sessionId) - this._onSessionLifecycle.fire({ type: 'started', sessionId, label: session.label }) + this._onSessionLifecycle.fire({ type: 'started', sessionId, label: session.label, cwd: session.cwd }) this.emit({ time: 0, @@ -520,7 +521,7 @@ export class SessionWatcher implements AgentSessionWatcher { ...(session.model ? { model: session.model } : {}), }, }, sessionId) - this._onSessionLifecycle.fire({ type: 'started', sessionId, label: session.label }) + this._onSessionLifecycle.fire({ type: 'started', sessionId, label: session.label, cwd: session.cwd }) } if (session.inactivityTimer) { diff --git a/extension/src/transcript-parser.ts b/extension/src/transcript-parser.ts index 4ff0198f..ae1d42d6 100644 --- a/extension/src/transcript-parser.ts +++ b/extension/src/transcript-parser.ts @@ -32,7 +32,7 @@ export interface TranscriptParserDelegate { emit(event: AgentEvent, sessionId?: string): void elapsed(sessionId?: string): number getSession(sessionId: string): WatchedSession | undefined - fireSessionLifecycle(event: { type: 'started' | 'ended' | 'updated'; sessionId: string; label: string }): void + fireSessionLifecycle(event: { type: 'started' | 'ended' | 'updated'; sessionId: string; label: string; cwd?: string }): void emitContextUpdate(agentName: string, session: WatchedSession, sessionId?: string): void } @@ -130,6 +130,7 @@ export class TranscriptParser { sessionId: parsed.sessionId as string, type: parsed.type as string, uuid: parsed.uuid as string | undefined, + cwd: typeof parsed.cwd === 'string' ? parsed.cwd : undefined, message: msg, } @@ -561,6 +562,7 @@ export class TranscriptParser { extractSessionLabel(entries: TranscriptEntry[], session: WatchedSession): void { if (session.labelSet) return for (const entry of entries) { + if (!session.cwd && entry.cwd) session.cwd = entry.cwd if (entry.type !== 'user') continue const text = this.extractUserMessageText(entry) if (text) { @@ -606,12 +608,14 @@ export class TranscriptParser { /** Update session label on first user message and notify the webview */ maybeSetSessionLabel(entry: TranscriptEntry, sessionId: string): void { const session = this.delegate.getSession(sessionId) - if (!session || session.labelSet) return + if (!session) return + if (!session.cwd && entry.cwd) session.cwd = entry.cwd + if (session.labelSet) return if (entry.type !== 'user') return const text = this.extractUserMessageText(entry) if (!text) return session.label = this.truncateLabel(text) session.labelSet = true - this.delegate.fireSessionLifecycle({ type: 'updated', sessionId, label: session.label }) + this.delegate.fireSessionLifecycle({ type: 'updated', sessionId, label: session.label, cwd: session.cwd }) } } diff --git a/scripts/relay.ts b/scripts/relay.ts index cbca8298..8016d617 100644 --- a/scripts/relay.ts +++ b/scripts/relay.ts @@ -100,16 +100,16 @@ function broadcastEvent(event: AgentEvent) { broadcast(JSON.stringify({ type: 'agent-event', event })) } -function broadcastSessionLifecycle(type: 'started' | 'ended' | 'updated', sessionId: string, label: string) { +function broadcastSessionLifecycle(type: 'started' | 'ended' | 'updated', sessionId: string, label: string, cwd?: string) { if (type === 'started') { broadcast(JSON.stringify({ type: 'session-started', - session: { id: sessionId, label, status: 'active', startTime: Date.now(), lastActivityTime: Date.now() } as SessionInfo, + session: { id: sessionId, label, cwd, status: 'active', startTime: Date.now(), lastActivityTime: Date.now() } as SessionInfo, })) } else if (type === 'ended') { broadcast(JSON.stringify({ type: 'session-ended', sessionId })) } else if (type === 'updated') { - broadcast(JSON.stringify({ type: 'session-updated', sessionId, label })) + broadcast(JSON.stringify({ type: 'session-updated', sessionId, label, cwd })) } } @@ -144,7 +144,7 @@ const parser = new TranscriptParser({ emit: emitEvent, elapsed, getSession: (sessionId: string) => sessions.get(sessionId), - fireSessionLifecycle: (event) => broadcastSessionLifecycle(event.type, event.sessionId, event.label), + fireSessionLifecycle: (event) => broadcastSessionLifecycle(event.type, event.sessionId, event.label, event.cwd), emitContextUpdate, }) @@ -171,7 +171,7 @@ function resetInactivityTimer(sessionId: string) { payload: { name: ORCHESTRATOR_NAME, isMain: true, task: session.label, ...(session.model ? { model: session.model } : {}) }, sessionId, }) - broadcastSessionLifecycle('started', sessionId, session.label) + broadcastSessionLifecycle('started', sessionId, session.label, session.cwd) } if (session.inactivityTimer) clearTimeout(session.inactivityTimer) @@ -219,7 +219,7 @@ function watchSession(sessionId: string, filePath: string) { session.fileSize = stat.size parser.extractSessionLabel(catchUpEntries, session) - broadcastSessionLifecycle('started', sessionId, session.label) + broadcastSessionLifecycle('started', sessionId, session.label, session.cwd) broadcastEvent({ time: 0, type: 'agent_spawn', payload: { name: ORCHESTRATOR_NAME, isMain: true, task: session.label, ...(session.model ? { model: session.model } : {}) }, @@ -434,7 +434,7 @@ export async function createRelay(options: RelayOptions): Promise { codexWatcher = new CodexSessionWatcher(workspace) codexWatcher.onEvent((event) => broadcastEvent(event)) codexWatcher.onSessionLifecycle((lifecycle) => { - broadcastSessionLifecycle(lifecycle.type, lifecycle.sessionId, lifecycle.label) + broadcastSessionLifecycle(lifecycle.type, lifecycle.sessionId, lifecycle.label, lifecycle.cwd) }) codexWatcher.start() } @@ -490,7 +490,7 @@ export async function createRelay(options: RelayOptions): Promise { for (const session of sessions.values()) { if (!session.sessionDetected) continue sessionList.push({ - id: session.sessionId, label: session.label, + id: session.sessionId, label: session.label, cwd: session.cwd, status: session.sessionCompleted ? 'completed' : 'active', startTime: session.sessionStartTime, lastActivityTime: session.lastActivityTime, }) diff --git a/web/components/agent-visualizer/session-tabs.tsx b/web/components/agent-visualizer/session-tabs.tsx index aaad8174..419816f9 100644 --- a/web/components/agent-visualizer/session-tabs.tsx +++ b/web/components/agent-visualizer/session-tabs.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useCallback } from 'react' +import { useEffect, useRef, useCallback, useState } from 'react' import { COLORS } from '@/lib/colors' import type { SessionInfo } from '@/lib/vscode-bridge' @@ -10,6 +10,18 @@ interface SessionTabsProps { sessionsWithActivity: Set onSelectSession: (id: string) => void onCloseSession: (id: string) => void + /** Prefix tab labels with the session's folder name (standalone app, or multiple workspaces) */ + showFolder: boolean +} + +const RENAME_STORAGE_KEY = 'agent-flow-session-names' + +function loadCustomNames(): Record { + try { return JSON.parse(localStorage.getItem(RENAME_STORAGE_KEY) || '{}') } catch { return {} } +} + +function folderName(cwd: string): string { + return cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || cwd } export function SessionTabs({ @@ -18,8 +30,29 @@ export function SessionTabs({ sessionsWithActivity, onSelectSession, onCloseSession, + showFolder, }: SessionTabsProps) { const buttonRefs = useRef>(new Map()) + const [customNames, setCustomNames] = useState>(loadCustomNames) + const [editingId, setEditingId] = useState(null) + const [draft, setDraft] = useState('') + + const commitRename = useCallback((id: string, name: string) => { + setCustomNames(prev => { + const next = { ...prev } + if (name.trim()) next[id] = name.trim() + else delete next[id] // empty name = reset to default label + try { localStorage.setItem(RENAME_STORAGE_KEY, JSON.stringify(next)) } catch { /* ignore */ } + return next + }) + setEditingId(null) + }, []) + + const displayLabel = useCallback((session: SessionInfo) => { + const custom = customNames[session.id] + if (custom) return custom + return showFolder && session.cwd ? `${folderName(session.cwd)} · ${session.label}` : session.label + }, [customNames, showFolder]) const setButtonRef = useCallback((id: string, el: HTMLButtonElement | null) => { if (el) buttonRefs.current.set(id, el) @@ -46,6 +79,12 @@ export function SessionTabs({ key={session.id} ref={(el) => setButtonRef(session.id, el)} onClick={() => onSelectSession(session.id)} + onContextMenu={(e) => { + e.preventDefault() + setDraft(customNames[session.id] ?? '') + setEditingId(session.id) + }} + title="Right-click to rename" className="group px-1.5 py-0.5 rounded transition-all flex items-center gap-1" style={{ flexShrink: 0, @@ -63,7 +102,22 @@ export function SessionTabs({ animation: hasActivity && !isSelected ? 'pulse 1.5s infinite' : 'none', }} /> - {session.label} + {editingId === session.id ? ( + setDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => commitRename(session.id, draft)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(session.id, draft) + else if (e.key === 'Escape') setEditingId(null) + }} + className="bg-transparent outline-none font-mono text-[10px]" + style={{ color: COLORS.holoBright, width: Math.max(8, (draft || displayLabel(session)).length) + 'ch' }} + /> + ) : displayLabel(session)} s.cwd).filter(Boolean)).size > 1} /> )} diff --git a/web/hooks/use-vscode-bridge.ts b/web/hooks/use-vscode-bridge.ts index e3a589c4..ad0f7e48 100644 --- a/web/hooks/use-vscode-bridge.ts +++ b/web/hooks/use-vscode-bridge.ts @@ -220,9 +220,9 @@ export function useVSCodeBridge(): BridgeHookResult { selectedSessionIdRef.current = session.id setSelectedSessionId(session.id) } else if (type === 'updated') { - const { sessionId, label } = data as { sessionId: string; label: string } + const { sessionId, label, cwd } = data as { sessionId: string; label: string; cwd?: string } setSessions(prev => prev.map(s => - s.id === sessionId ? { ...s, label } : s + s.id === sessionId ? { ...s, label, cwd: cwd ?? s.cwd } : s )) } else if (type === 'ended') { const sessionId = data as string diff --git a/web/lib/bridge-types.ts b/web/lib/bridge-types.ts index 60f7f26e..70b5867a 100644 --- a/web/lib/bridge-types.ts +++ b/web/lib/bridge-types.ts @@ -16,6 +16,7 @@ export interface AgentEvent { export interface SessionInfo { id: string label: string + cwd?: string status: 'active' | 'completed' startTime: number lastActivityTime: number diff --git a/web/lib/vscode-bridge.ts b/web/lib/vscode-bridge.ts index 19f18cc5..86f9277d 100644 --- a/web/lib/vscode-bridge.ts +++ b/web/lib/vscode-bridge.ts @@ -13,7 +13,7 @@ type InitCallback = () => void type EventCallback = (event: AgentEvent) => void type StatusCallback = (status: ConnectionStatus, source: string) => void type ConfigCallback = (config: Partial<{ mode: string; autoPlay: boolean; showMockData: boolean; disable1MContext: boolean }>) => void -type SessionCallback = (type: 'list' | 'started' | 'ended' | 'updated' | 'reset', data: SessionInfo[] | SessionInfo | string | { sessionId: string; label: string }) => void +type SessionCallback = (type: 'list' | 'started' | 'ended' | 'updated' | 'reset', data: SessionInfo[] | SessionInfo | string | { sessionId: string; label: string; cwd?: string }) => void class VSCodeBridge { private _isVSCode = false @@ -98,7 +98,7 @@ class VSCodeBridge { case 'session-updated': for (const cb of this.sessionListeners) { - cb('updated', { sessionId: data.sessionId, label: data.label }) + cb('updated', { sessionId: data.sessionId, label: data.label, cwd: data.cwd }) } break }