fix: clients sync to server terminals instead of creating new ones

- Remove auto-creation of terminal sessions from init/selectSession/switchAgent
- Clients only connect to existing alive terminals from server registry
- Remove localStorage persistence (agent/sessionId) — state derived from server
- Refine session-state types: new AgentStatus values, LastError interface
- UI improvements: AgentBadge, ChatContainer, UserInput, BashCard updates
- Simplify claude-hook routes, update session-state service
This commit is contained in:
2026-02-20 22:26:17 -06:00
parent 653c4e6d23
commit a6c68f1b9e
17 changed files with 1036 additions and 189 deletions

View File

@@ -1,14 +1,43 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import type { TerminalSlot } from '@/types/transcript-debug'
import { useSessionState, type AgentStatus } from '@/stores/session-state'
const props = defineProps<{
agent: string
connected: boolean
terminals: TerminalSlot[]
activeSessionId: string | null
model?: string
version?: string
}>()
const sessionStore = useSessionState()
const STATUS_COLORS: Record<AgentStatus, string> = {
idle: '#6b7280',
thinking: '#60a5fa',
reading: '#22d3ee',
writing: '#4ade80',
toolUse: '#fbbf24',
permissionRequest: '#fb923c',
interrupted: '#f87171',
error: '#f87171',
sessionStart: '#60a5fa',
sessionEnd: '#6b7280',
}
const agentStatusColor = computed(() => {
const state = sessionStore.agents[props.agent]
if (!state) return null
return STATUS_COLORS[state.status] || '#6b7280'
})
const agentStatusClass = computed(() => {
const state = sessionStore.agents[props.agent]
return state?.status || 'idle'
})
const activeIndex = computed(() => {
if (!props.activeSessionId) return -1
return props.terminals.findIndex(t => t.sessionId === props.activeSessionId)
@@ -60,7 +89,8 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
<template>
<div ref="wrapperRef" class="agent-badge-wrapper" :class="{ connected }" @click="toggle">
<span v-if="activeSlot" class="state-dot badge-dot" :style="{ background: slotColor(activeSlot) }" />
<span v-if="agentStatusColor" class="state-dot status-dot" :class="agentStatusClass" :style="{ background: agentStatusColor }" />
<span v-else-if="activeSlot" class="state-dot badge-dot" :style="{ background: slotColor(activeSlot) }" />
<span class="agent-label">{{ agent }}</span>
<span v-if="terminals.length" class="term-count">{{ activeIndex >= 0 ? `${activeIndex + 1}/${terminals.length}` : terminals.length }}</span>
<svg class="caret" :class="{ open: isOpen }" width="6" height="6" viewBox="0 0 6 6" shape-rendering="crispEdges">
@@ -70,6 +100,10 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
</svg>
<Transition name="dropdown">
<div v-if="isOpen" class="dropdown">
<div v-if="model || version" class="dropdown-meta">
<span v-if="model" class="meta-model">{{ model }}</span>
<span v-if="version" class="meta-version">v{{ version }}</span>
</div>
<div v-if="terminals.length === 0" class="dropdown-item empty">No terminals</div>
<div
v-for="(t, idx) in terminals"
@@ -136,12 +170,22 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
color: #86efac;
}
.badge-dot {
.badge-dot,
.status-dot {
width: 5px;
height: 5px;
flex-shrink: 0;
}
.status-dot.thinking {
animation: pulse-badge 1.5s ease-in-out infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.term-count {
font-size: 8px;
font-weight: 700;
@@ -188,6 +232,30 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
padding: 3px 0;
}
.dropdown-meta {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px 3px;
border-bottom: 1px solid rgba(99, 102, 241, 0.1);
margin-bottom: 2px;
}
.meta-model {
font-size: 8px;
font-family: 'Courier New', monospace;
color: rgba(165, 180, 252, 0.7);
background: rgba(99, 102, 241, 0.12);
padding: 0 4px;
line-height: 14px;
}
.meta-version {
font-size: 8px;
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.3);
}
.dropdown-item {
padding: 5px 10px;
font-size: 9px;

View File

@@ -10,6 +10,7 @@ import type {
SectionSummary
} from '@/types/transcript-debug'
import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal'
import { useSessionState, type AgentStatus } from '@/stores/session-state'
import UserMessageBubble from './UserMessageBubble.vue'
import AssistantMessageBubble from './AssistantMessageBubble.vue'
import ProgressEvent from './ProgressEvent.vue'
@@ -21,7 +22,7 @@ import ResumeTerminalButton from './ResumeTerminalButton.vue'
const props = defineProps<{
conversation: ParsedConversation
processing?: boolean
terminalReady?: boolean
terminalReady?: boolean | null
terminal?: EphemeralTerminal | null
showSelector?: boolean
agents?: { id: AgentName; label: string }[]
@@ -44,6 +45,36 @@ const props = defineProps<{
hookPermissionMode?: string
}>()
// ── Agent status display ──
const sessionStore = useSessionState()
const STATUS_DISPLAY: Record<AgentStatus, { color: string; label: string }> = {
idle: { color: '#6b7280', label: 'Available' },
thinking: { color: '#60a5fa', label: 'Thinking...' },
reading: { color: '#22d3ee', label: 'Reading' },
writing: { color: '#4ade80', label: 'Writing' },
toolUse: { color: '#fbbf24', label: 'Using' },
permissionRequest: { color: '#fb923c', label: 'Waiting approval' },
interrupted: { color: '#f87171', label: 'Interrupted' },
error: { color: '#f87171', label: 'Error' },
sessionStart: { color: '#60a5fa', label: 'Starting' },
sessionEnd: { color: '#6b7280', label: 'Session ended' },
}
const agentStatus = computed(() => {
const agent = props.selectedAgent
if (!agent) return null
const state = sessionStore.agents[agent]
if (!state) return null
const display = STATUS_DISPLAY[state.status] || STATUS_DISPLAY.idle
const toolSuffix = state.currentTool ? ` ${state.currentTool.name}` : ''
return {
...display,
label: display.label + toolSuffix,
status: state.status,
}
})
// ── Derived display values ──
const permissionMode = computed(() => props.hookPermissionMode || '')
const fullCwd = computed(() => props.conversation.metadata.cwd || '')
@@ -629,8 +660,12 @@ function formatDuration(start: string, end: string): string {
/>
<div class="status-bar">
<span v-if="agentStatus" class="agent-status-indicator" :title="agentStatus.label">
<span class="agent-status-dot" :class="agentStatus.status" :style="{ background: agentStatus.color }" />
<span class="agent-status-label">{{ agentStatus.label }}</span>
</span>
<span v-if="permissionMode" class="meta-badge mode">{{ permissionMode }}</span>
<span v-if="displayCwd" class="meta-badge origin" :title="fullCwd">{{ displayCwd }}</span>
<span v-if="displayCwd" class="meta-badge origin" tabindex="0">{{ fullCwd }}</span>
<span class="meta-count">{{ conversation.messages.length }} msgs</span>
<button class="new-session-status-btn" @click="emit('createSession')" title="New session">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@@ -888,9 +923,21 @@ function formatDuration(start: string, end: string): string {
.meta-badge.origin {
background: rgba(99, 102, 241, 0.1);
color: var(--accent, #6366f1);
max-width: 120px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
cursor: default;
transition: max-width 0.35s ease;
outline: none;
}
.meta-badge.origin:hover,
.meta-badge.origin:focus {
max-width: 400px;
direction: ltr;
background: rgba(99, 102, 241, 0.18);
}
.meta-duration {
@@ -1300,4 +1347,40 @@ function formatDuration(start: string, end: string): string {
flex-shrink: 0;
}
/* ── Agent status indicator ── */
.agent-status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.agent-status-dot {
width: 5px;
height: 5px;
flex-shrink: 0;
border-radius: 0;
transition: background 0.2s;
}
.agent-status-dot.thinking {
animation: pulse-status 1.5s ease-in-out infinite;
}
@keyframes pulse-status {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.agent-status-label {
font-size: 9px;
font-weight: 600;
font-family: 'Courier New', monospace;
color: var(--text-muted, rgba(255,255,255,0.4));
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -4,7 +4,7 @@ import VoiceMicButton from './VoiceMicButton.vue'
const props = defineProps<{
processing?: boolean
terminalReady?: boolean
terminalReady?: boolean | null // null = no terminal, false = starting, true = ready
voiceTranscript?: string
isRecording?: boolean
voiceMode?: 'web' | 'whisper'
@@ -25,12 +25,15 @@ const emit = defineEmits<{
const input = ref('')
const notReady = computed(() => props.terminalReady === false)
const isDisabled = computed(() => !input.value.trim() || props.processing || notReady.value)
// terminalReady: null = no terminal (show "no terminal"), false = starting, true = ready
const terminalStarting = computed(() => props.terminalReady === false) // explicitly false, not null
const noTerminal = computed(() => props.terminalReady === null)
const canSend = computed(() => props.terminalReady === true && !props.processing)
const isDisabled = computed(() => !input.value.trim() || !canSend.value)
function handleSend() {
const msg = input.value.trim()
if (!msg || props.processing || notReady.value) return
if (!msg || !canSend.value) return
emit('send', msg)
input.value = ''
}
@@ -63,7 +66,7 @@ watch(() => props.voiceTranscript, (newText) => {
<template>
<div class="user-input">
<div v-if="notReady" class="processing-bar starting">
<div v-if="terminalStarting" class="processing-bar starting">
<span class="processing-dots">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</span>
@@ -75,14 +78,14 @@ watch(() => props.voiceTranscript, (newText) => {
</span>
<span>Agent is processing...</span>
</div>
<div class="input-container" :class="{ disabled: processing || notReady }">
<div class="input-container" :class="{ disabled: !canSend }">
<textarea
v-model="input"
class="input-field"
:style="{ maxHeight: maxH }"
:placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
:placeholder="terminalStarting ? 'Starting terminal...' : noTerminal ? 'No terminal — use + to create session' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
rows="1"
:disabled="processing || notReady"
:disabled="!canSend"
@keydown="handleKeydown"
/>
<VoiceMicButton
@@ -90,7 +93,7 @@ watch(() => props.voiceTranscript, (newText) => {
:is-recording="isRecording ?? false"
:voice-mode="voiceMode"
:whisper-status="whisperStatus ?? 'offline'"
:disabled="processing || notReady"
:disabled="!canSend"
@start="emit('startRecording')"
@stop="emit('stopRecording')"
/>

View File

@@ -19,9 +19,14 @@ const highlightedCommand = computed(() => highlightCode(command.value, 'bash'))
const expanded = ref(false)
const descPreview = computed(() => {
const d = description.value.trim()
return d.length > 50 ? d.slice(0, 50) + '...' : d
})
const cmdPreview = computed(() => {
const c = command.value.replace(/\n/g, ' ').trim()
return c.length > 60 ? c.slice(0, 60) + '...' : c
return c.length > 40 ? c.slice(0, 40) + '...' : c
})
</script>
@@ -35,6 +40,7 @@ const cmdPreview = computed(() => {
</svg>
</span>
<span class="card-label">Bash</span>
<span v-if="description" class="desc-preview" :title="description">{{ descPreview }}</span>
<code class="cmd-preview" :title="command">$ {{ cmdPreview }}</code>
<span v-if="runInBackground" class="info-badge">bg</span>
<span v-if="timeout" class="info-badge">{{ timeout }}ms</span>
@@ -90,7 +96,7 @@ const cmdPreview = computed(() => {
}
.bash-card.error .card-label { color: rgba(239, 68, 68, 0.7); }
.cmd-preview {
.desc-preview {
font-size: 11px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
@@ -101,6 +107,17 @@ const cmdPreview = computed(() => {
opacity: 0.7;
}
.cmd-preview {
font-size: 11px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
opacity: 0.4;
}
.info-badge, .error-badge {
font-size: 9px;
padding: 0.05rem 0.3rem;

View File

@@ -27,39 +27,14 @@ import type {
export function useTranscriptDebug() {
// ── Centralized session state ──
const sessionStore = useSessionState()
// ── Persistence ──
const STORAGE_KEY = 'transcript-debug-selection'
function restoreState(): { agent: AgentName; sessionId: string | null } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const data = JSON.parse(raw)
if (data.agent === 'ejecutor' || data.agent === 'nucleo000' || data.agent === 'claude') {
return { agent: data.agent, sessionId: data.sessionId || null }
}
} catch {}
return null
}
function saveState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
agent: selectedAgent.value,
sessionId: selectedSessionId.value
}))
} catch {}
}
const saved = restoreState()
const selectedAgent = ref<AgentName>(saved?.agent || 'ejecutor')
const selectedAgent = ref<AgentName>('ejecutor')
const sessions = ref<SessionInfo[]>([])
const selectedSessionId = ref<string | null>(saved?.sessionId || null)
const selectedSessionId = ref<string | null>(null)
const rawContent = ref<string>('')
const conversation = ref<ParsedConversation | null>(null)
const loading = ref(false)
const transitioning = ref(!!saved?.sessionId)
const transitioning = ref(false)
const error = ref<string | null>(null)
const isRealtime = ref(false)
@@ -92,7 +67,11 @@ export function useTranscriptDebug() {
return localTerminals.get(activeTerminalSessionId.value) ?? null
})
const terminalReady = computed(() => ephemeral.value?.state.value === 'running')
// null = no terminal exists, false = terminal connecting/starting, true = running
const terminalReady = computed<boolean | null>(() => {
if (!ephemeral.value) return null
return ephemeral.value.state.value === 'running'
})
// Computed: terminal list for AgentBadge dropdown (from server registry)
const openTerminals = computed<TerminalSlot[]>(() =>
@@ -276,7 +255,6 @@ export function useTranscriptDebug() {
// Load the target session's transcript
selectedSessionId.value = transcriptSessionId
saveState()
await fetchSessionContent(transcriptSessionId)
// Connect to the terminal
@@ -386,7 +364,6 @@ export function useTranscriptDebug() {
}
selectedSessionId.value = changedSessionId
saveState()
await fetchSessionContent(changedSessionId)
// Auto-send queued initial prompt
@@ -449,7 +426,7 @@ export function useTranscriptDebug() {
// Clear optimistic processing if server state is now idle
if (optimisticProcessing.value) {
const agentState = sessionStore.agents[selectedAgent.value]
if (agentState && (agentState.status === 'idle' || agentState.status === 'sessionStart')) {
if (agentState && ['idle', 'sessionStart', 'sessionEnd'].includes(agentState.status)) {
optimisticProcessing.value = false
}
}
@@ -498,13 +475,17 @@ export function useTranscriptDebug() {
if (optimisticProcessing.value) return true
const agentState = sessionStore.agents[selectedAgent.value]
if (!agentState) return false
return agentState.status !== 'idle' && agentState.status !== 'sessionStart'
return !['idle', 'sessionStart', 'sessionEnd'].includes(agentState.status)
})
async function sendPrompt(text: string) {
if (!text.trim() || !selectedSessionId.value) return
if (!ephemeral.value || ephemeral.value.state.value !== 'running') {
if (!ephemeral.value) {
error.value = 'No terminal — use + to create a new session'
return
}
if (ephemeral.value.state.value !== 'running') {
error.value = 'Terminal not ready — wait for it to start'
return
}
@@ -557,38 +538,43 @@ export function useTranscriptDebug() {
}
async function init() {
await fetchSessions()
let targetSession = selectedSessionId.value
// Validate saved session still exists
if (targetSession && !sessions.value.some(s => s.id === targetSession)) {
targetSession = null
// 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) {
selectedAgent.value = (aliveEntry.agent as AgentName) || 'ejecutor'
}
// Fall back to newest
await fetchSessions()
let targetSession: string | null = null
if (aliveEntry) {
// Sync to the alive terminal's transcript session
targetSession = aliveEntry.transcriptSessionId
// Validate it exists in sessions list
if (targetSession && !sessions.value.some(s => s.id === targetSession)) {
targetSession = null
}
}
// Fall back to newest session
if (!targetSession && sessions.value.length > 0) {
targetSession = sessions.value[0].id
}
if (targetSession) {
selectedSessionId.value = targetSession
saveState()
await fetchSessionContent(targetSession)
// Check if there's already a terminal for this session in the registry
// 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 => e.transcriptSessionId === targetSession && e.alive
)
if (existing && existing.alive) {
// Connect to existing terminal (may have been created by another client)
if (existing) {
connectToTerminal(targetSession)
} else {
startTerminal(targetSession)
}
} else {
selectedSessionId.value = null
saveState()
}
transitioning.value = false
@@ -617,20 +603,17 @@ export function useTranscriptDebug() {
selectedSessionId.value = sessions.value[0].id
await fetchSessionContent(sessions.value[0].id)
// Check if there's already a terminal for this session
// Connect to existing terminal if one exists (clients never create terminals)
const existing = serverRegistry.value.find(
e => e.transcriptSessionId === sessions.value[0].id && e.alive
)
if (existing) {
connectToTerminal(sessions.value[0].id)
} else {
startTerminal(sessions.value[0].id)
}
} else {
selectedSessionId.value = null
}
saveState()
transitioning.value = false
loading.value = false
}
@@ -647,20 +630,17 @@ export function useTranscriptDebug() {
await new Promise(r => setTimeout(r, 150))
selectedSessionId.value = sessionId
saveState()
await fetchSessionContent(sessionId)
transitioning.value = false
loading.value = false
// Check if there's already a terminal for this session in the registry
// Connect to existing terminal if one exists (clients never create terminals)
const existing = serverRegistry.value.find(
e => e.transcriptSessionId === sessionId && e.alive
)
if (existing) {
connectToTerminal(sessionId)
} else {
startTerminal(sessionId)
}
}

View File

@@ -42,7 +42,10 @@ export interface EphemeralTerminal {
export function useEphemeralTerminal(
command: string,
existingSessionId?: string
existingSessionId?: string,
options?: {
onExtraMessage?: (msg: Record<string, any>) => void
}
): EphemeralTerminal {
const containerRef = ref<HTMLElement | null>(null)
const connected = ref(false)
@@ -141,6 +144,9 @@ export function useEphemeralTerminal(
case 'error':
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
break
default:
options?.onExtraMessage?.(msg)
break
}
} catch { /* ignore parse errors */ }
}

View File

@@ -20,6 +20,7 @@ const {
processing,
ephemeral,
terminalReady,
hookMeta,
init,
switchAgent,
selectSession,
@@ -144,6 +145,7 @@ onUnmounted(() => {
:voice-transcript="voiceTranscript + voiceInterim"
:last-audio-url="lastAudioUrl"
:is-playing-audio="isPlayingAudio"
:hook-permission-mode="hookMeta.permissionMode"
@send="handleSend"
@create-session="handleCreateSession"
@start-recording="voice.startRecording()"

View File

@@ -5,14 +5,15 @@ import { ref, computed } from 'vue'
export type AgentStatus =
| 'idle'
| 'processing'
| 'thinking'
| 'reading'
| 'writing'
| 'toolUse'
| 'toolDone'
| 'permissionRequest'
| 'notification'
| 'interrupted'
| 'error'
| 'sessionStart'
| 'sessionEnd'
export interface ActiveTool {
name: string
@@ -48,6 +49,13 @@ export interface AgentTerminalInfo {
connectedClients: number
}
export interface LastError {
tool: string
message: string
interrupted: boolean
timestamp: number
}
export interface AgentSessionState {
agent: string
sessionId: string | null
@@ -59,6 +67,7 @@ export interface AgentSessionState {
currentTool: ActiveTool | null
lastActivity: number
lastStopResponse: string | null
lastError: LastError | null
pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo
notifications: SessionNotification[]
@@ -130,6 +139,14 @@ export const useSessionState = defineStore('session-state', () => {
)
)
const isProcessing = computed(() =>
agentList.value.some(a => !['idle', 'sessionStart', 'sessionEnd'].includes(a.status))
)
const hasErrors = computed(() =>
agentList.value.some(a => a.status === 'error' || a.status === 'interrupted')
)
const visibleNotifications = computed(() => {
const now = Date.now()
return agentList.value
@@ -199,6 +216,8 @@ export const useSessionState = defineStore('session-state', () => {
anyPendingApprovals,
totalPendingApprovals,
allPendingApprovals,
isProcessing,
hasErrors,
visibleNotifications,
handleMessage,
respondApproval,