diff --git a/packages/web/src/components/dashboard/DashboardView.tsx b/packages/web/src/components/dashboard/DashboardView.tsx index 5d46c0c..eaee61b 100644 --- a/packages/web/src/components/dashboard/DashboardView.tsx +++ b/packages/web/src/components/dashboard/DashboardView.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback, useEffect } from 'react'; import { useSessionStore } from '../../stores/sessionStore.js'; import { SessionCard } from './SessionCard.js'; import { SidePanel } from './SidePanel.js'; +import { saveAndBookmarkSession } from '../../lib/bookmarks-api.js'; import type { ClientMessage } from '../../lib/ws-protocol.js'; import './dashboard.css'; @@ -13,6 +14,7 @@ export function DashboardView({ onSend }: DashboardViewProps) { const { sessions, events: allEvents, + bookmarks, dashboardFocusedSession, setDashboardFocusedSession, dashboardSessionOrder, @@ -21,8 +23,14 @@ export function DashboardView({ onSend }: DashboardViewProps) { dismissDashboardSession, navigateFromDashboard, navigateFromDashboardToLogs, + navigateToLogsForSession, } = useSessionStore(); + const bookmarkedSessionIds = useMemo( + () => new Set(bookmarks.map((b) => b.sessionId)), + [bookmarks] + ); + // Drag state const [dragIndex, setDragIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -253,6 +261,8 @@ export function DashboardView({ onSend }: DashboardViewProps) { onDismiss={handleDismiss} onDrilldown={handleDrilldown} onDrilldownToLogs={handleDrilldownToLogs} + onOpenInLogs={navigateToLogsForSession} + onBookmark={bookmarkedSessionIds.has(session.sessionId) ? undefined : saveAndBookmarkSession} onSend={onSend} index={orderIndex} onDragStart={handleDragStart} diff --git a/packages/web/src/components/dashboard/SessionCard.tsx b/packages/web/src/components/dashboard/SessionCard.tsx index efab30a..f87d2cf 100644 --- a/packages/web/src/components/dashboard/SessionCard.tsx +++ b/packages/web/src/components/dashboard/SessionCard.tsx @@ -17,6 +17,8 @@ interface SessionCardProps { onDismiss: (sessionId: string) => void; onDrilldown: (sessionId: string, eventId: string) => void; onDrilldownToLogs: (sessionId: string, eventId: string) => void; + onOpenInLogs?: (sessionId: string) => void; + onBookmark?: (sessionId: string, name: string) => void; onSend: (msg: ClientMessage) => void; index: number; onDragStart: (index: number) => void; @@ -596,7 +598,7 @@ function RateLimitMini({ label, pct, resetsAt }: { label: string; pct: number; r } export function SessionCard({ - session, events, isFocused, onFocus, onDismiss, onDrilldown, onDrilldownToLogs, onSend, index, + session, events, isFocused, onFocus, onDismiss, onDrilldown, onDrilldownToLogs, onOpenInLogs, onBookmark, onSend, index, onDragStart, onDragOver, onDragEnd, isDragging, isDragOver, totalCards, isSpanning, }: SessionCardProps) { const { sessionMetrics, investigatedSessions } = useSessionStore(s => ({ @@ -610,6 +612,8 @@ export function SessionCard({ const dragRef = useRef(null); const chainContainerRef = useRef(null); const [chainCapacity, setChainCapacity] = useState(6); + const [showBookmarkInput, setShowBookmarkInput] = useState(false); + const [bookmarkName, setBookmarkName] = useState(''); // Measure the chain container width and compute how many nodes fit. // Each node is 28px, each connector is 12px → N nodes need (N-1)*40 + 28 px. @@ -625,6 +629,12 @@ export function SessionCard({ return () => observer.disconnect(); }, []); + const submitBookmark = useCallback(() => { + const name = bookmarkName.trim() || getSessionDisplayName(session); + onBookmark?.(session.sessionId, name); + setShowBookmarkInput(false); + }, [bookmarkName, session, onBookmark]); + const handleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onFocus(session.sessionId); @@ -684,15 +694,18 @@ export function SessionCard({ )} - {/* Session name */} + {/* Session name — double-click opens in Logs */} { e.stopPropagation(); onOpenInLogs?.(session.sessionId); }} > {getSessionDisplayName(session)} @@ -754,6 +767,24 @@ export function SessionCard({ )} + {/* Bookmark button */} + {onBookmark && !showBookmarkInput && ( + + )} + {/* Dismiss button */} + + + )} + {/* Activity chain */}
diff --git a/packages/web/src/components/layout/Header.tsx b/packages/web/src/components/layout/Header.tsx index be80233..4e72256 100644 --- a/packages/web/src/components/layout/Header.tsx +++ b/packages/web/src/components/layout/Header.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useMemo, useState, useCallback, useEffect } from 'react'; import { useSessionStore } from '../../stores/sessionStore.js'; import { SessionLaymansTerms } from '../shared/SessionLaymansTerms.js'; +import { saveAndBookmarkSession } from '../../lib/bookmarks-api.js'; function getSessionName(cwd: string, sessionId: string, agentType?: string, showAgentPrefix?: boolean, sessionName?: string): string { const name = sessionName || (cwd ? (cwd.split('/').filter(Boolean).pop() ?? cwd) : sessionId.slice(0, 8)); @@ -77,8 +78,29 @@ export function Header() { dashboardFocusedSession, setDashboardFocusedSession, sessionMetrics, investigatedSessions, + bookmarks, } = useSessionStore(); + const bookmarkedSessionIds = useMemo( + () => new Set(bookmarks.map((b) => b.sessionId)), + [bookmarks] + ); + + const [showBookmarkInput, setShowBookmarkInput] = useState(false); + const [bookmarkName, setBookmarkName] = useState(''); + + // Reset bookmark UI when the active session changes + useEffect(() => { + setShowBookmarkInput(false); + setBookmarkName(''); + }, [activeSessionId]); + + const handleBookmarkSession = useCallback(async (name: string) => { + if (!activeSessionId) return; + setShowBookmarkInput(false); + void saveAndBookmarkSession(activeSessionId, name.trim() || activeSessionId.slice(0, 8)); + }, [activeSessionId]); + const statusConfig = { connecting: { dot: 'bg-[#d29922]', text: 'Connecting...', textColor: 'text-[#d29922]' }, connected: { dot: 'bg-[#3fb950]', text: 'Connected', textColor: 'text-[#3fb950]' }, @@ -229,6 +251,50 @@ export function Header() { })}
+ {/* Bookmark button — shown in Logs view when a session is active and not yet bookmarked */} + {currentView === 'stream' && activeSessionId && !bookmarkedSessionIds.has(activeSessionId) && ( + <> + {showBookmarkInput ? ( +
+ setBookmarkName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { void handleBookmarkSession(bookmarkName); } + if (e.key === 'Escape') { setShowBookmarkInput(false); } + }} + placeholder="Bookmark name..." + className="text-xs bg-[#0d1117] border border-[#58a6ff] rounded px-2 py-0.5 text-[#e6edf3] placeholder-[#484f58] focus:outline-none w-40" + /> + + +
+ ) : ( + + )} + + )} + {/* Divider */}
diff --git a/packages/web/src/components/layout/InvestigationPanel.tsx b/packages/web/src/components/layout/InvestigationPanel.tsx index a1ae3eb..4b4b3ce 100644 --- a/packages/web/src/components/layout/InvestigationPanel.tsx +++ b/packages/web/src/components/layout/InvestigationPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import { useSessionStore } from '../../stores/sessionStore.js'; import { useEventStore } from '../../hooks/useEventStore.js'; @@ -12,6 +12,8 @@ import type { ClientMessage } from '../../lib/ws-protocol.js'; import type { TimelineEvent } from '../../lib/types.js'; import { ThinkingBlock } from '../shared/ThinkingBlock.js'; +type AskPhase = 'connecting' | 'waiting'; + function AgentResponsePrompt({ event }: { event: TimelineEvent }) { const { thinking, response } = getEffectiveAgentContent(event); return ( @@ -89,6 +91,34 @@ export function InvestigationPanel({ onSend, eventId: embeddedEventId, onClose } const [analysisDepth, setAnalysisDepth] = useState<'quick' | 'detailed' | null>(null); const [isAskingFailure, setIsAskingFailure] = useState(false); + const [pendingAsk, setPendingAsk] = useState<{ + question: string; + phase: AskPhase; + startedAt: number; + elapsedMs: number; + } | null>(null); + const pendingTimerRef = useRef | null>(null); + + const startPendingTimer = useCallback((question: string) => { + const startedAt = Date.now(); + setPendingAsk({ question, phase: 'connecting', startedAt, elapsedMs: 0 }); + if (pendingTimerRef.current) clearInterval(pendingTimerRef.current); + pendingTimerRef.current = setInterval(() => { + setPendingAsk((prev) => { + if (!prev) return null; + const elapsed = Date.now() - prev.startedAt; + return { ...prev, elapsedMs: elapsed, phase: elapsed > 800 ? 'waiting' : 'connecting' }; + }); + }, 200); + }, []); + + const clearPendingTimer = useCallback(() => { + if (pendingTimerRef.current) { + clearInterval(pendingTimerRef.current); + pendingTimerRef.current = null; + } + }, []); + const fetchModels = useCallback(async () => { if (!config) return; const p = config.analysis.provider; @@ -128,6 +158,9 @@ export function InvestigationPanel({ onSend, eventId: embeddedEventId, onClose } setAnalysisDepth(null); }, [selectedEventId]); + // Cancel the tick timer when the panel unmounts + useEffect(() => () => clearPendingTimer(), [clearPendingTimer]); + const isEmbedded = !!embeddedEventId; if (!isEmbedded && (!investigationOpen || !selectedEventId)) return null; if (!selectedEventId) return null; @@ -166,6 +199,7 @@ export function InvestigationPanel({ onSend, eventId: embeddedEventId, onClose } : 'Why did this tool call fail? What was wrong with the approach, what error occurred, and what was the eventual solution or workaround? Provide a detailed analysis.'; markSessionInvestigated(event.sessionId); setIsAskingFailure(true); + startPendingTimer(question); try { const response = await fetch(`/api/analysis/${selectedEventId}/ask`, { method: 'POST', @@ -182,15 +216,19 @@ export function InvestigationPanel({ onSend, eventId: embeddedEventId, onClose } }); if (response.ok) { const data = await response.json() as { answer: string; tokens?: { input: number; output: number }; latencyMs?: number; model?: string }; - addInvestigationQuestion(selectedEventId, question, data.answer, { - tokens: data.tokens, - latencyMs: data.latencyMs, - model: data.model, - }); + const answer = data.answer?.trim(); + addInvestigationQuestion(selectedEventId, question, + answer || '[The model returned a blank response.]', + { tokens: data.tokens, latencyMs: data.latencyMs, model: data.model }); + } else { + addInvestigationQuestion(selectedEventId, question, `Request failed (HTTP ${response.status}). Please try again.`); } - } catch { - addInvestigationQuestion(selectedEventId, question, 'Failed to get answer. Please try again.'); + } catch (err) { + addInvestigationQuestion(selectedEventId, question, + `Network error: ${err instanceof Error ? err.message : 'Could not reach the server.'}`); } finally { + clearPendingTimer(); + setPendingAsk(null); setIsAskingFailure(false); } }; @@ -198,6 +236,7 @@ export function InvestigationPanel({ onSend, eventId: embeddedEventId, onClose } const handleAsk = async (question: string) => { markSessionInvestigated(event.sessionId); setIsAskingQuestion(true); + startPendingTimer(question); try { const response = await fetch(`/api/analysis/${selectedEventId}/ask`, { method: 'POST', @@ -214,15 +253,26 @@ export function InvestigationPanel({ onSend, eventId: embeddedEventId, onClose } }); if (response.ok) { const data = await response.json() as { answer: string; tokens?: { input: number; output: number }; latencyMs?: number; model?: string }; - addInvestigationQuestion(selectedEventId, question, data.answer, { + const answer = data.answer?.trim(); + const finalAnswer = answer + ? answer + : '[The model returned a blank response. This may indicate a token limit was reached, the model refused to answer, or a provider-side issue occurred.]'; + addInvestigationQuestion(selectedEventId, question, finalAnswer, { tokens: data.tokens, latencyMs: data.latencyMs, model: data.model, }); + } else { + const errData = await response.json().catch(() => ({})) as { error?: string }; + addInvestigationQuestion(selectedEventId, question, + `Request failed (HTTP ${response.status})${errData.error ? `: ${errData.error}` : '. Please try again.'}`); } - } catch { - addInvestigationQuestion(selectedEventId, question, 'Failed to get answer. Please try again.'); + } catch (err) { + addInvestigationQuestion(selectedEventId, question, + `Network error: ${err instanceof Error ? err.message : 'Could not reach the server. Please try again.'}`); } finally { + clearPendingTimer(); + setPendingAsk(null); setIsAskingQuestion(false); } }; @@ -431,11 +481,31 @@ export function InvestigationPanel({ onSend, eventId: embeddedEventId, onClose } )} {/* Investigation Q&A */} - {state.questions.length > 0 && ( + {(state.questions.length > 0 || pendingAsk) && (
Questions + {/* In-flight ask — shown immediately after submission, above prior answers */} + {pendingAsk && ( +
+
+ Q: + {pendingAsk.question} +
+
+ A: + + {/* 800ms threshold: first phase covers network round-trip, second covers LLM inference */} + {pendingAsk.phase === 'connecting' ? 'Connecting...' : 'Waiting for response...'} + + + {(pendingAsk.elapsedMs / 1000).toFixed(1)}s + +
+
+ )} + {state.questions.map((qa, i) => (
diff --git a/packages/web/src/lib/bookmarks-api.ts b/packages/web/src/lib/bookmarks-api.ts new file mode 100644 index 0000000..03c09a7 --- /dev/null +++ b/packages/web/src/lib/bookmarks-api.ts @@ -0,0 +1,20 @@ +/** Snapshot a live session to SQLite then create a named bookmark. + * Returns false if the snapshot fails (e.g. recording disabled). */ +export async function saveAndBookmarkSession(sessionId: string, name: string): Promise { + try { + const snapRes = await fetch('/api/bookmarks/sessions/save-current', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }), + }); + if (!snapRes.ok) return false; + const bookmarkRes = await fetch('/api/bookmarks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, name, folderId: null }), + }); + return bookmarkRes.ok; + } catch { + return false; + } +} diff --git a/packages/web/src/stores/sessionStore.ts b/packages/web/src/stores/sessionStore.ts index eec38ab..1f94dde 100644 --- a/packages/web/src/stores/sessionStore.ts +++ b/packages/web/src/stores/sessionStore.ts @@ -134,6 +134,7 @@ export interface SessionState { dismissDashboardSession: (sessionId: string) => void; navigateFromDashboard: (sessionId: string, eventId: string) => void; navigateFromDashboardToLogs: (sessionId: string, eventId: string) => void; + navigateToLogsForSession: (sessionId: string) => void; clearScrollToEvent: () => void; returnFromDashboardDrilldown: () => void; setAccessLogOpen: (open: boolean) => void; @@ -508,6 +509,15 @@ export const useSessionStore = create((set) => ({ investigationOpen: true, scrollToEventId: eventId, }), + navigateToLogsForSession: (sessionId) => set((state) => ({ + dashboardOpen: false, + returnToDashboard: state.dashboardOpen, + flowchartOpen: false, + activeSessionId: sessionId, + selectedEventId: null, + investigationOpen: false, + scrollToEventId: null, + })), clearScrollToEvent: () => set({ scrollToEventId: null }), returnFromDashboardDrilldown: () => set({ dashboardOpen: true,