diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b9bb9b6..e98ef44 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -388,8 +388,9 @@ watch(() => route.name, (newPage) => { - + + +
+ + + + + + {{ error }} +
+
@@ -333,6 +345,24 @@ function handleResume(sessionId: string, agent: AgentName) { border-bottom-color: #6366f1; } +/* Error banner */ +.nsm-error { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(239, 68, 68, 0.12); + border-bottom: 1px solid rgba(239, 68, 68, 0.3); + color: #f87171; + font-size: 12px; + flex-shrink: 0; +} + +.nsm-error svg { + flex-shrink: 0; + color: #ef4444; +} + /* Body */ .nsm-body { flex: 1; diff --git a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts index 985cb9b..4be58bd 100644 --- a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts +++ b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts @@ -90,6 +90,10 @@ export function useTranscriptDebug() { const awaitingNewSession = ref(false) const pendingPrompt = ref(null) + const lastStartError = ref(null) + + // Guard: when a FAB click triggers switchToTerminal before init(), init() must not overwrite + const pendingSwitchTarget = ref(null) // ── Server registry HTTP helpers ── @@ -151,8 +155,10 @@ export function useTranscriptDebug() { if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown error' })) console.error(`[TranscriptDebug] Failed to create terminal: ${err.error}`) + lastStartError.value = err.error || 'Failed to create terminal' return } + lastStartError.value = null const { ephemeralSessionId } = await res.json() @@ -235,6 +241,7 @@ export function useTranscriptDebug() { async function switchToTerminal(transcriptSessionId: string) { if (transcriptSessionId === activeTerminalSessionId.value) return + pendingSwitchTarget.value = transcriptSessionId transitioning.value = true transitionError.value = null @@ -281,6 +288,7 @@ export function useTranscriptDebug() { // Connect to the terminal connectToTerminal(transcriptSessionId) + pendingSwitchTarget.value = null transitioning.value = false } @@ -561,6 +569,14 @@ export function useTranscriptDebug() { } async function init() { + // If a FAB click already triggered switchToTerminal(), let it handle the connection. + // Only load sessions and connect realtime — don't auto-connect to a terminal. + if (pendingSwitchTarget.value) { + await fetchSessions() + connectRealtime() + return + } + // Derive initial state from server registry: if there's an alive terminal, sync to it const aliveEntry = serverRegistry.value.find(e => e.alive) if (aliveEntry) { @@ -569,6 +585,12 @@ export function useTranscriptDebug() { await fetchSessions() + // Re-check after async gap — switchToTerminal() may have started during fetchSessions + if (pendingSwitchTarget.value) { + connectRealtime() + return + } + let targetSession: string | null = null if (aliveEntry) { @@ -589,6 +611,12 @@ export function useTranscriptDebug() { selectedSessionId.value = targetSession await fetchSessionContent(targetSession) + // Final check before connecting — bail if user clicked a FAB during content fetch + if (pendingSwitchTarget.value) { + connectRealtime() + return + } + // Connect to existing terminal if one exists (clients never create terminals — only the server does) const existing = serverRegistry.value.find( e => e.transcriptSessionId === targetSession && e.alive @@ -968,6 +996,7 @@ export function useTranscriptDebug() { terminalReady, hookMeta, awaitingNewSession, + lastStartError, openTerminals, activeTerminalSessionId, init, diff --git a/server/services/terminal.ts b/server/services/terminal.ts index f1d54eb..5b00cc6 100644 --- a/server/services/terminal.ts +++ b/server/services/terminal.ts @@ -1,7 +1,33 @@ import { spawn, type IPty } from '@skitee3000/bun-pty' +import { existsSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES, MAX_TERMINALS } from '../config' import { sessionState, type SessionStatePatch } from './session-state' +// Agent transcript directories (mirrored from transcript-debug.ts) +const AGENT_TRANSCRIPT_DIRS: Record = { + ejecutor: join(WORKING_DIR, '.claude-ejecutor', 'projects'), + nucleo000: join(WORKING_DIR, '.claude-nucleo000', 'projects'), + claude: join(homedir(), '.claude', 'projects') +} + +const PROJECT_HASH = 'C--Users-jodar-agent-ui' + +function getTranscriptProjectDir(agent: string): string | null { + const baseDir = AGENT_TRANSCRIPT_DIRS[agent] + if (!baseDir || !existsSync(baseDir)) return null + const exact = join(baseDir, PROJECT_HASH) + if (existsSync(exact)) return exact + return null +} + +function transcriptSessionExists(agent: string, sessionId: string): boolean { + const projectDir = getTranscriptProjectDir(agent) + if (!projectDir) return false + return existsSync(join(projectDir, `${sessionId}.jsonl`)) +} + interface TerminalSession { id: string pty: IPty @@ -400,6 +426,18 @@ export function startTerminalServer() { ) } + // Validate transcript session exists before resuming + const tsId = body.transcriptSessionId + if (tsId && tsId !== '__new__' && body.agent) { + if (!transcriptSessionExists(body.agent, tsId)) { + console.warn(`[Terminal] Transcript session not found: ${tsId} (agent: ${body.agent})`) + return Response.json( + { error: `Transcript session "${tsId}" not found. It may have been deleted.` }, + { status: 404, headers: corsHeaders } + ) + } + } + // Generate ephemeralSessionId server-side const ephemeralSessionId = `pty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`