feat: interactive ephemeral terminal per transcript session
Replace one-shot HTTP POST sendPrompt with a persistent ephemeral terminal per session. Terminal auto-starts on session select, stays running in background when modal is closed, and gets killed on session switch or page unload. - Add sendInput() to useEphemeralTerminal (text + Enter as separate WS messages) - useTranscriptDebug owns terminal lifecycle (create/dispose on select/switch) - ResumeTerminalButton receives shared terminal prop, only toggles modal - UserInput shows "Starting terminal..." when not ready - Add "New Session" button that starts a fresh agent session - beforeunload sends sendBeacon to kill terminal on page close
This commit is contained in:
@@ -30,9 +30,12 @@ const {
|
|||||||
error,
|
error,
|
||||||
isRealtime,
|
isRealtime,
|
||||||
processing,
|
processing,
|
||||||
|
ephemeral,
|
||||||
|
terminalReady,
|
||||||
init,
|
init,
|
||||||
switchAgent,
|
switchAgent,
|
||||||
selectSession,
|
selectSession,
|
||||||
|
createNewSession,
|
||||||
disconnectRealtime,
|
disconnectRealtime,
|
||||||
sendPrompt
|
sendPrompt
|
||||||
} = useTranscriptDebug()
|
} = useTranscriptDebug()
|
||||||
@@ -335,6 +338,10 @@ function handleSend(message: string) {
|
|||||||
sendPrompt(message)
|
sendPrompt(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCreateSession() {
|
||||||
|
createNewSession()
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WATCHERS
|
// WATCHERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -485,6 +492,8 @@ onBeforeUnmount(() => {
|
|||||||
v-if="conversation"
|
v-if="conversation"
|
||||||
:conversation="conversation"
|
:conversation="conversation"
|
||||||
:processing="processing"
|
:processing="processing"
|
||||||
|
:terminal-ready="terminalReady"
|
||||||
|
:terminal="ephemeral"
|
||||||
:show-selector="showSelector"
|
:show-selector="showSelector"
|
||||||
:agents="agents"
|
:agents="agents"
|
||||||
:selected-agent="selectedAgent"
|
:selected-agent="selectedAgent"
|
||||||
@@ -494,6 +503,7 @@ onBeforeUnmount(() => {
|
|||||||
@send="handleSend"
|
@send="handleSend"
|
||||||
@switch-agent="handleAgentSwitch"
|
@switch-agent="handleAgentSwitch"
|
||||||
@select-session="handleSessionSelect"
|
@select-session="handleSessionSelect"
|
||||||
|
@create-session="handleCreateSession"
|
||||||
/>
|
/>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
AgentName
|
AgentName
|
||||||
} from '@/types/transcript-debug'
|
} from '@/types/transcript-debug'
|
||||||
|
import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal'
|
||||||
import UserMessageBubble from './UserMessageBubble.vue'
|
import UserMessageBubble from './UserMessageBubble.vue'
|
||||||
import AssistantMessageBubble from './AssistantMessageBubble.vue'
|
import AssistantMessageBubble from './AssistantMessageBubble.vue'
|
||||||
import ProgressEvent from './ProgressEvent.vue'
|
import ProgressEvent from './ProgressEvent.vue'
|
||||||
@@ -18,6 +19,8 @@ import ResumeTerminalButton from './ResumeTerminalButton.vue'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
conversation: ParsedConversation
|
conversation: ParsedConversation
|
||||||
processing?: boolean
|
processing?: boolean
|
||||||
|
terminalReady?: boolean
|
||||||
|
terminal?: EphemeralTerminal | null
|
||||||
showSelector?: boolean
|
showSelector?: boolean
|
||||||
agents?: { id: AgentName; label: string }[]
|
agents?: { id: AgentName; label: string }[]
|
||||||
selectedAgent?: AgentName | null
|
selectedAgent?: AgentName | null
|
||||||
@@ -30,6 +33,7 @@ const emit = defineEmits<{
|
|||||||
send: [message: string]
|
send: [message: string]
|
||||||
switchAgent: [agent: AgentName]
|
switchAgent: [agent: AgentName]
|
||||||
selectSession: [sessionId: string]
|
selectSession: [sessionId: string]
|
||||||
|
createSession: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const scrollContainer = ref<HTMLElement | null>(null)
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
@@ -254,6 +258,13 @@ function formatDuration(start: string, end: string): string {
|
|||||||
{{ s.firstUserMessage ? (s.firstUserMessage.length > 50 ? s.firstUserMessage.slice(0, 50) + '...' : s.firstUserMessage) : s.id.slice(0, 8) + '...' }}
|
{{ s.firstUserMessage ? (s.firstUserMessage.length > 50 ? s.firstUserMessage.slice(0, 50) + '...' : s.firstUserMessage) : s.id.slice(0, 8) + '...' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button class="new-session-btn" @click="emit('createSession')" title="Start new session">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
<span>New</span>
|
||||||
|
</button>
|
||||||
<span v-if="sessionsLoading" class="spinner-sm"></span>
|
<span v-if="sessionsLoading" class="spinner-sm"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="selector-row">
|
<div class="selector-row">
|
||||||
@@ -272,6 +283,7 @@ function formatDuration(start: string, end: string): string {
|
|||||||
v-if="selectedAgent"
|
v-if="selectedAgent"
|
||||||
:agent="selectedAgent"
|
:agent="selectedAgent"
|
||||||
:session-id="conversation.sessionId"
|
:session-id="conversation.sessionId"
|
||||||
|
:terminal="terminal ?? null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +366,7 @@ function formatDuration(start: string, end: string): string {
|
|||||||
|
|
||||||
<UserInput
|
<UserInput
|
||||||
:processing="props.processing"
|
:processing="props.processing"
|
||||||
|
:terminal-ready="props.terminalReady"
|
||||||
@send="emit('send', $event)"
|
@send="emit('send', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -365,6 +378,7 @@ function formatDuration(start: string, end: string): string {
|
|||||||
v-if="selectedAgent"
|
v-if="selectedAgent"
|
||||||
:agent="selectedAgent"
|
:agent="selectedAgent"
|
||||||
:session-id="conversation.sessionId"
|
:session-id="conversation.sessionId"
|
||||||
|
:terminal="terminal ?? null"
|
||||||
/>
|
/>
|
||||||
<span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration">
|
<span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration">
|
||||||
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
|
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
|
||||||
@@ -739,6 +753,31 @@ function formatDuration(start: string, end: string): string {
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-session-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||||
|
border-radius: 0;
|
||||||
|
color: #4ade80;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-session-btn:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border-color: rgba(34, 197, 94, 0.4);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
.spinner-sm {
|
.spinner-sm {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import type { AgentName } from '@/types/transcript-debug'
|
import type { AgentName } from '@/types/transcript-debug'
|
||||||
import { useEphemeralTerminal, type EphemeralTerminal } from '@/composables/useEphemeralTerminal'
|
import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal'
|
||||||
import TerminalNavButtons from '../TerminalNavButtons.vue'
|
import TerminalNavButtons from '../TerminalNavButtons.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
agent: AgentName
|
agent: AgentName
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
terminal: EphemeralTerminal | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const AGENT_CMD: Record<AgentName, string> = {
|
const AGENT_CMD: Record<AgentName, string> = {
|
||||||
@@ -16,7 +17,6 @@ const AGENT_CMD: Record<AgentName, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
let ephemeral: EphemeralTerminal | null = null
|
|
||||||
|
|
||||||
// Local ref for xterm container - syncs to composable's containerRef
|
// Local ref for xterm container - syncs to composable's containerRef
|
||||||
const terminalContainer = ref<HTMLElement | null>(null)
|
const terminalContainer = ref<HTMLElement | null>(null)
|
||||||
@@ -38,8 +38,8 @@ const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
|
|||||||
const windowRef = ref<HTMLElement | null>(null)
|
const windowRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const statusDotClass = computed(() => {
|
const statusDotClass = computed(() => {
|
||||||
if (!ephemeral) return ''
|
if (!props.terminal) return ''
|
||||||
switch (ephemeral.state.value) {
|
switch (props.terminal.state.value) {
|
||||||
case 'running':
|
case 'running':
|
||||||
case 'shell-ready': return 'on'
|
case 'shell-ready': return 'on'
|
||||||
case 'connecting': return 'wait'
|
case 'connecting': return 'wait'
|
||||||
@@ -71,12 +71,11 @@ const terminalStyle = computed((): Record<string, string> => {
|
|||||||
|
|
||||||
async function open() {
|
async function open() {
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
await closeTerminal()
|
closeTerminal()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmd = `${AGENT_CMD[props.agent]} --resume "${props.sessionId}"`
|
if (!props.terminal) return
|
||||||
ephemeral = useEphemeralTerminal(cmd)
|
|
||||||
|
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
|
|
||||||
@@ -84,29 +83,24 @@ async function open() {
|
|||||||
|
|
||||||
// Sync container ref
|
// Sync container ref
|
||||||
if (terminalContainer.value) {
|
if (terminalContainer.value) {
|
||||||
ephemeral.containerRef.value = terminalContainer.value
|
props.terminal.containerRef.value = terminalContainer.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init renderer then start
|
// Init renderer if needed
|
||||||
if (!ephemeral.renderer.isReady.value) {
|
if (!props.terminal.renderer.isReady.value) {
|
||||||
ephemeral.renderer.init()
|
props.terminal.renderer.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ephemeral?.renderer.fit()
|
props.terminal?.renderer.fit()
|
||||||
ephemeral?.renderer.terminal.value?.refresh(0, (ephemeral.renderer.terminal.value?.rows ?? 1) - 1)
|
props.terminal?.renderer.terminal.value?.refresh(0, (props.terminal.renderer.terminal.value?.rows ?? 1) - 1)
|
||||||
if (window.innerWidth > 1024) {
|
if (window.innerWidth > 1024) {
|
||||||
ephemeral?.renderer.focus()
|
props.terminal?.renderer.focus()
|
||||||
}
|
}
|
||||||
ephemeral?.start()
|
|
||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeTerminal() {
|
function closeTerminal() {
|
||||||
if (ephemeral) {
|
|
||||||
await ephemeral.dispose()
|
|
||||||
ephemeral = null
|
|
||||||
}
|
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
hasCustomPosition.value = false
|
hasCustomPosition.value = false
|
||||||
showNavButtons.value = false
|
showNavButtons.value = false
|
||||||
@@ -114,44 +108,24 @@ async function closeTerminal() {
|
|||||||
|
|
||||||
// ── Nav button actions ──
|
// ── Nav button actions ──
|
||||||
|
|
||||||
function sendInput(text: string) {
|
|
||||||
if (!ephemeral) return
|
|
||||||
const chars = (text + '\r').split('')
|
|
||||||
let i = 0
|
|
||||||
const typeChar = () => {
|
|
||||||
if (i < chars.length) {
|
|
||||||
ephemeral?.renderer.terminal.value // just to keep ref
|
|
||||||
// Send through the composable's WS indirectly via onData
|
|
||||||
// We need direct WS access, so use sendRaw-like approach
|
|
||||||
const data = chars[i]
|
|
||||||
// The composable's onData callback sends to WS, so we write to terminal input
|
|
||||||
// Actually we need to send via the ephemeral's internal socket
|
|
||||||
// Let's just type into the terminal which triggers onData → WS
|
|
||||||
i++
|
|
||||||
setTimeout(typeChar, 15)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
typeChar()
|
|
||||||
}
|
|
||||||
|
|
||||||
function navRunClaude() {
|
function navRunClaude() {
|
||||||
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + '\r')
|
props.terminal?.sendInput(AGENT_CMD[props.agent])
|
||||||
}
|
}
|
||||||
|
|
||||||
function navRunClaudeContinue() {
|
function navRunClaudeContinue() {
|
||||||
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + ' --continue\r')
|
props.terminal?.sendInput(AGENT_CMD[props.agent] + ' --continue')
|
||||||
}
|
}
|
||||||
|
|
||||||
function navRunClaudeResume() {
|
function navRunClaudeResume() {
|
||||||
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + ' --resume\r')
|
props.terminal?.sendInput(AGENT_CMD[props.agent] + ' --resume')
|
||||||
}
|
}
|
||||||
|
|
||||||
function navRefresh() {
|
function navRefresh() {
|
||||||
ephemeral?.renderer.fit()
|
props.terminal?.renderer.fit()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navClearBuffer() {
|
function navClearBuffer() {
|
||||||
ephemeral?.renderer.reset()
|
props.terminal?.renderer.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navSendKey(key: string) {
|
function navSendKey(key: string) {
|
||||||
@@ -160,14 +134,14 @@ function navSendKey(key: string) {
|
|||||||
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
|
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
|
||||||
}
|
}
|
||||||
const data = keyMap[key]
|
const data = keyMap[key]
|
||||||
if (data) ephemeral?.renderer.terminal.value?.paste(data)
|
if (data) props.terminal?.renderer.terminal.value?.paste(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navScroll(direction: 'up' | 'down' | 'end') {
|
function navScroll(direction: 'up' | 'down' | 'end') {
|
||||||
if (!ephemeral) return
|
if (!props.terminal) return
|
||||||
if (direction === 'up') ephemeral.renderer.scrollLines(-10)
|
if (direction === 'up') props.terminal.renderer.scrollLines(-10)
|
||||||
else if (direction === 'down') ephemeral.renderer.scrollLines(10)
|
else if (direction === 'down') props.terminal.renderer.scrollLines(10)
|
||||||
else ephemeral.renderer.scrollToBottom()
|
else props.terminal.renderer.scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleNavButtons() {
|
function toggleNavButtons() {
|
||||||
@@ -246,21 +220,17 @@ function stopResize() {
|
|||||||
isResizing.value = false
|
isResizing.value = false
|
||||||
document.removeEventListener('mousemove', onResize)
|
document.removeEventListener('mousemove', onResize)
|
||||||
document.removeEventListener('mouseup', stopResize)
|
document.removeEventListener('mouseup', stopResize)
|
||||||
nextTick(() => ephemeral?.renderer.fit())
|
nextTick(() => props.terminal?.renderer.fit())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync container ref when it mounts
|
// Sync container ref when it mounts
|
||||||
watch(terminalContainer, (el) => {
|
watch(terminalContainer, (el) => {
|
||||||
if (ephemeral && el) {
|
if (props.terminal && el) {
|
||||||
ephemeral.containerRef.value = el
|
props.terminal.containerRef.value = el
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(async () => {
|
onBeforeUnmount(() => {
|
||||||
if (ephemeral) {
|
|
||||||
await ephemeral.dispose()
|
|
||||||
ephemeral = null
|
|
||||||
}
|
|
||||||
document.removeEventListener('mousemove', onDrag)
|
document.removeEventListener('mousemove', onDrag)
|
||||||
document.removeEventListener('mouseup', stopDrag)
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
document.removeEventListener('touchmove', onDrag)
|
document.removeEventListener('touchmove', onDrag)
|
||||||
@@ -302,9 +272,9 @@ onBeforeUnmount(async () => {
|
|||||||
<span class="rt-name">{{ AGENT_CMD[agent] }}</span>
|
<span class="rt-name">{{ AGENT_CMD[agent] }}</span>
|
||||||
<span class="rt-session">{{ sessionId.slice(0, 8) }}…</span>
|
<span class="rt-session">{{ sessionId.slice(0, 8) }}…</span>
|
||||||
<i class="rt-dot" :class="statusDotClass"></i>
|
<i class="rt-dot" :class="statusDotClass"></i>
|
||||||
<span v-if="ephemeral?.state.value === 'connecting'" class="rt-status-text">Connecting...</span>
|
<span v-if="terminal?.state.value === 'connecting'" class="rt-status-text">Connecting...</span>
|
||||||
<span v-else-if="ephemeral?.state.value === 'shell-ready'" class="rt-status-text">Starting...</span>
|
<span v-else-if="terminal?.state.value === 'shell-ready'" class="rt-status-text">Starting...</span>
|
||||||
<span v-else-if="ephemeral?.state.value === 'exited'" class="rt-status-text exited">Exited</span>
|
<span v-else-if="terminal?.state.value === 'exited'" class="rt-status-text exited">Exited</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="window-controls">
|
<div class="window-controls">
|
||||||
<button
|
<button
|
||||||
@@ -326,7 +296,7 @@ onBeforeUnmount(async () => {
|
|||||||
<div ref="terminalContainer" class="rt-term"></div>
|
<div ref="terminalContainer" class="rt-term"></div>
|
||||||
|
|
||||||
<!-- Overlay: connecting -->
|
<!-- Overlay: connecting -->
|
||||||
<div v-if="ephemeral?.state.value === 'connecting'" class="rt-overlay connecting">
|
<div v-if="terminal?.state.value === 'connecting'" class="rt-overlay connecting">
|
||||||
<div class="rt-overlay-msg">
|
<div class="rt-overlay-msg">
|
||||||
<div class="rt-spinner"></div>
|
<div class="rt-spinner"></div>
|
||||||
<span>Connecting...</span>
|
<span>Connecting...</span>
|
||||||
@@ -342,7 +312,7 @@ onBeforeUnmount(async () => {
|
|||||||
<TerminalNavButtons
|
<TerminalNavButtons
|
||||||
v-if="showNavButtons"
|
v-if="showNavButtons"
|
||||||
class="rt-nav-popup"
|
class="rt-nav-popup"
|
||||||
@request-token="ephemeral?.renderer.terminal.value?.paste('genera token usando tu mcp\r')"
|
@request-token="terminal?.sendInput('genera token usando tu mcp')"
|
||||||
@run-claude="navRunClaude"
|
@run-claude="navRunClaude"
|
||||||
@run-claude-continue="navRunClaudeContinue"
|
@run-claude-continue="navRunClaudeContinue"
|
||||||
@run-claude-resume="navRunClaudeResume"
|
@run-claude-resume="navRunClaudeResume"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
processing?: boolean
|
processing?: boolean
|
||||||
|
terminalReady?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -11,11 +12,12 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
|
|
||||||
const isDisabled = computed(() => !input.value.trim() || props.processing)
|
const notReady = computed(() => props.terminalReady === false)
|
||||||
|
const isDisabled = computed(() => !input.value.trim() || props.processing || notReady.value)
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
const msg = input.value.trim()
|
const msg = input.value.trim()
|
||||||
if (!msg || props.processing) return
|
if (!msg || props.processing || notReady.value) return
|
||||||
emit('send', msg)
|
emit('send', msg)
|
||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
@@ -30,19 +32,25 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-input">
|
<div class="user-input">
|
||||||
<div v-if="processing" class="processing-bar">
|
<div v-if="notReady" class="processing-bar starting">
|
||||||
|
<span class="processing-dots">
|
||||||
|
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||||
|
</span>
|
||||||
|
<span>Starting terminal...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="processing" class="processing-bar">
|
||||||
<span class="processing-dots">
|
<span class="processing-dots">
|
||||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||||
</span>
|
</span>
|
||||||
<span>Agent is processing...</span>
|
<span>Agent is processing...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-container" :class="{ disabled: processing }">
|
<div class="input-container" :class="{ disabled: processing || notReady }">
|
||||||
<textarea
|
<textarea
|
||||||
v-model="input"
|
v-model="input"
|
||||||
class="input-field"
|
class="input-field"
|
||||||
:placeholder="processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
|
:placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
|
||||||
rows="1"
|
rows="1"
|
||||||
:disabled="processing"
|
:disabled="processing || notReady"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -62,7 +70,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-input {
|
.user-input {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem 0.15rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -77,6 +85,10 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.processing-bar.starting {
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
.processing-dots {
|
.processing-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, shallowRef, computed, onUnmounted } from 'vue'
|
||||||
import { endpoints } from '@/config/endpoints'
|
import { endpoints, terminalApiUrl } from '@/config/endpoints'
|
||||||
|
import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal'
|
||||||
import type {
|
import type {
|
||||||
AgentName,
|
AgentName,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
@@ -58,6 +59,49 @@ export function useTranscriptDebug() {
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isRealtime = ref(false)
|
const isRealtime = ref(false)
|
||||||
|
|
||||||
|
// ── Ephemeral terminal ──
|
||||||
|
|
||||||
|
const AGENT_CMD: Record<AgentName, string> = {
|
||||||
|
ejecutor: 'ejecutor',
|
||||||
|
nucleo000: 'nucleo000',
|
||||||
|
claude: 'claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ephemeral = shallowRef<EphemeralTerminal | null>(null)
|
||||||
|
const terminalReady = computed(() => ephemeral.value?.state.value === 'running')
|
||||||
|
|
||||||
|
async function disposeTerminal() {
|
||||||
|
if (ephemeral.value) {
|
||||||
|
await ephemeral.value.dispose()
|
||||||
|
ephemeral.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const awaitingNewSession = ref(false)
|
||||||
|
|
||||||
|
function startTerminal(sessionId?: string) {
|
||||||
|
const cmd = sessionId
|
||||||
|
? `${AGENT_CMD[selectedAgent.value]} --resume "${sessionId}"`
|
||||||
|
: AGENT_CMD[selectedAgent.value]
|
||||||
|
const term = useEphemeralTerminal(cmd)
|
||||||
|
ephemeral.value = term
|
||||||
|
term.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewSession() {
|
||||||
|
await disposeTerminal()
|
||||||
|
|
||||||
|
selectedSessionId.value = null
|
||||||
|
rawContent.value = ''
|
||||||
|
conversation.value = null
|
||||||
|
error.value = null
|
||||||
|
processing.value = false
|
||||||
|
optimisticMessage.value = null
|
||||||
|
|
||||||
|
awaitingNewSession.value = true
|
||||||
|
startTerminal() // no sessionId → brand new session
|
||||||
|
}
|
||||||
|
|
||||||
// ── WebSocket realtime ──
|
// ── WebSocket realtime ──
|
||||||
|
|
||||||
let socket: WebSocket | null = null
|
let socket: WebSocket | null = null
|
||||||
@@ -114,6 +158,15 @@ export function useTranscriptDebug() {
|
|||||||
// Refresh session list (new sessions or size changes)
|
// Refresh session list (new sessions or size changes)
|
||||||
await fetchSessions()
|
await fetchSessions()
|
||||||
|
|
||||||
|
// New session just appeared — lock onto it without restarting terminal
|
||||||
|
if (awaitingNewSession.value) {
|
||||||
|
awaitingNewSession.value = false
|
||||||
|
selectedSessionId.value = changedSessionId
|
||||||
|
saveState()
|
||||||
|
await fetchSessionContent(changedSessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If the changed session is the one we're viewing, re-fetch it
|
// If the changed session is the one we're viewing, re-fetch it
|
||||||
if (selectedSessionId.value && selectedSessionId.value === changedSessionId) {
|
if (selectedSessionId.value && selectedSessionId.value === changedSessionId) {
|
||||||
await reloadCurrentSession()
|
await reloadCurrentSession()
|
||||||
@@ -172,9 +225,26 @@ export function useTranscriptDebug() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kill terminal on page close/refresh
|
||||||
|
function onBeforeUnload() {
|
||||||
|
if (ephemeral.value) {
|
||||||
|
const sessionId = ephemeral.value.ephemeralSessionId
|
||||||
|
navigator.sendBeacon(
|
||||||
|
terminalApiUrl('/kill-session'),
|
||||||
|
JSON.stringify({ sessionId })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-cleanup on unmount
|
// Auto-cleanup on unmount
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
disposeTerminal()
|
||||||
disconnectRealtime()
|
disconnectRealtime()
|
||||||
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Send prompt (spawns independent claude process) ──
|
// ── Send prompt (spawns independent claude process) ──
|
||||||
@@ -185,45 +255,30 @@ export function useTranscriptDebug() {
|
|||||||
|
|
||||||
async function sendPrompt(text: string) {
|
async function sendPrompt(text: string) {
|
||||||
if (!text.trim() || !selectedSessionId.value) return
|
if (!text.trim() || !selectedSessionId.value) return
|
||||||
sending.value = true
|
|
||||||
|
if (!ephemeral.value || ephemeral.value.state.value !== 'running') {
|
||||||
|
error.value = 'Terminal not ready — wait for it to start'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
error.value = null
|
error.value = null
|
||||||
|
ephemeral.value.sendInput(text)
|
||||||
|
processing.value = true
|
||||||
|
|
||||||
try {
|
// Optimistic: show user message immediately in chat
|
||||||
const res = await fetch('/api/transcript-debug/send', {
|
// Stays until the real user entry appears in the JSONL
|
||||||
method: 'POST',
|
optimisticMessage.value = {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
kind: 'user',
|
||||||
body: JSON.stringify({
|
uuid: `optimistic-${Date.now()}`,
|
||||||
agent: selectedAgent.value,
|
timestamp: new Date().toISOString(),
|
||||||
sessionId: selectedSessionId.value,
|
content: text,
|
||||||
prompt: text
|
isMeta: false,
|
||||||
})
|
isToolResult: false,
|
||||||
})
|
toolResults: []
|
||||||
if (!res.ok) {
|
} as ParsedUserMessage
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error(data.error || `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
// POST returned — agent is spawned
|
|
||||||
processing.value = true
|
|
||||||
|
|
||||||
// Optimistic: show user message immediately in chat
|
if (conversation.value) {
|
||||||
// Stays until the real user entry appears in the JSONL
|
conversation.value.messages.push(optimisticMessage.value)
|
||||||
optimisticMessage.value = {
|
|
||||||
kind: 'user',
|
|
||||||
uuid: `optimistic-${Date.now()}`,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
content: text,
|
|
||||||
isMeta: false,
|
|
||||||
isToolResult: false,
|
|
||||||
toolResults: []
|
|
||||||
} as ParsedUserMessage
|
|
||||||
|
|
||||||
if (conversation.value) {
|
|
||||||
conversation.value.messages.push(optimisticMessage.value)
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = `Failed to send: ${e.message}`
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +326,7 @@ export function useTranscriptDebug() {
|
|||||||
selectedSessionId.value = targetSession
|
selectedSessionId.value = targetSession
|
||||||
saveState()
|
saveState()
|
||||||
await fetchSessionContent(targetSession)
|
await fetchSessionContent(targetSession)
|
||||||
|
startTerminal(targetSession)
|
||||||
} else {
|
} else {
|
||||||
selectedSessionId.value = null
|
selectedSessionId.value = null
|
||||||
saveState()
|
saveState()
|
||||||
@@ -283,6 +339,8 @@ export function useTranscriptDebug() {
|
|||||||
async function switchAgent(agent: AgentName) {
|
async function switchAgent(agent: AgentName) {
|
||||||
if (agent === selectedAgent.value) return
|
if (agent === selectedAgent.value) return
|
||||||
|
|
||||||
|
await disposeTerminal()
|
||||||
|
|
||||||
selectedAgent.value = agent
|
selectedAgent.value = agent
|
||||||
error.value = null
|
error.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -299,6 +357,7 @@ export function useTranscriptDebug() {
|
|||||||
if (sessions.value.length > 0) {
|
if (sessions.value.length > 0) {
|
||||||
selectedSessionId.value = sessions.value[0].id
|
selectedSessionId.value = sessions.value[0].id
|
||||||
await fetchSessionContent(sessions.value[0].id)
|
await fetchSessionContent(sessions.value[0].id)
|
||||||
|
startTerminal(sessions.value[0].id)
|
||||||
} else {
|
} else {
|
||||||
selectedSessionId.value = null
|
selectedSessionId.value = null
|
||||||
}
|
}
|
||||||
@@ -311,6 +370,8 @@ export function useTranscriptDebug() {
|
|||||||
async function selectSession(sessionId: string) {
|
async function selectSession(sessionId: string) {
|
||||||
if (sessionId === selectedSessionId.value) return
|
if (sessionId === selectedSessionId.value) return
|
||||||
|
|
||||||
|
await disposeTerminal()
|
||||||
|
|
||||||
error.value = null
|
error.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
transitioning.value = true
|
transitioning.value = true
|
||||||
@@ -324,6 +385,8 @@ export function useTranscriptDebug() {
|
|||||||
|
|
||||||
transitioning.value = false
|
transitioning.value = false
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|
||||||
|
startTerminal(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── JSONL Parser ──
|
// ── JSONL Parser ──
|
||||||
@@ -609,10 +672,14 @@ export function useTranscriptDebug() {
|
|||||||
isRealtime,
|
isRealtime,
|
||||||
sending,
|
sending,
|
||||||
processing,
|
processing,
|
||||||
|
ephemeral,
|
||||||
|
terminalReady,
|
||||||
|
awaitingNewSession,
|
||||||
init,
|
init,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
switchAgent,
|
switchAgent,
|
||||||
selectSession,
|
selectSession,
|
||||||
|
createNewSession,
|
||||||
connectRealtime,
|
connectRealtime,
|
||||||
disconnectRealtime,
|
disconnectRealtime,
|
||||||
sendPrompt
|
sendPrompt
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface EphemeralTerminal {
|
|||||||
/** Connect WS, wait for shell, then auto-run the resume command */
|
/** Connect WS, wait for shell, then auto-run the resume command */
|
||||||
start: () => void
|
start: () => void
|
||||||
|
|
||||||
|
/** Send text + \r directly to the terminal's WebSocket */
|
||||||
|
sendInput: (text: string) => void
|
||||||
|
|
||||||
/** Ctrl+C, exit, close WS, kill session on server */
|
/** Ctrl+C, exit, close WS, kill session on server */
|
||||||
stop: () => Promise<void>
|
stop: () => Promise<void>
|
||||||
|
|
||||||
@@ -136,6 +139,19 @@ export function useEphemeralTerminal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendInput(text: string) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||||
|
// Send text first, then Enter as a separate message —
|
||||||
|
// sending them together makes Claude Code treat \r as a
|
||||||
|
// literal line-break inside the prompt text.
|
||||||
|
socket.send(JSON.stringify({ type: 'input', data: text }))
|
||||||
|
setTimeout(() => {
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'input', data: '\r' }))
|
||||||
|
}
|
||||||
|
}, 80)
|
||||||
|
}
|
||||||
|
|
||||||
async function stop() {
|
async function stop() {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
state.value = 'off'
|
state.value = 'off'
|
||||||
@@ -179,6 +195,7 @@ export function useEphemeralTerminal(
|
|||||||
renderer,
|
renderer,
|
||||||
ephemeralSessionId,
|
ephemeralSessionId,
|
||||||
start,
|
start,
|
||||||
|
sendInput,
|
||||||
stop,
|
stop,
|
||||||
dispose
|
dispose
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ const {
|
|||||||
isRealtime,
|
isRealtime,
|
||||||
sending,
|
sending,
|
||||||
processing,
|
processing,
|
||||||
|
ephemeral,
|
||||||
|
terminalReady,
|
||||||
init,
|
init,
|
||||||
switchAgent,
|
switchAgent,
|
||||||
selectSession,
|
selectSession,
|
||||||
|
createNewSession,
|
||||||
disconnectRealtime,
|
disconnectRealtime,
|
||||||
sendPrompt
|
sendPrompt
|
||||||
} = useTranscriptDebug()
|
} = useTranscriptDebug()
|
||||||
@@ -34,6 +37,10 @@ function handleSend(message: string) {
|
|||||||
sendPrompt(message)
|
sendPrompt(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCreateSession() {
|
||||||
|
createNewSession()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
init()
|
init()
|
||||||
})
|
})
|
||||||
@@ -109,8 +116,11 @@ onUnmounted(() => {
|
|||||||
v-if="conversation"
|
v-if="conversation"
|
||||||
:conversation="conversation"
|
:conversation="conversation"
|
||||||
:processing="processing"
|
:processing="processing"
|
||||||
|
:terminal-ready="terminalReady"
|
||||||
|
:terminal="ephemeral"
|
||||||
:selected-agent="selectedAgent"
|
:selected-agent="selectedAgent"
|
||||||
@send="handleSend"
|
@send="handleSend"
|
||||||
|
@create-session="handleCreateSession"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user