feat: ResumeTerminalButton with ephemeral terminal for transcript-debug
- Add ResumeTerminalButton component with floating terminal modal (drag, resize, glass morphism, TerminalNavButtons bar) - Add useEphemeralTerminal composable for temporary PTY sessions that auto-run `<agent> --resume <sessionId>` and cleanup on close - Add /kill-session POST endpoint to terminal server for ephemeral session cleanup - Integrate button in ChatContainer header (ID row) and status bar - Pass selectedAgent to ChatContainer from TranscriptDebugPage
This commit is contained in:
@@ -5,21 +5,31 @@ import type {
|
||||
ParsedUserMessage,
|
||||
ParsedAssistantMessage,
|
||||
ParsedSystemMessage,
|
||||
ConversationMessage
|
||||
ConversationMessage,
|
||||
AgentName
|
||||
} from '@/types/transcript-debug'
|
||||
import UserMessageBubble from './UserMessageBubble.vue'
|
||||
import AssistantMessageBubble from './AssistantMessageBubble.vue'
|
||||
import ProgressEvent from './ProgressEvent.vue'
|
||||
import SystemMessage from './SystemMessage.vue'
|
||||
import UserInput from './UserInput.vue'
|
||||
import ResumeTerminalButton from './ResumeTerminalButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
conversation: ParsedConversation
|
||||
processing?: boolean
|
||||
showSelector?: boolean
|
||||
agents?: { id: AgentName; label: string }[]
|
||||
selectedAgent?: AgentName | null
|
||||
sessions?: { id: string; firstUserMessage?: string }[]
|
||||
selectedSessionId?: string | null
|
||||
sessionsLoading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [message: string]
|
||||
switchAgent: [agent: AgentName]
|
||||
selectSession: [sessionId: string]
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
@@ -42,6 +52,8 @@ function toggleSelectMode() {
|
||||
if (!selectMode.value) selectedUuids.value = new Set()
|
||||
}
|
||||
|
||||
defineExpose({ selectMode, toggleSelectMode })
|
||||
|
||||
function toggleSelect(uuid: string) {
|
||||
if (!selectMode.value) return
|
||||
const s = new Set(selectedUuids.value)
|
||||
@@ -213,42 +225,54 @@ function formatDuration(start: string, end: string): string {
|
||||
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<!-- Agent/Session selector header (only visible via settings toggle) -->
|
||||
<div class="chat-header">
|
||||
<div class="chat-title-row">
|
||||
<span class="session-id" :title="conversation.sessionId">{{ conversation.sessionId }}</span>
|
||||
<div class="selector-row">
|
||||
<label class="selector-label">Agent</label>
|
||||
<div class="agent-selector" v-if="agents">
|
||||
<button
|
||||
v-for="a in agents"
|
||||
:key="a.id"
|
||||
:class="['agent-btn', { active: selectedAgent === a.id }]"
|
||||
@click="emit('switchAgent', a.id)"
|
||||
>
|
||||
{{ a.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selector-row">
|
||||
<label class="selector-label">Session</label>
|
||||
<select
|
||||
class="session-select"
|
||||
:value="selectedSessionId || ''"
|
||||
@change="emit('selectSession', ($event.target as HTMLSelectElement).value)"
|
||||
:disabled="sessionsLoading"
|
||||
v-if="sessions"
|
||||
>
|
||||
<option value="" disabled>Select session...</option>
|
||||
<option v-for="s in sessions" :key="s.id" :value="s.id">
|
||||
{{ s.firstUserMessage ? (s.firstUserMessage.length > 50 ? s.firstUserMessage.slice(0, 50) + '...' : s.firstUserMessage) : s.id.slice(0, 8) + '...' }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="sessionsLoading" class="spinner-sm"></span>
|
||||
</div>
|
||||
<div class="selector-row">
|
||||
<label class="selector-label">ID</label>
|
||||
<span class="status-id" :title="conversation.sessionId">{{ conversation.sessionId.slice(0, 8) }}…</span>
|
||||
<button class="copy-id-btn" :class="{ copied: idCopied }" @click="copySessionId" title="Copy session ID">
|
||||
<svg v-if="!idCopied" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg v-if="!idCopied" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg v-else width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="header-spacer"></div>
|
||||
<button :class="['select-mode-btn', { active: selectMode }]" @click="toggleSelectMode" title="Toggle select mode">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline v-if="selectMode" points="20 6 9 17 4 12"/>
|
||||
<template v-else>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</template>
|
||||
</svg>
|
||||
<span class="select-mode-label">{{ selectMode ? 'Done' : 'Select' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-meta">
|
||||
<span v-if="conversation.model" class="meta-badge model">{{ conversation.model }}</span>
|
||||
<span v-if="conversation.version" class="meta-badge version">v{{ conversation.version }}</span>
|
||||
<span v-if="conversation.metadata.cwd" class="meta-cwd" :title="conversation.metadata.cwd">
|
||||
{{ conversation.metadata.cwd }}
|
||||
</span>
|
||||
<span class="meta-duration">
|
||||
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
|
||||
</span>
|
||||
<span class="meta-count">{{ conversation.messages.length }} messages</span>
|
||||
<ResumeTerminalButton
|
||||
v-if="selectedAgent"
|
||||
:agent="selectedAgent"
|
||||
:session-id="conversation.sessionId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="scrollContainer" class="messages-scroll">
|
||||
@@ -332,6 +356,20 @@ function formatDuration(start: string, end: string): string {
|
||||
:processing="props.processing"
|
||||
@send="emit('send', $event)"
|
||||
/>
|
||||
|
||||
<div class="status-bar">
|
||||
<span v-if="conversation.model" class="meta-badge model">{{ conversation.model }}</span>
|
||||
<span v-if="conversation.version" class="meta-badge version">v{{ conversation.version }}</span>
|
||||
<span class="meta-count">{{ conversation.messages.length }} msgs</span>
|
||||
<ResumeTerminalButton
|
||||
v-if="selectedAgent"
|
||||
:agent="selectedAgent"
|
||||
:session-id="conversation.sessionId"
|
||||
/>
|
||||
<span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration">
|
||||
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -348,18 +386,15 @@ function formatDuration(start: string, end: string): string {
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.header-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -395,27 +430,15 @@ function formatDuration(start: string, end: string): string {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-id {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 320px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.copy-id-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
@@ -431,19 +454,31 @@ function formatDuration(start: string, end: string): string {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.chat-meta {
|
||||
/* ── Status bar (below input) ── */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.75rem 0.3rem;
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-id {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-badge {
|
||||
font-size: 10px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 9px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-badge.model {
|
||||
@@ -456,26 +491,17 @@ function formatDuration(start: string, end: string): string {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-cwd {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.meta-duration {
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.meta-count {
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.messages-scroll {
|
||||
@@ -639,4 +665,92 @@ function formatDuration(start: string, end: string): string {
|
||||
from { opacity: 0.7; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selector-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
min-width: 48px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.agent-selector {
|
||||
display: flex;
|
||||
background: var(--bg-primary, rgba(255,255,255,0.04));
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-btn {
|
||||
padding: 3px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.agent-btn:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
.agent-btn:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
color: var(--text-secondary, rgba(255,255,255,0.7));
|
||||
}
|
||||
|
||||
.agent-btn.active {
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.session-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 6px;
|
||||
background: var(--bg-primary, rgba(255,255,255,0.04));
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 0;
|
||||
color: var(--text-primary, rgba(255,255,255,0.8));
|
||||
font-size: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.session-select option {
|
||||
background: #0a0a10;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-color, rgba(255,255,255,0.1));
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import type { AgentName } from '@/types/transcript-debug'
|
||||
import { useEphemeralTerminal, type EphemeralTerminal } from '@/composables/useEphemeralTerminal'
|
||||
import TerminalNavButtons from '../TerminalNavButtons.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentName
|
||||
sessionId: string
|
||||
}>()
|
||||
|
||||
const AGENT_CMD: Record<AgentName, string> = {
|
||||
ejecutor: 'ejecutor',
|
||||
nucleo000: 'nucleo000',
|
||||
claude: 'claude'
|
||||
}
|
||||
|
||||
const isOpen = ref(false)
|
||||
let ephemeral: EphemeralTerminal | null = null
|
||||
|
||||
// Local ref for xterm container - syncs to composable's containerRef
|
||||
const terminalContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// Nav buttons toggle
|
||||
const showNavButtons = ref(false)
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const hasCustomPosition = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// Resize state
|
||||
const isResizing = ref(false)
|
||||
const size = ref({ w: 620, h: 400 })
|
||||
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
|
||||
|
||||
const windowRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
if (!ephemeral) return ''
|
||||
switch (ephemeral.state.value) {
|
||||
case 'running':
|
||||
case 'shell-ready': return 'on'
|
||||
case 'connecting': return 'wait'
|
||||
case 'exited': return 'error'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const terminalStyle = computed((): Record<string, string> => {
|
||||
if (!hasCustomPosition.value) {
|
||||
return {
|
||||
width: `${size.value.w}px`,
|
||||
height: `${size.value.h}px`,
|
||||
bottom: '80px',
|
||||
right: '16px'
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: `${size.value.w}px`,
|
||||
height: `${size.value.h}px`,
|
||||
top: `${position.value.y}px`,
|
||||
left: `${position.value.x}px`,
|
||||
bottom: 'auto',
|
||||
right: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
// ── Open / Close ──
|
||||
|
||||
async function open() {
|
||||
if (isOpen.value) {
|
||||
await closeTerminal()
|
||||
return
|
||||
}
|
||||
|
||||
const cmd = `${AGENT_CMD[props.agent]} --resume "${props.sessionId}"`
|
||||
ephemeral = useEphemeralTerminal(cmd)
|
||||
|
||||
isOpen.value = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Sync container ref
|
||||
if (terminalContainer.value) {
|
||||
ephemeral.containerRef.value = terminalContainer.value
|
||||
}
|
||||
|
||||
// Init renderer then start
|
||||
if (!ephemeral.renderer.isReady.value) {
|
||||
ephemeral.renderer.init()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
ephemeral?.renderer.fit()
|
||||
ephemeral?.renderer.terminal.value?.refresh(0, (ephemeral.renderer.terminal.value?.rows ?? 1) - 1)
|
||||
if (window.innerWidth > 1024) {
|
||||
ephemeral?.renderer.focus()
|
||||
}
|
||||
ephemeral?.start()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
async function closeTerminal() {
|
||||
if (ephemeral) {
|
||||
await ephemeral.dispose()
|
||||
ephemeral = null
|
||||
}
|
||||
isOpen.value = false
|
||||
hasCustomPosition.value = false
|
||||
showNavButtons.value = false
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + '\r')
|
||||
}
|
||||
|
||||
function navRunClaudeContinue() {
|
||||
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + ' --continue\r')
|
||||
}
|
||||
|
||||
function navRunClaudeResume() {
|
||||
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + ' --resume\r')
|
||||
}
|
||||
|
||||
function navRefresh() {
|
||||
ephemeral?.renderer.fit()
|
||||
}
|
||||
|
||||
function navClearBuffer() {
|
||||
ephemeral?.renderer.reset()
|
||||
}
|
||||
|
||||
function navSendKey(key: string) {
|
||||
const keyMap: Record<string, string> = {
|
||||
'up': '\x1b[A', 'down': '\x1b[B', 'left': '\x1b[D', 'right': '\x1b[C',
|
||||
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
|
||||
}
|
||||
const data = keyMap[key]
|
||||
if (data) ephemeral?.renderer.terminal.value?.paste(data)
|
||||
}
|
||||
|
||||
function navScroll(direction: 'up' | 'down' | 'end') {
|
||||
if (!ephemeral) return
|
||||
if (direction === 'up') ephemeral.renderer.scrollLines(-10)
|
||||
else if (direction === 'down') ephemeral.renderer.scrollLines(10)
|
||||
else ephemeral.renderer.scrollToBottom()
|
||||
}
|
||||
|
||||
function toggleNavButtons() {
|
||||
showNavButtons.value = !showNavButtons.value
|
||||
}
|
||||
|
||||
// ── Drag ──
|
||||
|
||||
function startDrag(e: MouseEvent | TouchEvent) {
|
||||
if ((e.target as HTMLElement).closest('.window-controls')) return
|
||||
if (e instanceof TouchEvent) e.preventDefault()
|
||||
isDragging.value = true
|
||||
|
||||
const touch = e instanceof TouchEvent ? e.touches[0] : null
|
||||
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
|
||||
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
|
||||
|
||||
const rect = windowRef.value?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
if (!hasCustomPosition.value) {
|
||||
position.value = { x: rect.left, y: rect.top }
|
||||
}
|
||||
dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top }
|
||||
}
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||||
document.addEventListener('touchend', stopDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent | TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
if (e instanceof TouchEvent) e.preventDefault()
|
||||
|
||||
const touch = e instanceof TouchEvent ? e.touches[0] : null
|
||||
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
|
||||
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
|
||||
|
||||
const w = windowRef.value?.offsetWidth || 620
|
||||
const h = windowRef.value?.offsetHeight || 400
|
||||
position.value = {
|
||||
x: Math.max(-w * 0.75, Math.min(clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
|
||||
y: Math.max(-h * 0.75, Math.min(clientY - dragOffset.value.y, window.innerHeight - h * 0.25))
|
||||
}
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
isDragging.value = false
|
||||
hasCustomPosition.value = true
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
}
|
||||
|
||||
// ── Resize ──
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.value = true
|
||||
resizeStart.value = { x: e.clientX, y: e.clientY, w: size.value.w, h: size.value.h }
|
||||
document.addEventListener('mousemove', onResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
function onResize(e: MouseEvent) {
|
||||
if (!isResizing.value) return
|
||||
size.value = {
|
||||
w: Math.max(400, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
|
||||
h: Math.max(250, Math.min(resizeStart.value.h + e.clientY - resizeStart.value.y, window.innerHeight - 40))
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
nextTick(() => ephemeral?.renderer.fit())
|
||||
}
|
||||
|
||||
// Sync container ref when it mounts
|
||||
watch(terminalContainer, (el) => {
|
||||
if (ephemeral && el) {
|
||||
ephemeral.containerRef.value = el
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
if (ephemeral) {
|
||||
await ephemeral.dispose()
|
||||
ephemeral = null
|
||||
}
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Inline button -->
|
||||
<button class="resume-terminal-btn" @click.stop="open" title="Open terminal (resume session)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Floating terminal modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="rt-slide">
|
||||
<div
|
||||
v-show="isOpen"
|
||||
ref="windowRef"
|
||||
class="resume-terminal"
|
||||
:class="{ dragging: isDragging, resizing: isResizing }"
|
||||
:style="terminalStyle"
|
||||
>
|
||||
<div class="rt-glass">
|
||||
<!-- Titlebar -->
|
||||
<div class="rt-titlebar" @mousedown="startDrag" @touchstart="startDrag">
|
||||
<div class="rt-left">
|
||||
<div class="rt-badge">
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="rt-name">{{ AGENT_CMD[agent] }}</span>
|
||||
<span class="rt-session">{{ sessionId.slice(0, 8) }}…</span>
|
||||
<i class="rt-dot" :class="statusDotClass"></i>
|
||||
<span v-if="ephemeral?.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="ephemeral?.state.value === 'exited'" class="rt-status-text exited">Exited</span>
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
<button
|
||||
class="wc-btn nav-toggle"
|
||||
:class="{ active: showNavButtons }"
|
||||
title="Toggle navigation"
|
||||
@click.stop="toggleNavButtons"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
<button class="wc-btn x" title="Close" @click.stop="closeTerminal">
|
||||
<svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<div class="rt-content">
|
||||
<div ref="terminalContainer" class="rt-term"></div>
|
||||
|
||||
<!-- Overlay: connecting -->
|
||||
<div v-if="ephemeral?.state.value === 'connecting'" class="rt-overlay connecting">
|
||||
<div class="rt-overlay-msg">
|
||||
<div class="rt-spinner"></div>
|
||||
<span>Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div class="rt-resize" @mousedown="startResize"></div>
|
||||
</div>
|
||||
|
||||
<!-- Nav buttons bar (outside glass, hangs from bottom) -->
|
||||
<TerminalNavButtons
|
||||
v-if="showNavButtons"
|
||||
class="rt-nav-popup"
|
||||
@request-token="ephemeral?.renderer.terminal.value?.paste('genera token usando tu mcp\r')"
|
||||
@run-claude="navRunClaude"
|
||||
@run-claude-continue="navRunClaudeContinue"
|
||||
@run-claude-resume="navRunClaudeResume"
|
||||
@clear-buffer="navClearBuffer"
|
||||
@refresh="navRefresh"
|
||||
@send-key="navSendKey"
|
||||
@scroll="navScroll"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Inline button ── */
|
||||
.resume-terminal-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.resume-terminal-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
/* ── Floating terminal ── */
|
||||
.resume-terminal {
|
||||
position: fixed;
|
||||
min-width: 400px;
|
||||
min-height: 250px;
|
||||
z-index: 10002;
|
||||
}
|
||||
|
||||
.rt-glass {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(200, 215, 235, 0.35);
|
||||
backdrop-filter: blur(24px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 0 0 1px rgba(80, 120, 180, 0.25), 0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rt-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 24px;
|
||||
padding: 0 4px 0 6px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.resume-terminal.dragging .rt-titlebar { cursor: grabbing; }
|
||||
|
||||
.rt-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: 500 10px/1 system-ui, sans-serif;
|
||||
color: #222;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rt-badge {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rt-name {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rt-session {
|
||||
font-size: 9px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #666;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.rt-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rt-dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
|
||||
.rt-dot.wait { background: #a80; animation: rt-pulse 0.8s infinite; }
|
||||
.rt-dot.error { background: #e44; box-shadow: 0 0 4px #e44; }
|
||||
|
||||
.rt-status-text {
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
.rt-status-text.exited { color: #c33; }
|
||||
|
||||
.window-controls { display: flex; gap: 1px; }
|
||||
.wc-btn {
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wc-btn:hover { background: rgba(255, 255, 255, 0.5); }
|
||||
.wc-btn.x:hover { background: linear-gradient(180deg, #e66 0%, #c33 100%); border-color: #a22; color: #fff; }
|
||||
.wc-btn.nav-toggle { width: 44px; }
|
||||
.wc-btn.nav-toggle.active { background: rgba(99, 102, 241, 0.3); border-color: rgba(99, 102, 241, 0.4); color: #818cf8; }
|
||||
.wc-btn.nav-toggle:hover { background: rgba(99, 102, 241, 0.2); color: #818cf8; }
|
||||
|
||||
.rt-content {
|
||||
flex: 1;
|
||||
margin: 2px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: rgba(12, 12, 12, 0.95);
|
||||
}
|
||||
|
||||
.rt-term { width: 100%; height: 100%; }
|
||||
.rt-term :deep(.xterm) { height: 100%; padding: 2px; }
|
||||
.rt-term :deep(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.rt-term :deep(.xterm-viewport::-webkit-scrollbar) { width: 8px; background: rgba(0, 0, 0, 0.2); }
|
||||
.rt-term :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
|
||||
|
||||
.rt-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.rt-overlay.connecting { cursor: wait; }
|
||||
|
||||
.rt-overlay-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
.rt-overlay-msg span { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.rt-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: rt-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.rt-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0.1) 100%);
|
||||
border-radius: 0 0 5px 0;
|
||||
}
|
||||
.rt-resize:hover { background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0.2) 100%); }
|
||||
|
||||
.resume-terminal.resizing { user-select: none; }
|
||||
.resume-terminal.resizing .rt-term { pointer-events: none; }
|
||||
|
||||
/* Nav buttons popup */
|
||||
.rt-nav-popup {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
border-radius: 0 0 6px 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: none;
|
||||
backdrop-filter: blur(14px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rt-slide-enter-active, .rt-slide-leave-active { transition: all 0.15s ease; }
|
||||
.rt-slide-enter-from, .rt-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
|
||||
|
||||
@keyframes rt-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
@keyframes rt-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
@@ -13,4 +13,5 @@ export { default as PermissionApproval } from './PermissionApproval.vue'
|
||||
export { default as PlanApproval } from './PlanApproval.vue'
|
||||
export { default as CodeBlock } from './CodeBlock.vue'
|
||||
export { default as AgentBadge } from './AgentBadge.vue'
|
||||
export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
|
||||
export { AquaticBackground } from './aquaticBackground'
|
||||
|
||||
185
frontend/src/composables/useEphemeralTerminal.ts
Normal file
185
frontend/src/composables/useEphemeralTerminal.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* useEphemeralTerminal
|
||||
*
|
||||
* Lightweight composable for ephemeral terminal sessions.
|
||||
* Creates a temporary PTY via WebSocket, runs a command, and
|
||||
* cleans up completely when disposed (no persistent session).
|
||||
*
|
||||
* Used by ResumeTerminalButton to open `<agent> --resume <sessionId>`.
|
||||
*/
|
||||
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { useTerminalRenderer, type TerminalRenderer } from './useTerminalRenderer'
|
||||
import { endpoints, terminalApiUrl } from '../config/endpoints'
|
||||
|
||||
export type EphemeralState = 'off' | 'connecting' | 'shell-ready' | 'running' | 'exited'
|
||||
|
||||
export interface EphemeralTerminal {
|
||||
state: Ref<EphemeralState>
|
||||
connected: Ref<boolean>
|
||||
containerRef: Ref<HTMLElement | null>
|
||||
renderer: TerminalRenderer
|
||||
ephemeralSessionId: string
|
||||
|
||||
/** Connect WS, wait for shell, then auto-run the resume command */
|
||||
start: () => void
|
||||
|
||||
/** Ctrl+C, exit, close WS, kill session on server */
|
||||
stop: () => Promise<void>
|
||||
|
||||
/** Full cleanup (stop + dispose renderer) */
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useEphemeralTerminal(
|
||||
command: string
|
||||
): EphemeralTerminal {
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const connected = ref(false)
|
||||
const state = ref<EphemeralState>('off')
|
||||
|
||||
const ephemeralSessionId = `resume-${Date.now()}`
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
|
||||
const renderer = useTerminalRenderer({
|
||||
container: containerRef,
|
||||
onData: (data) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
},
|
||||
onResize: (cols, rows) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
},
|
||||
onKeyEvent: (e) => {
|
||||
// Ctrl+V: Paste
|
||||
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
|
||||
e.preventDefault()
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text && socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data: text }))
|
||||
}
|
||||
}).catch(console.error)
|
||||
return false
|
||||
}
|
||||
// Ctrl+C: Copy selection
|
||||
if (e.ctrlKey && e.key === 'c' && e.type === 'keydown') {
|
||||
const sel = renderer.getSelection()
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel).catch(console.error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
function start() {
|
||||
if (state.value !== 'off') return
|
||||
state.value = 'connecting'
|
||||
|
||||
const wsBase = endpoints.terminal
|
||||
const sep = wsBase.includes('?') ? '&' : '?'
|
||||
const wsUrl = `${wsBase}${sep}session=${ephemeralSessionId}`
|
||||
|
||||
socket = new WebSocket(wsUrl)
|
||||
|
||||
socket.onopen = () => {
|
||||
connected.value = true
|
||||
const term = renderer.terminal.value
|
||||
if (term) {
|
||||
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
state.value = 'shell-ready'
|
||||
// Wait for shell prompt to render, then send the command
|
||||
setTimeout(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data: command + '\r' }))
|
||||
}
|
||||
state.value = 'running'
|
||||
}, 500)
|
||||
break
|
||||
case 'replay':
|
||||
renderer.handleReplay(msg.data || '')
|
||||
break
|
||||
case 'output':
|
||||
renderer.write(msg.data)
|
||||
break
|
||||
case 'exit':
|
||||
renderer.write(msg.data)
|
||||
state.value = 'exited'
|
||||
break
|
||||
case 'error':
|
||||
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
|
||||
break
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
connected.value = false
|
||||
if (state.value !== 'off') state.value = 'exited'
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
state.value = 'off'
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
state.value = 'off'
|
||||
return
|
||||
}
|
||||
|
||||
// Send Ctrl+C to interrupt running process
|
||||
socket.send(JSON.stringify({ type: 'input', data: '\x03' }))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Send exit to close the shell
|
||||
socket.send(JSON.stringify({ type: 'input', data: 'exit\r' }))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Close WebSocket
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
socket = null
|
||||
connected.value = false
|
||||
state.value = 'off'
|
||||
|
||||
// Force-kill via HTTP as safety net
|
||||
try {
|
||||
await fetch(terminalApiUrl('/kill-session'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: ephemeralSessionId })
|
||||
})
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
async function dispose() {
|
||||
await stop()
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
connected,
|
||||
containerRef,
|
||||
renderer,
|
||||
ephemeralSessionId,
|
||||
start,
|
||||
stop,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ onUnmounted(() => {
|
||||
v-if="conversation"
|
||||
:conversation="conversation"
|
||||
:processing="processing"
|
||||
:selected-agent="selectedAgent"
|
||||
@send="handleSend"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user