perf: incremental transcript updates via WebSocket, eliminate polling
- Replace 1500ms polling with fs.watch recursive (reliable on Windows) - Enrich WS broadcast with newContent delta + session metadata - Client appends incrementally instead of 2 sequential HTTP requests - Pre-initialize Tauri HTTP plugin at module load to avoid dynamic import overhead - Per-file debounce timers (150ms) instead of single shared timer - Size-based validation for safe incremental appends with HTTP fallback
This commit is contained in:
@@ -33,6 +33,7 @@ export function useTranscriptDebug() {
|
||||
const sessions = ref<SessionInfo[]>([])
|
||||
const selectedSessionId = ref<string | null>(null)
|
||||
const rawContent = ref<string>('')
|
||||
let knownByteSize = 0 // Track byte size for incremental WS updates
|
||||
const conversation = ref<ParsedConversation | null>(null)
|
||||
const loading = ref(false)
|
||||
const transitioning = ref(false)
|
||||
@@ -336,7 +337,7 @@ export function useTranscriptDebug() {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'transcript-debug-change') {
|
||||
handleRealtimeChange(msg.sessionId)
|
||||
handleRealtimeChange(msg)
|
||||
} else if (msg.type === 'transcript-debug-done') {
|
||||
handleRealtimeDone(msg.sessionId)
|
||||
}
|
||||
@@ -368,13 +369,77 @@ export function useTranscriptDebug() {
|
||||
isRealtime.value = false
|
||||
}
|
||||
|
||||
async function handleRealtimeChange(changedSessionId: string) {
|
||||
// Refresh session list (new sessions or size changes)
|
||||
await fetchSessions()
|
||||
// Update session list from enriched WS event (no HTTP request needed)
|
||||
function updateSessionFromEvent(msg: {
|
||||
sessionId: string; agent: string; filename: string
|
||||
size: number; mtime: number; firstUserMessage: string
|
||||
}) {
|
||||
// Only update if this event matches the selected agent
|
||||
if (msg.agent !== selectedAgent.value) return
|
||||
|
||||
// Terminal registry is now updated via WS broadcast (no polling needed)
|
||||
const idx = sessions.value.findIndex(s => s.id === msg.sessionId)
|
||||
if (idx >= 0) {
|
||||
// Update existing session metadata
|
||||
sessions.value[idx] = {
|
||||
...sessions.value[idx],
|
||||
size: msg.size,
|
||||
mtime: msg.mtime,
|
||||
mtimeISO: new Date(msg.mtime).toISOString(),
|
||||
firstUserMessage: msg.firstUserMessage || sessions.value[idx].firstUserMessage
|
||||
}
|
||||
} else {
|
||||
// New session appeared — add to list
|
||||
sessions.value.unshift({
|
||||
id: msg.sessionId,
|
||||
filename: msg.filename,
|
||||
size: msg.size,
|
||||
mtime: msg.mtime,
|
||||
mtimeISO: new Date(msg.mtime).toISOString(),
|
||||
firstUserMessage: msg.firstUserMessage || ''
|
||||
})
|
||||
}
|
||||
// Re-sort by mtime (newest first)
|
||||
sessions.value.sort((a, b) => b.mtime - a.mtime)
|
||||
}
|
||||
|
||||
// New session just appeared — lock onto it, re-key from __new__
|
||||
// Apply optimistic message/processing state to a parsed conversation
|
||||
function applyOptimisticState(parsed: ParsedConversation) {
|
||||
if (optimisticMessage.value) {
|
||||
const optimisticText = optimisticMessage.value.content
|
||||
const found = parsed.messages.some(
|
||||
m => m.kind === 'user' && (m as ParsedUserMessage).content.includes(optimisticText)
|
||||
)
|
||||
if (found) {
|
||||
optimisticMessage.value = null
|
||||
} else {
|
||||
parsed.messages.push(optimisticMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (optimisticProcessing.value) {
|
||||
const agentState = sessionStore.agents[selectedAgent.value]
|
||||
if (agentState && ['idle', 'sessionStart', 'sessionEnd'].includes(agentState.status)) {
|
||||
optimisticProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
conversation.value = parsed
|
||||
}
|
||||
|
||||
async function handleRealtimeChange(msg: {
|
||||
sessionId: string; agent: string; filename: string
|
||||
size: number; prevSize: number; mtime: number
|
||||
firstUserMessage: string; newContent: string; timestamp: number
|
||||
}) {
|
||||
const changedSessionId = msg.sessionId
|
||||
|
||||
// 1. Update session list from WS data (no HTTP!)
|
||||
updateSessionFromEvent(msg)
|
||||
console.log(`[TranscriptDebug] WS update: ${changedSessionId.slice(0, 8)}... ` +
|
||||
`(+${msg.size - msg.prevSize} bytes, incremental=${msg.newContent ? 'yes' : 'no'}, ` +
|
||||
`aligned=${msg.prevSize === knownByteSize})`)
|
||||
|
||||
// 2. New session just appeared — lock onto it, re-key from __new__
|
||||
if (awaitingNewSession.value) {
|
||||
awaitingNewSession.value = false
|
||||
|
||||
@@ -396,27 +461,45 @@ export function useTranscriptDebug() {
|
||||
}
|
||||
|
||||
selectedSessionId.value = changedSessionId
|
||||
await fetchSessionContent(changedSessionId)
|
||||
|
||||
// Use WS content if available (prevSize=0 for new files)
|
||||
if (msg.newContent) {
|
||||
rawContent.value = msg.newContent
|
||||
knownByteSize = msg.size
|
||||
conversation.value = parseJsonl(rawContent.value, changedSessionId)
|
||||
} else {
|
||||
await fetchSessionContent(changedSessionId)
|
||||
}
|
||||
|
||||
// Auto-send queued initial prompt
|
||||
if (pendingPrompt.value) {
|
||||
const prompt = pendingPrompt.value
|
||||
pendingPrompt.value = null
|
||||
// Small delay to let terminal fully settle
|
||||
setTimeout(() => sendPrompt(prompt), 300)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If the changed session is the one we're viewing, re-fetch it
|
||||
// 3. If the changed session is the one we're viewing, update incrementally
|
||||
if (selectedSessionId.value && selectedSessionId.value === changedSessionId) {
|
||||
await reloadCurrentSession()
|
||||
if (msg.newContent && msg.prevSize === knownByteSize) {
|
||||
// Incremental append — no HTTP request!
|
||||
rawContent.value += msg.newContent
|
||||
knownByteSize = msg.size
|
||||
console.log(`[TranscriptDebug] ✓ Incremental update (0 HTTP) — knownByteSize=${knownByteSize}`)
|
||||
const parsed = parseJsonl(rawContent.value, changedSessionId)
|
||||
applyOptimisticState(parsed)
|
||||
} else if (msg.size !== knownByteSize) {
|
||||
console.log(`[TranscriptDebug] ✗ Fallback to HTTP — prevSize=${msg.prevSize} knownByteSize=${knownByteSize} size=${msg.size}`)
|
||||
// Mismatch or delta too large — fallback to full HTTP reload
|
||||
await reloadCurrentSession()
|
||||
}
|
||||
} else if (!selectedSessionId.value && sessions.value.length > 0) {
|
||||
// No session selected yet — auto-select the newest
|
||||
await selectSession(sessions.value[0].id)
|
||||
}
|
||||
|
||||
// Update label for any terminal in the registry matching this session
|
||||
// 4. Update terminal label if changed
|
||||
const entry = serverRegistry.value.find(e => e.transcriptSessionId === changedSessionId)
|
||||
if (entry) {
|
||||
const newLabel = getSessionLabel(changedSessionId)
|
||||
@@ -440,30 +523,9 @@ export function useTranscriptDebug() {
|
||||
const res = await apiFetch(`/api/transcript-debug/${selectedSessionId.value}/raw?agent=${selectedAgent.value}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
rawContent.value = await res.text()
|
||||
knownByteSize = new TextEncoder().encode(rawContent.value).length
|
||||
const parsed = parseJsonl(rawContent.value, selectedSessionId.value)
|
||||
|
||||
// Check if the optimistic user message now exists in the real JSONL
|
||||
if (optimisticMessage.value) {
|
||||
const optimisticText = optimisticMessage.value.content
|
||||
const found = parsed.messages.some(
|
||||
m => m.kind === 'user' && (m as ParsedUserMessage).content.includes(optimisticText)
|
||||
)
|
||||
if (found) {
|
||||
optimisticMessage.value = null
|
||||
} else {
|
||||
parsed.messages.push(optimisticMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear optimistic processing if server state is now idle
|
||||
if (optimisticProcessing.value) {
|
||||
const agentState = sessionStore.agents[selectedAgent.value]
|
||||
if (agentState && ['idle', 'sessionStart', 'sessionEnd'].includes(agentState.status)) {
|
||||
optimisticProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
conversation.value = parsed
|
||||
applyOptimisticState(parsed)
|
||||
} catch (e: any) {
|
||||
console.error('[TranscriptDebug] Reload failed:', e.message)
|
||||
}
|
||||
@@ -561,6 +623,7 @@ export function useTranscriptDebug() {
|
||||
const res = await apiFetch(`/api/transcript-debug/${sessionId}/raw?agent=${agent}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
rawContent.value = await res.text()
|
||||
knownByteSize = new TextEncoder().encode(rawContent.value).length
|
||||
conversation.value = parseJsonl(rawContent.value, sessionId)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -17,6 +17,9 @@ export function isMobileTauri(): boolean {
|
||||
// Server URL storage (in-memory, loaded from Tauri store on init)
|
||||
let _serverUrl = ''
|
||||
|
||||
// Cached Tauri HTTP fetch function (avoid dynamic import on every request)
|
||||
let _tauriFetch: typeof fetch | null = null
|
||||
|
||||
export function getServerUrl(): string {
|
||||
return _serverUrl
|
||||
}
|
||||
@@ -55,10 +58,13 @@ export async function apiFetch(input: string | URL | Request, init?: RequestInit
|
||||
// Resolve URL
|
||||
const url = typeof input === 'string' ? resolveUrl(input) : input
|
||||
|
||||
// Use Tauri HTTP plugin for cross-origin requests
|
||||
// Use Tauri HTTP plugin for cross-origin requests (cached after first load)
|
||||
try {
|
||||
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
|
||||
return tauriFetch(url, init)
|
||||
if (!_tauriFetch) {
|
||||
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
|
||||
_tauriFetch = tauriFetch
|
||||
}
|
||||
return _tauriFetch(url, init)
|
||||
} catch (e) {
|
||||
// Fallback to native fetch if plugin fails
|
||||
console.warn('[Tauri] HTTP plugin failed, falling back to fetch:', e)
|
||||
@@ -66,6 +72,26 @@ export async function apiFetch(input: string | URL | Request, init?: RequestInit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly initialize the Tauri HTTP plugin to avoid dynamic import overhead
|
||||
* on the first apiFetch call. Call this once at app startup.
|
||||
*/
|
||||
export async function initTauriFetch() {
|
||||
if (!isTauri || _tauriFetch) return
|
||||
try {
|
||||
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
|
||||
_tauriFetch = tauriFetch
|
||||
console.log('[Tauri] HTTP plugin pre-initialized')
|
||||
} catch (e) {
|
||||
console.warn('[Tauri] Failed to pre-init HTTP plugin:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-init on module load in Tauri mode
|
||||
if (isTauri) {
|
||||
initTauriFetch()
|
||||
}
|
||||
|
||||
// Dynamic plugin imports (only used behind isTauri checks)
|
||||
export async function getTauriStore() {
|
||||
const { LazyStore } = await import('@tauri-apps/plugin-store')
|
||||
|
||||
Reference in New Issue
Block a user