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
15 changes: 9 additions & 6 deletions extension/src/codex-session-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface WatchedCodexSession {
sessionDetected: boolean
sessionCompleted: boolean
label: string
cwd?: string
rolloutState: CodexRolloutState
parser: CodexRolloutParser
}
Expand Down Expand Up @@ -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,
Expand All @@ -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 })
}
}

Expand Down Expand Up @@ -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)) {
Expand All @@ -243,7 +245,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher {
}
}

this.attachSession(filePath, stat)
this.attachSession(filePath, stat, cwd ?? undefined)
}
}

Expand Down Expand Up @@ -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)}`

Expand All @@ -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 })
},
})

Expand All @@ -313,6 +315,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher {
sessionDetected: false,
sessionCompleted: false,
label,
cwd,
rolloutState: createCodexRolloutState(),
parser,
}
Expand All @@ -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))
Expand Down
6 changes: 5 additions & 1 deletion extension/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -71,6 +73,7 @@ export interface TranscriptEntry {
sessionId: string
type: string
uuid?: string
cwd?: string
message: {
role: string
model?: string
Expand Down Expand Up @@ -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<string, string>
Expand Down
4 changes: 3 additions & 1 deletion extension/src/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 })
}
Expand Down
5 changes: 3 additions & 2 deletions extension/src/session-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 7 additions & 3 deletions extension/src/transcript-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 })
}
}
16 changes: 8 additions & 8 deletions scripts/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
}
}

Expand Down Expand Up @@ -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,
})

Expand All @@ -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)
Expand Down Expand Up @@ -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 } : {}) },
Expand Down Expand Up @@ -434,7 +434,7 @@ export async function createRelay(options: RelayOptions): Promise<Relay> {
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()
}
Expand Down Expand Up @@ -490,7 +490,7 @@ export async function createRelay(options: RelayOptions): Promise<Relay> {
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,
})
Expand Down
58 changes: 56 additions & 2 deletions web/components/agent-visualizer/session-tabs.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -10,6 +10,18 @@ interface SessionTabsProps {
sessionsWithActivity: Set<string>
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<string, string> {
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({
Expand All @@ -18,8 +30,29 @@ export function SessionTabs({
sessionsWithActivity,
onSelectSession,
onCloseSession,
showFolder,
}: SessionTabsProps) {
const buttonRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
const [customNames, setCustomNames] = useState<Record<string, string>>(loadCustomNames)
const [editingId, setEditingId] = useState<string | null>(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)
Expand All @@ -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,
Expand All @@ -63,7 +102,22 @@ export function SessionTabs({
animation: hasActivity && !isSelected ? 'pulse 1.5s infinite' : 'none',
}}
/>
{session.label}
{editingId === session.id ? (
<input
autoFocus
value={draft}
placeholder={displayLabel(session)}
onChange={(e) => 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)}
<span
className="ml-0.5 opacity-0 group-hover:opacity-60 transition-opacity cursor-pointer"
style={{ color: COLORS.tabClose, fontSize: 8, lineHeight: '10px' }}
Expand Down
1 change: 1 addition & 0 deletions web/components/agent-visualizer/top-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const TopBar = memo(function TopBar({
sessionsWithActivity={sessionsWithActivity}
onSelectSession={onSelectSession}
onCloseSession={onCloseSession}
showFolder={!isVSCode || new Set(sessions.map(s => s.cwd).filter(Boolean)).size > 1}
/>
</div>
)}
Expand Down
4 changes: 2 additions & 2 deletions web/hooks/use-vscode-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions web/lib/bridge-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface AgentEvent {
export interface SessionInfo {
id: string
label: string
cwd?: string
status: 'active' | 'completed'
startTime: number
lastActivityTime: number
Expand Down
Loading