Files
agent-ui/frontend/src/components/transcript-debug/NewSessionModal.vue
josedario87 e1aa8b1bdb feat: integrate Tauri v2 with Android widget and voice assistant
- 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
2026-02-23 15:33:43 -06:00

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>