From 467bcff94d46b4bf6987798eb0b9c32f065607b7 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 12 Feb 2026 15:14:27 +0200 Subject: [PATCH] Add frontend session management with localStorage persistence, deduplicate shared code --- CHANGELOG.md | 5 + app/frontend/app/globals.css | 1143 +++++++++++++++++++- app/frontend/app/layout.tsx | 1 + app/frontend/components/Chat.tsx | 684 ++++++------ app/frontend/components/CitationBlock.tsx | 178 +-- app/frontend/components/ContextPanel.tsx | 322 ++---- app/frontend/components/DbInfo.tsx | 95 +- app/frontend/components/DocumentFilter.tsx | 357 ++---- app/frontend/components/SessionManager.tsx | 268 +++++ app/frontend/lib/format.ts | 18 + app/frontend/lib/icons.tsx | 46 + app/frontend/lib/sessionStorage.ts | 172 +++ app/frontend/package.json | 9 +- app/frontend/pnpm-lock.yaml | 1127 ++++++++----------- 14 files changed, 2598 insertions(+), 1827 deletions(-) create mode 100644 app/frontend/components/SessionManager.tsx create mode 100644 app/frontend/lib/format.ts create mode 100644 app/frontend/lib/icons.tsx create mode 100644 app/frontend/lib/sessionStorage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 443f2d50..14d7f795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ - **AG-UI state sync**: `ask` tool now emits `StateSnapshotEvent` instead of `StateDeltaEvent`, ensuring background summarization results are reliably delivered to clients - **TUI simplified**: Chat TUI reads directly from `ToolContext` namespace states instead of maintaining a separate `ChatSessionState` and manually syncing via AG-UI state events - **AG-UI web app**: Uses `ToolContextCache` to maintain per-thread state across requests +- **Frontend session management**: Persistent chat sessions with localStorage, wired to backend `ToolContextCache` via CopilotKit `threadId` + - Session manager dropdown: create, switch, delete, and export sessions to markdown + - Messages, chat state, and citations restored on session switch + - Session title derived from first user message + - Inline citation blocks injected after assistant responses via `qa_history` correlation ### Removed diff --git a/app/frontend/app/globals.css b/app/frontend/app/globals.css index 4ce84c9a..dc35ede0 100644 --- a/app/frontend/app/globals.css +++ b/app/frontend/app/globals.css @@ -23,48 +23,104 @@ a { text-decoration: none; } -/* CopilotKit markdown styling */ -.copilotKitAssistantMessage { - line-height: 1.6; +/* CopilotKit v2 theme overrides */ +:root { + --muted: #dbeafe; + --muted-foreground: #1e293b; + --primary: #1e293b; + --primary-foreground: #ffffff; + --secondary: #f1f5f9; + --secondary-foreground: #1e293b; + --border: #e2e8f0; + --input: #e2e8f0; + --ring: #3b82f6; } -.copilotKitAssistantMessage h1, -.copilotKitAssistantMessage h2, -.copilotKitAssistantMessage h3, -.copilotKitAssistantMessage h4 { +/* Chat message area — use full width instead of max-w-3xl */ +.chat-content .max-w-3xl { + max-width: 100%; +} + +/* Render-prop layout: flex column so citations sit between messages and input */ +.chat-layout { + display: flex; + flex-direction: column; + height: 100%; +} + +.chat-scroll-area { + flex: 1; + min-height: 0; + position: relative; + padding: 0 1rem 5rem; +} + +.chat-input-area { + position: relative; +} + +/* Override CopilotKit's absolute positioning on the input container + so it flows in the flex layout instead of overlaying */ +.chat-input-area > div { + position: relative; + bottom: auto; + left: auto; + right: auto; +} + +/* User message bubble spacing and padding */ +[data-message-id].items-end { + padding-top: 1rem; +} + +[data-message-id].items-end > .bg-muted { + color: #1e293b; + padding: 0.75rem 1.25rem; +} + +/* Assistant message markdown styling */ +.prose[data-message-id] { + line-height: 1.6; + padding-top: 0.5rem; +} + +.prose[data-message-id] h1, +.prose[data-message-id] h2, +.prose[data-message-id] h3, +.prose[data-message-id] h4 { margin-top: 1.25em; margin-bottom: 0.5em; font-weight: 600; line-height: 1.3; } -.copilotKitAssistantMessage h1 { +.prose[data-message-id] h1 { font-size: 1.5em; } -.copilotKitAssistantMessage h2 { +.prose[data-message-id] h2 { font-size: 1.3em; } -.copilotKitAssistantMessage h3 { +.prose[data-message-id] h3 { font-size: 1.15em; } -.copilotKitAssistantMessage p { +.prose[data-message-id] p { margin-bottom: 0.75em; } -.copilotKitAssistantMessage ul, -.copilotKitAssistantMessage ol { +.prose[data-message-id] ul, +.prose[data-message-id] ol { margin: 0.75em 0; padding-left: 1.5em; } -.copilotKitAssistantMessage li { +.prose[data-message-id] li { margin-bottom: 0.35em; } -.copilotKitAssistantMessage code { +.prose[data-message-id] code { background: #f1f5f9; padding: 0.15em 0.4em; border-radius: 4px; @@ -73,7 +129,7 @@ a { ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; } -.copilotKitAssistantMessage pre { +.prose[data-message-id] pre { background: #1e293b; color: #e2e8f0; padding: 1em; @@ -82,13 +138,13 @@ a { margin: 1em 0; } -.copilotKitAssistantMessage pre code { +.prose[data-message-id] pre code { background: transparent; padding: 0; color: inherit; } -.copilotKitAssistantMessage blockquote { +.prose[data-message-id] blockquote { border-left: 3px solid #3b82f6; padding-left: 1em; margin: 1em 0; @@ -96,43 +152,1076 @@ a { font-style: italic; } -.copilotKitAssistantMessage a { +.prose[data-message-id] a { color: #3b82f6; text-decoration: underline; } -.copilotKitAssistantMessage a:hover { +.prose[data-message-id] a:hover { color: #2563eb; } -.copilotKitAssistantMessage hr { +.prose[data-message-id] hr { border: none; border-top: 1px solid #e2e8f0; margin: 1.5em 0; } -.copilotKitAssistantMessage table { +.prose[data-message-id] table { border-collapse: collapse; width: 100%; margin: 1em 0; } -.copilotKitAssistantMessage th, -.copilotKitAssistantMessage td { +.prose[data-message-id] th, +.prose[data-message-id] td { border: 1px solid #e2e8f0; padding: 0.5em 0.75em; text-align: left; } -.copilotKitAssistantMessage th { +.prose[data-message-id] th { background: #f8fafc; font-weight: 600; } -.copilotKitAssistantMessage strong { +.prose[data-message-id] strong { font-weight: 600; } -.copilotKitAssistantMessage em { +.prose[data-message-id] em { font-style: italic; } + +/* ── Chat layout ── */ + +.chat-wrapper { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 1rem; +} + +.chat-container { + width: calc(100% - 2rem); + max-width: 1400px; + height: 90vh; + border-radius: 12px; + overflow: hidden; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + background: white; + display: flex; + flex-direction: column; +} + +.chat-header { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; +} + +.header-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + background: white; + border: 1px solid #e2e8f0; + border-radius: 6px; + cursor: pointer; + color: #64748b; + font-size: 0.8125rem; + transition: all 0.15s; +} + +.header-btn:hover { + background: #f1f5f9; + border-color: #cbd5e1; + color: #475569; +} + +.header-btn.has-content { + background: #eff6ff; + border-color: #bfdbfe; + color: #2563eb; +} + +.header-btn.has-content:hover { + background: #dbeafe; + border-color: #93c5fd; +} + +.chat-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.chat-content > * { + flex: 1; + min-height: 0; +} + +/* ── Tool call indicator ── */ + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +.tool-call-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + margin: 8px 0; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-radius: 10px; + font-size: 13px; + color: #475569; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + animation: fadeIn 0.2s ease-out; + transition: all 0.2s ease; +} + +.tool-call-card.loading { + border-color: #bfdbfe; + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); +} + +.tool-call-card.complete { + border-color: #bbf7d0; + background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); +} + +.tool-status-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + flex-shrink: 0; +} + +.tool-call-card.loading .tool-status-icon { + background: #dbeafe; + color: #2563eb; +} + +.tool-call-card.complete .tool-status-icon { + background: #bbf7d0; + color: #16a34a; +} + +.tool-spinner { + animation: spin 1s linear infinite; +} + +.tool-content { + flex: 1; + min-width: 0; +} + +.tool-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.tool-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: rgba(59, 130, 246, 0.1); + color: #2563eb; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.tool-call-card.complete .tool-badge { + background: rgba(22, 163, 74, 0.1); + color: #16a34a; +} + +.tool-status-text { + font-size: 11px; + color: #94a3b8; +} + +.tool-call-card.loading .tool-status-text { + animation: pulse 1.5s ease-in-out infinite; +} + +.tool-description { + color: #334155; + line-height: 1.5; + word-break: break-word; +} + +.tool-query { + color: #0f172a; + font-weight: 500; +} + +.tool-context { + color: #64748b; +} + +.tool-context em { + color: #475569; + font-style: normal; + font-weight: 500; +} + +/* ── Streaming cursor ── */ + +@keyframes dotBounce { + 0%, + 80%, + 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-4px); + } +} + +.streaming-cursor { + display: flex; + align-items: center; + gap: 4px; + padding: 1rem 0 0 0.25rem; +} + +.streaming-cursor .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #94a3b8; + animation: dotBounce 1.2s ease-in-out infinite; +} + +.streaming-cursor .dot:nth-child(2) { + animation-delay: 0.15s; +} + +.streaming-cursor .dot:nth-child(3) { + animation-delay: 0.3s; +} + +/* ── Session manager dropdown ── */ + +.session-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + width: 320px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12); + z-index: 100; + max-height: 400px; + display: flex; + flex-direction: column; +} + +.session-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid #e2e8f0; +} + +.session-dropdown-header span { + font-size: 12px; + font-weight: 600; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.new-session-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: #2563eb; + color: white; + border: none; + border-radius: 5px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.new-session-btn:hover { + background: #1d4ed8; +} + +.session-list { + overflow-y: auto; + flex: 1; +} + +.session-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background 0.1s; + border-bottom: 1px solid #f1f5f9; +} + +.session-item:hover { + background: #f8fafc; +} + +.session-item.active { + background: #eff6ff; +} + +.session-item-content { + flex: 1; + min-width: 0; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + font: inherit; + color: inherit; +} + +.session-item-title { + font-size: 13px; + font-weight: 500; + color: #0f172a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-item-meta { + font-size: 11px; + color: #94a3b8; + display: flex; + gap: 8px; +} + +.session-actions { + display: flex; + gap: 2px; +} + +.session-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + color: #94a3b8; + transition: all 0.1s; +} + +.session-action-btn:hover { + background: #f1f5f9; + color: #475569; +} + +.session-action-btn.danger:hover { + background: #fef2f2; + color: #dc2626; +} + +.confirm-delete { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #dc2626; +} + +.confirm-delete button { + padding: 2px 8px; + border-radius: 4px; + border: 1px solid; + font-size: 11px; + cursor: pointer; + font-weight: 500; +} + +.confirm-yes { + background: #dc2626; + color: white; + border-color: #dc2626; +} + +.confirm-no { + background: white; + color: #475569; + border-color: #e2e8f0; +} + +/* ── Citation blocks ── */ + +.citation-block { + margin-top: 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + font-size: 0.875rem; +} + +.citation-block-header { + background: #f8fafc; + padding: 0.5rem 0.75rem; + font-weight: 500; + color: #475569; + border-bottom: 1px solid #e2e8f0; +} + +.citation-item { + border-bottom: 1px solid #f1f5f9; +} + +.citation-item:last-child { + border-bottom: none; +} + +.citation-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.15s; +} + +.citation-header:hover { + background: #f8fafc; +} + +.citation-index { + font-weight: 600; + color: #3b82f6; + flex-shrink: 0; +} + +.citation-title { + flex: 1; + color: #334155; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.citation-page { + color: #94a3b8; + font-size: 0.75rem; + flex-shrink: 0; +} + +.citation-chevron { + color: #94a3b8; + font-size: 0.625rem; + flex-shrink: 0; + transition: transform 0.15s; +} + +.citation-chevron.expanded { + transform: rotate(0deg); +} + +.citation-content { + padding: 0.75rem; + background: #fafafa; + border-top: 1px solid #f1f5f9; +} + +.citation-headings { + font-size: 0.75rem; + color: #64748b; + margin-bottom: 0.5rem; + font-style: italic; +} + +.citation-text { + color: #475569; + line-height: 1.5; + white-space: pre-wrap; + max-height: 200px; + overflow-y: auto; +} + +.citation-view-btn { + margin-top: 0.5rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + background: #3b82f6; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; +} + +.citation-view-btn:hover { + background: #2563eb; +} + +/* ── Visual grounding modal ── */ + +.visual-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.visual-modal { + background: white; + border-radius: 8px; + padding: 1.5rem; + max-width: 90vw; + max-height: 90vh; + overflow: auto; + position: relative; +} + +.visual-modal-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: #ef4444; + color: white; + border: none; + border-radius: 50%; + width: 2rem; + height: 2rem; + cursor: pointer; + font-size: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.visual-modal-close:hover { + background: #dc2626; +} + +.visual-modal-title { + margin: 0 0 1rem 0; + font-size: 1.125rem; + color: #1e293b; +} + +.visual-modal-loading { + padding: 2rem; + text-align: center; + color: #64748b; +} + +.visual-modal-error { + padding: 1rem; + background: #fef2f2; + color: #dc2626; + border-radius: 4px; +} + +.visual-modal-images { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.visual-modal-page-label { + font-size: 0.75rem; + color: #64748b; + margin-bottom: 0.5rem; +} + +.visual-modal-image { + max-width: 100%; + border: 1px solid #e2e8f0; + border-radius: 4px; +} + +/* ── Context panel modal ── */ + +.context-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.context-modal { + background: white; + border-radius: 12px; + padding: 1.5rem; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: auto; + position: relative; + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.context-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.context-modal-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + color: #3b82f6; +} + +.context-modal-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #1e293b; +} + +.context-modal-description { + margin: 0 0 1rem 0; + font-size: 0.875rem; + color: #64748b; + line-height: 1.5; +} + +.context-content { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; + font-size: 0.875rem; + line-height: 1.6; + color: #334155; + white-space: pre-wrap; + max-height: 400px; + overflow-y: auto; +} + +.context-textarea { + width: 100%; + min-height: 200px; + padding: 1rem; + font-size: 0.875rem; + line-height: 1.6; + color: #334155; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + resize: vertical; + font-family: inherit; +} + +.context-textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.context-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + text-align: center; + color: #94a3b8; +} + +.context-empty-icon { + margin-bottom: 0.75rem; + opacity: 0.5; +} + +.context-empty-text { + font-size: 0.875rem; + line-height: 1.5; +} + +.context-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; +} + +.context-timestamp { + font-size: 0.75rem; + color: #94a3b8; +} + +.context-btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; +} + +.context-btn-close { + background: white; + color: #475569; + border: 1px solid #e2e8f0; +} + +.context-btn-close:hover { + background: #f8fafc; + border-color: #cbd5e1; +} + +.context-btn-save { + background: #3b82f6; + color: white; + border: 1px solid #3b82f6; + margin-left: 0.5rem; +} + +.context-btn-save:hover { + background: #2563eb; + border-color: #2563eb; +} + +.context-footer-buttons { + display: flex; + gap: 0.5rem; +} + +/* ── Document filter modal ── */ + +.filter-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.filter-modal { + background: white; + border-radius: 12px; + padding: 1.5rem; + width: 90%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + position: relative; + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.filter-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.filter-modal-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + color: #d97706; +} + +.filter-modal-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #1e293b; +} + +.filter-modal-description { + margin: 0 0 1rem 0; + font-size: 0.875rem; + color: #64748b; + line-height: 1.5; +} + +.filter-search { + width: 100%; + padding: 0.625rem 0.875rem; + font-size: 0.875rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + margin-bottom: 0.75rem; + outline: none; + transition: border-color 0.15s; +} + +.filter-search:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.filter-list { + flex: 1; + min-height: 200px; + max-height: 300px; + overflow-y: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.filter-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + cursor: pointer; + transition: background 0.1s; + border-bottom: 1px solid #e2e8f0; +} + +.filter-item:last-child { + border-bottom: none; +} + +.filter-item:hover { + background: #f1f5f9; +} + +.filter-item input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: #3b82f6; +} + +.filter-item-label { + flex: 1; + font-size: 0.875rem; + color: #334155; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-loading, +.filter-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100px; + color: #94a3b8; + font-size: 0.875rem; +} + +.filter-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; +} + +.filter-count { + font-size: 0.75rem; + color: #64748b; +} + +.filter-count strong { + color: #3b82f6; +} + +.filter-buttons { + display: flex; + gap: 0.5rem; +} + +.filter-btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; +} + +.filter-btn-secondary { + background: white; + color: #475569; + border: 1px solid #e2e8f0; +} + +.filter-btn-secondary:hover { + background: #f8fafc; + border-color: #cbd5e1; +} + +.filter-btn-primary { + background: #3b82f6; + color: white; + border: 1px solid #3b82f6; +} + +.filter-btn-primary:hover { + background: #2563eb; + border-color: #2563eb; +} + +.filter-btn-clear { + background: transparent; + color: #ef4444; + border: none; + padding: 0.5rem; + font-size: 0.75rem; +} + +.filter-btn-clear:hover { + text-decoration: underline; +} + +/* ── Database info bar ── */ + +.db-info { + display: flex; + gap: 1.5rem; + padding: 0.5rem 1rem; + font-size: 0.75rem; + color: #64748b; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + justify-content: center; +} + +.db-info-error { + color: #dc2626; + background: #fef2f2; +} + +.db-info-loading { + color: #64748b; +} + +.db-info-empty { + color: #f59e0b; + background: #fffbeb; +} + +.db-stat { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.db-stat-value { + font-weight: 600; + color: #334155; +} + +.db-stat-label { + color: #94a3b8; +} + +.db-index-badge { + padding: 0.125rem 0.375rem; + border-radius: 9999px; + font-size: 0.625rem; + font-weight: 500; +} + +.db-index-badge.indexed { + background: #dcfce7; + color: #166534; +} + +.db-index-badge.not-indexed { + background: #fef3c7; + color: #92400e; +} diff --git a/app/frontend/app/layout.tsx b/app/frontend/app/layout.tsx index ebe61f01..aef75028 100644 --- a/app/frontend/app/layout.tsx +++ b/app/frontend/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import "@copilotkit/react-core/v2/styles.css"; import "./globals.css"; export const metadata: Metadata = { diff --git a/app/frontend/components/Chat.tsx b/app/frontend/components/Chat.tsx index d6212ab3..12271549 100644 --- a/app/frontend/components/Chat.tsx +++ b/app/frontend/components/Chat.tsx @@ -1,60 +1,51 @@ "use client"; import { - CopilotKit, - useCoAgent, - useCoAgentStateRender, - useCopilotAction, -} from "@copilotkit/react-core"; -import { CopilotChat } from "@copilotkit/react-ui"; -import { useState } from "react"; -import "@copilotkit/react-ui/styles.css"; + CopilotChatMessageView, + CopilotChatView, + CopilotKitProvider, + defineToolCallRenderer, + UseAgentUpdate, + useAgent, + useCopilotKit, +} from "@copilotkit/react-core/v2"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { BrainIcon, FilterIcon } from "../lib/icons"; +import type { ChatSessionState } from "../lib/sessionStorage"; +import { + createSession, + getActiveSessionId, + getSession, + normalizeChatState, + updateSessionMessages, +} from "../lib/sessionStorage"; import CitationBlock from "./CitationBlock"; import ContextPanel from "./ContextPanel"; import DbInfo from "./DbInfo"; import DocumentFilter from "./DocumentFilter"; +import SessionManager from "./SessionManager"; // Must match AGUI_STATE_KEY from haiku.rag.agents.chat const AGUI_STATE_KEY = "haiku.rag.chat"; -interface Citation { - index: number; - document_id: string; - chunk_id: string; - document_uri: string; - document_title: string | null; - page_numbers: number[]; - headings: string[] | null; - content: string; -} - -interface QAResponse { - question: string; - answer: string; - confidence: number; - citations: Citation[]; -} - -interface SessionContext { - summary: string; - last_updated: string | null; -} - -interface ChatSessionState { - session_id: string; - initial_context: string | null; - citations: Citation[]; - qa_history: QAResponse[]; - session_context: SessionContext | null; - document_filter: string[]; - citation_registry: Record; -} - // AG-UI state is namespaced under AGUI_STATE_KEY interface AgentState { [AGUI_STATE_KEY]?: ChatSessionState; } +// biome-ignore lint/suspicious/noExplicitAny: CopilotKit message objects vary at runtime +function serializeMessages(messages: any[]): any[] { + return JSON.parse(JSON.stringify(messages)); +} + function SpinnerIcon() { return ( - - - - - - - - - - - ); -} - function ToolCallIndicator({ toolName, status, @@ -246,114 +212,6 @@ function ToolCallIndicator({ return (
-
{isComplete ? : }
@@ -373,60 +231,164 @@ function ToolCallIndicator({ ); } -function FilterIcon() { +// Context for sharing chat state with the message view +const ChatStateContext = createContext(null); + +// Wildcard tool call renderer for all server-side tools +const toolCallRenderers = [ + defineToolCallRenderer({ + name: "*", + render: ({ name, args, result }) => ( + } + /> + ), + }), +]; + +// Custom message view that injects CitationBlocks after assistant responses. +// Uses CopilotChatMessageView's children render prop to post-process the +// rendered message elements and inject citations at the right positions. +function MessageViewWithCitations({ + messages, + isRunning, +}: { + // biome-ignore lint/suspicious/noExplicitAny: AG-UI Message type is a broad union + messages: any[]; + isRunning: boolean; +}) { + const chatState = useContext(ChatStateContext); + + const cursor = isRunning ? ( +
+ + + +
+ ) : null; + return ( - - - + + {({ messageElements }) => { + if (!chatState?.qa_history?.length) { + return ( + <> + {messageElements} + {cursor} + + ); + } + + // CopilotChatMessageView renders one element per user/assistant/activity + // message (tool messages produce nothing). We correlate elements with + // messages to inject CitationBlocks after the right assistant responses. + // + // Tool call objects on messages only carry `id` (no `name`), so we + // can't identify which tool was called from the message alone. Instead + // we rely on the fact that qa_history only grows when the `ask` tool + // runs: after each assistant text response that followed tool calls, + // we inject any new qa_history citations. + const result: React.ReactNode[] = []; + let qaIdx = 0; + let seenToolCalls = false; + let elemIdx = 0; + + for (const msg of messages) { + if (msg.role === "user") { + seenToolCalls = false; + } + + if ( + msg.role === "assistant" && + Array.isArray(msg.toolCalls) && + msg.toolCalls.length > 0 + ) { + seenToolCalls = true; + } + + const isRendered = + msg.role === "user" || + msg.role === "assistant" || + msg.role === "activity"; + if (!isRendered) continue; + + if (elemIdx < messageElements.length) { + result.push(messageElements[elemIdx]); + elemIdx++; + } + + // After an assistant text response that followed tool calls, + // inject the next qa_history entry's citations (one per turn) + if (msg.role === "assistant" && msg.content && seenToolCalls) { + if (qaIdx < chatState.qa_history.length) { + const qa = chatState.qa_history[qaIdx]; + if (qa.citations?.length) { + result.push( + , + ); + } + qaIdx++; + } + seenToolCalls = false; + } + } + + while (elemIdx < messageElements.length) { + result.push(messageElements[elemIdx]); + elemIdx++; + } + + return ( + <> + {result} + {cursor} + + ); + }} + ); } -function ChatContentInner() { +function ChatContentInner({ + sessionId, + onSessionChange, +}: { + sessionId: string; + onSessionChange: (id: string) => void; +}) { const [contextOpen, setContextOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false); - const { state: agentState, setState: setAgentState } = useCoAgent( - { - name: "chat_agent", - initialState: { - [AGUI_STATE_KEY]: { - session_id: "", - initial_context: null, - citations: [], - qa_history: [], - session_context: null, - document_filter: [], - citation_registry: {}, - }, - }, - }, + const { agent } = useAgent({ + agentId: "chat_agent", + updates: [ + UseAgentUpdate.OnMessagesChanged, + UseAgentUpdate.OnStateChanged, + UseAgentUpdate.OnRunStatusChanged, + ], + }); + const { copilotkit: ck } = useCopilotKit(); + + // Set threadId (CopilotChat normally does this in its connect effect) + useEffect(() => { + agent.threadId = sessionId; + }, [agent, sessionId]); + + const chatState = normalizeChatState( + (agent.state as AgentState)?.[AGUI_STATE_KEY], ); - const normalizeChatState = ( - state: ChatSessionState | undefined, - ): ChatSessionState => ({ - session_id: state?.session_id ?? "", - initial_context: state?.initial_context ?? null, - citations: state?.citations ?? [], - qa_history: state?.qa_history ?? [], - session_context: state?.session_context ?? null, - document_filter: state?.document_filter ?? [], - citation_registry: state?.citation_registry ?? {}, - }); - const mergeChatState = (partial: Partial) => { - const current = normalizeChatState(agentState?.[AGUI_STATE_KEY]); - setAgentState({ - ...agentState, + const current = normalizeChatState( + (agent.state as AgentState)?.[AGUI_STATE_KEY], + ); + agent.setState({ + ...agent.state, [AGUI_STATE_KEY]: { ...current, ...partial, @@ -434,14 +396,105 @@ function ChatContentInner() { }); }; - // Extract session context, document filter, and initial context from agent state - const sessionContext = agentState?.[AGUI_STATE_KEY]?.session_context ?? null; - const documentFilter = agentState?.[AGUI_STATE_KEY]?.document_filter ?? []; - const initialContext = agentState?.[AGUI_STATE_KEY]?.initial_context ?? ""; + // Restore session from localStorage when agent reference changes. + // useAgent returns a provisional agent initially, then the real agent + // after runtime connects — re-run restore each time so messages stick. + useEffect(() => { + if (agent.messages.length > 0) return; + const session = getSession(sessionId); + if (!session) return; + if (session.chatState) { + agent.setState({ + [AGUI_STATE_KEY]: normalizeChatState(session.chatState), + }); + } + if (session.messages.length > 0) { + // biome-ignore lint/suspicious/noExplicitAny: AG-UI Message type is a broad union + agent.setMessages(session.messages as any[]); + } + }, [agent, sessionId]); + + // Persist messages and state to localStorage. + // Read chatState from agent.state at effect time (not render time) so that + // restore and persist effects in the same commit see consistent state. + // biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify tracks content changes + useEffect(() => { + if (sessionId && agent.messages.length > 0) { + const currentChatState = normalizeChatState( + (agent.state as AgentState)?.[AGUI_STATE_KEY], + ); + updateSessionMessages( + sessionId, + serializeMessages(agent.messages), + currentChatState, + ); + } + }, [JSON.stringify(agent.messages), chatState, sessionId]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: stable identity via agent ref + const messages = useMemo( + () => [...agent.messages], + [JSON.stringify(agent.messages)], + ); + + const onSubmitMessage = useCallback( + async (text: string) => { + agent.addMessage({ + id: crypto.randomUUID(), + role: "user", + content: text, + }); + try { + await ck.runAgent({ agent }); + } catch (error) { + console.error("runAgent failed", error); + } + }, + [agent, ck], + ); + + const onStop = useCallback(() => { + try { + ck.stopAgent({ agent }); + } catch { + agent.abortRun(); + } + }, [agent, ck]); + + // Strip the scroll view's internal paddingBottom (inline style set by + // CopilotChatView to reserve space for its absolutely-positioned input + // container, which we've overridden to flow in a flex layout). + // Uses a ref callback so it runs when the element actually mounts. + const paddingObserver = useRef(null); + const scrollAreaCallbackRef = useCallback((node: HTMLDivElement | null) => { + if (paddingObserver.current) { + paddingObserver.current.disconnect(); + paddingObserver.current = null; + } + if (!node) return; + const strip = () => { + for (const div of node.querySelectorAll("div")) { + if (div.style.paddingBottom && div.style.paddingBottom !== "1rem") { + div.style.paddingBottom = "1rem"; + } + } + }; + strip(); + const observer = new MutationObserver(strip); + observer.observe(node, { + subtree: true, + attributes: true, + attributeFilter: ["style"], + }); + paddingObserver.current = observer; + }, []); + + const sessionContext = chatState.session_context; + const documentFilter = chatState.document_filter; + const initialContext = chatState.initial_context ?? ""; // Context is locked after first message (qa_history has entries) - const isContextLocked = - (agentState?.[AGUI_STATE_KEY]?.qa_history?.length ?? 0) > 0; + const isContextLocked = (chatState.qa_history?.length ?? 0) > 0; const handleFilterApply = (selected: string[]) => { mergeChatState({ document_filter: selected }); @@ -452,132 +505,15 @@ function ChatContentInner() { mergeChatState({ initial_context: value || null }); }; - useCoAgentStateRender({ - name: "chat_agent", - render: ({ state }) => { - const chatState = state[AGUI_STATE_KEY]; - if (chatState?.citations.length) { - return ; - } - return null; - }, - }); - - useCopilotAction({ - name: "search", - available: "disabled", - parameters: [ - { name: "query", type: "string" }, - { name: "document_name", type: "string" }, - ], - render: ({ status, args }) => ( - } - /> - ), - }); - - useCopilotAction({ - name: "ask", - available: "disabled", - parameters: [ - { name: "question", type: "string" }, - { name: "document_name", type: "string" }, - ], - render: ({ status, args }) => ( - } - /> - ), - }); - - useCopilotAction({ - name: "get_document", - available: "disabled", - parameters: [{ name: "query", type: "string" }], - render: ({ status, args }) => ( - } - /> - ), - }); - return ( - <> - +
+
- + > + {({ scrollView, feather, inputContainer }) => ( +
+
+ {scrollView} + {feather} +
+
{inputContainer}
+
+ )} +
@@ -637,18 +585,34 @@ function ChatContentInner() { selected={documentFilter} onApply={handleFilterApply} /> - + ); } -function ChatContent() { - return ; -} - export default function Chat() { + const [activeSessionId, setActiveSessionId] = useState(null); + + useEffect(() => { + let id = getActiveSessionId(); + if (!id) { + id = createSession().id; + } + setActiveSessionId(id); + }, []); + + if (!activeSessionId) return null; + return ( - - - + + + ); } diff --git a/app/frontend/components/CitationBlock.tsx b/app/frontend/components/CitationBlock.tsx index 17d07ab1..b4c463bd 100644 --- a/app/frontend/components/CitationBlock.tsx +++ b/app/frontend/components/CitationBlock.tsx @@ -1,17 +1,7 @@ "use client"; import { useCallback, useState } from "react"; - -interface Citation { - index: number; - document_id: string; - chunk_id: string; - document_uri: string; - document_title: string | null; - page_numbers: number[]; - headings: string[] | null; - content: string; -} +import type { Citation } from "../lib/sessionStorage"; interface CitationBlockProps { citations: Citation[]; @@ -133,172 +123,6 @@ export default function CitationBlock({ citations }: CitationBlockProps) { return ( <> -
Sources ({citations.length}) diff --git a/app/frontend/components/ContextPanel.tsx b/app/frontend/components/ContextPanel.tsx index 04bf40ea..69a8f7c7 100644 --- a/app/frontend/components/ContextPanel.tsx +++ b/app/frontend/components/ContextPanel.tsx @@ -1,11 +1,9 @@ "use client"; import { useCallback, useEffect, useId, useState } from "react"; - -interface SessionContext { - summary: string; - last_updated: string | null; -} +import { formatRelativeTime } from "../lib/format"; +import { BrainIcon } from "../lib/icons"; +import type { SessionContext } from "../lib/sessionStorage"; interface ContextPanelProps { isOpen: boolean; @@ -16,51 +14,6 @@ interface ContextPanelProps { isLocked?: boolean; } -function formatRelativeTime(isoString: string): string { - const date = new Date(isoString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSec = Math.floor(diffMs / 1000); - const diffMin = Math.floor(diffSec / 60); - const diffHour = Math.floor(diffMin / 60); - - if (diffSec < 60) { - return "just now"; - } - if (diffMin < 60) { - return `${diffMin} minute${diffMin === 1 ? "" : "s"} ago`; - } - if (diffHour < 24) { - return `${diffHour} hour${diffHour === 1 ? "" : "s"} ago`; - } - return date.toLocaleDateString(); -} - -function BrainIcon() { - return ( - - - - - - - - - - - - ); -} - export default function ContextPanel({ isOpen, onClose, @@ -101,223 +54,78 @@ export default function ContextPanel({ const isEditMode = !isLocked && !hasSessionContext; return ( - <> - +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: modal content wrapper */}
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} > - {/* biome-ignore lint/a11y/noStaticElementInteractions: modal content wrapper */} -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > -
-
- -
-

- {isEditMode ? "Initial Context" : "Session Context"} -

+
+
+
-

- {isEditMode - ? "Set background context to guide the conversation. This will be locked after you send your first message." - : "This is what the assistant has learned from your conversation so far. It uses this context to provide more relevant answers."} -

- {isEditMode ? ( -