- Add Tauri v2 shell (Cargo, tauri.conf.json, capabilities, plugins) - Migrate all fetch() calls to apiFetch() for Tauri-aware HTTP - Migrate WebSocket endpoints to resolveEndpoints() for dynamic URLs - Add ServerConfigDialog for remote server URL configuration - Add tauri.ts lib with isTauri detection, apiFetch wrapper, plugin helpers - Add server-config Pinia store with persistence via plugin-store - Conditional PWA (disabled in Tauri builds) - Android: home screen transcript widget (last 5 messages, 30s refresh) - Android: voice command / share activity (SpeechRecognizer + WebSocket) - Android: signed release APK with auto-copy to installers/ - Remove stale frontend/src-tauri directory
643 lines
15 KiB
Vue
643 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import { apiFetch } from '@/lib/tauri'
|
|
import type { AgentName, SessionInfo } from '@/types/transcript-debug'
|
|
|
|
const props = defineProps<{
|
|
visible: boolean
|
|
agents: { id: AgentName; label: string }[]
|
|
currentAgent: AgentName
|
|
error?: string | null
|
|
}>()
|
|
|
|
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) {
|
|
// If reopening after a resume error, show the resume tab
|
|
activeTab.value = props.error ? 'resume' : '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 apiFetch(`/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>
|
|
|
|
<!-- Error banner -->
|
|
<div v-if="error" class="nsm-error">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
</svg>
|
|
{{ error }}
|
|
</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;
|
|
}
|
|
|
|
/* Error banner */
|
|
.nsm-error {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
background: rgba(239, 68, 68, 0.12);
|
|
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
|
|
color: #f87171;
|
|
font-size: 12px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.nsm-error svg {
|
|
flex-shrink: 0;
|
|
color: #ef4444;
|
|
}
|
|
|
|
/* Body */
|
|
.nsm-body {
|
|
flex: 1;
|
|
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>
|