feat: new session modal, status bar controls (Esc, C-m, close), sendRaw
- NewSessionModal with tabs: new session (agent + optional initial prompt) and resume existing (filterable by agent) - Status bar: +new, Esc, Ctrl+M, terminal buttons; close with confirm - sendRaw on EphemeralTerminal for raw control characters - createNewSession accepts optional initialPrompt, auto-sent on ready
This commit is contained in:
@@ -681,6 +681,7 @@ onBeforeUnmount(() => {
|
||||
@switch-agent="handleAgentSwitch"
|
||||
@select-session="handleSessionSelect"
|
||||
@create-session="handleCreateSession"
|
||||
@close-session="closeTerminal"
|
||||
@start-recording="voice.startRecording()"
|
||||
@stop-recording="voice.stopRecording()"
|
||||
@set-voice-mode="voice.setMode($event)"
|
||||
|
||||
@@ -45,6 +45,7 @@ const emit = defineEmits<{
|
||||
switchAgent: [agent: AgentName]
|
||||
selectSession: [sessionId: string]
|
||||
createSession: []
|
||||
closeSession: [sessionId: string]
|
||||
startRecording: []
|
||||
stopRecording: []
|
||||
setVoiceMode: [mode: 'web' | 'whisper']
|
||||
@@ -64,6 +65,30 @@ async function copySessionId() {
|
||||
setTimeout(() => (idCopied.value = false), 1500)
|
||||
}
|
||||
|
||||
// ── Terminal control keys ──
|
||||
function sendCtrlM() {
|
||||
props.terminal?.sendRaw('\x0d')
|
||||
}
|
||||
|
||||
function sendEsc() {
|
||||
props.terminal?.sendRaw('\x1b')
|
||||
}
|
||||
|
||||
// ── Close session confirm ──
|
||||
const closeConfirm = ref(false)
|
||||
let closeConfirmTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleCloseSession() {
|
||||
if (!closeConfirm.value) {
|
||||
closeConfirm.value = true
|
||||
closeConfirmTimer = setTimeout(() => { closeConfirm.value = false }, 3000)
|
||||
return
|
||||
}
|
||||
if (closeConfirmTimer) clearTimeout(closeConfirmTimer)
|
||||
closeConfirm.value = false
|
||||
emit('closeSession', props.conversation.sessionId)
|
||||
}
|
||||
|
||||
// ── Multi-select ──
|
||||
const selectMode = ref(false)
|
||||
const selectedUuids = ref(new Set<string>())
|
||||
@@ -502,21 +527,45 @@ function formatDuration(start: string, end: string): string {
|
||||
<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"
|
||||
:terminal="terminal ?? null"
|
||||
/>
|
||||
<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">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="key-btn" @click="sendEsc" title="Esc" :disabled="!terminal">
|
||||
<span class="key-label">Esc</span>
|
||||
</button>
|
||||
<button class="key-btn" @click="sendCtrlM" title="Ctrl+M (Enter)" :disabled="!terminal">
|
||||
<span class="key-label">C-m</span>
|
||||
</button>
|
||||
<ResumeTerminalButton
|
||||
v-if="selectedAgent"
|
||||
:agent="selectedAgent"
|
||||
:session-id="conversation.sessionId"
|
||||
:terminal="terminal ?? null"
|
||||
/>
|
||||
<span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration">
|
||||
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
|
||||
</span>
|
||||
<span class="status-spacer" />
|
||||
<button
|
||||
:class="['close-session-btn', { confirming: closeConfirm }]"
|
||||
@click="handleCloseSession"
|
||||
:title="closeConfirm ? 'Click again to confirm' : 'Close session'"
|
||||
>
|
||||
<svg v-if="!closeConfirm" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<template v-else>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<span class="confirm-label">confirm?</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -632,6 +681,82 @@ function formatDuration(start: string, end: string): string {
|
||||
color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.key-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 16px;
|
||||
padding: 0 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.key-btn:hover:not(:disabled) {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.key-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.key-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.status-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.close-session-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.close-session-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.close-session-btn.confirming {
|
||||
width: auto;
|
||||
padding: 0 0.35rem;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.close-session-btn.confirming:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.confirm-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-id {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
|
||||
611
frontend/src/components/transcript-debug/NewSessionModal.vue
Normal file
611
frontend/src/components/transcript-debug/NewSessionModal.vue
Normal file
@@ -0,0 +1,611 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { AgentName, SessionInfo } from '@/types/transcript-debug'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
agents: { id: AgentName; label: string }[]
|
||||
currentAgent: AgentName
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'create-new': [agent: AgentName, initialPrompt: string]
|
||||
resume: [sessionId: string, agent: AgentName]
|
||||
}>()
|
||||
|
||||
type Tab = 'new' | 'resume'
|
||||
const activeTab = ref<Tab>('new')
|
||||
const selectedAgent = ref<AgentName>(props.currentAgent)
|
||||
const sessionsMap = ref<Record<AgentName, SessionInfo[]>>({} as any)
|
||||
const loadingSessions = ref(false)
|
||||
const resumeFilter = ref<AgentName | 'all'>('all')
|
||||
const initialPrompt = ref('')
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
if (resumeFilter.value === 'all') return props.agents
|
||||
return props.agents.filter(a => a.id === resumeFilter.value)
|
||||
})
|
||||
|
||||
const hasAnySessions = computed(() =>
|
||||
props.agents.some(a => (sessionsMap.value[a.id]?.length ?? 0) > 0)
|
||||
)
|
||||
|
||||
// Reset state when modal opens
|
||||
watch(() => props.visible, async (open) => {
|
||||
if (open) {
|
||||
activeTab.value = 'new'
|
||||
selectedAgent.value = props.currentAgent
|
||||
resumeFilter.value = 'all'
|
||||
initialPrompt.value = ''
|
||||
await fetchAllSessions()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchAllSessions() {
|
||||
loadingSessions.value = true
|
||||
const map: Record<string, SessionInfo[]> = {}
|
||||
|
||||
await Promise.all(
|
||||
props.agents.map(async (a) => {
|
||||
try {
|
||||
const res = await fetch(`/api/transcript-debug/sessions?agent=${a.id}`)
|
||||
if (res.ok) map[a.id] = await res.json()
|
||||
else map[a.id] = []
|
||||
} catch {
|
||||
map[a.id] = []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
sessionsMap.value = map as Record<AgentName, SessionInfo[]>
|
||||
loadingSessions.value = false
|
||||
}
|
||||
|
||||
function truncateMessage(msg: string, max = 60): string {
|
||||
if (!msg) return ''
|
||||
return msg.length > max ? msg.slice(0, max) + '...' : msg
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
emit('create-new', selectedAgent.value, initialPrompt.value)
|
||||
}
|
||||
|
||||
function handleResume(sessionId: string, agent: AgentName) {
|
||||
emit('resume', sessionId, agent)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="nsm">
|
||||
<div v-if="visible" class="nsm-backdrop" @click.self="emit('close')">
|
||||
<div class="nsm-panel">
|
||||
<!-- Header -->
|
||||
<div class="nsm-header">
|
||||
<span class="nsm-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
New Session
|
||||
</span>
|
||||
<button class="nsm-close" @click="emit('close')" title="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="nsm-tabs">
|
||||
<button
|
||||
:class="['nsm-tab', { active: activeTab === 'new' }]"
|
||||
@click="activeTab = 'new'"
|
||||
>
|
||||
New session
|
||||
</button>
|
||||
<button
|
||||
:class="['nsm-tab', { active: activeTab === 'resume' }]"
|
||||
@click="activeTab = 'resume'"
|
||||
>
|
||||
Resume existing
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="nsm-body">
|
||||
<!-- Tab: New session -->
|
||||
<div v-if="activeTab === 'new'" class="nsm-new">
|
||||
<label class="nsm-label">Agent</label>
|
||||
<div class="nsm-agent-grid">
|
||||
<button
|
||||
v-for="a in agents"
|
||||
:key="a.id"
|
||||
:class="['nsm-agent-btn', { active: selectedAgent === a.id }]"
|
||||
@click="selectedAgent = a.id"
|
||||
>
|
||||
{{ a.label }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="nsm-label">Initial prompt <span class="nsm-optional">(optional)</span></label>
|
||||
<textarea
|
||||
v-model="initialPrompt"
|
||||
class="nsm-prompt-input"
|
||||
placeholder="What should the agent do first?"
|
||||
rows="3"
|
||||
@keydown.ctrl.enter="handleStart"
|
||||
@keydown.meta.enter="handleStart"
|
||||
/>
|
||||
<button class="nsm-start-btn" @click="handleStart">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Resume existing -->
|
||||
<div v-if="activeTab === 'resume'" class="nsm-resume">
|
||||
<!-- Agent filter -->
|
||||
<div class="nsm-filter">
|
||||
<button
|
||||
:class="['nsm-filter-btn', { active: resumeFilter === 'all' }]"
|
||||
@click="resumeFilter = 'all'"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="a in agents"
|
||||
:key="a.id"
|
||||
:class="['nsm-filter-btn', { active: resumeFilter === a.id }]"
|
||||
@click="resumeFilter = a.id"
|
||||
>
|
||||
{{ a.label }}
|
||||
<span v-if="sessionsMap[a.id]?.length" class="nsm-filter-count">{{ sessionsMap[a.id].length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingSessions" class="nsm-loading">
|
||||
Loading sessions...
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="a in filteredAgents"
|
||||
:key="a.id"
|
||||
class="nsm-agent-group"
|
||||
>
|
||||
<template v-if="sessionsMap[a.id]?.length">
|
||||
<div v-if="resumeFilter === 'all'" class="nsm-group-header">
|
||||
<span class="nsm-group-agent">{{ a.label }}</span>
|
||||
<span class="nsm-group-count">{{ sessionsMap[a.id].length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in sessionsMap[a.id]"
|
||||
:key="s.id"
|
||||
class="nsm-session-row"
|
||||
@click="handleResume(s.id, a.id)"
|
||||
>
|
||||
<div class="nsm-session-info">
|
||||
<span class="nsm-session-msg">
|
||||
{{ truncateMessage(s.firstUserMessage) || s.id.slice(0, 12) + '...' }}
|
||||
</span>
|
||||
<span class="nsm-session-meta">
|
||||
{{ formatDate(s.mtimeISO) }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="nsm-open-btn" @click.stop="handleResume(s.id, a.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="!hasAnySessions" class="nsm-empty">
|
||||
No existing sessions found
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredAgents.every(a => !sessionsMap[a.id]?.length)"
|
||||
class="nsm-empty"
|
||||
>
|
||||
No sessions for this agent
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nsm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10013;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nsm-panel {
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 70vh;
|
||||
background: var(--bg-primary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.nsm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary, #16162a);
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nsm-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.nsm-title svg {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.nsm-close {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-close:hover {
|
||||
background: var(--bg-hover, #2a2a4a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.nsm-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nsm-tab {
|
||||
flex: 1;
|
||||
padding: 0.6rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nsm-tab:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
background: var(--bg-hover, #2a2a4a);
|
||||
}
|
||||
|
||||
.nsm-tab.active {
|
||||
color: #6366f1;
|
||||
border-bottom-color: #6366f1;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.nsm-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* New session tab */
|
||||
.nsm-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nsm-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nsm-agent-grid {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nsm-agent-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #16162a);
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-agent-btn:hover {
|
||||
border-color: #6366f1;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.nsm-agent-btn.active {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nsm-optional {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.nsm-prompt-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #16162a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
border-radius: 6px;
|
||||
resize: vertical;
|
||||
min-height: 2.5rem;
|
||||
max-height: 8rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.nsm-prompt-input::placeholder {
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
.nsm-prompt-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.nsm-start-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border: none;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.nsm-start-btn:hover {
|
||||
background: #5558e6;
|
||||
}
|
||||
|
||||
/* Resume tab */
|
||||
.nsm-resume {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nsm-filter {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nsm-filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: transparent;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-filter-btn:hover {
|
||||
border-color: #6366f1;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.nsm-filter-btn.active {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nsm-filter-count {
|
||||
font-size: 9px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nsm-filter-btn.active .nsm-filter-count {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
.nsm-loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nsm-agent-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nsm-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.nsm-group-agent {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #6366f1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nsm-group-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #888);
|
||||
background: var(--bg-secondary, #16162a);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nsm-session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-session-row:hover {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.nsm-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.nsm-session-msg {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nsm-session-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.nsm-open-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: transparent;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-open-btn:hover {
|
||||
border-color: #6366f1;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.nsm-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.nsm-enter-active,
|
||||
.nsm-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.nsm-enter-active .nsm-panel,
|
||||
.nsm-leave-active .nsm-panel {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.nsm-enter-from,
|
||||
.nsm-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nsm-enter-from .nsm-panel,
|
||||
.nsm-leave-to .nsm-panel {
|
||||
transform: scale(0.95) translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,9 @@ export interface EphemeralTerminal {
|
||||
/** Send text + \r directly to the terminal's WebSocket */
|
||||
sendInput: (text: string) => void
|
||||
|
||||
/** Send raw bytes to the terminal without appending \r */
|
||||
sendRaw: (data: string) => void
|
||||
|
||||
/** Ctrl+C, exit, close WS, kill session on server */
|
||||
stop: () => Promise<void>
|
||||
|
||||
@@ -165,6 +168,11 @@ export function useEphemeralTerminal(
|
||||
}, 80)
|
||||
}
|
||||
|
||||
function sendRaw(data: string) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
state.value = 'off'
|
||||
@@ -221,6 +229,7 @@ export function useEphemeralTerminal(
|
||||
ephemeralSessionId,
|
||||
start,
|
||||
sendInput,
|
||||
sendRaw,
|
||||
stop,
|
||||
dispose,
|
||||
park
|
||||
|
||||
Reference in New Issue
Block a user