Merge pull request #60 from QRun-IO/feature/assistant-panel
feat(assistant): QQQ Assistant panel, phase 1 (read-only Q&A and navigation)
This commit was merged in pull request #60.
This commit is contained in:
36 files changed
+5920
-15
No files matched your search
@@ -2,6 +2,10 @@
|
||||
|
||||
[RC14 preparation](1.0.0-RC.14.md) collects every change merged since [RC13](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13): the record search fix and the Next.js security update. RC13 is published and immutable. Changes merged after the RC14 preparation commit are listed below. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
|
||||
## Features
|
||||
|
||||
- **QQQ Assistant panel (Phase 1: read-only questions and navigation).** When the `qbit-assistant` backend is installed and `GET /qqq/v1/assistant/config` enables it for the user, a header button (with unread badge), `Ctrl/⌘+J` and a command palette "Ask Assistant" item open a chat panel. It docks beside the page from 1024 px (resizable 360-640 px, its own scrolling message list) and is a full-height sheet below that. Replies stream over SSE with resume after a dropped connection and a Stop button; tool steps, record chips, records/record/count/history renders and chart widgets come from structured payloads, and markdown renders without raw HTML or images, with links only to same-origin `/app` paths. `?assistant=open&assistantPrompt=...` opens a new conversation with the prompt pre-filled. Without the backend nothing renders and the shortcut is inert. The panel code loads on first open (about 1.7 KB added to first load).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- **Record developer view** (`/app/{table}/{id}/dev`): the record screen's navigation (back to the record, record title, Edit, Actions, Audit, breadcrumbs) and a sticky section bar (a section picker on phones, wrapping links elsewhere) over Fields (display and raw values, types, possible-value sources and adornments, filterable), **API Access** (the v1 routes this screen uses and every application API that exposes the table, read from its OpenAPI spec and limited to what the user may do, with copyable curl, fetch and Java examples built from the record's values, placeholder credentials only, and links to the API docs and specs), **developer panels** from QQQ's provider contract ([qqq#1076](https://github.com/QRun-IO/qqq/pull/1076): record security, ESB events, and any qbit's panel such as quick search, SQL workbench or the assistant, shown only when installed and active for the table; QQQ before 4.1.0-RC.4 has no panels endpoint, so the view shows none and asks for it only once per session), Relations, recent Audit, associated scripts, raw JSON and a searchable Table Metadata explorer. Readable from 360 to 3200px with no sideways scrolling. The view loads on demand, keeping the route's first-load JavaScript at 333.5 KB (budget 360 KB).
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-markdown": "10.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "15.6.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Readable widget widths (feature/widget-width-readability, on PR #50): the gridMinWidth container-query ladder (about 80 @container rules) and the table-widget card layout raise shared CSS from 20.4 KB (PR #50 CI) to 21.1 KB gzip, over the 21 KB cap, so the CSS cap becomes 22 KB (about 4% headroom); JS routes are unchanged. Raise a cap only with a measured reason in the commit.",
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Readable widget widths (feature/widget-width-readability, on PR #50): the gridMinWidth container-query ladder (about 80 @container rules) and the table-widget card layout raise shared CSS from 20.4 KB (PR #50 CI) to 21.1 KB gzip, over the 21 KB cap, so the CSS cap becomes 22 KB (about 4% headroom); JS routes are unchanged. Raise a cap only with a measured reason in the commit. QQQ Assistant panel (2026-10-12): its Tailwind utilities (panel, sheet, markdown typography) add 0.9 KB gzip to the shared CSS (21.3 KB measured against the 21 KB cap); merged with the widget width ladder the shared CSS measured 21.3 KB (2026-10-12 local export), inside the 22 KB cap; the panel's JS is budgeted under lazyChunks.assistant. Assistant panel plus SQL Workbench dark mode (#78, 2026-10-12): main with #78 measured 21.5 KB of shared CSS, and merging the assistant panel measured 22.3 KB (local export), over the 22 KB cap, so the CSS cap becomes 23 KB (about 3% headroom); JS routes and lazy chunks are unchanged.",
|
||||
"firstLoadJsGzipKB": 350,
|
||||
"routes": {
|
||||
"/": 255,
|
||||
@@ -18,7 +18,7 @@
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350
|
||||
},
|
||||
"cssGzipKB": 22,
|
||||
"cssGzipKB": 23,
|
||||
"totalJsGzipKB": 1050,
|
||||
"largestChunkGzipKB": 100,
|
||||
"lazyChunks": {
|
||||
@@ -36,6 +36,11 @@
|
||||
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
|
||||
"marker": "logicalOperatorNewline",
|
||||
"gzipKB": 65
|
||||
},
|
||||
"assistant": {
|
||||
"$comment": "The QQQ Assistant panel (qbit-assistant): chat UI, SSE streaming client, react-markdown + rehype-sanitize, loaded on first open through import() in AssistantMount; the shell keeps only the /assistant/config probe, header button and Ctrl/⌘+J (about 1.7 KB first load). Measured 50.8 KB gzip at introduction; budget about 8% above.",
|
||||
"marker": "assistant-composer-form",
|
||||
"gzipKB": 55
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+698
File diff suppressed because it is too large.
Load diff
@@ -48,6 +48,9 @@ import { buildBreadcrumbs, buildDocumentTitle } from '@/components/layout/Breadc
|
||||
import { CommandMenu } from '@/components/feedback/CommandMenu'
|
||||
import { SearchDialog } from '@/components/feedback/SearchDialog'
|
||||
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
|
||||
import { AssistantProvider, useAssistantController } from '@/components/assistant/AssistantProvider'
|
||||
import { AssistantHeaderButton } from '@/components/assistant/AssistantHeaderButton'
|
||||
import { AssistantMount } from '@/components/assistant/AssistantMount'
|
||||
|
||||
/**
|
||||
* Inner layout that renders the full dashboard chrome.
|
||||
@@ -98,6 +101,10 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
// QQQ Assistant (qbit-assistant): hidden and inert unless its /assistant/config probe enables it
|
||||
const assistant = useAssistantController(metaData, isAuthenticated)
|
||||
const { enabled: assistantEnabled, toggle: toggleAssistant } = assistant
|
||||
|
||||
// Generate sidebar routes and path map from app tree
|
||||
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, homePath } = useAppTreeRoutes(metaData)
|
||||
|
||||
@@ -180,6 +187,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
* Global `keydown` handler that drives the application-level keyboard shortcuts.
|
||||
*
|
||||
* - Cmd+K / Ctrl+K — toggles the command palette.
|
||||
* - Cmd+J / Ctrl+J — toggles the assistant panel (when available; works in text fields too).
|
||||
* - Escape — closes all overlays (command palette, search dialog, help dialog, mobile sidebar).
|
||||
* - `.` — opens the command palette (only when not in a text field).
|
||||
* - `/` — opens the search dialog (only when not in a text field).
|
||||
@@ -192,6 +200,11 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
e.preventDefault()
|
||||
setCommandOpen((prev) => !prev)
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'j' && assistantEnabled) {
|
||||
e.preventDefault()
|
||||
setCommandOpen(false)
|
||||
toggleAssistant()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setCommandOpen(false)
|
||||
setSearchOpen(false)
|
||||
@@ -215,7 +228,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
setHelpOpen(true)
|
||||
}
|
||||
}
|
||||
}, [isInputFocused])
|
||||
}, [isInputFocused, assistantEnabled, toggleAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
@@ -260,6 +273,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
// cannot clip it, and one near the end of a long page (the company footer's "opens in a new
|
||||
// tab") makes the document taller than the window, a second scrollbar beside main's.
|
||||
return (
|
||||
<AssistantProvider value={assistant}>
|
||||
<div className="relative flex h-screen flex-col overflow-hidden bg-background" data-qqq-id="app-shell">
|
||||
{/* Top-of-site banner spans the whole window, above the sidebar */}
|
||||
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_SITE" className="border-x-0 border-t-0" />
|
||||
@@ -299,8 +313,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
data-qqq-id="sidebar-mobile"
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Main content area (min-w-0: the docked assistant panel never pushes it wider than the window) */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Header — breadcrumbs + search in one row */}
|
||||
<Header
|
||||
appName={metaData?.branding?.appName}
|
||||
@@ -316,6 +330,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
homePath={homePath}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
assistantButton={<AssistantHeaderButton />}
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
@@ -338,17 +353,27 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Assistant panel: docked beside the page from 1024 px, a sheet below; lazily loaded */}
|
||||
<AssistantMount />
|
||||
</div>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} navTargets={navTargets} metaData={metaData} />
|
||||
<CommandMenu
|
||||
open={commandOpen}
|
||||
onClose={() => setCommandOpen(false)}
|
||||
navTargets={navTargets}
|
||||
metaData={metaData}
|
||||
onAskAssistant={assistantEnabled ? (text) => assistant.openWith(text) : undefined}
|
||||
/>
|
||||
|
||||
{/* Search Dialog */}
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} navTargets={navTargets} searchTables={searchTables} />
|
||||
|
||||
{/* Keyboard Shortcuts Help Dialog */}
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} returnFocusRef={helpButtonRef} />
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} returnFocusRef={helpButtonRef} assistantEnabled={assistantEnabled} />
|
||||
</div>
|
||||
</AssistantProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantComposer — the message box: the dismissible page-context chip, a growing
|
||||
* textarea (Enter sends, Shift+Enter adds a line), and Send or Stop.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { MapPin, SendHorizontal, Square, X } from 'lucide-react'
|
||||
|
||||
import type { AssistantPageInfo } from '@/lib/utils/assistant-links'
|
||||
|
||||
/** Props of {@link AssistantComposer}. */
|
||||
interface AssistantComposerProps {
|
||||
/** The current page as context, or null. */
|
||||
pageInfo: AssistantPageInfo | null
|
||||
/** Whether the user removed the context for this page. */
|
||||
contextDismissed: boolean
|
||||
/** Removes the page context. */
|
||||
onDismissContext: () => void
|
||||
/** Re-reads the page context (the filter can change without a navigation). */
|
||||
onRefreshContext: () => void
|
||||
/** Whether a reply is streaming in this conversation. */
|
||||
streaming: boolean
|
||||
/** Whether a stop was requested. */
|
||||
stopping: boolean
|
||||
/**
|
||||
* Sends the text.
|
||||
*
|
||||
* @param text - The message.
|
||||
*/
|
||||
onSend: (text: string) => void
|
||||
/** Stops the reply. */
|
||||
onStop: () => void
|
||||
/** Text to put in the box (not sent); a new `id` replaces the current text. */
|
||||
prefill?: { id: number; text: string } | null
|
||||
}
|
||||
|
||||
/** Tallest the textarea grows before it scrolls, in pixels. */
|
||||
const MAX_TEXTAREA_HEIGHT = 160
|
||||
|
||||
/**
|
||||
* The composer.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param ref - The textarea, focused when the panel opens.
|
||||
* @returns The composer form.
|
||||
*/
|
||||
function AssistantComposerInner(
|
||||
{ pageInfo, contextDismissed, onDismissContext, onRefreshContext, streaming, stopping, onSend, onStop, prefill }: AssistantComposerProps,
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||
) {
|
||||
const [text, setText] = useState('')
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const localRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// A deep link pre-fills the box; the user reviews and sends it
|
||||
useEffect(() => {
|
||||
if (!prefill) return
|
||||
setText(prefill.text)
|
||||
const element = localRef.current
|
||||
if (element) {
|
||||
element.focus()
|
||||
element.setSelectionRange(prefill.text.length, prefill.text.length)
|
||||
}
|
||||
}, [prefill])
|
||||
|
||||
// Grow with the text up to MAX_TEXTAREA_HEIGHT
|
||||
useLayoutEffect(() => {
|
||||
const element = localRef.current
|
||||
if (!element) return
|
||||
element.style.height = 'auto'
|
||||
element.style.height = `${Math.min(element.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`
|
||||
}, [text])
|
||||
|
||||
/** Sends the text unless empty or a reply is streaming. */
|
||||
const submit = () => {
|
||||
if (!text.trim() || streaming) return
|
||||
onSend(text)
|
||||
setText('')
|
||||
}
|
||||
|
||||
const showContext = pageInfo && !contextDismissed
|
||||
return (
|
||||
<form
|
||||
className="shrink-0 border-t border-border bg-card px-3 pb-3 pt-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
data-qqq-id="assistant-composer-form"
|
||||
>
|
||||
{showContext && (
|
||||
<div className="mb-2 flex min-w-0">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full border border-border bg-muted py-0.5 pl-2 pr-0.5 text-xs text-muted-foreground" data-qqq-id="assistant-context-chip">
|
||||
<MapPin className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">On: {pageInfo.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissContext}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-8 pointer-coarse:w-8"
|
||||
aria-label={`Don't send page context (${pageInfo.label})`}
|
||||
data-qqq-id="assistant-context-dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-lg border border-input bg-background px-2 py-1.5 focus-within:ring-2 focus-within:ring-ring">
|
||||
<label htmlFor={inputId} className="sr-only">Message the assistant</label>
|
||||
<textarea
|
||||
id={inputId}
|
||||
ref={(element) => {
|
||||
localRef.current = element
|
||||
if (typeof ref === 'function') ref(element)
|
||||
else if (ref) ref.current = element
|
||||
}}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onFocus={onRefreshContext}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
placeholder="Ask about your data..."
|
||||
aria-describedby={hintId}
|
||||
className="max-h-40 min-h-6 min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-1 text-base text-foreground placeholder:text-muted-foreground focus:outline-none md:text-sm"
|
||||
data-qqq-id="assistant-composer"
|
||||
/>
|
||||
{streaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
disabled={stopping}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 pointer-coarse:h-11 pointer-coarse:w-11"
|
||||
aria-label={stopping ? 'Stopping' : 'Stop the reply'}
|
||||
data-qqq-id="assistant-stop"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim()}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 pointer-coarse:h-11 pointer-coarse:w-11"
|
||||
aria-label="Send"
|
||||
data-qqq-id="assistant-send"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p id={hintId} className="mt-1 hidden text-[11px] text-muted-foreground pointer-fine:block">Enter to send, Shift+Enter for a new line</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** The assistant composer; its ref is the textarea. */
|
||||
export const AssistantComposer = forwardRef(AssistantComposerInner)
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantHeaderButton — the header's assistant toggle with its unread badge; renders
|
||||
* nothing when the assistant is unavailable.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
import { useAssistant } from './assistant-context'
|
||||
|
||||
/**
|
||||
* Toggles the assistant panel; the badge counts unread replies.
|
||||
*
|
||||
* @returns The button, or null when the assistant is unavailable.
|
||||
*/
|
||||
export function AssistantHeaderButton() {
|
||||
const assistant = useAssistant()
|
||||
if (!assistant?.enabled) return null
|
||||
const { open, unreadCount, toggle, triggerRef } = assistant
|
||||
const label = unreadCount > 0 ? `Assistant, ${unreadCount} unread (Ctrl+J or ⌘J)` : 'Assistant (Ctrl+J or ⌘J)'
|
||||
return (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
// WebKit can activate a button without focusing it; focus returns here on close
|
||||
event.currentTarget.focus({ preventScroll: true })
|
||||
toggle()
|
||||
}}
|
||||
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={label}
|
||||
aria-pressed={open}
|
||||
data-qqq-id="button-assistant"
|
||||
>
|
||||
<Sparkles className="h-5 w-5 text-foreground/60" aria-hidden="true" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
data-qqq-id="assistant-unread-badge"
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantMarkdown — renders model-written markdown safely: raw HTML is dropped, the
|
||||
* tree is sanitized, images never load, and only same-origin `/app` links are links (others
|
||||
* are plain text with a copy button), so model text cannot exfiltrate data or inject markup.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
|
||||
/** The default (GitHub) sanitize schema without inline styles, classes or ids from the model. */
|
||||
const SCHEMA = {
|
||||
...defaultSchema,
|
||||
clobberPrefix: 'assistant-md-',
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
'*': (defaultSchema.attributes?.['*'] ?? []).filter((attribute) => !['className', 'id', 'style'].includes(String(attribute))),
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies text to the clipboard and confirms it.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.text - The text to copy.
|
||||
* @returns The button.
|
||||
*/
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(text).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}).catch(() => undefined)
|
||||
}}
|
||||
className="ml-1 inline-flex h-5 w-5 items-center justify-center rounded align-middle text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-8 pointer-coarse:w-8"
|
||||
aria-label={copied ? 'Copied' : `Copy link ${text}`}
|
||||
data-qqq-id="assistant-copy-link"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" aria-hidden="true" /> : <Copy className="h-3 w-3" aria-hidden="true" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Element overrides: safe links, blocked images, focusable scrolling code blocks. */
|
||||
const COMPONENTS: Components = {
|
||||
a: ({ href, children }) => {
|
||||
const internal = internalAppHref(href)
|
||||
if (internal) {
|
||||
return <Link href={internal} className="font-medium text-primary underline underline-offset-2 hover:text-primary/80" data-qqq-id="assistant-link">{children}</Link>
|
||||
}
|
||||
const text = typeof href === 'string' ? href : ''
|
||||
const childText = React.Children.toArray(children).join('')
|
||||
return (
|
||||
<span data-qqq-id="assistant-external-link">
|
||||
{children}
|
||||
{text && childText !== text && <span className="break-all text-muted-foreground"> ({text})</span>}
|
||||
{text && <CopyButton text={text} />}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
img: ({ alt }) => <span className="text-muted-foreground">[image{alt ? `: ${alt}` : ''} not shown]</span>,
|
||||
pre: ({ children }) => (
|
||||
<pre tabIndex={0} className="my-2 overflow-x-auto rounded-md bg-muted p-2 text-xs focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">{children}</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div tabIndex={0} role="region" aria-label="Table" className="my-2 overflow-x-auto focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<table className="w-full text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/** Typography for the rendered markdown (the app has no typography plugin). */
|
||||
const PROSE = [
|
||||
'min-w-0 break-words text-sm leading-relaxed text-foreground',
|
||||
'[&_p]:my-2 first:[&_p]:mt-0 last:[&_p]:mb-0',
|
||||
'[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5',
|
||||
'[&_h1]:mt-3 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-3 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-2 [&_h3]:text-sm [&_h3]:font-semibold',
|
||||
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:font-mono [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_th]:border-b [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_td]:border-b [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
|
||||
].join(' ')
|
||||
|
||||
/**
|
||||
* Renders assistant markdown.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.text - Markdown from the model.
|
||||
* @returns The rendered markdown.
|
||||
*/
|
||||
export function AssistantMarkdown({ text }: { text: string }) {
|
||||
return (
|
||||
<div className={PROSE} data-qqq-id="assistant-markdown">
|
||||
<ReactMarkdown skipHtml rehypePlugins={[[rehypeSanitize, SCHEMA]]} components={COMPONENTS}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* AssistantMessageView stories — the assistant panel's message rendering: user text, a reply
|
||||
* with a tool step, records table, record chips and markdown, a streaming reply, and notices.
|
||||
*/
|
||||
import React from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import type { AssistantMessage } from '@/types/assistant'
|
||||
import { AssistantMessageView } from './AssistantMessageView'
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } })
|
||||
|
||||
const meta = {
|
||||
title: 'Assistant/AssistantMessageView',
|
||||
component: AssistantMessageView,
|
||||
parameters: { layout: 'padded' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="@container w-[400px] space-y-4 bg-card p-3">
|
||||
<Story />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AssistantMessageView>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const BASE = { turnId: 9, createDate: '2026-10-12T00:00:00Z' }
|
||||
|
||||
/** The user's question. */
|
||||
export const UserMessage: Story = {
|
||||
args: { message: { ...BASE, id: 10, role: 'USER', status: 'COMPLETE', parts: [{ type: 'text', text: 'Who is active?' }] } as AssistantMessage },
|
||||
}
|
||||
|
||||
/** A finished reply with a query step, its records, chips, markdown and a navigation. */
|
||||
export const Reply: Story = {
|
||||
args: {
|
||||
message: {
|
||||
...BASE,
|
||||
id: 11,
|
||||
role: 'ASSISTANT',
|
||||
status: 'COMPLETE',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool', toolCallId: 't1', name: 'query_records', summary: 'Queried People · 2 rows', outcome: 'OK',
|
||||
render: {
|
||||
type: 'records', tableName: 'person', tableLabel: 'People', truncated: false, totalCount: 2, openHref: '/app/person',
|
||||
columns: [{ name: 'firstName', label: 'First Name' }, { name: 'lastName', label: 'Last Name' }, { name: 'status', label: 'Status' }],
|
||||
rows: [
|
||||
{ id: 1, href: '/app/person/1', values: { firstName: 'Alice', lastName: 'Johnson', status: 'Active' } },
|
||||
{ id: 2, href: '/app/person/2', values: { firstName: 'Bob', lastName: 'Martinez', status: 'Active' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'record_refs', refs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }] },
|
||||
{ type: 'text', text: 'Two people are **active**. See [Alice](/app/person/1); [external](https://example.com) links stay plain text.' },
|
||||
{ type: 'navigate', href: '/app/person/1' },
|
||||
],
|
||||
} as AssistantMessage,
|
||||
},
|
||||
}
|
||||
|
||||
/** A reply still streaming, with a running tool step. */
|
||||
export const Streaming: Story = {
|
||||
args: {
|
||||
message: {
|
||||
...BASE,
|
||||
id: 12,
|
||||
role: 'ASSISTANT',
|
||||
status: 'STREAMING',
|
||||
parts: [{ type: 'tool', toolCallId: 't2', name: 'count_records', summary: 'Counting People' }],
|
||||
} as AssistantMessage,
|
||||
},
|
||||
}
|
||||
|
||||
/** A denied tool call and an error notice. */
|
||||
export const DeniedAndError: Story = {
|
||||
args: {
|
||||
message: {
|
||||
...BASE,
|
||||
id: 13,
|
||||
role: 'ASSISTANT',
|
||||
status: 'ERROR',
|
||||
parts: [
|
||||
{ type: 'tool', toolCallId: 't3', name: 'get_record_history', summary: 'Record history is not available to you', outcome: 'DENIED' },
|
||||
{ type: 'notice', level: 'error', text: 'The daily assistant budget was reached.' },
|
||||
],
|
||||
} as AssistantMessage,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantMessageView — one message of a conversation. Assistant messages render
|
||||
* their parts through {@link PART_RENDERERS}, so later part types (approval cards) slot in
|
||||
* by adding an entry; unknown part types are skipped.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, ChevronRight, CircleX, Info, Loader2, Navigation, ThumbsDown, ThumbsUp } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantNavigatePart,
|
||||
AssistantNoticePart,
|
||||
AssistantPart,
|
||||
AssistantRecordRefsPart,
|
||||
AssistantTextPart,
|
||||
AssistantToolOutcome,
|
||||
AssistantToolPart,
|
||||
} from '@/types/assistant'
|
||||
import { sendAssistantFeedback } from '@/lib/api/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown'
|
||||
import { AssistantRenderView, RecordChips } from './AssistantRenders'
|
||||
import Link from 'next/link'
|
||||
|
||||
/** Plain-language outcome of a tool call. */
|
||||
const OUTCOME_LABEL: Record<AssistantToolOutcome, string> = {
|
||||
OK: 'Done',
|
||||
DENIED: 'Not permitted',
|
||||
NOT_FOUND: 'Not found',
|
||||
INVALID_INPUT: 'Invalid request',
|
||||
TRUNCATED: 'Partial result',
|
||||
ERROR: 'Failed',
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool call as a compact, expandable step, followed by its structured result.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.part - The tool part.
|
||||
* @returns The step.
|
||||
*/
|
||||
function ToolStep({ part }: { part: AssistantToolPart }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const detailsId = useId()
|
||||
const running = !part.outcome
|
||||
const ok = part.outcome === 'OK' || part.outcome === 'TRUNCATED'
|
||||
return (
|
||||
<div className="space-y-1.5" data-qqq-id="assistant-tool-step" data-outcome={part.outcome ?? 'RUNNING'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
className="flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 motion-safe:animate-spin" aria-hidden="true" />
|
||||
) : ok ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-primary" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{part.summary || part.name}</span>
|
||||
<span className="sr-only">{running ? ', running' : `, ${OUTCOME_LABEL[part.outcome as AssistantToolOutcome] ?? part.outcome}`}</span>
|
||||
<ChevronRight className={cn('h-3 w-3 shrink-0 motion-safe:transition-transform', expanded && 'rotate-90')} aria-hidden="true" />
|
||||
</button>
|
||||
<dl id={detailsId} hidden={!expanded} className="ml-5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 text-xs text-muted-foreground">
|
||||
<dt>Tool</dt>
|
||||
<dd className="break-all font-mono">{part.name}</dd>
|
||||
<dt>Result</dt>
|
||||
<dd>{running ? 'Running' : OUTCOME_LABEL[part.outcome as AssistantToolOutcome] ?? part.outcome}</dd>
|
||||
</dl>
|
||||
{part.render && <AssistantRenderView render={part.render} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A notice inside a reply.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.part - The notice part.
|
||||
* @returns The notice.
|
||||
*/
|
||||
function NoticeView({ part }: { part: AssistantNoticePart }) {
|
||||
const Icon = part.level === 'error' ? CircleX : part.level === 'warning' ? AlertTriangle : Info
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-start gap-1.5 rounded-md border px-2 py-1.5 text-xs',
|
||||
part.level === 'error' ? 'border-destructive/40 text-destructive' : 'border-border text-muted-foreground'
|
||||
)}
|
||||
role={part.level === 'error' ? 'alert' : undefined}
|
||||
data-qqq-id="assistant-notice"
|
||||
data-level={part.level}
|
||||
>
|
||||
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 break-words">{part.text}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the assistant took the user.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.part - The navigate part.
|
||||
* @returns A link back to that page.
|
||||
*/
|
||||
function NavigateView({ part }: { part: AssistantNavigatePart }) {
|
||||
const href = internalAppHref(part.href)
|
||||
if (!href) return null
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" data-qqq-id="assistant-navigate">
|
||||
<Navigation className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>Opened</span>
|
||||
<Link href={href} className="min-w-0 truncate font-medium text-primary hover:underline">{href}</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders one part type. */
|
||||
type PartRenderer<T extends AssistantPart> = (props: { part: T }) => React.ReactNode
|
||||
|
||||
/**
|
||||
* Renderers by part type. Phase 2 adds `approval` here; anything without a renderer is skipped.
|
||||
*/
|
||||
export const PART_RENDERERS: { [K in AssistantPart['type']]: PartRenderer<Extract<AssistantPart, { type: K }>> } = {
|
||||
text: ({ part }: { part: AssistantTextPart }) => (part.text ? <AssistantMarkdown text={part.text} /> : null),
|
||||
tool: ({ part }: { part: AssistantToolPart }) => <ToolStep part={part} />,
|
||||
record_refs: ({ part }: { part: AssistantRecordRefsPart }) => <RecordChips refs={part.refs} />,
|
||||
navigate: ({ part }: { part: AssistantNavigatePart }) => <NavigateView part={part} />,
|
||||
notice: ({ part }: { part: AssistantNoticePart }) => <NoticeView part={part} />,
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a part through {@link PART_RENDERERS}.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.part - Any part, including types this version does not know.
|
||||
* @returns The rendered part, or null.
|
||||
*/
|
||||
function PartView({ part }: { part: AssistantPart }) {
|
||||
const render = (PART_RENDERERS as Record<string, PartRenderer<AssistantPart> | undefined>)[part.type]
|
||||
return render ? <>{render({ part })}</> : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbs up or down on a finished reply; thumbs down asks for an optional comment.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.messageId - The assistant message id.
|
||||
* @returns The feedback controls.
|
||||
*/
|
||||
function FeedbackButtons({ messageId }: { messageId: number }) {
|
||||
const [rating, setRating] = useState<1 | -1 | null>(null)
|
||||
const [asking, setAsking] = useState(false)
|
||||
const [comment, setComment] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
const commentId = useId()
|
||||
|
||||
/**
|
||||
* Sends the rating.
|
||||
*
|
||||
* @param value - Up or down.
|
||||
* @param text - Optional comment.
|
||||
*/
|
||||
const send = (value: 1 | -1, text?: string) => {
|
||||
setRating(value)
|
||||
setSent(true)
|
||||
sendAssistantFeedback(messageId, text ? { rating: value, comment: text } : { rating: value }).catch(() => setSent(false))
|
||||
}
|
||||
|
||||
const buttonClass = 'inline-flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11 pointer-coarse:w-11 aria-pressed:text-primary'
|
||||
return (
|
||||
<div className="space-y-1" data-qqq-id="assistant-feedback">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button type="button" className={buttonClass} aria-label="Good response" aria-pressed={rating === 1} disabled={sent && rating === 1} onClick={() => { setAsking(false); send(1) }} data-qqq-id="assistant-feedback-up">
|
||||
<ThumbsUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className={buttonClass} aria-label="Bad response" aria-pressed={rating === -1} disabled={sent && rating === -1} onClick={() => { setRating(-1); setAsking(true) }} data-qqq-id="assistant-feedback-down">
|
||||
<ThumbsDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{sent && <span className="text-xs text-muted-foreground" role="status">Thanks for the feedback.</span>}
|
||||
</div>
|
||||
{asking && !sent && (
|
||||
<form
|
||||
className="flex items-center gap-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setAsking(false)
|
||||
send(-1, comment.trim() || undefined)
|
||||
}}
|
||||
>
|
||||
<label htmlFor={commentId} className="sr-only">What went wrong? (optional)</label>
|
||||
<input
|
||||
id={commentId}
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="What went wrong? (optional)"
|
||||
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="h-8 rounded-md border border-border px-2 text-xs font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">Send</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One message.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.message - The message.
|
||||
* @returns The message article.
|
||||
*/
|
||||
export function AssistantMessageView({ message }: { message: AssistantMessage }) {
|
||||
if (message.role === 'USER') {
|
||||
const text = message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
|
||||
return (
|
||||
<article className="flex justify-end" aria-label="You" data-qqq-id="assistant-message" data-role="user">
|
||||
<p className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm bg-muted px-3 py-2 text-sm text-foreground">{text}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (message.role === 'SYSTEM_NOTICE') {
|
||||
return (
|
||||
<article className="space-y-1" aria-label="Notice" data-qqq-id="assistant-message" data-role="notice">
|
||||
{message.parts.map((part, index) => <PartView key={index} part={part} />)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
const streaming = message.status === 'STREAMING'
|
||||
return (
|
||||
<article className="min-w-0 space-y-2" aria-label="Assistant" aria-busy={streaming} data-qqq-id="assistant-message" data-role="assistant" data-status={message.status}>
|
||||
{message.parts.map((part, index) => <PartView key={part.type === 'tool' ? part.toolCallId : index} part={part} />)}
|
||||
{streaming && message.parts.length === 0 && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" data-qqq-id="assistant-thinking">
|
||||
<Loader2 className="h-3.5 w-3.5 motion-safe:animate-spin" aria-hidden="true" />
|
||||
Working on it...
|
||||
</p>
|
||||
)}
|
||||
{message.status === 'CANCELLED' && <p className="text-xs text-muted-foreground">Stopped.</p>}
|
||||
{message.status === 'ERROR' && !message.parts.some((part) => part.type === 'notice' && part.level === 'error') && (
|
||||
<p className="text-xs text-destructive" role="alert">The reply did not finish.</p>
|
||||
)}
|
||||
{message.status === 'COMPLETE' && message.id > 0 && <FeedbackButtons messageId={message.id} />}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantMount — the panel's place in the dashboard shell. The panel code (markdown,
|
||||
* streaming, record renders) loads on first open, so the shell's first load stays small; once
|
||||
* loaded it stays mounted, so a running reply keeps streaming while the panel is closed.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react'
|
||||
|
||||
import { useAssistant } from './assistant-context'
|
||||
|
||||
const AssistantPanel = lazy(() => import('./AssistantPanel'))
|
||||
|
||||
/**
|
||||
* Mounts the lazily loaded assistant panel once it has been opened.
|
||||
*
|
||||
* @returns The panel, or null until the assistant is available and first opened.
|
||||
*/
|
||||
export function AssistantMount() {
|
||||
const assistant = useAssistant()
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const open = Boolean(assistant?.open)
|
||||
useEffect(() => {
|
||||
if (open) setLoaded(true)
|
||||
}, [open])
|
||||
if (!assistant?.enabled || (!loaded && !open)) return null
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AssistantPanel />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the assistant panel: probe gating, streaming, cancel, markdown safety, deep link
|
||||
|
||||
import React from 'react'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import { server } from '@/mocks/node'
|
||||
import { AssistantProvider, useAssistantController } from './AssistantProvider'
|
||||
import { AssistantHeaderButton } from './AssistantHeaderButton'
|
||||
import { AssistantMount } from './AssistantMount'
|
||||
|
||||
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => router,
|
||||
usePathname: () => window.location.pathname,
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
}))
|
||||
|
||||
const BASE = '/qqq/v1/assistant'
|
||||
const META = { tables: { person: { name: 'person', label: 'People' } }, supplementalInstanceMetaData: { assistant: {} } } as unknown as QInstance
|
||||
const CONVERSATION = { id: 4, title: null, status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z' }
|
||||
|
||||
/**
|
||||
* Formats stream events as SSE frames.
|
||||
*
|
||||
* @param events - `[type, data]` pairs.
|
||||
* @returns Event-stream text.
|
||||
*/
|
||||
function sse(...events: Array<[string, Record<string, unknown>]>): string {
|
||||
return events.map(([type, data]) => `id: ${data.seq}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* A stream the test writes to and closes.
|
||||
*
|
||||
* @returns The response factory, a writer and a closer.
|
||||
*/
|
||||
function controllableStream() {
|
||||
const encoder = new TextEncoder()
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
||||
const body = new ReadableStream<Uint8Array>({ start(c) { controller = c } })
|
||||
return {
|
||||
response: () => new HttpResponse(body, { headers: { 'Content-Type': 'text/event-stream' } }),
|
||||
write: (text: string) => controller?.enqueue(encoder.encode(text)),
|
||||
close: () => controller?.close(),
|
||||
}
|
||||
}
|
||||
|
||||
/** What the server has stored for conversation 4 (served after a turn ends). */
|
||||
const persisted: { messages: unknown[] } = { messages: [] }
|
||||
|
||||
/**
|
||||
* The stored messages of a finished turn.
|
||||
*
|
||||
* @param text - The user's text.
|
||||
* @param parts - The reply's parts.
|
||||
* @param status - The reply's status.
|
||||
* @returns The messages.
|
||||
*/
|
||||
function storedTurn(text: string, parts: unknown[], status = 'COMPLETE'): unknown[] {
|
||||
return [
|
||||
{ id: 10, role: 'USER', status: 'COMPLETE', turnId: 9, createDate: '2026-10-12T00:00:00Z', parts: [{ type: 'text', text }] },
|
||||
{ id: 11, role: 'ASSISTANT', status, turnId: 9, createDate: '2026-10-12T00:00:00Z', parts },
|
||||
]
|
||||
}
|
||||
|
||||
const START = ['message.start', { seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }] as [string, Record<string, unknown>]
|
||||
|
||||
/**
|
||||
* Harness: the dashboard's assistant wiring.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.metaData - Instance metadata (defaults to one announcing the qbit).
|
||||
* @returns The shell.
|
||||
*/
|
||||
function Shell({ metaData = META }: { metaData?: QInstance }) {
|
||||
const assistant = useAssistantController(metaData, true)
|
||||
return (
|
||||
<AssistantProvider value={assistant}>
|
||||
<header><AssistantHeaderButton /></header>
|
||||
<main>page</main>
|
||||
<AssistantMount />
|
||||
</AssistantProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the harness.
|
||||
*
|
||||
* @param metaData - Instance metadata (defaults to one announcing the qbit).
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderShell(metaData?: QInstance) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><Shell metaData={metaData} /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the probe and conversation routes.
|
||||
*
|
||||
* @param config - The probe response, or a status code.
|
||||
*/
|
||||
function mockAssistant(config: Record<string, unknown> | number = { enabled: true }) {
|
||||
persisted.messages = []
|
||||
server.use(
|
||||
http.get(`${BASE}/config`, () => (typeof config === 'number' ? HttpResponse.json({ error: 'x' }, { status: config }) : HttpResponse.json(config))),
|
||||
http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [] })),
|
||||
http.post(`${BASE}/conversations`, () => HttpResponse.json(CONVERSATION, { status: 201 })),
|
||||
http.get(`${BASE}/conversations/4`, () => HttpResponse.json({ conversation: CONVERSATION, messages: persisted.messages })),
|
||||
)
|
||||
}
|
||||
|
||||
// The panel is lazily loaded; load its module once so the tests do not race the import
|
||||
beforeAll(async () => {
|
||||
await import('./AssistantPanel')
|
||||
}, 30_000)
|
||||
|
||||
beforeEach(() => {
|
||||
router.push.mockReset()
|
||||
router.replace.mockReset()
|
||||
window.history.replaceState(null, '', '/app/person')
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/')
|
||||
})
|
||||
|
||||
describe('assistant gating', () => {
|
||||
it('does not probe an instance whose metadata does not announce the qbit', async () => {
|
||||
const probe = vi.fn()
|
||||
mockAssistant()
|
||||
server.use(http.get(`${BASE}/config`, () => {
|
||||
probe()
|
||||
return HttpResponse.json({ enabled: true })
|
||||
}))
|
||||
renderShell({ tables: META.tables } as unknown as QInstance)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
|
||||
})
|
||||
|
||||
it.each([404, 403])('renders nothing when the probe answers %i', async (status) => {
|
||||
mockAssistant(status)
|
||||
renderShell()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when the assistant is disabled for the user', async () => {
|
||||
mockAssistant({ enabled: false, reason: 'NO_PERMISSION' })
|
||||
renderShell()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the unread count on the header button', async () => {
|
||||
mockAssistant()
|
||||
server.use(http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [{ ...CONVERSATION, unreadCount: 2 }, { ...CONVERSATION, id: 5, unreadCount: 1 }] })))
|
||||
renderShell()
|
||||
expect(await screen.findByRole('button', { name: 'Assistant, 3 unread (Ctrl+J or ⌘J)' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('asking a question', () => {
|
||||
it('streams the reply with a tool step and record chip, follows navigation, and passes axe', async () => {
|
||||
mockAssistant()
|
||||
const stream = controllableStream()
|
||||
let turnBody: unknown
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
|
||||
turnBody = await request.json()
|
||||
return stream.response()
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
const composer = within(panel).getByRole('textbox', { name: 'Message the assistant' })
|
||||
expect(within(panel).getByText('On: People')).toBeInTheDocument()
|
||||
await user.type(composer, 'Who is active?{Enter}')
|
||||
|
||||
await waitFor(() => expect(turnBody).toEqual({ text: 'Who is active?', pageContext: { route: '/app/person', tableName: 'person' } }))
|
||||
expect(within(panel).getByText('Who is active?')).toBeInTheDocument()
|
||||
expect(within(panel).getByRole('button', { name: 'Stop the reply' })).toBeInTheDocument()
|
||||
|
||||
act(() => stream.write(sse(
|
||||
START,
|
||||
['tool.start', { seq: 2, messageId: 11, toolCallId: 't1', name: 'query_records', summary: 'Querying People' }],
|
||||
['tool.result', {
|
||||
seq: 3, messageId: 11, toolCallId: 't1', name: 'query_records', outcome: 'OK', summary: 'Queried People · 1 row',
|
||||
recordRefs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }],
|
||||
}],
|
||||
['text.delta', { seq: 4, messageId: 11, text: 'One person is **active**. ' }],
|
||||
['ui.navigate', { seq: 5, messageId: 11, href: '/app/person/1' }],
|
||||
['text.delta', { seq: 6, messageId: 11, text: 'Opened it.' }],
|
||||
)))
|
||||
|
||||
expect(await within(panel).findByText('active')).toBeInTheDocument()
|
||||
expect(within(panel).getByText('Queried People · 1 row')).toBeInTheDocument()
|
||||
const chip = within(panel).getByRole('link', { name: /Alice Johnson/ })
|
||||
expect(chip).toHaveAttribute('href', '/app/person/1')
|
||||
expect(router.push).toHaveBeenCalledWith('/app/person/1')
|
||||
|
||||
persisted.messages = storedTurn('Who is active?', [
|
||||
{ type: 'tool', toolCallId: 't1', name: 'query_records', summary: 'Queried People · 1 row', outcome: 'OK' },
|
||||
{ type: 'record_refs', refs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }] },
|
||||
{ type: 'text', text: 'One person is **active**. Opened it.' },
|
||||
])
|
||||
act(() => stream.write(sse(['message.end', { seq: 7, messageId: 11, status: 'COMPLETE' }])))
|
||||
act(() => stream.close())
|
||||
await waitFor(() => expect(within(panel).getByRole('button', { name: 'Send' })).toBeInTheDocument())
|
||||
expect(await within(panel).findByRole('button', { name: 'Good response' })).toBeInTheDocument()
|
||||
expect(within(panel).getByText('active')).toBeInTheDocument()
|
||||
expect(await axe(panel)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('stops a reply through the cancel endpoint', async () => {
|
||||
mockAssistant()
|
||||
const stream = controllableStream()
|
||||
let cancelled = false
|
||||
server.use(
|
||||
http.post(`${BASE}/conversations/4/turns`, () => stream.response()),
|
||||
http.post(`${BASE}/turns/9/cancel`, () => {
|
||||
cancelled = true
|
||||
persisted.messages = storedTurn('Count people', [{ type: 'text', text: 'Counting' }], 'CANCELLED')
|
||||
stream.write(sse(['message.end', { seq: 3, messageId: 11, status: 'CANCELLED' }]))
|
||||
stream.close()
|
||||
return HttpResponse.json({ status: 'CANCELLING' }, { status: 202 })
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Count people{Enter}')
|
||||
act(() => stream.write(sse(START, ['text.delta', { seq: 2, messageId: 11, text: 'Counting' }])))
|
||||
await within(panel).findByText('Counting')
|
||||
await user.click(within(panel).getByRole('button', { name: 'Stop the reply' }))
|
||||
await waitFor(() => expect(cancelled).toBe(true))
|
||||
expect(await within(panel).findByText('Stopped.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a refused turn as an error in the reply', async () => {
|
||||
mockAssistant()
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => HttpResponse.json({ error: 'Daily budget reached' }, { status: 429 })))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Hi{Enter}')
|
||||
expect(await within(panel).findByText('Daily budget reached')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the page context once the chip is dismissed', async () => {
|
||||
mockAssistant()
|
||||
let turnBody: unknown
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
|
||||
turnBody = await request.json()
|
||||
const stream = controllableStream()
|
||||
stream.write(sse(START, ['message.end', { seq: 2, messageId: 11, status: 'COMPLETE' }]))
|
||||
stream.close()
|
||||
return stream.response()
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.click(within(panel).getByRole('button', { name: "Don't send page context (People)" }))
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Hi{Enter}')
|
||||
await waitFor(() => expect(turnBody).toEqual({ text: 'Hi' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdown safety', () => {
|
||||
it('renders only same-origin /app links, never raw HTML or images', async () => {
|
||||
mockAssistant()
|
||||
const text = [
|
||||
'See [the record](/app/person/1) or [elsewhere](https://evil.example.com/x?d=secret).',
|
||||
'<img src="https://evil.example.com/p.png"><b>bold html</b>',
|
||||
' [js](javascript:alert(1))',
|
||||
].join('\n\n')
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => {
|
||||
const stream = controllableStream()
|
||||
persisted.messages = storedTurn('Links?', [{ type: 'text', text }])
|
||||
stream.write(sse(START, ['text.delta', { seq: 2, messageId: 11, text }], ['message.end', { seq: 3, messageId: 11, status: 'COMPLETE' }]))
|
||||
stream.close()
|
||||
return stream.response()
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Links?{Enter}')
|
||||
const internal = await within(panel).findByRole('link', { name: 'the record' })
|
||||
expect(internal).toHaveAttribute('href', '/app/person/1')
|
||||
expect(within(panel).queryByRole('link', { name: 'elsewhere' })).toBeNull()
|
||||
expect(within(panel).getByText('(https://evil.example.com/x?d=secret)')).toBeInTheDocument()
|
||||
expect(within(panel).getByRole('button', { name: 'Copy link https://evil.example.com/x?d=secret' })).toBeInTheDocument()
|
||||
expect(within(panel).queryByRole('link', { name: 'js' })).toBeNull()
|
||||
expect(panel.querySelector('img')).toBeNull()
|
||||
expect(panel.querySelector('b')).toBeNull()
|
||||
expect(within(panel).getByText('[image: tracker not shown]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deep link', () => {
|
||||
it('opens a new conversation with the prompt pre-filled, not sent, and strips the parameters', async () => {
|
||||
mockAssistant()
|
||||
const turns = vi.fn()
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => {
|
||||
turns()
|
||||
return HttpResponse.json({ error: 'no' }, { status: 500 })
|
||||
}))
|
||||
window.history.replaceState(null, '', '/app/person/1?assistant=open&assistantPrompt=Tell%20me%20about%20this%20record.&tab=2')
|
||||
renderShell()
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
const composer = within(panel).getByRole('textbox', { name: 'Message the assistant' })
|
||||
await waitFor(() => expect(composer).toHaveValue('Tell me about this record.'))
|
||||
expect(router.replace).toHaveBeenCalledWith('/app/person/1?tab=2', { scroll: false })
|
||||
expect(within(panel).getByText('On: People #1')).toBeInTheDocument()
|
||||
expect(turns).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is inert when the assistant is unavailable', async () => {
|
||||
mockAssistant(404)
|
||||
window.history.replaceState(null, '', '/app/person/1?assistant=open&assistantPrompt=Hi')
|
||||
renderShell()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(router.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,565 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantPanel — the assistant chat panel (loaded lazily by AssistantMount). From
|
||||
* 1024 px it docks beside the page as a resizable column (360-640 px) with its own scrolling
|
||||
* message list; below that it is a full-height dialog sheet that follows the on-screen keyboard.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Archive, ArchiveRestore, MoreHorizontal, PanelLeft, Pencil, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { AssistantConversation, AssistantConversationDetail, AssistantConversationStatus, AssistantConversationUpdate } from '@/types/assistant'
|
||||
import {
|
||||
deleteAssistantConversation,
|
||||
getAssistantConversation,
|
||||
listAssistantConversations,
|
||||
updateAssistantConversation,
|
||||
} from '@/lib/api/assistant'
|
||||
import { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { assistantPageInfo, type AssistantPageInfo } from '@/lib/utils/assistant-links'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { ASSISTANT_DOCKED_MEDIA_QUERY, assistantKeys, useAssistant, type AssistantController } from './assistant-context'
|
||||
import { AssistantComposer } from './AssistantComposer'
|
||||
import { AssistantMessageView } from './AssistantMessageView'
|
||||
import { ConversationRail, conversationTitle } from './ConversationRail'
|
||||
import { useAssistantEngine, type AssistantEngine } from './use-assistant-engine'
|
||||
import { useStreamAnnouncer } from './use-stream-announcer'
|
||||
|
||||
/** Docked width limits, in pixels. */
|
||||
export const PANEL_MIN_WIDTH = 360
|
||||
export const PANEL_MAX_WIDTH = 640
|
||||
const PANEL_DEFAULT_WIDTH = 400
|
||||
/** Below this panel width the conversation list replaces the messages instead of sitting beside them. */
|
||||
const SIDE_BY_SIDE_MIN_WIDTH = 560
|
||||
/** Room kept for the sidebar (16rem) and a readable page (25rem) beside the docked panel. */
|
||||
const PAGE_RESERVE = '41rem'
|
||||
|
||||
/**
|
||||
* Clamps a docked width.
|
||||
*
|
||||
* @param width - Requested width.
|
||||
* @returns The width within 360-640 px.
|
||||
*/
|
||||
export function clampPanelWidth(width: number): number {
|
||||
if (!Number.isFinite(width)) return PANEL_DEFAULT_WIDTH
|
||||
return Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, Math.round(width)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual viewport's height and top offset, so the sheet stays above the on-screen keyboard.
|
||||
*
|
||||
* @param active - Whether to track it.
|
||||
* @returns The height and offset, or null without `visualViewport`.
|
||||
*/
|
||||
function useVisualViewport(active: boolean): { height: number; top: number } | null {
|
||||
const [box, setBox] = useState<{ height: number; top: number } | null>(null)
|
||||
useEffect(() => {
|
||||
const viewport = typeof window === 'undefined' ? undefined : window.visualViewport
|
||||
if (!active || !viewport) return
|
||||
/**
|
||||
* Copies the viewport's box.
|
||||
*
|
||||
* @returns Nothing.
|
||||
*/
|
||||
const update = () => setBox({ height: viewport.height, top: viewport.offsetTop })
|
||||
update()
|
||||
viewport.addEventListener('resize', update)
|
||||
viewport.addEventListener('scroll', update)
|
||||
return () => {
|
||||
viewport.removeEventListener('resize', update)
|
||||
viewport.removeEventListener('scroll', update)
|
||||
}
|
||||
}, [active])
|
||||
return active ? box : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The current page as assistant context, read from the location (the query filter can change
|
||||
* without a navigation, so callers re-read it before sending).
|
||||
*
|
||||
* @param metaData - Instance metadata.
|
||||
* @returns The page info now.
|
||||
*/
|
||||
function readPageInfo(metaData: QInstance | undefined): AssistantPageInfo | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const segments = window.location.pathname.split('/').filter(Boolean)
|
||||
let recordLabel: string | undefined
|
||||
if (segments.length >= 3) {
|
||||
let recordId = segments[2]
|
||||
try {
|
||||
recordId = decodeURIComponent(recordId)
|
||||
} catch {
|
||||
// keep the raw segment
|
||||
}
|
||||
recordLabel = getRecentRecords().find((record) => record.tableName === segments[1] && record.recordId === recordId)?.recordLabel
|
||||
}
|
||||
return assistantPageInfo(window.location.pathname, window.location.search, metaData, recordLabel)
|
||||
}
|
||||
|
||||
/** Props of {@link PanelBody}. */
|
||||
interface PanelBodyProps {
|
||||
assistant: AssistantController
|
||||
engine: AssistantEngine
|
||||
/** Whether the list and the messages fit side by side. */
|
||||
wide: boolean
|
||||
/** The title element id (labels the panel). */
|
||||
titleId: string
|
||||
composerRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Header, conversation list, messages and composer, shared by the docked panel and the sheet.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The panel body.
|
||||
*/
|
||||
function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { activeConversationId, setActiveConversationId, pendingRequest, consumeRequest, metaData } = assistant
|
||||
const pathname = usePathname()
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
const [railStatus, setRailStatus] = useState<AssistantConversationStatus>('ACTIVE')
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [dismissedRoute, setDismissedRoute] = useState<string | null>(null)
|
||||
const [pageInfo, setPageInfo] = useState(() => readPageInfo(metaData))
|
||||
const [prefill, setPrefill] = useState<{ id: number; text: string } | null>(null)
|
||||
const listId = useId()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const stickToBottom = useRef(true)
|
||||
|
||||
useEffect(() => setPageInfo(readPageInfo(metaData)), [pathname, metaData])
|
||||
|
||||
const conversationsQuery = useQuery({
|
||||
queryKey: assistantKeys.conversations(railStatus),
|
||||
queryFn: () => listAssistantConversations(railStatus),
|
||||
staleTime: 1000 * 60,
|
||||
})
|
||||
const run = activeConversationId !== null ? engine.runs[activeConversationId] : undefined
|
||||
const detailQuery = useQuery({
|
||||
queryKey: assistantKeys.conversation(activeConversationId ?? 0),
|
||||
queryFn: () => getAssistantConversation(activeConversationId as number),
|
||||
enabled: activeConversationId !== null,
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const detail = activeConversationId !== null ? detailQuery.data : undefined
|
||||
const messages = useMemo(() => detail?.messages ?? [], [detail])
|
||||
const conversation: AssistantConversation | undefined = detail?.conversation
|
||||
?? conversationsQuery.data?.find((item) => item.id === activeConversationId)
|
||||
const announcement = useStreamAnnouncer(messages)
|
||||
|
||||
// Opening a conversation reads it: the badge drops its unread replies
|
||||
useEffect(() => {
|
||||
if (activeConversationId === null || !assistant.open) return
|
||||
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) =>
|
||||
list?.map((item) => (item.id === activeConversationId && item.unreadCount ? { ...item, unreadCount: 0 } : item)))
|
||||
}, [activeConversationId, assistant.open, queryClient, messages.length])
|
||||
|
||||
// A reply still streaming on the server (another tab, a reload): follow it
|
||||
useEffect(() => {
|
||||
if (activeConversationId === null || run) return
|
||||
const streaming = messages.find((message) => message.role === 'ASSISTANT' && message.status === 'STREAMING' && message.id > 0 && message.turnId !== null)
|
||||
if (streaming) engine.attach(activeConversationId, streaming)
|
||||
}, [activeConversationId, messages, run, engine])
|
||||
|
||||
// Hand-overs: the command palette sends at once, the deep link only pre-fills
|
||||
useEffect(() => {
|
||||
if (!pendingRequest) return
|
||||
consumeRequest(pendingRequest.id)
|
||||
setActiveConversationId(null)
|
||||
setRailOpen(false)
|
||||
if (pendingRequest.mode === 'prefill') {
|
||||
setPrefill({ id: pendingRequest.id, text: pendingRequest.prompt })
|
||||
return
|
||||
}
|
||||
const context = pendingRequest.context === undefined ? readPageInfo(metaData)?.context ?? null : pendingRequest.context
|
||||
void engine.send(pendingRequest.prompt, null, context)
|
||||
}, [pendingRequest, consumeRequest, setActiveConversationId, metaData, engine])
|
||||
|
||||
// Keep the newest message in view while the user has not scrolled up
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRef.current
|
||||
if (element && stickToBottom.current) element.scrollTop = element.scrollHeight
|
||||
}, [messages])
|
||||
useEffect(() => {
|
||||
stickToBottom.current = true
|
||||
}, [activeConversationId])
|
||||
|
||||
const contextDismissed = pageInfo !== null && dismissedRoute === pageInfo.context.route
|
||||
|
||||
/**
|
||||
* Sends from the composer, with the page context unless dismissed.
|
||||
*
|
||||
* @param text - The message.
|
||||
*/
|
||||
const send = (text: string) => {
|
||||
const info = readPageInfo(metaData)
|
||||
const context = info && dismissedRoute !== info.context.route ? info.context : null
|
||||
stickToBottom.current = true
|
||||
void engine.send(text, activeConversationId, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a change to the active conversation.
|
||||
*
|
||||
* @param update - The change.
|
||||
*/
|
||||
const updateConversation = async (update: AssistantConversationUpdate) => {
|
||||
if (activeConversationId === null) return
|
||||
try {
|
||||
const updated = await updateAssistantConversation(activeConversationId, update)
|
||||
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(updated.id), (current) => (current ? { ...current, conversation: updated } : current))
|
||||
await queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
|
||||
if (update.status && update.status !== railStatus) setActiveConversationId(null)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Could not update the conversation.'))
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes the active conversation. */
|
||||
const deleteConversation = async () => {
|
||||
if (activeConversationId === null) return
|
||||
const id = activeConversationId
|
||||
try {
|
||||
await deleteAssistantConversation(id)
|
||||
setActiveConversationId(null)
|
||||
queryClient.removeQueries({ queryKey: assistantKeys.conversation(id) })
|
||||
await queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Could not delete the conversation.'))
|
||||
}
|
||||
}
|
||||
|
||||
const showMessages = wide || !railOpen
|
||||
const title = activeConversationId === null ? 'Assistant' : conversationTitle(conversation)
|
||||
const iconButton = 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11 pointer-coarse:w-11'
|
||||
const menuItem = 'flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm text-foreground outline-none data-[highlighted]:bg-accent pointer-coarse:min-h-11'
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2" style={{ minHeight: 'var(--qqq-header-height)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
onClick={() => setRailOpen((value) => !value)}
|
||||
aria-expanded={railOpen}
|
||||
aria-controls={listId}
|
||||
aria-label={railOpen ? 'Hide conversations' : 'Show conversations'}
|
||||
data-qqq-id="assistant-rail-toggle"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
{renaming && conversation ? (
|
||||
<form
|
||||
className="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const value = String(new FormData(event.currentTarget).get('title') ?? '').trim()
|
||||
setRenaming(false)
|
||||
if (value && value !== conversation.title) void updateConversation({ title: value })
|
||||
}}
|
||||
>
|
||||
<label className="sr-only" htmlFor={`${titleId}-rename`}>Conversation name</label>
|
||||
<input
|
||||
id={`${titleId}-rename`}
|
||||
name="title"
|
||||
defaultValue={conversation.title ?? ''}
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
onBlur={(event) => event.currentTarget.form?.requestSubmit()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
setRenaming(false)
|
||||
}
|
||||
}}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-base text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-sm"
|
||||
data-qqq-id="assistant-rename-input"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<h2 id={titleId} className="min-w-0 flex-1 truncate px-1 text-sm font-semibold text-foreground" data-qqq-id="assistant-title">{title}</h2>
|
||||
)}
|
||||
{conversation && activeConversationId !== null && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button type="button" className={iconButton} aria-label="Conversation actions" data-qqq-id="assistant-conversation-menu">
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content align="end" sideOffset={4} className="z-[2100] min-w-44 rounded-lg border border-border bg-popover p-1 shadow-lg" data-qqq-id="assistant-conversation-menu-content">
|
||||
<DropdownMenu.Item className={menuItem} onSelect={() => setRenaming(true)} data-qqq-id="assistant-rename">
|
||||
<Pencil className="h-4 w-4 text-muted-foreground" aria-hidden="true" />Rename
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={menuItem} onSelect={() => void updateConversation({ pinned: !conversation.pinned })} data-qqq-id="assistant-pin">
|
||||
{conversation.pinned ? <PinOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> : <Pin className="h-4 w-4 text-muted-foreground" aria-hidden="true" />}
|
||||
{conversation.pinned ? 'Unpin' : 'Pin'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className={menuItem}
|
||||
onSelect={() => void updateConversation({ status: conversation.status === 'ARCHIVED' ? 'ACTIVE' : 'ARCHIVED' })}
|
||||
data-qqq-id="assistant-archive"
|
||||
>
|
||||
{conversation.status === 'ARCHIVED' ? <ArchiveRestore className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> : <Archive className="h-4 w-4 text-muted-foreground" aria-hidden="true" />}
|
||||
{conversation.status === 'ARCHIVED' ? 'Unarchive' : 'Archive'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={cn(menuItem, 'text-destructive')} onSelect={() => setConfirmDelete(true)} data-qqq-id="assistant-delete">
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
onClick={() => {
|
||||
setActiveConversationId(null)
|
||||
setRailOpen(false)
|
||||
requestAnimationFrame(() => composerRef.current?.focus())
|
||||
}}
|
||||
aria-label="New conversation"
|
||||
data-qqq-id="assistant-new"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className={iconButton} onClick={() => assistant.setOpen(false)} aria-label="Close assistant" data-qqq-id="assistant-close">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{railOpen && (
|
||||
<div id={listId} className={cn('flex min-h-0', wide ? 'w-52 shrink-0 border-r border-border' : 'min-w-0 flex-1')}>
|
||||
<ConversationRail
|
||||
className="min-w-0 flex-1"
|
||||
conversations={conversationsQuery.data}
|
||||
loading={conversationsQuery.isLoading}
|
||||
activeId={activeConversationId}
|
||||
status={railStatus}
|
||||
onStatusChange={setRailStatus}
|
||||
onSelect={(id) => {
|
||||
setActiveConversationId(id)
|
||||
if (!engine.runs[id]) void queryClient.invalidateQueries({ queryKey: assistantKeys.conversation(id) })
|
||||
if (!wide) setRailOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showMessages && (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(event) => {
|
||||
const element = event.currentTarget
|
||||
stickToBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48
|
||||
}}
|
||||
className="@container min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-3 py-3"
|
||||
tabIndex={0}
|
||||
role="log"
|
||||
aria-live="off"
|
||||
aria-label="Messages"
|
||||
data-qqq-id="assistant-messages"
|
||||
>
|
||||
{activeConversationId === null && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground" data-qqq-id="assistant-empty">
|
||||
<p className="font-medium text-foreground">Ask about your data</p>
|
||||
<p className="mt-1">Find records, count and explain them, or ask to be taken to a screen. The assistant sees only what you can see.</p>
|
||||
</div>
|
||||
)}
|
||||
{activeConversationId !== null && detailQuery.isLoading && <p className="text-xs text-muted-foreground" role="status">Loading conversation...</p>}
|
||||
{activeConversationId !== null && detailQuery.isError && (
|
||||
<p className="text-xs text-destructive" role="alert">{getErrorMessage(detailQuery.error, 'Could not load the conversation.')}</p>
|
||||
)}
|
||||
{messages.map((message) => <AssistantMessageView key={message.id} message={message} />)}
|
||||
</div>
|
||||
<AssistantComposer
|
||||
ref={composerRef}
|
||||
pageInfo={pageInfo}
|
||||
contextDismissed={contextDismissed}
|
||||
onDismissContext={() => setDismissedRoute(pageInfo?.context.route ?? null)}
|
||||
onRefreshContext={() => setPageInfo(readPageInfo(metaData))}
|
||||
streaming={Boolean(run)}
|
||||
stopping={run?.status === 'cancelling'}
|
||||
onSend={send}
|
||||
onStop={() => activeConversationId !== null && engine.cancel(activeConversationId)}
|
||||
prefill={prefill}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" data-qqq-id="assistant-announcer">{announcement}</div>
|
||||
|
||||
<AlertDialog.Root open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-[2200] bg-black/40" />
|
||||
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-[2201] w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-5 shadow-lg focus:outline-none" data-qqq-id="assistant-delete-dialog">
|
||||
<AlertDialog.Title className="text-base font-semibold text-foreground">Delete this conversation?</AlertDialog.Title>
|
||||
<AlertDialog.Description className="mt-1 text-sm text-muted-foreground">{conversationTitle(conversation)} and its messages are deleted. This cannot be undone.</AlertDialog.Description>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<AlertDialog.Cancel className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11">Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
onClick={() => void deleteConversation()}
|
||||
data-qqq-id="assistant-delete-confirm"
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The docked panel: a resizable complementary column beside the page.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.assistant - The controller.
|
||||
* @param props.engine - The turn runner.
|
||||
* @returns The panel.
|
||||
*/
|
||||
function DockedPanel({ assistant, engine }: { assistant: AssistantController; engine: AssistantEngine }) {
|
||||
const [storedWidth, setStoredWidth] = useLocalStorage<number>('qqq-assistant-width', PANEL_DEFAULT_WIDTH)
|
||||
const width = clampPanelWidth(storedWidth)
|
||||
const titleId = useId()
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const drag = useRef<{ x: number; width: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
composerRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const onPointerMove = useCallback((event: React.PointerEvent) => {
|
||||
if (!drag.current) return
|
||||
setStoredWidth(clampPanelWidth(drag.current.width + drag.current.x - event.clientX))
|
||||
}, [setStoredWidth])
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="relative flex h-full min-h-0 shrink-0 flex-col overflow-hidden border-l border-border bg-card"
|
||||
style={{ width, maxWidth: `max(${PANEL_MIN_WIDTH}px, calc(100vw - ${PAGE_RESERVE}))` }}
|
||||
aria-labelledby={titleId}
|
||||
data-qqq-id="assistant-panel"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
if ((event.target as HTMLElement).closest('[role="menu"]')) return
|
||||
event.preventDefault()
|
||||
assistant.setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize assistant panel"
|
||||
aria-valuemin={PANEL_MIN_WIDTH}
|
||||
aria-valuemax={PANEL_MAX_WIDTH}
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
onPointerDown={(event) => {
|
||||
drag.current = { x: event.clientX, width }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={() => { drag.current = null }}
|
||||
onPointerCancel={() => { drag.current = null }}
|
||||
onKeyDown={(event) => {
|
||||
const step = event.shiftKey ? 64 : 16
|
||||
if (event.key === 'ArrowLeft') setStoredWidth(clampPanelWidth(width + step))
|
||||
else if (event.key === 'ArrowRight') setStoredWidth(clampPanelWidth(width - step))
|
||||
else if (event.key === 'Home') setStoredWidth(PANEL_MIN_WIDTH)
|
||||
else if (event.key === 'End') setStoredWidth(PANEL_MAX_WIDTH)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}}
|
||||
className="absolute inset-y-0 left-0 z-10 w-1.5 -translate-x-1/2 cursor-col-resize touch-none hover:bg-primary/30 focus:outline-none focus-visible:bg-primary/40"
|
||||
data-qqq-id="assistant-resize"
|
||||
/>
|
||||
<PanelBody assistant={assistant} engine={engine} wide={width >= SIDE_BY_SIDE_MIN_WIDTH} titleId={titleId} composerRef={composerRef} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet below 1024 px: a modal dialog, full screen on phones and a side sheet on tablets.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.assistant - The controller.
|
||||
* @param props.engine - The turn runner.
|
||||
* @returns The dialog.
|
||||
*/
|
||||
function SheetPanel({ assistant, engine }: { assistant: AssistantController; engine: AssistantEngine }) {
|
||||
const titleId = useId()
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const viewport = useVisualViewport(assistant.open)
|
||||
return (
|
||||
<Dialog.Root open={assistant.open} onOpenChange={(next) => { if (!next) assistant.setOpen(false) }}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-[var(--qqq-z-modal,1000)] bg-black/40 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=open]:fade-in-0" />
|
||||
<Dialog.Content
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
composerRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
// focus returns to the opener through the controller
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="fixed inset-x-0 top-0 z-[var(--qqq-z-modal,1000)] flex h-dvh flex-col overflow-hidden bg-card shadow-lg focus:outline-none md:left-auto md:w-[min(30rem,100vw)] md:border-l md:border-border"
|
||||
style={viewport ? { top: viewport.top, height: viewport.height } : undefined}
|
||||
data-qqq-id="assistant-panel"
|
||||
data-variant="sheet"
|
||||
>
|
||||
<Dialog.Title className="sr-only">Assistant</Dialog.Title>
|
||||
<PanelBody assistant={assistant} engine={engine} wide={false} titleId={titleId} composerRef={composerRef} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The assistant panel: docked on wide screens, a sheet below 1024 px. The turn runner lives
|
||||
* here, above both, so a reply keeps streaming while the panel is closed.
|
||||
*
|
||||
* @returns The panel, or null when closed on a wide screen.
|
||||
*/
|
||||
export default function AssistantPanel() {
|
||||
const assistant = useAssistant()
|
||||
const docked = useMediaQuery(ASSISTANT_DOCKED_MEDIA_QUERY)
|
||||
const engine = useAssistantEngine()
|
||||
if (!assistant) return null
|
||||
if (docked) return assistant.open ? <DockedPanel assistant={assistant} engine={engine} /> : null
|
||||
return <SheetPanel assistant={assistant} engine={engine} />
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantProvider — creates the shell-level assistant controller (probe, open state,
|
||||
* unread count, hand-over) for the dashboard layout. The panel itself loads lazily.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { AssistantPageContext } from '@/types/assistant'
|
||||
import { getAssistantConfig, listAssistantConversations } from '@/lib/api/assistant-probe'
|
||||
import { readAssistantDeepLink } from '@/lib/utils/assistant-deep-link'
|
||||
import { ASSISTANT_DOCKED_MEDIA_QUERY, AssistantContext, assistantKeys, type AssistantController, type AssistantRequest } from './assistant-context'
|
||||
|
||||
/** localStorage key remembering that the docked panel was open. */
|
||||
const OPEN_STORAGE_KEY = 'qqq-assistant-open'
|
||||
|
||||
/**
|
||||
* Reads a localStorage flag; storage can be unavailable (private mode, blocked site data).
|
||||
*
|
||||
* @param key - The key.
|
||||
* @returns Whether the stored value is `true`.
|
||||
*/
|
||||
function readFlag(key: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(key) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a localStorage flag, ignoring unavailable storage.
|
||||
*
|
||||
* @param key - The key.
|
||||
* @param value - The value.
|
||||
*/
|
||||
function writeFlag(key: string, value: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value))
|
||||
} catch {
|
||||
// storage unavailable: the panel just does not reopen on the next visit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the instance metadata announces the assistant qbit (`supplementalInstanceMetaData.assistant`,
|
||||
* published by qbit-assistant), the precondition for probing `/assistant/config`.
|
||||
*
|
||||
* @param metaData - Instance metadata.
|
||||
* @returns True when the entry is present.
|
||||
*/
|
||||
export function isAssistantInstalled(metaData: QInstance | undefined): boolean {
|
||||
const entry = metaData?.supplementalInstanceMetaData?.assistant
|
||||
return Boolean(entry) && typeof entry === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the assistant controller. Call it once in the dashboard layout (which also needs it
|
||||
* for the Ctrl/⌘+J shortcut) and pass the result to {@link AssistantProvider}.
|
||||
*
|
||||
* @param metaData - Instance metadata (tables, for the panel's page context).
|
||||
* @param isAuthenticated - The probe runs only for a signed-in user.
|
||||
* @returns The controller.
|
||||
*/
|
||||
export function useAssistantController(metaData: QInstance | undefined, isAuthenticated: boolean): AssistantController {
|
||||
const pathname = usePathname() ?? ''
|
||||
const router = useRouter()
|
||||
// Only an instance that announces the qbit is probed: elsewhere /assistant/config would answer
|
||||
// 404 on every page load (a console error in every browser)
|
||||
const installed = isAssistantInstalled(metaData)
|
||||
const { data: config = null } = useQuery({
|
||||
queryKey: assistantKeys.config(),
|
||||
queryFn: getAssistantConfig,
|
||||
enabled: isAuthenticated && installed,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
retry: false,
|
||||
})
|
||||
const enabled = installed && Boolean(config?.enabled)
|
||||
|
||||
const { data: conversations } = useQuery({
|
||||
queryKey: assistantKeys.conversations(),
|
||||
queryFn: () => listAssistantConversations(),
|
||||
enabled,
|
||||
staleTime: 1000 * 60,
|
||||
})
|
||||
const unreadCount = useMemo(() => (conversations ?? []).reduce((total, conversation) => total + (conversation.unreadCount || 0), 0), [conversations])
|
||||
|
||||
const [open, setOpenState] = useState(false)
|
||||
const [activeConversationId, setActiveConversationId] = useState<number | null>(null)
|
||||
const [pendingRequest, setPendingRequest] = useState<AssistantRequest | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const originRef = useRef<HTMLElement | null>(null)
|
||||
const wasOpen = useRef(false)
|
||||
|
||||
const setOpen = useCallback((next: boolean) => {
|
||||
if (next && typeof document !== 'undefined') {
|
||||
const active = document.activeElement
|
||||
originRef.current = active instanceof HTMLElement && active !== document.body && !active.closest('[data-qqq-id="assistant-panel"]') ? active : null
|
||||
}
|
||||
setOpenState(next)
|
||||
if (typeof window !== 'undefined' && window.matchMedia?.(ASSISTANT_DOCKED_MEDIA_QUERY)?.matches) writeFlag(OPEN_STORAGE_KEY, next)
|
||||
}, [])
|
||||
|
||||
// Closing returns focus to what had it before opening, else to the header button
|
||||
useEffect(() => {
|
||||
if (open || !wasOpen.current) {
|
||||
wasOpen.current = open
|
||||
return
|
||||
}
|
||||
wasOpen.current = false
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const target = originRef.current?.isConnected ? originRef.current : triggerRef.current
|
||||
originRef.current = null
|
||||
const active = document.activeElement
|
||||
if (target && (!active || active === document.body || !active.isConnected)) target.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [open])
|
||||
|
||||
// The docked panel reopens on the next visit when it was left open
|
||||
useEffect(() => {
|
||||
if (enabled && readFlag(OPEN_STORAGE_KEY) && window.matchMedia?.(ASSISTANT_DOCKED_MEDIA_QUERY)?.matches) setOpenState(true)
|
||||
}, [enabled])
|
||||
|
||||
// Disabled (kill switch, permission removed): close and hide everything
|
||||
useEffect(() => {
|
||||
if (!enabled) setOpenState(false)
|
||||
}, [enabled])
|
||||
|
||||
const toggle = useCallback(() => setOpen(!open), [open, setOpen])
|
||||
const openWith = useCallback((prompt: string, options?: { context?: AssistantPageContext | null; mode?: 'send' | 'prefill' }) => {
|
||||
const mode = options?.mode ?? 'send'
|
||||
if (mode === 'send' && !prompt.trim()) return
|
||||
setPendingRequest({ id: Date.now(), prompt: prompt.trim(), context: options?.context, mode })
|
||||
setOpen(true)
|
||||
}, [setOpen])
|
||||
|
||||
// Deep link `?assistant=open&assistantPrompt=...` (the record Developer screen's "Ask the
|
||||
// assistant about this record"): a new conversation on this page with the prompt pre-filled,
|
||||
// not sent; both parameters are then removed from the URL. Inert while the assistant is unavailable.
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') return
|
||||
const deepLink = readAssistantDeepLink(window.location.search)
|
||||
if (!deepLink) return
|
||||
openWith(deepLink.prompt, { mode: 'prefill' })
|
||||
router.replace(`${window.location.pathname}${deepLink.remainingSearch}${window.location.hash}`, { scroll: false })
|
||||
}, [enabled, pathname, openWith, router])
|
||||
const consumeRequest = useCallback((id: number) => {
|
||||
setPendingRequest((current) => (current?.id === id ? null : current))
|
||||
}, [])
|
||||
|
||||
return useMemo<AssistantController>(() => ({
|
||||
config,
|
||||
enabled,
|
||||
open: enabled && open,
|
||||
setOpen,
|
||||
toggle,
|
||||
activeConversationId,
|
||||
setActiveConversationId,
|
||||
unreadCount: enabled ? unreadCount : 0,
|
||||
openWith,
|
||||
pendingRequest,
|
||||
consumeRequest,
|
||||
triggerRef,
|
||||
metaData,
|
||||
}), [config, enabled, open, setOpen, toggle, activeConversationId, unreadCount, openWith, pendingRequest, consumeRequest, metaData])
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the assistant controller to the dashboard shell.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.value - The controller from {@link useAssistantController}.
|
||||
* @param props.children - The shell.
|
||||
* @returns The provider.
|
||||
*/
|
||||
export function AssistantProvider({ value, children }: { value: AssistantController; children: React.ReactNode }) {
|
||||
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantRenders — structured tool-result renders (records table, record, count,
|
||||
* history, widget) and record chips. Everything comes from server payloads, never from
|
||||
* model-written HTML; links pass the same-origin `/app` check.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowRight, Table2 } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
AssistantCountRender,
|
||||
AssistantHistoryRender,
|
||||
AssistantRecordRef,
|
||||
AssistantRecordRender,
|
||||
AssistantRecordsRender,
|
||||
AssistantRender,
|
||||
AssistantWidgetRender,
|
||||
} from '@/types/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
import { RecordHoverCard } from '@/components/records/RecordHoverCard'
|
||||
|
||||
const WidgetRenderer = lazy(() => import('@/components/widgets/WidgetRenderer').then((m) => ({ default: m.WidgetRenderer })))
|
||||
|
||||
/** Widget types the panel renders inline (charts and statistics); others link to their screen. */
|
||||
const INLINE_WIDGET_TYPES = new Set([
|
||||
'barChart', 'horizontalBarChart', 'stackedBarChart', 'lineChart', 'smallLineChart', 'pieChart', 'chart', 'statistics', 'multiStatistics',
|
||||
])
|
||||
|
||||
/** Classes of a link styled as a small action. */
|
||||
const ACTION_LINK = 'inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded pointer-coarse:min-h-11'
|
||||
|
||||
/**
|
||||
* A same-origin link, or plain text when the href fails the check.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.href - The server-built href.
|
||||
* @param props.children - Link content.
|
||||
* @param props.className - Classes.
|
||||
* @param props.qqqId - The `data-qqq-id`.
|
||||
* @returns The link or a span.
|
||||
*/
|
||||
function SafeLink({ href, children, className, qqqId }: { href: string | undefined; children: React.ReactNode; className?: string; qqqId?: string }) {
|
||||
const internal = internalAppHref(href)
|
||||
if (!internal) return <span className={className} data-qqq-id={qqqId}>{children}</span>
|
||||
return <Link href={internal} className={className} data-qqq-id={qqqId}>{children}</Link>
|
||||
}
|
||||
|
||||
/**
|
||||
* Record references as chips that open the record; hovering previews it.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.refs - The references.
|
||||
* @returns The chip list.
|
||||
*/
|
||||
export function RecordChips({ refs }: { refs: AssistantRecordRef[] }) {
|
||||
if (!refs.length) return null
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-1.5" aria-label="Records" data-qqq-id="assistant-record-refs">
|
||||
{refs.map((ref) => {
|
||||
const href = internalAppHref(ref.href)
|
||||
const chip = (
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 truncate">
|
||||
<Table2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="truncate">{ref.label || `${ref.tableLabel} #${ref.id}`}</span>
|
||||
<span className="sr-only">, {ref.tableLabel}</span>
|
||||
</span>
|
||||
)
|
||||
const chipClass = 'inline-flex max-w-full items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-foreground pointer-coarse:min-h-11'
|
||||
return (
|
||||
<li key={`${ref.tableName}:${ref.id}`} className="max-w-full">
|
||||
{href ? (
|
||||
<RecordHoverCard tableName={ref.tableName} primaryKey={ref.id} tableMetaData={{ label: ref.tableLabel }}>
|
||||
<Link href={href} className={`${chipClass} hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring`} data-qqq-id="assistant-record-chip">
|
||||
{chip}
|
||||
</Link>
|
||||
</RecordHoverCard>
|
||||
) : (
|
||||
<span className={chipClass} data-qqq-id="assistant-record-chip">{chip}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query results: a table where the panel is wide enough, cards where it is narrow.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The records payload.
|
||||
* @returns The records view.
|
||||
*/
|
||||
function RecordsView({ render }: { render: AssistantRecordsRender }) {
|
||||
const shown = render.rows.length
|
||||
const total = render.totalCount ?? shown
|
||||
const caption = `${render.tableLabel}: ${shown === total ? `${total} ${total === 1 ? 'row' : 'rows'}` : `${shown} of ${total} rows`}`
|
||||
const [firstColumn, ...otherColumns] = render.columns
|
||||
return (
|
||||
<div className="@container rounded-lg border border-border bg-card" data-qqq-id="assistant-records">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">{caption}</span>
|
||||
<SafeLink href={render.openHref} className={ACTION_LINK} qqqId="assistant-open-query">
|
||||
Open in Query screen<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</SafeLink>
|
||||
</div>
|
||||
{/* wide: a table (scrolls sideways inside its own box when it has many columns) */}
|
||||
<div className="hidden overflow-x-auto @lg:block" tabIndex={0} role="region" aria-label={caption}>
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground">
|
||||
{render.columns.map((column) => <th key={column.name} scope="col" className="whitespace-nowrap px-3 py-1.5 font-medium">{column.label}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{render.rows.map((row) => (
|
||||
<tr key={String(row.id)} className="border-b border-border last:border-0">
|
||||
{render.columns.map((column, index) => (
|
||||
<td key={column.name} className="max-w-[16rem] truncate px-3 py-1.5 text-foreground">
|
||||
{index === 0 ? (
|
||||
<SafeLink href={row.href} className="font-medium text-primary hover:underline">{row.values[column.name] ?? String(row.id)}</SafeLink>
|
||||
) : (row.values[column.name] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* narrow: one card per row */}
|
||||
<ul className="divide-y divide-border @lg:hidden">
|
||||
{render.rows.map((row) => (
|
||||
<li key={String(row.id)} className="px-3 py-2">
|
||||
{firstColumn && (
|
||||
<SafeLink href={row.href} className="block truncate text-sm font-medium text-primary hover:underline">
|
||||
{row.values[firstColumn.name] ?? String(row.id)}
|
||||
</SafeLink>
|
||||
)}
|
||||
<dl className="mt-1 grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
|
||||
{otherColumns.slice(0, 4).map((column) => (
|
||||
<React.Fragment key={column.name}>
|
||||
<dt className="truncate text-muted-foreground">{column.label}</dt>
|
||||
<dd className="truncate text-foreground">{row.values[column.name] ?? ''}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{render.truncated && <p className="border-t border-border px-3 py-1.5 text-xs text-muted-foreground">More rows match; open the query screen to see them all.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One record's fields.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The record payload.
|
||||
* @returns The record card.
|
||||
*/
|
||||
function RecordView({ render }: { render: AssistantRecordRender }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-record">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{render.tableLabel} #{render.id}</span>
|
||||
<SafeLink href={render.href} className={ACTION_LINK}>Open<ArrowRight className="h-3 w-3" aria-hidden="true" /></SafeLink>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
|
||||
{render.fields.map((field) => (
|
||||
<React.Fragment key={field.name}>
|
||||
<dt className="truncate text-muted-foreground">{field.label}</dt>
|
||||
<dd className="break-words text-foreground">{field.value ?? '—'}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A record count.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The count payload.
|
||||
* @returns The count card.
|
||||
*/
|
||||
function CountView({ render }: { render: AssistantCountRender }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-count">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xl font-semibold tabular-nums text-foreground">{render.count.toLocaleString()}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{render.tableLabel}</p>
|
||||
</div>
|
||||
<SafeLink href={render.openHref} className={ACTION_LINK} qqqId="assistant-open-query">
|
||||
Open in Query screen<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</SafeLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A record's change history.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The history payload.
|
||||
* @returns The history list.
|
||||
*/
|
||||
function HistoryView({ render }: { render: AssistantHistoryRender }) {
|
||||
if (!render.entries.length) return <p className="text-xs text-muted-foreground">No changes recorded.</p>
|
||||
return (
|
||||
<ol className="space-y-2 rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-history">
|
||||
{render.entries.map((entry, index) => (
|
||||
<li key={index} className="text-xs">
|
||||
<p className="text-muted-foreground">{formatTimestamp(entry.timestamp)} · {entry.user}</p>
|
||||
<p className="text-foreground">{entry.message}</p>
|
||||
{entry.details.length > 0 && (
|
||||
<ul className="mt-0.5 space-y-0.5 pl-3">
|
||||
{entry.details.map((detail, detailIndex) => (
|
||||
<li key={detailIndex} className="break-words text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{detail.field}</span>: {detail.oldValue ?? '—'} {'→'} {detail.newValue ?? '—'}{detail.truncated ? ' …' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an ISO timestamp for display, keeping unparseable values as they are.
|
||||
*
|
||||
* @param value - The timestamp.
|
||||
* @returns The formatted date and time.
|
||||
*/
|
||||
function formatTimestamp(value: string): string {
|
||||
try {
|
||||
return formatDateTime(value) || value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget payload rendered by the dashboard's own chart and statistics widgets.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The widget payload.
|
||||
* @returns The widget, or its label when the type is not rendered inline.
|
||||
*/
|
||||
function WidgetView({ render }: { render: AssistantWidgetRender }) {
|
||||
if (!INLINE_WIDGET_TYPES.has(render.widgetType)) {
|
||||
return <p className="text-xs text-muted-foreground">{render.label}: this widget type is shown on its dashboard only.</p>
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg border border-border bg-card p-2" data-qqq-id="assistant-widget">
|
||||
<p className="mb-1 truncate text-xs font-medium text-foreground">{render.label}</p>
|
||||
<Suspense fallback={<div className="h-40 rounded bg-muted motion-safe:animate-pulse" />}>
|
||||
<WidgetRenderer widgetMetaData={{ name: render.widgetName, label: render.label, type: render.widgetType, hasPermission: true }} data={render.data} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tool result payload.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The payload.
|
||||
* @returns The matching view, or null for an unknown type.
|
||||
*/
|
||||
export function AssistantRenderView({ render }: { render: AssistantRender }) {
|
||||
switch (render.type) {
|
||||
case 'records':
|
||||
return <RecordsView render={render} />
|
||||
case 'record':
|
||||
return <RecordView render={render} />
|
||||
case 'count':
|
||||
return <CountView render={render} />
|
||||
case 'history':
|
||||
return <HistoryView render={render} />
|
||||
case 'widget':
|
||||
return <WidgetView render={render} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file ConversationRail — the panel's conversation list (pinned, recent, archived) as a
|
||||
* listbox: arrow keys move, Enter or Space opens, unread conversations show a dot.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useId, useRef } from 'react'
|
||||
import { Pin } from 'lucide-react'
|
||||
|
||||
import type { AssistantConversation, AssistantConversationStatus } from '@/types/assistant'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/** Props of {@link ConversationRail}. */
|
||||
interface ConversationRailProps {
|
||||
conversations: AssistantConversation[] | undefined
|
||||
loading: boolean
|
||||
activeId: number | null
|
||||
/** Which list is shown. */
|
||||
status: AssistantConversationStatus
|
||||
/**
|
||||
* Switches between active and archived conversations.
|
||||
*
|
||||
* @param status - The list to show.
|
||||
*/
|
||||
onStatusChange: (status: AssistantConversationStatus) => void
|
||||
/**
|
||||
* Opens a conversation.
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
*/
|
||||
onSelect: (id: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation's display title.
|
||||
*
|
||||
* @param conversation - The conversation.
|
||||
* @returns Its title, or a placeholder for an untitled one.
|
||||
*/
|
||||
export function conversationTitle(conversation: AssistantConversation | undefined | null): string {
|
||||
return conversation?.title?.trim() || 'New conversation'
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation list.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The rail.
|
||||
*/
|
||||
export function ConversationRail({ conversations, loading, activeId, status, onStatusChange, onSelect, className }: ConversationRailProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const labelId = useId()
|
||||
const list = conversations ?? []
|
||||
const pinned = list.filter((conversation) => conversation.pinned)
|
||||
const recent = list.filter((conversation) => !conversation.pinned)
|
||||
const ordered = [...pinned, ...recent]
|
||||
const focusIndex = Math.max(0, ordered.findIndex((conversation) => conversation.id === activeId))
|
||||
|
||||
/**
|
||||
* Arrow-key navigation between options.
|
||||
*
|
||||
* @param event - The key event.
|
||||
*/
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const options = Array.from(listRef.current?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])
|
||||
const current = options.indexOf(document.activeElement as HTMLElement)
|
||||
let next = -1
|
||||
if (event.key === 'ArrowDown') next = Math.min(options.length - 1, current + 1)
|
||||
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1)
|
||||
else if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = options.length - 1
|
||||
else if ((event.key === 'Enter' || event.key === ' ') && current >= 0) {
|
||||
event.preventDefault()
|
||||
onSelect(Number(options[current].dataset.conversationId))
|
||||
return
|
||||
}
|
||||
if (next >= 0 && options[next]) {
|
||||
event.preventDefault()
|
||||
options.forEach((option, index) => option.setAttribute('tabindex', index === next ? '0' : '-1'))
|
||||
options[next].focus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One option.
|
||||
*
|
||||
* @param conversation - The conversation.
|
||||
* @param index - Its position in keyboard order.
|
||||
* @returns The option.
|
||||
*/
|
||||
const option = (conversation: AssistantConversation, index: number) => {
|
||||
const selected = conversation.id === activeId
|
||||
const unread = conversation.unreadCount > 0 && !selected
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={index === focusIndex ? 0 : -1}
|
||||
data-conversation-id={conversation.id}
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11',
|
||||
selected ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-qqq-id="assistant-conversation"
|
||||
>
|
||||
{conversation.pinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
<span className={cn('min-w-0 flex-1 truncate', unread && 'font-semibold')}>{conversationTitle(conversation)}</span>
|
||||
{unread && (
|
||||
<>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" aria-hidden="true" data-qqq-id="assistant-conversation-unread" />
|
||||
<span className="sr-only">, unread</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={cn('flex min-h-0 flex-col bg-sidebar', className)} aria-label="Conversations" data-qqq-id="assistant-rail">
|
||||
<div className="flex shrink-0 gap-1 border-b border-border p-1.5" role="group" aria-label="Show">
|
||||
{(['ACTIVE', 'ARCHIVED'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={status === value}
|
||||
onClick={() => onStatusChange(value)}
|
||||
className="flex-1 rounded px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-background aria-pressed:text-foreground pointer-coarse:min-h-11"
|
||||
>
|
||||
{value === 'ACTIVE' ? 'Recent' : 'Archived'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span id={labelId} className="sr-only">{status === 'ACTIVE' ? 'Conversations' : 'Archived conversations'}</span>
|
||||
<div ref={listRef} role="listbox" aria-labelledby={labelId} onKeyDown={onKeyDown} className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-1.5" data-qqq-id="assistant-conversation-list">
|
||||
{loading && <p className="px-2 py-1 text-xs text-muted-foreground" role="status">Loading...</p>}
|
||||
{!loading && ordered.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{status === 'ACTIVE' ? 'No conversations yet.' : 'No archived conversations.'}</p>
|
||||
)}
|
||||
{pinned.length > 0 && (
|
||||
<div role="group" aria-label="Pinned">
|
||||
<p className="px-2 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground" aria-hidden="true">Pinned</p>
|
||||
{pinned.map((conversation, index) => option(conversation, index))}
|
||||
</div>
|
||||
)}
|
||||
{recent.length > 0 && (
|
||||
<div role="group" aria-label={status === 'ACTIVE' ? 'Recent' : 'Archived'}>
|
||||
{pinned.length > 0 && <p className="px-2 pb-0.5 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground" aria-hidden="true">Recent</p>}
|
||||
{recent.map((conversation, index) => option(conversation, pinned.length + index))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Assistant context — the shell-level state of the assistant panel (feature probe,
|
||||
* open state, active conversation, unread count, hand-over requests), shared by the header
|
||||
* button, the command palette, the keyboard shortcut and the lazily loaded panel.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, type RefObject } from 'react'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { AssistantConfig, AssistantConversationStatus, AssistantPageContext } from '@/types/assistant'
|
||||
|
||||
/** The panel docks beside the page at this width and up; below it opens as a sheet. */
|
||||
export const ASSISTANT_DOCKED_MEDIA_QUERY = '(min-width: 1024px)'
|
||||
|
||||
/** TanStack Query keys of the assistant. */
|
||||
export const assistantKeys = {
|
||||
/**
|
||||
* Root of every assistant query.
|
||||
*
|
||||
* @returns The key.
|
||||
*/
|
||||
all: () => ['qqq', 'assistant'] as const,
|
||||
/**
|
||||
* The feature probe.
|
||||
*
|
||||
* @returns The key.
|
||||
*/
|
||||
config: () => [...assistantKeys.all(), 'config'] as const,
|
||||
/**
|
||||
* A conversation list.
|
||||
*
|
||||
* @param status - Active or archived conversations.
|
||||
* @returns The key.
|
||||
*/
|
||||
conversations: (status: AssistantConversationStatus = 'ACTIVE') => [...assistantKeys.all(), 'conversations', status] as const,
|
||||
/**
|
||||
* One conversation and its messages.
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
* @returns The key.
|
||||
*/
|
||||
conversation: (id: number) => [...assistantKeys.all(), 'conversation', id] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt handed to the panel in a new conversation: sent at once (command palette "Ask
|
||||
* Assistant") or only pre-filled in the composer (the `?assistant=open` deep link).
|
||||
*/
|
||||
export interface AssistantRequest {
|
||||
/** Distinguishes requests with the same text. */
|
||||
id: number
|
||||
prompt: string
|
||||
/** `send` asks right away; `prefill` puts the prompt in the composer for the user to send. */
|
||||
mode: 'send' | 'prefill'
|
||||
/** Page context for the request; `undefined` uses the current page. */
|
||||
context?: AssistantPageContext | null
|
||||
}
|
||||
|
||||
/** The shell-level assistant state and actions. */
|
||||
export interface AssistantController {
|
||||
/** The probe result; null while loading or when the qbit is absent. */
|
||||
config: AssistantConfig | null
|
||||
/** Whether the assistant is available: everything is hidden and inert otherwise. */
|
||||
enabled: boolean
|
||||
open: boolean
|
||||
/**
|
||||
* Opens or closes the panel; closing returns focus to where it was.
|
||||
*
|
||||
* @param open - The new state.
|
||||
*/
|
||||
setOpen: (open: boolean) => void
|
||||
/** Opens a closed panel, closes an open one. */
|
||||
toggle: () => void
|
||||
activeConversationId: number | null
|
||||
/**
|
||||
* Shows a conversation (null: a new, empty one).
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
*/
|
||||
setActiveConversationId: (id: number | null) => void
|
||||
/** Unread replies across conversations, for the header badge. */
|
||||
unreadCount: number
|
||||
/**
|
||||
* Opens the panel on a new conversation and asks `prompt` (or only pre-fills it).
|
||||
*
|
||||
* @param prompt - The question.
|
||||
* @param options - The page context to send (defaults to the current page) and whether to send.
|
||||
* @param options.context - The context, or null for none.
|
||||
* @param options.mode - `send` (default) or `prefill`.
|
||||
*/
|
||||
openWith: (prompt: string, options?: { context?: AssistantPageContext | null; mode?: 'send' | 'prefill' }) => void
|
||||
/** A hand-over the panel has not processed yet. */
|
||||
pendingRequest: AssistantRequest | null
|
||||
/**
|
||||
* Marks a hand-over as processed.
|
||||
*
|
||||
* @param id - The request id.
|
||||
*/
|
||||
consumeRequest: (id: number) => void
|
||||
/** The header button, where focus returns when nothing else had it. */
|
||||
triggerRef: RefObject<HTMLButtonElement | null>
|
||||
/** Instance metadata, for record previews. */
|
||||
metaData?: QInstance
|
||||
}
|
||||
|
||||
/** Context carrying the {@link AssistantController}; null outside the dashboard shell. */
|
||||
export const AssistantContext = createContext<AssistantController | null>(null)
|
||||
|
||||
/**
|
||||
* Reads the assistant controller.
|
||||
*
|
||||
* @returns The controller, or null outside the dashboard shell.
|
||||
*/
|
||||
export function useAssistant(): AssistantController | null {
|
||||
return useContext(AssistantContext)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for applying stream events to messages, and the announcer's sentence detection
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssistantMessage, AssistantStreamEvent } from '@/types/assistant'
|
||||
import { endReply, messageText, optimisticTurnMessages, reduceStreamEvent, type TurnMessageIds } from './message-reducer'
|
||||
import { completeSentenceLength } from './use-stream-announcer'
|
||||
|
||||
const REF = { tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice', href: '/app/person/1' }
|
||||
|
||||
/**
|
||||
* Applies events to a fresh optimistic turn.
|
||||
*
|
||||
* @param events - Stream events.
|
||||
* @returns The messages and ids.
|
||||
*/
|
||||
function apply(...events: AssistantStreamEvent[]): { messages: AssistantMessage[]; ids: TurnMessageIds } {
|
||||
const ids: TurnMessageIds = { userId: -1, assistantId: -2 }
|
||||
let messages = optimisticTurnMessages('Hi', { userId: -1, assistantId: -2 }, '2026-10-12T00:00:00Z')
|
||||
for (const event of events) messages = reduceStreamEvent(messages, event, ids)
|
||||
return { messages, ids }
|
||||
}
|
||||
|
||||
const START: AssistantStreamEvent = { type: 'message.start', seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }
|
||||
|
||||
describe('reduceStreamEvent', () => {
|
||||
it('swaps temporary ids for the server ids on message.start', () => {
|
||||
const { messages, ids } = apply(START)
|
||||
expect(messages.map((message) => [message.id, message.turnId])).toEqual([[10, 9], [11, 9]])
|
||||
expect(ids).toEqual({ userId: 10, assistantId: 11 })
|
||||
})
|
||||
|
||||
it('appends text deltas to the last text part and starts a new one after other parts', () => {
|
||||
const { messages } = apply(
|
||||
START,
|
||||
{ type: 'text.delta', seq: 2, messageId: 11, text: 'Hel' },
|
||||
{ type: 'text.delta', seq: 3, messageId: 11, text: 'lo' },
|
||||
{ type: 'tool.start', seq: 4, messageId: 11, toolCallId: 't', name: 'count_records', summary: 'Counting' },
|
||||
{ type: 'text.delta', seq: 5, messageId: 11, text: 'Done' },
|
||||
)
|
||||
expect(messages[1].parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
|
||||
expect(messageText(messages[1])).toBe('HelloDone')
|
||||
})
|
||||
|
||||
it('completes tool parts, adds record refs after them without duplicates, and ignores repeated starts', () => {
|
||||
const result: AssistantStreamEvent = { type: 'tool.result', seq: 3, messageId: 11, toolCallId: 't', name: 'get_record', outcome: 'OK', summary: 'Got Alice', recordRefs: [REF] }
|
||||
const { messages } = apply(
|
||||
START,
|
||||
{ type: 'tool.start', seq: 2, messageId: 11, toolCallId: 't', name: 'get_record', summary: 'Getting' },
|
||||
{ type: 'tool.start', seq: 2, messageId: 11, toolCallId: 't', name: 'get_record', summary: 'Getting' },
|
||||
result,
|
||||
{ ...result, seq: 4, recordRefs: [REF, { ...REF, id: 2, label: 'Bob' }] },
|
||||
{ type: 'tool.result', seq: 5, messageId: 11, toolCallId: 'u', name: 'count_records', outcome: 'DENIED', summary: 'Not allowed', render: { type: 'count', tableName: 'x', tableLabel: 'X', count: 1, openHref: '/app/x' } },
|
||||
)
|
||||
expect(messages[1].parts).toEqual([
|
||||
{ type: 'tool', toolCallId: 't', name: 'get_record', summary: 'Got Alice', outcome: 'OK' },
|
||||
{ type: 'record_refs', refs: [REF, { ...REF, id: 2, label: 'Bob' }] },
|
||||
{ type: 'tool', toolCallId: 'u', name: 'count_records', summary: 'Not allowed', outcome: 'DENIED', render: { type: 'count', tableName: 'x', tableLabel: 'X', count: 1, openHref: '/app/x' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('adds navigate, notice and error parts and applies the end status', () => {
|
||||
const { messages } = apply(
|
||||
START,
|
||||
{ type: 'ui.navigate', seq: 2, messageId: 11, href: '/app/person' },
|
||||
{ type: 'notice', seq: 3, messageId: 11, level: 'warning', text: 'Truncated' },
|
||||
{ type: 'error', seq: 4, code: 'BUDGET', message: 'Budget reached' },
|
||||
{ type: 'usage', seq: 5, turnId: 9, inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costMicros: 1 },
|
||||
{ type: 'message.end', seq: 6, messageId: 11, status: 'ERROR' },
|
||||
)
|
||||
expect(messages[1].parts).toEqual([
|
||||
{ type: 'navigate', href: '/app/person' },
|
||||
{ type: 'notice', level: 'warning', text: 'Truncated' },
|
||||
{ type: 'notice', level: 'error', text: 'Budget reached' },
|
||||
])
|
||||
expect(messages[1].status).toBe('ERROR')
|
||||
})
|
||||
|
||||
it('leaves messages unchanged for an unknown message id', () => {
|
||||
const { messages } = apply(START)
|
||||
expect(reduceStreamEvent(messages, { type: 'text.delta', seq: 2, messageId: 99, text: 'x' }, { userId: 10, assistantId: 99 })).toBe(messages)
|
||||
})
|
||||
|
||||
it('keeps the user message when attaching to a reply (no user id)', () => {
|
||||
const messages = optimisticTurnMessages('Hi', { userId: 10, assistantId: 11 }, '2026-10-12T00:00:00Z')
|
||||
const ids: TurnMessageIds = { userId: null, assistantId: 11 }
|
||||
expect(reduceStreamEvent(messages, START, ids).map((message) => message.id)).toEqual([10, 11])
|
||||
})
|
||||
})
|
||||
|
||||
describe('endReply', () => {
|
||||
it('ends a streaming reply with a notice, and keeps a finished status', () => {
|
||||
const { messages } = apply(START)
|
||||
const ended = endReply(messages, 11, 'ERROR', 'Lost')
|
||||
expect(ended[1]).toMatchObject({ status: 'ERROR', parts: [{ type: 'notice', level: 'error', text: 'Lost' }] })
|
||||
expect(endReply(ended, 11, 'CANCELLED')[1].status).toBe('ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeSentenceLength', () => {
|
||||
it('finds the end of the last complete sentence', () => {
|
||||
expect(completeSentenceLength('One. Two! Three')).toBe(9)
|
||||
expect(completeSentenceLength('Line one\nmore')).toBe(9)
|
||||
expect(completeSentenceLength('Version 1.5 is')).toBe(0)
|
||||
expect(completeSentenceLength('Done.')).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file message-reducer — applies turn stream events to a conversation's messages (pure, so
|
||||
* the live reply can be kept in the TanStack Query cache and survive the panel closing).
|
||||
*/
|
||||
|
||||
import type { AssistantMessage, AssistantPart, AssistantRecordRef, AssistantStreamEvent } from '@/types/assistant'
|
||||
|
||||
/** The messages a running turn writes: the user's (until the server id is known) and the reply. */
|
||||
export interface TurnMessageIds {
|
||||
/** The user message id (a temporary negative id until `message.start`), or null when attaching. */
|
||||
userId: number | null
|
||||
/** The assistant message id (temporary until `message.start`). */
|
||||
assistantId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the optimistic messages of a new turn.
|
||||
*
|
||||
* @param text - The user's text.
|
||||
* @param ids - Temporary ids.
|
||||
* @param now - Creation time (ISO).
|
||||
* @returns The user message and the empty streaming reply.
|
||||
*/
|
||||
export function optimisticTurnMessages(text: string, ids: { userId: number; assistantId: number }, now: string): AssistantMessage[] {
|
||||
return [
|
||||
{ id: ids.userId, role: 'USER', status: 'COMPLETE', turnId: null, createDate: now, parts: [{ type: 'text', text }] },
|
||||
{ id: ids.assistantId, role: 'ASSISTANT', status: 'STREAMING', turnId: null, createDate: now, parts: [] },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one stream event to the messages. `ids` is updated in place on `message.start`.
|
||||
*
|
||||
* @param messages - The conversation's messages.
|
||||
* @param event - The stream event.
|
||||
* @param ids - The ids of the running turn's messages.
|
||||
* @returns The new messages (the same array when nothing changed).
|
||||
*/
|
||||
export function reduceStreamEvent(messages: AssistantMessage[], event: AssistantStreamEvent, ids: TurnMessageIds): AssistantMessage[] {
|
||||
switch (event.type) {
|
||||
case 'message.start': {
|
||||
const userId = ids.userId
|
||||
const assistantId = ids.assistantId
|
||||
ids.assistantId = event.messageId
|
||||
if (userId !== null) ids.userId = event.userMessageId
|
||||
return messages.map((message) => {
|
||||
if (userId !== null && message.id === userId) return { ...message, id: event.userMessageId, turnId: event.turnId }
|
||||
if (message.id === assistantId) return { ...message, id: event.messageId, turnId: event.turnId }
|
||||
return message
|
||||
})
|
||||
}
|
||||
case 'text.delta':
|
||||
return updateMessage(messages, ids.assistantId, (message) => {
|
||||
const last = message.parts[message.parts.length - 1]
|
||||
if (last?.type === 'text') return { ...message, parts: [...message.parts.slice(0, -1), { type: 'text', text: last.text + event.text }] }
|
||||
return { ...message, parts: [...message.parts, { type: 'text', text: event.text }] }
|
||||
})
|
||||
case 'tool.start':
|
||||
return updateMessage(messages, ids.assistantId, (message) => {
|
||||
if (message.parts.some((part) => part.type === 'tool' && part.toolCallId === event.toolCallId)) return message
|
||||
return { ...message, parts: [...message.parts, { type: 'tool', toolCallId: event.toolCallId, name: event.name, summary: event.summary }] }
|
||||
})
|
||||
case 'tool.result':
|
||||
return updateMessage(messages, ids.assistantId, (message) => {
|
||||
const parts = [...message.parts]
|
||||
const tool: AssistantPart = { type: 'tool', toolCallId: event.toolCallId, name: event.name, summary: event.summary, outcome: event.outcome, ...(event.render ? { render: event.render } : {}) }
|
||||
let index = parts.findIndex((part) => part.type === 'tool' && part.toolCallId === event.toolCallId)
|
||||
if (index === -1) {
|
||||
parts.push(tool)
|
||||
index = parts.length - 1
|
||||
} else {
|
||||
parts[index] = tool
|
||||
}
|
||||
if (event.recordRefs?.length) {
|
||||
const next = parts[index + 1]
|
||||
if (next?.type === 'record_refs') parts[index + 1] = { type: 'record_refs', refs: mergeRefs(next.refs, event.recordRefs) }
|
||||
else parts.splice(index + 1, 0, { type: 'record_refs', refs: mergeRefs([], event.recordRefs) })
|
||||
}
|
||||
return { ...message, parts }
|
||||
})
|
||||
case 'ui.navigate':
|
||||
return appendPart(messages, ids.assistantId, { type: 'navigate', href: event.href })
|
||||
case 'notice':
|
||||
return appendPart(messages, ids.assistantId, { type: 'notice', level: event.level, text: event.text })
|
||||
case 'error':
|
||||
return appendPart(messages, ids.assistantId, { type: 'notice', level: 'error', text: event.message })
|
||||
case 'message.end':
|
||||
return updateMessage(messages, ids.assistantId, (message) => ({ ...message, status: event.status }))
|
||||
default:
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a reply locally (the stream failed or was aborted), with an optional error notice.
|
||||
*
|
||||
* @param messages - The conversation's messages.
|
||||
* @param assistantId - The reply.
|
||||
* @param status - Its final status.
|
||||
* @param notice - An error to show in the reply.
|
||||
* @returns The new messages.
|
||||
*/
|
||||
export function endReply(messages: AssistantMessage[], assistantId: number, status: AssistantMessage['status'], notice?: string): AssistantMessage[] {
|
||||
return updateMessage(messages, assistantId, (message) => ({
|
||||
...message,
|
||||
status: message.status === 'STREAMING' ? status : message.status,
|
||||
parts: notice ? [...message.parts, { type: 'notice', level: 'error', text: notice }] : message.parts,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain text of a message's text parts.
|
||||
*
|
||||
* @param message - A message.
|
||||
* @returns The concatenated text.
|
||||
*/
|
||||
export function messageText(message: AssistantMessage): string {
|
||||
return message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one message.
|
||||
*
|
||||
* @param messages - The messages.
|
||||
* @param id - The message id.
|
||||
* @param update - Produces the new message.
|
||||
* @returns The new messages, or the same array when the id is absent.
|
||||
*/
|
||||
function updateMessage(messages: AssistantMessage[], id: number, update: (message: AssistantMessage) => AssistantMessage): AssistantMessage[] {
|
||||
const index = messages.findIndex((message) => message.id === id)
|
||||
if (index === -1) return messages
|
||||
const next = [...messages]
|
||||
next[index] = update(messages[index])
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a part to a message.
|
||||
*
|
||||
* @param messages - The messages.
|
||||
* @param id - The message id.
|
||||
* @param part - The part.
|
||||
* @returns The new messages.
|
||||
*/
|
||||
function appendPart(messages: AssistantMessage[], id: number, part: AssistantPart): AssistantMessage[] {
|
||||
return updateMessage(messages, id, (message) => ({ ...message, parts: [...message.parts, part] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds record references, skipping ones already present.
|
||||
*
|
||||
* @param existing - References already shown.
|
||||
* @param added - New references.
|
||||
* @returns The merged list.
|
||||
*/
|
||||
function mergeRefs(existing: AssistantRecordRef[], added: AssistantRecordRef[]): AssistantRecordRef[] {
|
||||
const seen = new Set(existing.map((ref) => `${ref.tableName}:${ref.id}`))
|
||||
const merged = [...existing]
|
||||
for (const ref of added) {
|
||||
const key = `${ref.tableName}:${ref.id}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
merged.push(ref)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file useAssistantEngine — runs assistant turns: creates the conversation when needed, adds
|
||||
* the optimistic messages, streams the reply into the TanStack Query cache (so it survives the
|
||||
* panel closing and route changes), follows `ui.navigate`, cancels, and resumes a reply that
|
||||
* was still streaming when the conversation was opened.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
AssistantConversation,
|
||||
AssistantConversationDetail,
|
||||
AssistantMessage,
|
||||
AssistantPageContext,
|
||||
AssistantStreamEvent,
|
||||
} from '@/types/assistant'
|
||||
import {
|
||||
AssistantHttpError,
|
||||
cancelAssistantTurn,
|
||||
createAssistantConversation,
|
||||
resumeAssistantTurn,
|
||||
streamAssistantTurn,
|
||||
type AssistantTurnResult,
|
||||
} from '@/lib/api/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { assistantKeys, useAssistant } from './assistant-context'
|
||||
import { endReply, optimisticTurnMessages, reduceStreamEvent, type TurnMessageIds } from './message-reducer'
|
||||
|
||||
/** Give up waiting for the server to confirm a cancel after this long, and drop the stream. */
|
||||
const CANCEL_TIMEOUT_MS = 15_000
|
||||
|
||||
/** A turn streaming in one conversation. */
|
||||
export interface AssistantRun {
|
||||
turnId: number | null
|
||||
status: 'running' | 'cancelling'
|
||||
}
|
||||
|
||||
/** The turn runner used by the panel. */
|
||||
export interface AssistantEngine {
|
||||
/** Running turns by conversation id. */
|
||||
runs: Record<number, AssistantRun>
|
||||
/**
|
||||
* Sends the user's text, starting a conversation when `conversationId` is null.
|
||||
*
|
||||
* @param text - The user's message.
|
||||
* @param conversationId - The conversation, or null for a new one.
|
||||
* @param pageContext - Where the user is (omitted when dismissed).
|
||||
*/
|
||||
send: (text: string, conversationId: number | null, pageContext?: AssistantPageContext | null) => Promise<void>
|
||||
/**
|
||||
* Stops the running turn of a conversation.
|
||||
*
|
||||
* @param conversationId - The conversation.
|
||||
*/
|
||||
cancel: (conversationId: number) => void
|
||||
/**
|
||||
* Re-attaches to a reply that is still streaming on the server.
|
||||
*
|
||||
* @param conversationId - The conversation.
|
||||
* @param message - The streaming assistant message.
|
||||
*/
|
||||
attach: (conversationId: number, message: AssistantMessage) => void
|
||||
}
|
||||
|
||||
/** Temporary ids of optimistic messages, below any server id. */
|
||||
let nextTemporaryId = -1
|
||||
|
||||
/**
|
||||
* Creates the turn runner.
|
||||
*
|
||||
* @returns The engine.
|
||||
*/
|
||||
export function useAssistantEngine(): AssistantEngine {
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const assistant = useAssistant()
|
||||
const [runs, setRuns] = useState<Record<number, AssistantRun>>({})
|
||||
const controllers = useRef(new Map<number, AbortController>())
|
||||
const turnIds = useRef(new Map<number, number>())
|
||||
const assistantRef = useRef(assistant)
|
||||
assistantRef.current = assistant
|
||||
|
||||
// Abort every stream when the panel unmounts (sign-out); turns keep running on the server
|
||||
useEffect(() => {
|
||||
const active = controllers.current
|
||||
return () => active.forEach((controller) => controller.abort())
|
||||
}, [])
|
||||
|
||||
const setMessages = useCallback((conversationId: number, update: (messages: AssistantMessage[]) => AssistantMessage[]) => {
|
||||
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(conversationId), (detail) =>
|
||||
detail ? { ...detail, messages: update(detail.messages) } : detail)
|
||||
}, [queryClient])
|
||||
|
||||
const setRun = useCallback((conversationId: number, run: AssistantRun | null) => {
|
||||
setRuns((current) => {
|
||||
const next = { ...current }
|
||||
if (run) next[conversationId] = run
|
||||
else delete next[conversationId]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** Applies one event to the cache and follows navigation. */
|
||||
const handleEvent = useCallback((conversationId: number, event: AssistantStreamEvent, ids: TurnMessageIds) => {
|
||||
setMessages(conversationId, (messages) => reduceStreamEvent(messages, event, ids))
|
||||
if (event.type === 'message.start') {
|
||||
turnIds.current.set(conversationId, event.turnId)
|
||||
setRuns((current) => (current[conversationId] ? { ...current, [conversationId]: { ...current[conversationId], turnId: event.turnId } } : current))
|
||||
}
|
||||
if (event.type === 'ui.navigate') {
|
||||
const href = internalAppHref(event.href)
|
||||
if (href) router.push(href)
|
||||
}
|
||||
}, [router, setMessages])
|
||||
|
||||
/** Reconciles with the server and reports a finished reply the user is not looking at. */
|
||||
const finish = useCallback((conversationId: number, result: AssistantTurnResult | null) => {
|
||||
controllers.current.get(conversationId)?.abort()
|
||||
controllers.current.delete(conversationId)
|
||||
turnIds.current.delete(conversationId)
|
||||
setRun(conversationId, null)
|
||||
// a refused or lost turn keeps its local error (the server may have nothing to show for it)
|
||||
if (result) void queryClient.invalidateQueries({ queryKey: assistantKeys.conversation(conversationId) })
|
||||
void queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
|
||||
const current = assistantRef.current
|
||||
if (result?.status !== 'COMPLETE' || !current) return
|
||||
if (current.open && current.activeConversationId === conversationId) return
|
||||
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) =>
|
||||
list?.map((conversation) => (conversation.id === conversationId ? { ...conversation, unreadCount: conversation.unreadCount + 1 } : conversation)))
|
||||
toast('Assistant finished', {
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => {
|
||||
assistantRef.current?.setActiveConversationId(conversationId)
|
||||
assistantRef.current?.setOpen(true)
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [queryClient, setRun])
|
||||
|
||||
/** Streams a turn (new or resumed) and handles its end. */
|
||||
const run = useCallback(async (conversationId: number, ids: TurnMessageIds, start: (controller: AbortController) => Promise<AssistantTurnResult>) => {
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(conversationId, controller)
|
||||
let result: AssistantTurnResult | null = null
|
||||
try {
|
||||
result = await start(controller)
|
||||
if (result.status === 'ABORTED') setMessages(conversationId, (messages) => endReply(messages, ids.assistantId, 'CANCELLED'))
|
||||
} catch (error) {
|
||||
const message = error instanceof AssistantHttpError || error instanceof Error ? error.message : 'The assistant could not answer.'
|
||||
setMessages(conversationId, (messages) => endReply(messages, ids.assistantId, 'ERROR', message))
|
||||
} finally {
|
||||
finish(conversationId, result)
|
||||
}
|
||||
}, [finish, setMessages])
|
||||
|
||||
const send = useCallback(async (text: string, conversationId: number | null, pageContext?: AssistantPageContext | null) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
let id = conversationId
|
||||
if (id === null) {
|
||||
let conversation: AssistantConversation
|
||||
try {
|
||||
conversation = await createAssistantConversation(pageContext ? { context: pageContext } : {})
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Could not start a conversation.'))
|
||||
return
|
||||
}
|
||||
id = conversation.id
|
||||
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(id), { conversation, messages: [] })
|
||||
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) => [conversation, ...(list ?? []).filter((item) => item.id !== conversation.id)])
|
||||
assistantRef.current?.setActiveConversationId(id)
|
||||
}
|
||||
const conversation = id
|
||||
if (controllers.current.has(conversation)) return
|
||||
const ids: TurnMessageIds = { userId: nextTemporaryId--, assistantId: nextTemporaryId-- }
|
||||
const optimistic = optimisticTurnMessages(trimmed, { userId: ids.userId as number, assistantId: ids.assistantId }, new Date().toISOString())
|
||||
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(conversation), (detail) =>
|
||||
detail ? { ...detail, messages: [...detail.messages, ...optimistic] } : detail)
|
||||
setRun(conversation, { turnId: null, status: 'running' })
|
||||
await run(conversation, ids, (controller) => streamAssistantTurn(
|
||||
conversation,
|
||||
pageContext ? { text: trimmed, pageContext } : { text: trimmed },
|
||||
{ signal: controller.signal, onEvent: (event) => handleEvent(conversation, event, ids) }
|
||||
))
|
||||
}, [handleEvent, queryClient, run, setRun])
|
||||
|
||||
const attach = useCallback((conversationId: number, message: AssistantMessage) => {
|
||||
if (controllers.current.has(conversationId) || message.turnId === null) return
|
||||
const turnId = message.turnId
|
||||
const ids: TurnMessageIds = { userId: null, assistantId: message.id }
|
||||
// the replay rebuilds the reply from its first event
|
||||
setMessages(conversationId, (messages) => messages.map((item) => (item.id === message.id ? { ...item, parts: [] } : item)))
|
||||
turnIds.current.set(conversationId, turnId)
|
||||
setRun(conversationId, { turnId, status: 'running' })
|
||||
void run(conversationId, ids, (controller) => resumeAssistantTurn(turnId, 0, {
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => handleEvent(conversationId, event, ids),
|
||||
}))
|
||||
}, [handleEvent, run, setMessages, setRun])
|
||||
|
||||
const cancel = useCallback((conversationId: number) => {
|
||||
const controller = controllers.current.get(conversationId)
|
||||
if (!controller) return
|
||||
const turnId = turnIds.current.get(conversationId)
|
||||
setRuns((current) => (current[conversationId] ? { ...current, [conversationId]: { ...current[conversationId], status: 'cancelling' } } : current))
|
||||
if (turnId === undefined) {
|
||||
// the server has not started the turn yet: drop the request
|
||||
controller.abort()
|
||||
return
|
||||
}
|
||||
// the stream ends with a CANCELLED message.end; drop it if the server does not confirm in time
|
||||
const timer = setTimeout(() => controller.abort(), CANCEL_TIMEOUT_MS)
|
||||
controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
|
||||
cancelAssistantTurn(turnId).catch(() => controller.abort())
|
||||
}, [])
|
||||
|
||||
return { runs, send, cancel, attach }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file useStreamAnnouncer — screen reader announcements for a streaming reply, throttled to
|
||||
* whole sentences (at most one announcement per interval) and the rest on completion, instead
|
||||
* of every token.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { AssistantMessage } from '@/types/assistant'
|
||||
import { messageText } from './message-reducer'
|
||||
|
||||
/** Minimum time between two announcements while text streams. */
|
||||
export const ANNOUNCE_INTERVAL_MS = 1500
|
||||
|
||||
/**
|
||||
* The length of `text` up to and including its last sentence end (`.`, `!`, `?` or a line
|
||||
* break, followed by whitespace or the end).
|
||||
*
|
||||
* @param text - Text not yet announced.
|
||||
* @returns The length to announce, 0 when no sentence is complete.
|
||||
*/
|
||||
export function completeSentenceLength(text: string): number {
|
||||
let end = 0
|
||||
const pattern = /[.!?](?=\s|$)|\n/g
|
||||
for (let match = pattern.exec(text); match; match = pattern.exec(text)) end = match.index + 1
|
||||
return end
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the latest assistant reply and returns the text to put in a polite live region.
|
||||
*
|
||||
* @param messages - The visible conversation's messages.
|
||||
* @returns The current announcement.
|
||||
*/
|
||||
export function useStreamAnnouncer(messages: AssistantMessage[]): string {
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [tick, setTick] = useState(0)
|
||||
const state = useRef<{ id: number | null; spoken: number; at: number; streaming: boolean }>({ id: null, spoken: 0, at: 0, streaming: false })
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const reply = [...messages].reverse().find((message) => message.role === 'ASSISTANT')
|
||||
if (!reply) return
|
||||
const current = state.current
|
||||
const text = messageText(reply)
|
||||
if (current.id !== reply.id) {
|
||||
// a new reply (or the temporary id replaced by the server's): keep what was already spoken
|
||||
const continued = current.streaming && reply.status !== 'COMPLETE' && text.length >= current.spoken
|
||||
state.current = { id: reply.id, spoken: continued ? current.spoken : 0, at: continued ? current.at : 0, streaming: reply.status === 'STREAMING' }
|
||||
}
|
||||
const tracked = state.current
|
||||
if (reply.status === 'STREAMING') {
|
||||
tracked.streaming = true
|
||||
const pending = text.slice(tracked.spoken)
|
||||
const length = completeSentenceLength(pending)
|
||||
if (!length) return
|
||||
const wait = tracked.at + ANNOUNCE_INTERVAL_MS - Date.now()
|
||||
if (wait > 0) {
|
||||
if (!timer.current) timer.current = setTimeout(() => { timer.current = null; setTick((value) => value + 1) }, wait)
|
||||
return
|
||||
}
|
||||
tracked.spoken += length
|
||||
tracked.at = Date.now()
|
||||
setAnnouncement(pending.slice(0, length).trim())
|
||||
return
|
||||
}
|
||||
if (!tracked.streaming) return
|
||||
// finished: announce the rest, or how it ended
|
||||
tracked.streaming = false
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
const rest = text.slice(tracked.spoken).trim()
|
||||
tracked.spoken = text.length
|
||||
if (reply.status === 'CANCELLED') setAnnouncement(rest ? `${rest} Stopped.` : 'Stopped.')
|
||||
else if (reply.status === 'ERROR') setAnnouncement('The reply did not finish.')
|
||||
else setAnnouncement(rest || 'Reply complete.')
|
||||
}, [messages, tick])
|
||||
|
||||
return announcement
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Search, Sparkles, X } from 'lucide-react'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
@@ -51,6 +51,12 @@ interface CommandMenuProps {
|
||||
navTargets: NavTarget[]
|
||||
/** Instance metadata, for the current table's actions (omit for navigation only). */
|
||||
metaData?: QInstance
|
||||
/**
|
||||
* When set (the assistant is available), typed text can be handed to the assistant.
|
||||
*
|
||||
* @param text - The typed text.
|
||||
*/
|
||||
onAskAssistant?: (text: string) => void
|
||||
}
|
||||
|
||||
/** Shared classes of a palette entry (44 px tall on touch screens). */
|
||||
@@ -84,7 +90,7 @@ function GroupHeading({ children }: { children: React.ReactNode }) {
|
||||
* @returns A fixed full-screen backdrop with the command palette dialog, or
|
||||
* `null` when `open` is `false`. Keyboard: ↑↓ navigate, ↵ open, Esc close.
|
||||
*/
|
||||
export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenuProps) {
|
||||
export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistant }: CommandMenuProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -248,7 +254,7 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
aria-live="polite"
|
||||
data-qqq-id="command-menu-results"
|
||||
>
|
||||
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && (
|
||||
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && !(onAskAssistant && search.trim()) && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground" role="status" data-qqq-id="command-menu-empty">
|
||||
No results found.
|
||||
</p>
|
||||
@@ -312,6 +318,23 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
{/* Last, so Enter still opens the best navigation match; alone when nothing else matches */}
|
||||
{onAskAssistant && search.trim() && (
|
||||
<Command.Group heading={<GroupHeading>Assistant</GroupHeading>} data-qqq-id="command-group-assistant">
|
||||
<Command.Item
|
||||
value="assistant:ask"
|
||||
onSelect={() => {
|
||||
onClose()
|
||||
onAskAssistant(search.trim())
|
||||
}}
|
||||
className={ITEM_CLASS}
|
||||
data-qqq-id="command-ask-assistant"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="flex-1 truncate font-medium">Ask Assistant: {search.trim()}</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
{/* Footer hint */}
|
||||
|
||||
@@ -111,6 +111,8 @@ interface KeyboardShortcutsDialogProps {
|
||||
* opened (Safari and iPad do not focus a tapped or clicked button).
|
||||
*/
|
||||
returnFocusRef?: React.RefObject<HTMLElement | null>
|
||||
/** Lists the assistant shortcut (Ctrl/⌘+J) when the assistant is available. */
|
||||
assistantEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +128,12 @@ interface KeyboardShortcutsDialogProps {
|
||||
* sections (max 60 vh), and a footer reminder. Each section groups related
|
||||
* shortcuts under a heading with {@link Kbd} badges for each key combination.
|
||||
*/
|
||||
export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef }: KeyboardShortcutsDialogProps) {
|
||||
export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef, assistantEnabled = false }: KeyboardShortcutsDialogProps) {
|
||||
const sections = assistantEnabled
|
||||
? shortcutSections.map((section) => (section.title === 'Global'
|
||||
? { ...section, shortcuts: [...section.shortcuts, { keys: ['⌘', 'J'], description: 'Assistant (Ctrl+J on Windows and Linux)' }] }
|
||||
: section))
|
||||
: shortcutSections
|
||||
const restoreFocus = useRestoreFocus(open)
|
||||
/**
|
||||
* Returns focus to where it was, or else to the help button.
|
||||
@@ -177,7 +184,7 @@ export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef }: Keybo
|
||||
|
||||
{/* Shortcut sections */}
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
|
||||
{shortcutSections.map((section, sectionIndex) => (
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={section.title} className={cn(sectionIndex > 0 && 'mt-5')}>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface HeaderProps {
|
||||
navTargets?: NavTarget[]
|
||||
/** Tables the backend record search covers, for the header search (empty: local search only). */
|
||||
searchTables?: SearchableTable[]
|
||||
/** The assistant toggle, shown before the help button when the assistant is available. */
|
||||
assistantButton?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +79,7 @@ export interface HeaderProps {
|
||||
* {@link GlobalSearch} + notifications bell on the right. The hamburger and
|
||||
* GlobalSearch are each conditionally visible based on the `md` breakpoint.
|
||||
*/
|
||||
export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, onSearchOpen, onCommandOpen, onHelpOpen, helpButtonRef, pathToLabelMap = {}, ancestorAppMap = {}, homePath, navTargets = [], searchTables }: HeaderProps) {
|
||||
export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, onSearchOpen, onCommandOpen, onHelpOpen, helpButtonRef, pathToLabelMap = {}, ancestorAppMap = {}, homePath, navTargets = [], searchTables, assistantButton }: HeaderProps) {
|
||||
const [notificationCount] = useState(0)
|
||||
// toasts appear below the header, never over its controls
|
||||
const headerRef = useRef<HTMLElement>(null)
|
||||
@@ -141,6 +143,8 @@ export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, on
|
||||
</button>
|
||||
)}
|
||||
|
||||
{assistantButton}
|
||||
|
||||
{/* Keyboard shortcuts hint */}
|
||||
<button
|
||||
ref={helpButtonRef}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Assistant probe — `GET /assistant/config` (whether the QQQ Assistant is available to
|
||||
* this user) and the conversation list behind the header badge. Kept apart from the streaming
|
||||
* client in `assistant.ts` so the dashboard shell stays small.
|
||||
*/
|
||||
|
||||
import type { AssistantConfig, AssistantConversation, AssistantConversationStatus } from '@/types/assistant'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import apiClient from './client'
|
||||
|
||||
/**
|
||||
* Loads the assistant configuration, the feature probe for the whole UI. Like the ESB probe,
|
||||
* 403 and 404 mean the qbit is absent or not for this user, and a 2xx body that is not a
|
||||
* config (the root-SPA not-found page of a server without the route) means the same.
|
||||
*
|
||||
* @returns The configuration, or `null` when the assistant is not available.
|
||||
* @throws The request error for any other failure.
|
||||
*/
|
||||
export async function getAssistantConfig(): Promise<AssistantConfig | null> {
|
||||
let data: unknown
|
||||
try {
|
||||
data = await apiClient.get<unknown>('/assistant/config')
|
||||
} catch (error) {
|
||||
const status = getErrorStatusCode(error)
|
||||
if (status === 403 || status === 404) return null
|
||||
throw error
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
|
||||
return typeof (data as { enabled?: unknown }).enabled === 'boolean' ? (data as AssistantConfig) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the user's own conversations, newest first.
|
||||
*
|
||||
* @param status - `ARCHIVED` for archived conversations; active ones by default.
|
||||
* @returns The conversations.
|
||||
*/
|
||||
export async function listAssistantConversations(status?: AssistantConversationStatus): Promise<AssistantConversation[]> {
|
||||
const data = await apiClient.get<{ conversations?: AssistantConversation[] }>(
|
||||
'/assistant/conversations',
|
||||
status ? { params: { status } } : undefined
|
||||
)
|
||||
return Array.isArray(data?.conversations) ? data.conversations : []
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the assistant API client: probe, conversations, feedback and the SSE turn stream
|
||||
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssistantStreamEvent } from '@/types/assistant'
|
||||
import { server } from '@/mocks/node'
|
||||
import apiClient from './client'
|
||||
import {
|
||||
AssistantHttpError,
|
||||
AssistantStreamLostError,
|
||||
cancelAssistantTurn,
|
||||
createAssistantConversation,
|
||||
deleteAssistantConversation,
|
||||
getAssistantConfig,
|
||||
getAssistantConversation,
|
||||
listAssistantConversations,
|
||||
resumeAssistantTurn,
|
||||
sendAssistantFeedback,
|
||||
streamAssistantTurn,
|
||||
toAssistantEvent,
|
||||
updateAssistantConversation,
|
||||
} from './assistant'
|
||||
|
||||
const BASE = '/qqq/v1/assistant'
|
||||
|
||||
/**
|
||||
* Formats stream events as SSE frames.
|
||||
*
|
||||
* @param events - `[type, data]` pairs; `seq` is taken from the data.
|
||||
* @returns The event-stream text.
|
||||
*/
|
||||
function sse(...events: Array<[string, Record<string, unknown>]>): string {
|
||||
return events.map(([type, data]) => `${data.seq !== undefined ? `id: ${data.seq}\n` : ''}event: ${type}\ndata: ${JSON.stringify(data)}\n\n`).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* A streaming response that sends each chunk separately.
|
||||
*
|
||||
* @param chunks - Text chunks.
|
||||
* @returns The response.
|
||||
*/
|
||||
function streamResponse(...chunks: string[]): HttpResponse<ReadableStream> {
|
||||
const encoder = new TextEncoder()
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new HttpResponse(body, { headers: { 'Content-Type': 'text/event-stream' } })
|
||||
}
|
||||
|
||||
const START = ['message.start', { seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }] as [string, Record<string, unknown>]
|
||||
const DELTA_A = ['text.delta', { seq: 2, messageId: 11, text: 'Hello' }] as [string, Record<string, unknown>]
|
||||
const DELTA_B = ['text.delta', { seq: 3, messageId: 11, text: ' world' }] as [string, Record<string, unknown>]
|
||||
const END = ['message.end', { seq: 4, messageId: 11, status: 'COMPLETE' }] as [string, Record<string, unknown>]
|
||||
|
||||
describe('getAssistantConfig', () => {
|
||||
it('returns the config', async () => {
|
||||
server.use(http.get(`${BASE}/config`, () => HttpResponse.json({ enabled: true, shortcut: 'mod+j' })))
|
||||
await expect(getAssistantConfig()).resolves.toEqual({ enabled: true, shortcut: 'mod+j' })
|
||||
})
|
||||
|
||||
it.each([403, 404])('returns null on %i (qbit absent or not for this user)', async (status) => {
|
||||
server.use(http.get(`${BASE}/config`, () => HttpResponse.json({ error: 'nope' }, { status })))
|
||||
await expect(getAssistantConfig()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a 2xx body that is not a config (the SPA not-found page)', async () => {
|
||||
server.use(http.get(`${BASE}/config`, () => HttpResponse.text('<!doctype html><html></html>')))
|
||||
await expect(getAssistantConfig()).resolves.toBeNull()
|
||||
server.use(http.get(`${BASE}/config`, () => HttpResponse.json([1])))
|
||||
await expect(getAssistantConfig()).resolves.toBeNull()
|
||||
server.use(http.get(`${BASE}/config`, () => HttpResponse.json({ enabled: 'yes' })))
|
||||
await expect(getAssistantConfig()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rethrows other failures', async () => {
|
||||
server.use(http.get(`${BASE}/config`, () => HttpResponse.json({ error: 'boom' }, { status: 500 })))
|
||||
await expect(getAssistantConfig()).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversations', () => {
|
||||
const conversation = { id: 4, title: 'Posts', status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z' }
|
||||
|
||||
it('lists conversations, passing the status filter', async () => {
|
||||
let status: string | null = 'unset'
|
||||
server.use(http.get(`${BASE}/conversations`, ({ request }) => {
|
||||
status = new URL(request.url).searchParams.get('status')
|
||||
return HttpResponse.json({ conversations: [conversation] })
|
||||
}))
|
||||
await expect(listAssistantConversations()).resolves.toEqual([conversation])
|
||||
expect(status).toBeNull()
|
||||
await listAssistantConversations('ARCHIVED')
|
||||
expect(status).toBe('ARCHIVED')
|
||||
})
|
||||
|
||||
it('treats a list without conversations as empty', async () => {
|
||||
server.use(http.get(`${BASE}/conversations`, () => HttpResponse.json({})))
|
||||
await expect(listAssistantConversations()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('creates a conversation with its page context', async () => {
|
||||
let body: unknown
|
||||
server.use(http.post(`${BASE}/conversations`, async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json(conversation, { status: 201 })
|
||||
}))
|
||||
await expect(createAssistantConversation({ context: { tableName: 'person' } })).resolves.toEqual(conversation)
|
||||
expect(body).toEqual({ context: { tableName: 'person' } })
|
||||
await createAssistantConversation()
|
||||
expect(body).toEqual({})
|
||||
})
|
||||
|
||||
it('loads a conversation with paging parameters', async () => {
|
||||
let search = ''
|
||||
server.use(http.get(`${BASE}/conversations/4`, ({ request }) => {
|
||||
search = new URL(request.url).search
|
||||
return HttpResponse.json({ conversation, messages: [] })
|
||||
}))
|
||||
await expect(getAssistantConversation(4)).resolves.toEqual({ conversation, messages: [] })
|
||||
expect(search).toBe('')
|
||||
await getAssistantConversation(4, { before: 20, limit: 50 })
|
||||
expect(search).toBe('?before=20&limit=50')
|
||||
})
|
||||
|
||||
it('defaults missing messages to an empty list', async () => {
|
||||
server.use(http.get(`${BASE}/conversations/4`, () => HttpResponse.json({ conversation })))
|
||||
await expect(getAssistantConversation(4)).resolves.toEqual({ conversation, messages: [] })
|
||||
})
|
||||
|
||||
it('patches and deletes a conversation', async () => {
|
||||
let patch: unknown
|
||||
let deleted = false
|
||||
server.use(
|
||||
http.patch(`${BASE}/conversations/4`, async ({ request }) => {
|
||||
patch = await request.json()
|
||||
return HttpResponse.json({ ...conversation, pinned: true })
|
||||
}),
|
||||
http.delete(`${BASE}/conversations/4`, () => {
|
||||
deleted = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
})
|
||||
)
|
||||
await expect(updateAssistantConversation(4, { pinned: true })).resolves.toMatchObject({ pinned: true })
|
||||
expect(patch).toEqual({ pinned: true })
|
||||
await deleteAssistantConversation(4)
|
||||
expect(deleted).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels a turn and sends feedback', async () => {
|
||||
let feedback: unknown
|
||||
server.use(
|
||||
http.post(`${BASE}/turns/9/cancel`, () => HttpResponse.json({ status: 'CANCELLING' }, { status: 202 })),
|
||||
http.post(`${BASE}/messages/11/feedback`, async ({ request }) => {
|
||||
feedback = await request.json()
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
})
|
||||
)
|
||||
await expect(cancelAssistantTurn(9)).resolves.toEqual({ status: 'CANCELLING' })
|
||||
await sendAssistantFeedback(11, { rating: -1, comment: 'wrong table' })
|
||||
expect(feedback).toEqual({ rating: -1, comment: 'wrong table' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toAssistantEvent', () => {
|
||||
it('types known events and takes seq from the data, else from the id', () => {
|
||||
expect(toAssistantEvent({ event: 'text.delta', data: '{"seq":5,"messageId":1,"text":"a"}', id: '5' }))
|
||||
.toEqual({ event: { type: 'text.delta', seq: 5, messageId: 1, text: 'a' }, seq: 5 })
|
||||
expect(toAssistantEvent({ event: 'text.delta', data: '{"messageId":1,"text":"a"}', id: '6' }).seq).toBe(6)
|
||||
})
|
||||
|
||||
it('skips unknown types and malformed data but keeps their sequence number', () => {
|
||||
expect(toAssistantEvent({ event: 'approval.required', data: '{"seq":7}', id: '7' })).toEqual({ event: null, seq: 7 })
|
||||
expect(toAssistantEvent({ event: 'text.delta', data: 'not json', id: '8' })).toEqual({ event: null, seq: 8 })
|
||||
expect(toAssistantEvent({ event: 'text.delta', data: '[1]', id: 'x' })).toEqual({ event: null, seq: undefined })
|
||||
})
|
||||
|
||||
it('requires a sequence number except on ping', () => {
|
||||
expect(toAssistantEvent({ event: 'text.delta', data: '{"text":"a"}' })).toEqual({ event: null })
|
||||
expect(toAssistantEvent({ event: 'ping', data: '{}' }).event).toEqual({ type: 'ping' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamAssistantTurn', () => {
|
||||
it('posts the text and page context and delivers events in order without pings', async () => {
|
||||
let body: unknown
|
||||
let accept: string | null = null
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
|
||||
body = await request.json()
|
||||
accept = request.headers.get('accept')
|
||||
return streamResponse(sse(START, ['ping', {}], DELTA_A).slice(0, 37), sse(START, ['ping', {}], DELTA_A).slice(37), sse(DELTA_B, END))
|
||||
}))
|
||||
const events: AssistantStreamEvent[] = []
|
||||
const result = await streamAssistantTurn(4, { text: 'Hi', pageContext: { tableName: 'person' } }, { onEvent: (event) => events.push(event) })
|
||||
expect(body).toEqual({ text: 'Hi', pageContext: { tableName: 'person' } })
|
||||
expect(accept).toBe('text/event-stream')
|
||||
expect(events.map((event) => event.type)).toEqual(['message.start', 'text.delta', 'text.delta', 'message.end'])
|
||||
expect(result).toEqual({ status: 'COMPLETE', turnId: 9, lastSeq: 4 })
|
||||
})
|
||||
|
||||
it('throws the backend message for a refused turn', async () => {
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => HttpResponse.json({ error: 'Daily budget reached' }, { status: 429 })))
|
||||
const error = await streamAssistantTurn(4, { text: 'Hi' }, { onEvent: vi.fn() }).catch((caught: unknown) => caught)
|
||||
expect(error).toBeInstanceOf(AssistantHttpError)
|
||||
expect((error as AssistantHttpError).status).toBe(429)
|
||||
expect((error as Error).message).toBe('Daily budget reached')
|
||||
})
|
||||
|
||||
it('uses a generic message when the error body is not JSON', async () => {
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => HttpResponse.text('bad', { status: 400 })))
|
||||
await expect(streamAssistantTurn(4, { text: 'Hi' }, { onEvent: vi.fn() })).rejects.toThrow('HTTP 400')
|
||||
})
|
||||
|
||||
it('starts the sign-in flow on 401', async () => {
|
||||
const notify = vi.spyOn(apiClient, 'notifyUnauthorized')
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => HttpResponse.json({ error: 'Session expired' }, { status: 401 })))
|
||||
await expect(streamAssistantTurn(4, { text: 'Hi' }, { onEvent: vi.fn() })).rejects.toThrow('Session expired')
|
||||
expect(notify).toHaveBeenCalled()
|
||||
notify.mockRestore()
|
||||
})
|
||||
|
||||
it('resumes after a dropped connection from the last sequence number, skipping replayed events', async () => {
|
||||
const afters: Array<string | null> = []
|
||||
server.use(
|
||||
http.post(`${BASE}/conversations/4/turns`, () => streamResponse(sse(START, DELTA_A))),
|
||||
http.get(`${BASE}/turns/9/events`, ({ request }) => {
|
||||
afters.push(new URL(request.url).searchParams.get('after'))
|
||||
return streamResponse(sse(DELTA_A, DELTA_B, END))
|
||||
})
|
||||
)
|
||||
const texts: string[] = []
|
||||
const result = await streamAssistantTurn(4, { text: 'Hi' }, {
|
||||
onEvent: (event) => { if (event.type === 'text.delta') texts.push(event.text) },
|
||||
resumeDelayMs: 1,
|
||||
})
|
||||
expect(afters).toEqual(['2'])
|
||||
expect(texts).toEqual(['Hello', ' world'])
|
||||
expect(result.status).toBe('COMPLETE')
|
||||
})
|
||||
|
||||
it('retries a failing resume and gives up after the attempts without progress', async () => {
|
||||
let calls = 0
|
||||
server.use(
|
||||
http.post(`${BASE}/conversations/4/turns`, () => streamResponse(sse(START))),
|
||||
http.get(`${BASE}/turns/9/events`, () => {
|
||||
calls++
|
||||
return calls === 1 ? HttpResponse.error() : HttpResponse.json({ error: 'unavailable' }, { status: 503 })
|
||||
})
|
||||
)
|
||||
await expect(streamAssistantTurn(4, { text: 'Hi' }, { onEvent: vi.fn(), resumeDelayMs: 1, maxResumeAttempts: 2 }))
|
||||
.rejects.toBeInstanceOf(AssistantStreamLostError)
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('does not resume a stream that dropped before the turn id was known', async () => {
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => HttpResponse.error()))
|
||||
await expect(streamAssistantTurn(4, { text: 'Hi' }, { onEvent: vi.fn(), resumeDelayMs: 1 }))
|
||||
.rejects.toBeInstanceOf(AssistantStreamLostError)
|
||||
})
|
||||
|
||||
it('stops resuming when the turn is gone', async () => {
|
||||
server.use(
|
||||
http.post(`${BASE}/conversations/4/turns`, () => streamResponse(sse(START))),
|
||||
http.get(`${BASE}/turns/9/events`, () => HttpResponse.json({ error: 'Turn not found' }, { status: 404 }))
|
||||
)
|
||||
await expect(streamAssistantTurn(4, { text: 'Hi' }, { onEvent: vi.fn(), resumeDelayMs: 1 })).rejects.toThrow('Turn not found')
|
||||
})
|
||||
|
||||
it('reports ABORTED when the request is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => {
|
||||
controller.abort()
|
||||
return streamResponse(sse(START))
|
||||
}))
|
||||
await expect(streamAssistantTurn(4, { text: 'Hi' }, { onEvent: vi.fn(), signal: controller.signal }))
|
||||
.resolves.toMatchObject({ status: 'ABORTED' })
|
||||
})
|
||||
|
||||
it('reports ABORTED when aborted while waiting to resume', async () => {
|
||||
const controller = new AbortController()
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => streamResponse(sse(START))))
|
||||
const pending = streamAssistantTurn(4, { text: 'Hi' }, {
|
||||
onEvent: () => { setTimeout(() => controller.abort(), 5) },
|
||||
signal: controller.signal,
|
||||
resumeDelayMs: 1000,
|
||||
})
|
||||
await expect(pending).resolves.toMatchObject({ status: 'ABORTED', turnId: 9 })
|
||||
})
|
||||
|
||||
it('reports the server-side end status (cancelled)', async () => {
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () =>
|
||||
streamResponse(sse(START, ['message.end', { seq: 2, messageId: 11, status: 'CANCELLED' }]))))
|
||||
await expect(streamAssistantTurn(4, { text: 'Hi' }, { onEvent: vi.fn() })).resolves.toMatchObject({ status: 'CANCELLED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeAssistantTurn', () => {
|
||||
it('replays events after the given sequence number right away', async () => {
|
||||
let after: string | null = null
|
||||
server.use(http.get(`${BASE}/turns/9/events`, ({ request }) => {
|
||||
after = new URL(request.url).searchParams.get('after')
|
||||
return streamResponse(sse(DELTA_B, END))
|
||||
}))
|
||||
const events: AssistantStreamEvent[] = []
|
||||
await expect(resumeAssistantTurn(9, 2, { onEvent: (event) => events.push(event) })).resolves.toEqual({ status: 'COMPLETE', turnId: 9, lastSeq: 4 })
|
||||
expect(after).toBe('2')
|
||||
expect(events.map((event) => event.seq)).toEqual([3, 4])
|
||||
})
|
||||
|
||||
it('reports ABORTED when already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(resumeAssistantTurn(9, 0, { onEvent: vi.fn(), signal: controller.signal })).resolves.toMatchObject({ status: 'ABORTED' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Assistant API — the qbit-assistant routes under `/qqq/v1/assistant`: the feature
|
||||
* probe, conversations, feedback, and turns streamed as server-sent events (read with
|
||||
* `fetch`, because axios cannot stream and `EventSource` cannot POST), with resume after a
|
||||
* dropped connection through `/turns/{id}/events?after={seq}`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AssistantConversation,
|
||||
AssistantConversationDetail,
|
||||
AssistantConversationUpdate,
|
||||
AssistantFeedback,
|
||||
AssistantPageContext,
|
||||
AssistantStreamEvent,
|
||||
} from '@/types/assistant'
|
||||
import apiClient, { apiUrl } from './client'
|
||||
import { SseParser, type SseEvent } from './sse-parser'
|
||||
|
||||
/** Stream event types this client understands; others (later phases) are skipped. */
|
||||
const KNOWN_EVENTS = new Set<AssistantStreamEvent['type']>([
|
||||
'message.start', 'text.delta', 'tool.start', 'tool.result', 'ui.navigate', 'notice', 'usage', 'message.end', 'error', 'ping',
|
||||
])
|
||||
|
||||
/** An HTTP error from a streamed request, with the backend's `{ error }` message. */
|
||||
export class AssistantHttpError extends Error {
|
||||
/** The HTTP status code. */
|
||||
readonly status: number
|
||||
|
||||
/**
|
||||
* Creates the error.
|
||||
*
|
||||
* @param status - The HTTP status code.
|
||||
* @param message - The backend's message, or a generic one.
|
||||
*/
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'AssistantHttpError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/** The stream ended without `message.end` and could not be resumed. */
|
||||
export class AssistantStreamLostError extends Error {
|
||||
/**
|
||||
* Creates the error.
|
||||
*
|
||||
* @param message - What happened, for the user.
|
||||
*/
|
||||
constructor(message = 'The connection to the assistant was lost.') {
|
||||
super(message)
|
||||
this.name = 'AssistantStreamLostError'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Probe and conversations ──────────────────────────────────────────────────
|
||||
|
||||
// The probe and the conversation list live in a small module of their own: the dashboard shell
|
||||
// loads them on every page, while the rest of this module loads with the panel.
|
||||
export { getAssistantConfig, listAssistantConversations } from './assistant-probe'
|
||||
|
||||
/**
|
||||
* Starts a conversation.
|
||||
*
|
||||
* @param body - Optional title and the page context it starts from.
|
||||
* @param body.title - The title.
|
||||
* @param body.context - The page context.
|
||||
* @returns The new conversation.
|
||||
*/
|
||||
export async function createAssistantConversation(body: { title?: string; context?: AssistantPageContext } = {}): Promise<AssistantConversation> {
|
||||
return apiClient.post<AssistantConversation>('/assistant/conversations', body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a conversation and a page of its messages.
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
* @param page - Paging: messages before `before`, at most `limit`.
|
||||
* @param page.before - Only messages older than this message id.
|
||||
* @param page.limit - The page size (server default 50).
|
||||
* @returns The conversation and its messages, oldest first.
|
||||
*/
|
||||
export async function getAssistantConversation(id: number, page: { before?: number; limit?: number } = {}): Promise<AssistantConversationDetail> {
|
||||
const params: Record<string, number> = {}
|
||||
if (page.before !== undefined) params.before = page.before
|
||||
if (page.limit !== undefined) params.limit = page.limit
|
||||
const data = await apiClient.get<AssistantConversationDetail>(
|
||||
`/assistant/conversations/${id}`,
|
||||
Object.keys(params).length ? { params } : undefined
|
||||
)
|
||||
return { conversation: data.conversation, messages: Array.isArray(data.messages) ? data.messages : [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames, pins or archives a conversation.
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
* @param update - The changed properties.
|
||||
* @returns The updated conversation.
|
||||
*/
|
||||
export async function updateAssistantConversation(id: number, update: AssistantConversationUpdate): Promise<AssistantConversation> {
|
||||
return apiClient.patch<AssistantConversation>(`/assistant/conversations/${id}`, update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a conversation.
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
*/
|
||||
export async function deleteAssistantConversation(id: number): Promise<void> {
|
||||
await apiClient.delete(`/assistant/conversations/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server to stop a running turn. The stream then ends with a `CANCELLED` `message.end`.
|
||||
*
|
||||
* @param turnId - The turn id from `message.start`.
|
||||
* @returns The server's acknowledgement.
|
||||
*/
|
||||
export async function cancelAssistantTurn(turnId: number): Promise<{ status: string }> {
|
||||
return apiClient.post<{ status: string }>(`/assistant/turns/${turnId}/cancel`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records thumbs up or down on an assistant message.
|
||||
*
|
||||
* @param messageId - The assistant message id.
|
||||
* @param feedback - The rating and an optional reason or comment.
|
||||
*/
|
||||
export async function sendAssistantFeedback(messageId: number, feedback: AssistantFeedback): Promise<void> {
|
||||
await apiClient.post(`/assistant/messages/${messageId}/feedback`, feedback)
|
||||
}
|
||||
|
||||
// ─── Streaming ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts one SSE event into a typed stream event.
|
||||
*
|
||||
* @param sse - The parsed server-sent event.
|
||||
* @returns The typed event and its sequence number; `event` is null for unknown types and
|
||||
* malformed data (the sequence number still advances the resume position).
|
||||
*/
|
||||
export function toAssistantEvent(sse: SseEvent): { event: AssistantStreamEvent | null; seq?: number } {
|
||||
const idSeq = sse.id !== undefined && /^\d+$/.test(sse.id) ? Number(sse.id) : undefined
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(sse.data)
|
||||
} catch {
|
||||
return { event: null, seq: idSeq }
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return { event: null, seq: idSeq }
|
||||
const body = data as Record<string, unknown>
|
||||
const seq = typeof body.seq === 'number' ? body.seq : idSeq
|
||||
const type = sse.event as AssistantStreamEvent['type']
|
||||
if (!KNOWN_EVENTS.has(type)) return { event: null, seq }
|
||||
if (type !== 'ping' && seq === undefined) return { event: null }
|
||||
return { event: { ...body, type, ...(seq !== undefined ? { seq } : {}) } as AssistantStreamEvent, seq }
|
||||
}
|
||||
|
||||
/** Options of {@link streamAssistantTurn} and {@link resumeAssistantTurn}. */
|
||||
export interface AssistantStreamOptions {
|
||||
/** Called with every event, in order and without duplicates (pings excluded). */
|
||||
onEvent: (event: AssistantStreamEvent) => void
|
||||
/** Aborts the request (not the turn: use {@link cancelAssistantTurn} for that). */
|
||||
signal?: AbortSignal
|
||||
/** Resume attempts after a dropped connection without progress (default 3). */
|
||||
maxResumeAttempts?: number
|
||||
/** Base delay before a resume attempt, multiplied by the attempt number (default 1000 ms). */
|
||||
resumeDelayMs?: number
|
||||
}
|
||||
|
||||
/** How a streamed turn ended. */
|
||||
export interface AssistantTurnResult {
|
||||
/** `ABORTED` when the request was aborted locally before `message.end`. */
|
||||
status: 'COMPLETE' | 'CANCELLED' | 'ERROR' | 'ABORTED'
|
||||
turnId: number | null
|
||||
/** The last sequence number received. */
|
||||
lastSeq: number
|
||||
}
|
||||
|
||||
/** Mutable progress shared by the first request and its resumes. */
|
||||
interface StreamState {
|
||||
turnId: number | null
|
||||
lastSeq: number
|
||||
endStatus: 'COMPLETE' | 'CANCELLED' | 'ERROR' | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a turn: posts the user's text and streams the reply. When the connection drops
|
||||
* before `message.end`, the stream resumes from the last sequence number received.
|
||||
*
|
||||
* @param conversationId - The conversation.
|
||||
* @param body - The user's text and the page context hint.
|
||||
* @param body.text - The user's message.
|
||||
* @param body.pageContext - Where the user is, when they have not dismissed it.
|
||||
* @param options - Event callback, abort signal and resume tuning.
|
||||
* @returns How the turn ended.
|
||||
* @throws {AssistantHttpError} When the server refuses the turn (400/403/409/429/503...).
|
||||
* @throws {AssistantStreamLostError} When the stream cannot be resumed.
|
||||
*/
|
||||
export async function streamAssistantTurn(
|
||||
conversationId: number,
|
||||
body: { text: string; pageContext?: AssistantPageContext },
|
||||
options: AssistantStreamOptions
|
||||
): Promise<AssistantTurnResult> {
|
||||
const state: StreamState = { turnId: null, lastSeq: 0, endStatus: null }
|
||||
try {
|
||||
const response = await openStream(`/assistant/conversations/${conversationId}/turns`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}, options.signal)
|
||||
await readStream(response, state, options)
|
||||
} catch (error) {
|
||||
if (isAbort(error, options.signal)) return result(state, 'ABORTED')
|
||||
if (error instanceof AssistantHttpError) throw error
|
||||
// a network failure: resume below when the turn id is known
|
||||
}
|
||||
return finishWithResume(state, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches to a turn that is already running (or replays a finished one): events after
|
||||
* `after` are replayed, then the live tail follows.
|
||||
*
|
||||
* @param turnId - The turn id.
|
||||
* @param after - The last sequence number already applied (0 for all).
|
||||
* @param options - Event callback, abort signal and resume tuning.
|
||||
* @returns How the turn ended.
|
||||
* @throws {AssistantHttpError} When the server refuses (403/404).
|
||||
* @throws {AssistantStreamLostError} When the stream cannot be resumed.
|
||||
*/
|
||||
export async function resumeAssistantTurn(turnId: number, after: number, options: AssistantStreamOptions): Promise<AssistantTurnResult> {
|
||||
const state: StreamState = { turnId, lastSeq: after, endStatus: null }
|
||||
return finishWithResume(state, options, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes until `message.end` arrives, the request is aborted, or attempts run out.
|
||||
*
|
||||
* @param state - Stream progress.
|
||||
* @param options - Stream options.
|
||||
* @param immediate - Whether to make the first attempt without delay.
|
||||
* @returns How the turn ended.
|
||||
*/
|
||||
async function finishWithResume(state: StreamState, options: AssistantStreamOptions, immediate = false): Promise<AssistantTurnResult> {
|
||||
const maxAttempts = options.maxResumeAttempts ?? 3
|
||||
const baseDelay = options.resumeDelayMs ?? 1000
|
||||
let attempts = 0
|
||||
let first = immediate
|
||||
while (!state.endStatus) {
|
||||
if (options.signal?.aborted) return result(state, 'ABORTED')
|
||||
if (state.turnId === null || attempts >= maxAttempts) throw new AssistantStreamLostError()
|
||||
attempts++
|
||||
if (!first) {
|
||||
try {
|
||||
await delay(baseDelay * attempts, options.signal)
|
||||
} catch {
|
||||
return result(state, 'ABORTED')
|
||||
}
|
||||
}
|
||||
first = false
|
||||
const before = state.lastSeq
|
||||
try {
|
||||
const response = await openStream(`/assistant/turns/${state.turnId}/events?after=${state.lastSeq}`, { method: 'GET' }, options.signal)
|
||||
await readStream(response, state, options)
|
||||
} catch (error) {
|
||||
if (isAbort(error, options.signal)) return result(state, 'ABORTED')
|
||||
if (error instanceof AssistantHttpError && error.status !== 503 && error.status < 500) throw error
|
||||
}
|
||||
if (state.lastSeq > before) attempts = 0
|
||||
}
|
||||
return result(state, state.endStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the turn result.
|
||||
*
|
||||
* @param state - Stream progress.
|
||||
* @param status - The final status.
|
||||
* @returns The result.
|
||||
*/
|
||||
function result(state: StreamState, status: AssistantTurnResult['status']): AssistantTurnResult {
|
||||
return { status, turnId: state.turnId, lastSeq: state.lastSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a streamed request with the session cookie.
|
||||
*
|
||||
* @param path - Path under the API base URL.
|
||||
* @param init - Method, headers and body.
|
||||
* @param signal - Abort signal.
|
||||
* @returns The successful response, which has a body.
|
||||
* @throws {AssistantHttpError} For a non-2xx response (a 401 also starts the sign-in flow).
|
||||
*/
|
||||
async function openStream(path: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
||||
const url = typeof window === 'undefined' ? apiUrl(path) : new URL(apiUrl(path), window.location.href).toString()
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'text/event-stream', ...(init.headers as Record<string, string> | undefined) },
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) apiClient.notifyUnauthorized()
|
||||
throw new AssistantHttpError(response.status, await errorMessage(response))
|
||||
}
|
||||
if (!response.body) throw new AssistantStreamLostError('This browser cannot read streamed responses.')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the backend's `{ error }` message from a failed response.
|
||||
*
|
||||
* @param response - The failed response.
|
||||
* @returns The message, or a generic one with the status.
|
||||
*/
|
||||
async function errorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { error?: unknown }
|
||||
if (typeof body?.error === 'string' && body.error.trim()) return body.error.trim()
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return `The assistant request failed (HTTP ${response.status}).`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a response body to its end, applying each new event in order.
|
||||
*
|
||||
* @param response - A streaming response.
|
||||
* @param state - Stream progress, updated in place.
|
||||
* @param options - The event callback.
|
||||
*/
|
||||
async function readStream(response: Response, state: StreamState, options: AssistantStreamOptions): Promise<void> {
|
||||
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const parser = new SseParser()
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
const text = done ? decoder.decode() : decoder.decode(value, { stream: true })
|
||||
for (const sse of parser.push(text)) {
|
||||
applyEvent(sse, state, options)
|
||||
if (state.endStatus) return
|
||||
}
|
||||
if (done) return
|
||||
}
|
||||
} finally {
|
||||
parser.end()
|
||||
reader.cancel().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one event: skips replayed duplicates, tracks the turn and its end, and forwards it.
|
||||
*
|
||||
* @param sse - The parsed server-sent event.
|
||||
* @param state - Stream progress.
|
||||
* @param options - The event callback.
|
||||
*/
|
||||
function applyEvent(sse: SseEvent, state: StreamState, options: AssistantStreamOptions): void {
|
||||
const { event, seq } = toAssistantEvent(sse)
|
||||
if (event?.type === 'ping') return
|
||||
if (seq !== undefined) {
|
||||
if (seq <= state.lastSeq) return
|
||||
state.lastSeq = seq
|
||||
}
|
||||
if (!event) return
|
||||
if (event.type === 'message.start') state.turnId = event.turnId
|
||||
if (event.type === 'message.end') state.endStatus = event.status
|
||||
options.onEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an error is the abort of this request.
|
||||
*
|
||||
* @param error - The thrown value.
|
||||
* @param signal - The request's abort signal.
|
||||
* @returns True when the signal is aborted or the error is an `AbortError`.
|
||||
*/
|
||||
function isAbort(error: unknown, signal?: AbortSignal): boolean {
|
||||
return Boolean(signal?.aborted) || (error instanceof Error && error.name === 'AbortError')
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits, unless aborted first.
|
||||
*
|
||||
* @param ms - Milliseconds.
|
||||
* @param signal - Abort signal.
|
||||
* @returns A promise that resolves after `ms`, or rejects on abort.
|
||||
*/
|
||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
/** Stops waiting. */
|
||||
function onAbort() {
|
||||
clearTimeout(timer)
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -99,6 +99,14 @@ class APIClient {
|
||||
this.unauthorizedCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the re-authentication flow for a 401 received outside this client (streamed
|
||||
* `fetch` requests, which axios cannot make).
|
||||
*/
|
||||
notifyUnauthorized(): void {
|
||||
this.unauthorizedCallback?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an HTTP GET request and returns the response body.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the incremental server-sent events parser
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { SseParser, type SseEvent } from './sse-parser'
|
||||
|
||||
/**
|
||||
* Feeds chunks to a new parser.
|
||||
*
|
||||
* @param chunks - Stream chunks.
|
||||
* @returns Every event dispatched.
|
||||
*/
|
||||
function parse(...chunks: string[]): SseEvent[] {
|
||||
const parser = new SseParser()
|
||||
return [...chunks.flatMap((chunk) => parser.push(chunk)), ...parser.end()]
|
||||
}
|
||||
|
||||
const STREAM = 'id: 1\nevent: text.delta\ndata: {"seq":1,"text":"Hi"}\n\nid: 2\nevent: message.end\ndata: {"seq":2}\n\n'
|
||||
|
||||
describe('SseParser', () => {
|
||||
it('parses id, event and data fields', () => {
|
||||
expect(parse(STREAM)).toEqual([
|
||||
{ event: 'text.delta', data: '{"seq":1,"text":"Hi"}', id: '1' },
|
||||
{ event: 'message.end', data: '{"seq":2}', id: '2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('gives the same events however the stream is split, including one character at a time', () => {
|
||||
const expected = parse(STREAM)
|
||||
expect(parse(...STREAM.split(''))).toEqual(expected)
|
||||
for (let cut = 1; cut < STREAM.length; cut++) {
|
||||
expect(parse(STREAM.slice(0, cut), STREAM.slice(cut))).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts CRLF and CR line endings, also with the CR and LF in different chunks', () => {
|
||||
const crlf = STREAM.replace(/\n/g, '\r\n')
|
||||
expect(parse(crlf)).toEqual(parse(STREAM))
|
||||
expect(parse(...crlf.split(''))).toEqual(parse(STREAM))
|
||||
expect(parse(STREAM.replace(/\n/g, '\r'))).toEqual(parse(STREAM))
|
||||
expect(parse('data: a\r', '\n\r', '\n')).toEqual([{ event: 'message', data: 'a', id: undefined }])
|
||||
})
|
||||
|
||||
it('joins multi-line data with newlines', () => {
|
||||
expect(parse('data: line one\ndata: line two\ndata:\n\n')).toEqual([
|
||||
{ event: 'message', data: 'line one\nline two\n', id: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('removes exactly one space after the colon and treats a line without a colon as a field name', () => {
|
||||
expect(parse('data: two spaces\n\n')[0].data).toBe(' two spaces')
|
||||
expect(parse('data:none\n\n')[0].data).toBe('none')
|
||||
expect(parse('data\n\n')).toEqual([{ event: 'message', data: '', id: undefined }])
|
||||
})
|
||||
|
||||
it('skips comment lines (keep-alives) and unknown fields', () => {
|
||||
expect(parse(': ping\nfoo: bar\ndata: x\n\n')).toEqual([{ event: 'message', data: 'x', id: undefined }])
|
||||
})
|
||||
|
||||
it('does not dispatch an event without data, and resets the event type afterwards', () => {
|
||||
expect(parse('event: lonely\n\ndata: y\n\n')).toEqual([{ event: 'message', data: 'y', id: undefined }])
|
||||
})
|
||||
|
||||
it('keeps the last event id across events and ignores ids containing NUL', () => {
|
||||
expect(parse('id: 7\ndata: a\n\ndata: b\n\nid: 8\0\ndata: c\n\n').map((event) => event.id)).toEqual(['7', '7', '7'])
|
||||
})
|
||||
|
||||
it('reads retry only when it is all digits', () => {
|
||||
expect(parse('retry: 2500\ndata: a\n\nretry: soon\ndata: b\n\n')).toEqual([
|
||||
{ event: 'message', data: 'a', id: undefined, retry: 2500 },
|
||||
{ event: 'message', data: 'b', id: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a byte order mark at the start of the stream only', () => {
|
||||
expect(parse('data: a\n\n')).toEqual([{ event: 'message', data: 'a', id: undefined }])
|
||||
expect(parse('', 'data: a\n\n')[0].data).toBe('a')
|
||||
})
|
||||
|
||||
it('discards a final event that has no blank line', () => {
|
||||
expect(parse('data: complete\n\ndata: partial\n')).toEqual([{ event: 'message', data: 'complete', id: undefined }])
|
||||
})
|
||||
|
||||
it('keeps unicode split across chunks intact once decoded', () => {
|
||||
const bytes = new TextEncoder().encode('data: héllo ✓\n\n')
|
||||
const decoder = new TextDecoder()
|
||||
const parser = new SseParser()
|
||||
const events = [...bytes].flatMap((byte) => parser.push(decoder.decode(new Uint8Array([byte]), { stream: true })))
|
||||
expect(events).toEqual([{ event: 'message', data: 'héllo ✓', id: undefined }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file sse-parser — incremental `text/event-stream` parser (WHATWG HTML "event stream
|
||||
* interpretation"): fed arbitrary text chunks, it emits complete events. `EventSource`
|
||||
* cannot POST, so streamed API responses are read with `fetch` and parsed here.
|
||||
*/
|
||||
|
||||
/** One dispatched server-sent event. */
|
||||
export interface SseEvent {
|
||||
/** The `event:` field, or `message` when absent. */
|
||||
event: string
|
||||
/** The `data:` lines joined with `\n`. */
|
||||
data: string
|
||||
/** The last `id:` seen on the stream (it persists across events), or `undefined`. */
|
||||
id?: string
|
||||
/** The `retry:` reconnection delay of this event, in milliseconds. */
|
||||
retry?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental SSE parser. Lines may end in `\n`, `\r\n` or `\r`, and chunks may split
|
||||
* anywhere, including between a `\r` and its `\n`. Comment lines (`:`) are skipped
|
||||
* (servers use them as keep-alives), and an event without data is not dispatched.
|
||||
*/
|
||||
export class SseParser {
|
||||
/** Text received but not yet ending in a line break. */
|
||||
private buffer = ''
|
||||
/** Whether the previous chunk ended with `\r` (its `\n` may start the next chunk). */
|
||||
private pendingCarriageReturn = false
|
||||
/** Whether the stream's first character has been seen (a leading BOM is dropped). */
|
||||
private started = false
|
||||
private dataLines: string[] = []
|
||||
private eventType = ''
|
||||
private lastEventId: string | undefined
|
||||
private retry: number | undefined
|
||||
|
||||
/**
|
||||
* Parses one chunk of the stream.
|
||||
*
|
||||
* @param chunk - Decoded text, of any length.
|
||||
* @returns The events completed by this chunk, in order.
|
||||
*/
|
||||
push(chunk: string): SseEvent[] {
|
||||
let text = chunk
|
||||
if (!this.started && text.length > 0) {
|
||||
this.started = true
|
||||
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
||||
}
|
||||
if (this.pendingCarriageReturn && text.startsWith('\n')) text = text.slice(1)
|
||||
this.pendingCarriageReturn = false
|
||||
|
||||
const events: SseEvent[] = []
|
||||
this.buffer += text
|
||||
let start = 0
|
||||
for (let index = 0; index < this.buffer.length; index++) {
|
||||
const char = this.buffer[index]
|
||||
if (char !== '\n' && char !== '\r') continue
|
||||
const event = this.processLine(this.buffer.slice(start, index))
|
||||
if (event) events.push(event)
|
||||
if (char === '\r') {
|
||||
if (index + 1 === this.buffer.length) this.pendingCarriageReturn = true
|
||||
else if (this.buffer[index + 1] === '\n') index++
|
||||
}
|
||||
start = index + 1
|
||||
}
|
||||
this.buffer = this.buffer.slice(start)
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the stream. A final event without its blank line is discarded, as the
|
||||
* specification requires.
|
||||
*
|
||||
* @returns Always empty; kept so callers can treat `end` like `push`.
|
||||
*/
|
||||
end(): SseEvent[] {
|
||||
this.buffer = ''
|
||||
this.dataLines = []
|
||||
this.eventType = ''
|
||||
this.retry = undefined
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one line to the pending event.
|
||||
*
|
||||
* @param line - The line without its terminator.
|
||||
* @returns The dispatched event when the line is blank and data is pending.
|
||||
*/
|
||||
private processLine(line: string): SseEvent | null {
|
||||
if (line === '') return this.dispatch()
|
||||
if (line.startsWith(':')) return null
|
||||
const colon = line.indexOf(':')
|
||||
const field = colon === -1 ? line : line.slice(0, colon)
|
||||
let value = colon === -1 ? '' : line.slice(colon + 1)
|
||||
if (value.startsWith(' ')) value = value.slice(1)
|
||||
switch (field) {
|
||||
case 'data':
|
||||
this.dataLines.push(value)
|
||||
break
|
||||
case 'event':
|
||||
this.eventType = value
|
||||
break
|
||||
case 'id':
|
||||
if (!value.includes('\0')) this.lastEventId = value
|
||||
break
|
||||
case 'retry':
|
||||
if (/^\d+$/.test(value)) this.retry = Number(value)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the pending event and resets the per-event fields.
|
||||
*
|
||||
* @returns The event, or null when no data line was received.
|
||||
*/
|
||||
private dispatch(): SseEvent | null {
|
||||
const hasData = this.dataLines.length > 0
|
||||
const event: SseEvent = {
|
||||
event: this.eventType || 'message',
|
||||
data: this.dataLines.join('\n'),
|
||||
id: this.lastEventId,
|
||||
...(this.retry !== undefined ? { retry: this.retry } : {}),
|
||||
}
|
||||
this.dataLines = []
|
||||
this.eventType = ''
|
||||
this.retry = undefined
|
||||
return hasData ? event : null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file assistant-deep-link — the `?assistant=open&assistantPrompt=<text>` link that opens the
|
||||
* assistant on a page with a pre-filled prompt (the record Developer screen links to it).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reads the assistant deep link `?assistant=open&assistantPrompt=<text>` from a query string.
|
||||
*
|
||||
* @param search - The query string (with or without `?`).
|
||||
* @returns The prompt (may be empty) and the query string without both parameters (`?...` or
|
||||
* empty), or null when the link is absent.
|
||||
*/
|
||||
export function readAssistantDeepLink(search: string): { prompt: string; remainingSearch: string } | null {
|
||||
const params = new URLSearchParams(search)
|
||||
if (params.get('assistant') !== 'open') return null
|
||||
const prompt = (params.get('assistantPrompt') ?? '').trim()
|
||||
params.delete('assistant')
|
||||
params.delete('assistantPrompt')
|
||||
const rest = params.toString()
|
||||
return { prompt, remainingSearch: rest ? `?${rest}` : '' }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for assistant link safety and page context
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import { assistantPageInfo, internalAppHref } from './assistant-links'
|
||||
import { readAssistantDeepLink } from './assistant-deep-link'
|
||||
import { serializeFilter } from './filter-utils'
|
||||
|
||||
const ORIGIN = 'https://app.example.com'
|
||||
|
||||
describe('internalAppHref', () => {
|
||||
it.each([
|
||||
['/app/person/1', '/app/person/1'],
|
||||
['/app', '/app'],
|
||||
['/app/person?filter=abc#audit', '/app/person?filter=abc#audit'],
|
||||
['https://app.example.com/app/person/2', '/app/person/2'],
|
||||
[' /app/person ', '/app/person'],
|
||||
])('keeps the same-origin app path %s', (href, expected) => {
|
||||
expect(internalAppHref(href, ORIGIN)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://evil.example.com/app/person',
|
||||
'//evil.example.com/app/person',
|
||||
'/\\evil.example.com/app',
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>',
|
||||
'mailto:a@b.c',
|
||||
'/admin',
|
||||
'/application',
|
||||
'app/person',
|
||||
'/app/person\u0000',
|
||||
'\u0001/app',
|
||||
'',
|
||||
' ',
|
||||
'http://[::1',
|
||||
])('refuses %s', (href) => {
|
||||
expect(internalAppHref(href, ORIGIN)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses non-strings and defaults to the window origin', () => {
|
||||
expect(internalAppHref(undefined)).toBeNull()
|
||||
expect(internalAppHref(null)).toBeNull()
|
||||
expect(internalAppHref(`${window.location.origin}/app/x`)).toBe('/app/x')
|
||||
})
|
||||
})
|
||||
|
||||
const META = {
|
||||
tables: {
|
||||
person: { name: 'person', label: 'People' },
|
||||
},
|
||||
} as unknown as QInstance
|
||||
|
||||
describe('assistantPageInfo', () => {
|
||||
it('describes a table query screen', () => {
|
||||
expect(assistantPageInfo('/app/person', '', META)).toEqual({ context: { route: '/app/person', tableName: 'person' }, label: 'People' })
|
||||
})
|
||||
|
||||
it('adds the query filter and marks the chip filtered', () => {
|
||||
const filter = serializeFilter({ criteria: [{ fieldName: 'status', operator: 'EQUALS', values: ['Active'] }], booleanOperator: 'AND' })
|
||||
const info = assistantPageInfo('/app/person', `?filter=${encodeURIComponent(filter)}`, META)
|
||||
expect(info?.label).toBe('People (filtered)')
|
||||
expect(info?.context.filter).toMatchObject({ criteria: [{ fieldName: 'status', operator: 'EQUALS', values: ['Active'] }], booleanOperator: 'AND' })
|
||||
})
|
||||
|
||||
it('ignores an empty filter and treats a saved view as the table', () => {
|
||||
const empty = serializeFilter({ criteria: [] })
|
||||
expect(assistantPageInfo('/app/person', `?filter=${empty}`, META)?.label).toBe('People')
|
||||
expect(assistantPageInfo('/app/person/savedView/3', '', META)?.context).toEqual({ route: '/app/person/savedView/3', tableName: 'person' })
|
||||
})
|
||||
|
||||
it('describes a record screen, with the record label when known', () => {
|
||||
expect(assistantPageInfo('/app/person/7', '', META)).toEqual({ context: { route: '/app/person/7', tableName: 'person', recordId: '7' }, label: 'People #7' })
|
||||
expect(assistantPageInfo('/app/person/7/edit', '', META, 'Alice')?.label).toBe('Alice (People)')
|
||||
})
|
||||
|
||||
it('describes other table screens by the table only', () => {
|
||||
expect(assistantPageInfo('/app/person/create', '', META)).toEqual({ context: { route: '/app/person/create', tableName: 'person' }, label: 'People' })
|
||||
})
|
||||
|
||||
it('returns null away from table screens', () => {
|
||||
expect(assistantPageInfo('/app', '', META)).toBeNull()
|
||||
expect(assistantPageInfo('/login', '', META)).toBeNull()
|
||||
expect(assistantPageInfo('/app/crm', '', META)).toBeNull()
|
||||
expect(assistantPageInfo('/app/person', '', undefined)).toBeNull()
|
||||
expect(assistantPageInfo('/app/%E0%A4%A', '', META)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readAssistantDeepLink', () => {
|
||||
it('reads the prompt and strips both parameters, keeping the others', () => {
|
||||
expect(readAssistantDeepLink('?assistant=open&assistantPrompt=Tell%20me%20about%20this%20record.&tab=2'))
|
||||
.toEqual({ prompt: 'Tell me about this record.', remainingSearch: '?tab=2' })
|
||||
expect(readAssistantDeepLink('assistant=open')).toEqual({ prompt: '', remainingSearch: '' })
|
||||
})
|
||||
|
||||
it('ignores a missing or different assistant parameter', () => {
|
||||
expect(readAssistantDeepLink('')).toBeNull()
|
||||
expect(readAssistantDeepLink('?assistant=close&assistantPrompt=x')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file assistant-links — link safety and page context for the assistant panel. Model text
|
||||
* and record data are untrusted (prompt injection), so only same-origin `/app` paths ever
|
||||
* become links, and the page context sent with a turn is derived from the URL alone.
|
||||
*/
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { AssistantPageContext } from '@/types/assistant'
|
||||
import { deserializeFilter, isFilterEmpty } from './filter-utils'
|
||||
|
||||
/**
|
||||
* Returns the in-app path for a link the assistant may render, or null when the link must be
|
||||
* shown as plain text: anything that is not a same-origin path under `/app` (other origins,
|
||||
* protocol-relative and backslash tricks, `javascript:`, control characters).
|
||||
*
|
||||
* @param href - A link from model text, a record reference or a render payload.
|
||||
* @param origin - The page origin (defaults to `window.location.origin`).
|
||||
* @returns The path with its query and hash (e.g. `/app/person/1?x=1#audit`), or null.
|
||||
*/
|
||||
export function internalAppHref(href: string | null | undefined, origin?: string): string | null {
|
||||
if (typeof href !== 'string') return null
|
||||
const trimmed = href.trim()
|
||||
if (!trimmed || /[\u0000-\u001f\u007f\\]/.test(trimmed)) return null
|
||||
const base = origin ?? (typeof window === 'undefined' ? 'http://localhost' : window.location.origin)
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed, base)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.origin !== new URL(base).origin) return null
|
||||
if (!trimmed.startsWith('/') && !/^https?:\/\//i.test(trimmed)) return null
|
||||
if (trimmed.startsWith('//')) return null
|
||||
if (url.pathname !== '/app' && !url.pathname.startsWith('/app/')) return null
|
||||
return `${url.pathname}${url.search}${url.hash}`
|
||||
}
|
||||
|
||||
/** A record or table screen the user is on, with what to call it. */
|
||||
export interface AssistantPageInfo {
|
||||
/** Sent with the turn as a hint. */
|
||||
context: AssistantPageContext
|
||||
/** Shown on the composer chip after "On: ". */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the page context from the current location: the table of a query or record
|
||||
* screen, the record id, and the query filter from the `filter` parameter.
|
||||
*
|
||||
* @param pathname - The current path.
|
||||
* @param search - The current query string (`?filter=...`), or empty.
|
||||
* @param metaData - Instance metadata, to tell tables from apps and processes.
|
||||
* @param recordLabel - The record's label when known (recently viewed records).
|
||||
* @returns The page info, or null when the page is not a table screen.
|
||||
*/
|
||||
export function assistantPageInfo(pathname: string, search: string, metaData: QInstance | undefined, recordLabel?: string): AssistantPageInfo | null {
|
||||
const segments = pathname.split('/').filter(Boolean).map(safeDecode)
|
||||
if (segments[0] !== 'app' || !segments[1]) return null
|
||||
const table = metaData?.tables?.[segments[1]]
|
||||
if (!table) return null
|
||||
const rest = segments.slice(2)
|
||||
const route = pathname
|
||||
if (rest.length === 0 || (rest[0] === 'savedView' && rest.length === 2)) {
|
||||
const filterParam = new URLSearchParams(search).get('filter')
|
||||
const filter = filterParam ? deserializeFilter(filterParam) : null
|
||||
if (filter && !isFilterEmpty(filter)) {
|
||||
return {
|
||||
context: { route, tableName: table.name, filter: { criteria: filter.criteria, subFilters: filter.subFilters, booleanOperator: filter.booleanOperator } },
|
||||
label: `${table.label} (filtered)`,
|
||||
}
|
||||
}
|
||||
return { context: { route, tableName: table.name }, label: table.label }
|
||||
}
|
||||
if (rest.length >= 1 && !['create', 'dev', 'key', 'savedView'].includes(rest[0])) {
|
||||
return {
|
||||
context: { route, tableName: table.name, recordId: rest[0] },
|
||||
label: recordLabel ? `${recordLabel} (${table.label})` : `${table.label} #${rest[0]}`,
|
||||
}
|
||||
}
|
||||
return { context: { route, tableName: table.name }, label: table.label }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes one path segment, keeping it as-is when it is malformed.
|
||||
*
|
||||
* @param segment - A path segment.
|
||||
* @returns The decoded segment.
|
||||
*/
|
||||
function safeDecode(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Types of the QQQ Assistant HTTP/SSE contract (`/qqq/v1/assistant/*`, served by
|
||||
* the qbit-assistant route provider). Field names match the wire format exactly.
|
||||
*/
|
||||
|
||||
/** Why the assistant is unavailable to the current user. */
|
||||
export type AssistantDisabledReason = 'DISABLED' | 'NO_PERMISSION' | 'KILL_SWITCH' | 'NOT_CONFIGURED'
|
||||
|
||||
/** `GET /assistant/config`: the feature probe plus UI configuration. */
|
||||
export interface AssistantConfig {
|
||||
enabled: boolean
|
||||
reason?: AssistantDisabledReason
|
||||
/** Keyboard shortcut, e.g. `mod+j`. */
|
||||
shortcut?: string
|
||||
model?: string
|
||||
limits?: { maxTurnSeconds?: number; maxToolCallsPerTurn?: number }
|
||||
features?: { writes?: boolean }
|
||||
}
|
||||
|
||||
/** Lifecycle of a conversation. */
|
||||
export type AssistantConversationStatus = 'ACTIVE' | 'ARCHIVED'
|
||||
|
||||
/** One conversation (own conversations only). */
|
||||
export interface AssistantConversation {
|
||||
id: number
|
||||
title: string | null
|
||||
status: AssistantConversationStatus
|
||||
pinned: boolean
|
||||
lastMessageAt: string | null
|
||||
unreadCount: number
|
||||
createDate: string
|
||||
}
|
||||
|
||||
/** Page the user is on, sent as a hint with a turn (never trusted by the server). */
|
||||
export interface AssistantPageContext {
|
||||
route?: string
|
||||
tableName?: string
|
||||
recordId?: string | number
|
||||
/** A QQueryFilter as JSON. */
|
||||
filter?: Record<string, unknown>
|
||||
selectedIds?: Array<string | number>
|
||||
}
|
||||
|
||||
/** Outcome of one tool call. */
|
||||
export type AssistantToolOutcome = 'OK' | 'DENIED' | 'NOT_FOUND' | 'INVALID_INPUT' | 'TRUNCATED' | 'ERROR'
|
||||
|
||||
/** A record the assistant referred to; `href` is built by the server. */
|
||||
export interface AssistantRecordRef {
|
||||
tableName: string
|
||||
tableLabel: string
|
||||
id: string | number
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
/** Rows of a query, rendered as an inline table. */
|
||||
export interface AssistantRecordsRender {
|
||||
type: 'records'
|
||||
tableName: string
|
||||
tableLabel: string
|
||||
columns: Array<{ name: string; label: string }>
|
||||
rows: Array<{ id: string | number; href: string; values: Record<string, string | null> }>
|
||||
totalCount?: number
|
||||
truncated: boolean
|
||||
openHref: string
|
||||
}
|
||||
|
||||
/** One record's fields. */
|
||||
export interface AssistantRecordRender {
|
||||
type: 'record'
|
||||
tableName: string
|
||||
tableLabel: string
|
||||
id: string | number
|
||||
href: string
|
||||
fields: Array<{ name: string; label: string; value: string | null }>
|
||||
}
|
||||
|
||||
/** A record count. */
|
||||
export interface AssistantCountRender {
|
||||
type: 'count'
|
||||
tableName: string
|
||||
tableLabel: string
|
||||
count: number
|
||||
openHref: string
|
||||
}
|
||||
|
||||
/** A widget payload (the same shape the widget endpoint returns). */
|
||||
export interface AssistantWidgetRender {
|
||||
type: 'widget'
|
||||
widgetName: string
|
||||
label: string
|
||||
widgetType: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** One audit entry of a record's history. */
|
||||
export interface AssistantHistoryEntry {
|
||||
timestamp: string
|
||||
user: string
|
||||
message: string
|
||||
details: Array<{ field: string; oldValue: string | null; newValue: string | null; truncated: boolean }>
|
||||
}
|
||||
|
||||
/** A record's change history. */
|
||||
export interface AssistantHistoryRender {
|
||||
type: 'history'
|
||||
tableName: string
|
||||
recordId: string | number
|
||||
entries: AssistantHistoryEntry[]
|
||||
}
|
||||
|
||||
/** Structured UI payload of a tool result (never model-written HTML). */
|
||||
export type AssistantRender =
|
||||
| AssistantRecordsRender
|
||||
| AssistantRecordRender
|
||||
| AssistantCountRender
|
||||
| AssistantWidgetRender
|
||||
| AssistantHistoryRender
|
||||
|
||||
/** Markdown text written by the model. */
|
||||
export interface AssistantTextPart { type: 'text'; text: string }
|
||||
|
||||
/** One tool call and, once finished, its outcome. */
|
||||
export interface AssistantToolPart {
|
||||
type: 'tool'
|
||||
toolCallId: string
|
||||
name: string
|
||||
summary: string
|
||||
/** Absent while the tool is still running (streamed `tool.start`). */
|
||||
outcome?: AssistantToolOutcome
|
||||
render?: AssistantRender
|
||||
}
|
||||
|
||||
/** Records referenced by the reply. */
|
||||
export interface AssistantRecordRefsPart { type: 'record_refs'; refs: AssistantRecordRef[] }
|
||||
|
||||
/** The assistant took the user to a page. */
|
||||
export interface AssistantNavigatePart { type: 'navigate'; href: string }
|
||||
|
||||
/** An informational, warning or error notice. */
|
||||
export interface AssistantNoticePart { type: 'notice'; level: 'info' | 'warning' | 'error'; text: string }
|
||||
|
||||
/** One part of a message. Unknown part types (later phases) are ignored by the UI. */
|
||||
export type AssistantPart =
|
||||
| AssistantTextPart
|
||||
| AssistantToolPart
|
||||
| AssistantRecordRefsPart
|
||||
| AssistantNavigatePart
|
||||
| AssistantNoticePart
|
||||
|
||||
/** Who wrote a message. */
|
||||
export type AssistantRole = 'USER' | 'ASSISTANT' | 'SYSTEM_NOTICE'
|
||||
|
||||
/** Lifecycle of a message. */
|
||||
export type AssistantMessageStatus = 'STREAMING' | 'COMPLETE' | 'CANCELLED' | 'ERROR'
|
||||
|
||||
/** One message of a conversation. */
|
||||
export interface AssistantMessage {
|
||||
id: number
|
||||
role: AssistantRole
|
||||
status: AssistantMessageStatus
|
||||
turnId: number | null
|
||||
createDate: string
|
||||
parts: AssistantPart[]
|
||||
}
|
||||
|
||||
/** `GET /assistant/conversations/{id}`. */
|
||||
export interface AssistantConversationDetail {
|
||||
conversation: AssistantConversation
|
||||
messages: AssistantMessage[]
|
||||
}
|
||||
|
||||
/** Body of `PATCH /assistant/conversations/{id}`. */
|
||||
export interface AssistantConversationUpdate {
|
||||
title?: string
|
||||
pinned?: boolean
|
||||
status?: AssistantConversationStatus
|
||||
}
|
||||
|
||||
/** Body of `POST /assistant/messages/{id}/feedback`. */
|
||||
export interface AssistantFeedback {
|
||||
rating: 1 | -1
|
||||
reason?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
/** Error codes of the `error` stream event. */
|
||||
export type AssistantStreamErrorCode = 'KILL_SWITCH' | 'BUDGET' | 'RATE_LIMIT' | 'PROVIDER' | 'TIMEOUT' | 'INTERNAL'
|
||||
|
||||
/** Typed events of a turn stream; every event carries its `seq`. */
|
||||
export type AssistantStreamEvent =
|
||||
| { type: 'message.start'; seq: number; turnId: number; conversationId: number; userMessageId: number; messageId: number }
|
||||
| { type: 'text.delta'; seq: number; messageId: number; text: string }
|
||||
| { type: 'tool.start'; seq: number; messageId: number; toolCallId: string; name: string; summary: string }
|
||||
| {
|
||||
type: 'tool.result'; seq: number; messageId: number; toolCallId: string; name: string
|
||||
outcome: AssistantToolOutcome; summary: string; render?: AssistantRender; recordRefs?: AssistantRecordRef[]
|
||||
}
|
||||
| { type: 'ui.navigate'; seq: number; messageId: number; href: string }
|
||||
| { type: 'notice'; seq: number; messageId: number; level: 'info' | 'warning' | 'error'; text: string }
|
||||
| {
|
||||
type: 'usage'; seq: number; turnId: number; inputTokens: number; outputTokens: number
|
||||
cacheReadTokens: number; cacheWriteTokens: number; costMicros: number
|
||||
}
|
||||
| { type: 'message.end'; seq: number; messageId: number; status: 'COMPLETE' | 'CANCELLED' | 'ERROR'; stopReason?: string }
|
||||
| { type: 'error'; seq: number; code: AssistantStreamErrorCode; message: string }
|
||||
| { type: 'ping'; seq?: number }
|
||||
@@ -54,8 +54,9 @@ export interface QInstance {
|
||||
*/
|
||||
environmentValues?: Record<string, string>
|
||||
/**
|
||||
* Allow-listed supplemental metadata: `materialDashboard` (processes for every screen) and
|
||||
* `materialDashboardTheme` (the application theme, {@link QThemeMetaData}).
|
||||
* Allow-listed supplemental metadata: `materialDashboard` (processes for every screen),
|
||||
* `materialDashboardTheme` (the application theme, {@link QThemeMetaData}) and `assistant`
|
||||
* (present when qbit-assistant is installed; the UI then probes `/assistant/config`).
|
||||
*/
|
||||
supplementalInstanceMetaData?: Record<string, unknown>
|
||||
/** Optional theme overrides for colors and display mode. */
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// E2E: QQQ Assistant panel (qbit-assistant) against mocked /qqq/v1/assistant routes:
|
||||
// probe gating, Ctrl/⌘+J, streaming reply with tool step and record chip, navigation, cancel,
|
||||
// docked panel vs sheet (desktop / tablet / phone), deep link, command palette hand-over,
|
||||
// axe, and one scroll container per region (no double or horizontal page scrollbars).
|
||||
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
import { checkA11y } from './a11y-helpers'
|
||||
|
||||
const VIEWPORTS = {
|
||||
desktop: { width: 1440, height: 900 },
|
||||
tablet: { width: 820, height: 1180 },
|
||||
phone: { width: 390, height: 844 },
|
||||
smallPhone: { width: 360, height: 740 },
|
||||
}
|
||||
|
||||
/** Where screenshots go when QQQ_ASSISTANT_SCREENSHOTS is set (a directory). */
|
||||
const SCREENSHOT_DIR = process.env.QQQ_ASSISTANT_SCREENSHOTS
|
||||
|
||||
const CONVERSATION = { id: 4, title: 'Active people', status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z' }
|
||||
|
||||
/**
|
||||
* Formats stream events as SSE frames.
|
||||
*
|
||||
* @param events - `[type, data]` pairs.
|
||||
* @returns Event-stream text.
|
||||
*/
|
||||
function sse(...events: Array<[string, Record<string, unknown>]>): string {
|
||||
return events.map(([type, data]) => `id: ${data.seq}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`).join('')
|
||||
}
|
||||
|
||||
const LONG_TEXT = Array.from({ length: 30 }, (_, index) => `Paragraph ${index + 1}: People records are listed with their status.`).join('\n\n')
|
||||
|
||||
/** The records table of the query step. */
|
||||
const RECORDS_RENDER = {
|
||||
type: 'records', tableName: 'person', tableLabel: 'People', truncated: false, totalCount: 2, openHref: '/app/person?filter=e30=',
|
||||
columns: [{ name: 'firstName', label: 'First Name' }, { name: 'lastName', label: 'Last Name' }, { name: 'status', label: 'Status' }],
|
||||
rows: [
|
||||
{ id: 1, href: '/app/person/1', values: { firstName: 'Alice', lastName: 'Johnson', status: 'Active' } },
|
||||
{ id: 2, href: '/app/person/2', values: { firstName: 'Bob', lastName: 'Martinez', status: 'Active' } },
|
||||
],
|
||||
}
|
||||
|
||||
/** The reply to "Who is active?": a tool step with a records table and chips, text, then navigation. */
|
||||
const REPLY = sse(
|
||||
['message.start', { seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }],
|
||||
['tool.start', { seq: 2, messageId: 11, toolCallId: 't1', name: 'query_records', summary: 'Querying People' }],
|
||||
['tool.result', {
|
||||
seq: 3, messageId: 11, toolCallId: 't1', name: 'query_records', outcome: 'OK', summary: 'Queried People · 2 rows',
|
||||
render: RECORDS_RENDER,
|
||||
recordRefs: [
|
||||
{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' },
|
||||
{ tableName: 'person', tableLabel: 'People', id: 2, label: 'Bob Martinez', href: '/app/person/2' },
|
||||
],
|
||||
}],
|
||||
['text.delta', { seq: 4, messageId: 11, text: 'Two people are **active**. ' }],
|
||||
['text.delta', { seq: 5, messageId: 11, text: `Opening the first one.\n\n${LONG_TEXT}` }],
|
||||
['ui.navigate', { seq: 6, messageId: 11, href: '/app/person/1' }],
|
||||
['message.end', { seq: 7, messageId: 11, status: 'COMPLETE' }],
|
||||
)
|
||||
|
||||
/** Stored messages after the reply (what GET /conversations/4 serves once the turn ended). */
|
||||
const STORED = [
|
||||
{ id: 10, role: 'USER', status: 'COMPLETE', turnId: 9, createDate: '2026-10-12T00:00:00Z', parts: [{ type: 'text', text: 'Who is active?' }] },
|
||||
{
|
||||
id: 11, role: 'ASSISTANT', status: 'COMPLETE', turnId: 9, createDate: '2026-10-12T00:00:00Z', parts: [
|
||||
{ type: 'tool', toolCallId: 't1', name: 'query_records', summary: 'Queried People · 2 rows', outcome: 'OK', render: RECORDS_RENDER },
|
||||
{ type: 'record_refs', refs: [
|
||||
{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' },
|
||||
{ tableName: 'person', tableLabel: 'People', id: 2, label: 'Bob Martinez', href: '/app/person/2' },
|
||||
] },
|
||||
{ type: 'text', text: `Two people are **active**. Opening the first one.\n\n${LONG_TEXT}` },
|
||||
{ type: 'navigate', href: '/app/person/1' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const CANCELLED_END = sse(['message.end', { seq: 3, messageId: 11, status: 'CANCELLED' }])
|
||||
|
||||
/** Recorded assistant traffic of one test. */
|
||||
interface AssistantMock {
|
||||
turns: unknown[]
|
||||
cancels: number
|
||||
/** Resolves a held turn-events request (cancel scenario). */
|
||||
releaseEvents?: (body: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks the assistant routes (call after setupApiMocks: later routes win).
|
||||
*
|
||||
* @param page - The page.
|
||||
* @param options - `reply` streams on POST turns; `hold` leaves the resume request pending until released.
|
||||
* @param options.reply - The SSE body of a turn.
|
||||
* @param options.hold - Whether `/turns/9/events` waits for {@link AssistantMock.releaseEvents}.
|
||||
* @param options.stored - The messages stored once the turn ends (default {@link STORED}).
|
||||
* @returns The recorded traffic.
|
||||
*/
|
||||
async function mockAssistant(page: Page, options: { reply?: string; hold?: boolean; stored?: unknown[] } = {}): Promise<AssistantMock> {
|
||||
const mock: AssistantMock = { turns: [], cancels: 0 }
|
||||
let stored: unknown[] = []
|
||||
const json = (route: Route, body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
// the instance announces the qbit, so the UI probes /assistant/config
|
||||
await page.route(/\/qqq\/v1\/metaData(\?|$)/, (route) => json(route, { ...METADATA, supplementalInstanceMetaData: { assistant: {} } }))
|
||||
await page.route('**/qqq/v1/assistant/**', async (route) => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname.replace('/qqq/v1/assistant', '')
|
||||
const method = request.method()
|
||||
if (path === '/config') return json(route, { enabled: true, shortcut: 'mod+j', model: 'claude-sonnet-5-5', features: { writes: false } })
|
||||
if (path === '/conversations' && method === 'GET') return json(route, { conversations: stored.length ? [CONVERSATION] : [] })
|
||||
if (path === '/conversations' && method === 'POST') return json(route, { ...CONVERSATION, title: null }, 201)
|
||||
if (path === '/conversations/4' && method === 'GET') return json(route, { conversation: CONVERSATION, messages: stored })
|
||||
if (path === '/conversations/4/turns') {
|
||||
mock.turns.push(request.postDataJSON())
|
||||
stored = options.stored ?? STORED
|
||||
return route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, body: options.reply ?? REPLY })
|
||||
}
|
||||
if (path === '/turns/9/events' && options.hold) {
|
||||
if (mock.cancels) return route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, body: CANCELLED_END })
|
||||
const body = await new Promise<string>((resolve) => { mock.releaseEvents = resolve })
|
||||
return route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, body })
|
||||
}
|
||||
if (path === '/turns/9/cancel') {
|
||||
mock.cancels++
|
||||
mock.releaseEvents?.(CANCELLED_END)
|
||||
return json(route, { status: 'CANCELLING' }, 202)
|
||||
}
|
||||
if (path.endsWith('/feedback')) return route.fulfill({ status: 204 })
|
||||
return json(route, { error: 'not mocked' }, 404)
|
||||
})
|
||||
return mock
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the dashboard shell.
|
||||
*
|
||||
* @param page - The page.
|
||||
*/
|
||||
async function waitForAppReady(page: Page) {
|
||||
await page.waitForSelector('[role="status"][aria-label="Loading"]', { state: 'hidden', timeout: 30000 }).catch(() => null)
|
||||
await expect(page.locator('[data-qqq-id="main-content"]')).toBeVisible({ timeout: 30000 })
|
||||
await page.waitForSelector('[aria-label="Loading content"]', { state: 'hidden', timeout: 15000 }).catch(() => null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the scroll containers on the page: the document when it scrolls, and every element
|
||||
* whose content overflows a scrolling box (vertical or horizontal).
|
||||
*
|
||||
* @param page - The page.
|
||||
* @returns `data-qqq-id` (or tag) of each scroller, with its axis.
|
||||
*/
|
||||
async function scrollContainers(page: Page): Promise<{ document: { vertical: boolean; horizontal: boolean }; elements: string[] }> {
|
||||
return page.evaluate(() => {
|
||||
const root = document.scrollingElement as HTMLElement
|
||||
const elements: string[] = []
|
||||
for (const element of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
|
||||
const style = getComputedStyle(element)
|
||||
if (style.display === 'none' || style.visibility === 'hidden') continue
|
||||
const vertical = /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 1
|
||||
const horizontal = /(auto|scroll)/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 1
|
||||
if (vertical || horizontal) elements.push(`${element.dataset.qqqId ?? element.tagName.toLowerCase()}:${vertical ? 'y' : ''}${horizontal ? 'x' : ''}`)
|
||||
}
|
||||
return {
|
||||
document: { vertical: root.scrollHeight > window.innerHeight + 1, horizontal: root.scrollWidth > window.innerWidth + 1 },
|
||||
elements,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks a question in the open panel.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @param text - The question.
|
||||
*/
|
||||
async function ask(page: Page, text: string) {
|
||||
const composer = page.locator('[data-qqq-id="assistant-composer"]')
|
||||
await composer.fill(text)
|
||||
await composer.press('Enter')
|
||||
}
|
||||
|
||||
test.describe('Assistant panel: gating', () => {
|
||||
test('makes no assistant request when the metadata does not announce the qbit', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
const requests: string[] = []
|
||||
page.on('request', (request) => { if (request.url().includes('/assistant/')) requests.push(request.url()) })
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
await expect(page.locator('[data-qqq-id="button-keyboard-shortcuts"]')).toBeVisible()
|
||||
await expect(page.locator('[data-qqq-id="button-assistant"]')).toHaveCount(0)
|
||||
await page.locator('body').click()
|
||||
await page.keyboard.press('Control+j')
|
||||
await expect(page.locator('[data-qqq-id="assistant-panel"]')).toHaveCount(0)
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
test('is absent and Ctrl+J is inert when the probe 404s', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
await page.route(/\/qqq\/v1\/metaData(\?|$)/, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ...METADATA, supplementalInstanceMetaData: { assistant: {} } }) }))
|
||||
await page.route('**/qqq/v1/assistant/**', (route) => route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"Not found"}' }))
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
await expect(page.locator('[data-qqq-id="button-keyboard-shortcuts"]')).toBeVisible()
|
||||
await expect(page.locator('[data-qqq-id="button-assistant"]')).toHaveCount(0)
|
||||
await page.locator('body').click()
|
||||
await page.keyboard.press('Control+j')
|
||||
await expect(page.locator('[data-qqq-id="assistant-panel"]')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assistant panel: desktop (docked)', () => {
|
||||
test.use({ viewport: VIEWPORTS.desktop })
|
||||
|
||||
test('Ctrl+J opens it docked; a question streams a tool step, record chips and text, navigation keeps it open', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
const mock = await mockAssistant(page)
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
|
||||
// works while typing in a text field
|
||||
await page.locator('input[data-qqq-id="quick-search"]').click()
|
||||
await page.keyboard.press('Control+j')
|
||||
const panel = page.locator('aside[data-qqq-id="assistant-panel"]')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(page.locator('[data-qqq-id="assistant-composer"]')).toBeFocused()
|
||||
await expect(panel.locator('[data-qqq-id="assistant-context-chip"]')).toContainText('On: People')
|
||||
|
||||
await ask(page, 'Who is active?')
|
||||
await expect(panel.locator('[data-qqq-id="assistant-markdown"]').first()).toContainText('Two people are active.')
|
||||
expect(mock.turns).toEqual([{ text: 'Who is active?', pageContext: { route: '/app/person', tableName: 'person' } }])
|
||||
await expect(panel.locator('[data-qqq-id="assistant-tool-step"] button').first()).toContainText('Queried People · 2 rows')
|
||||
const chip = panel.locator('[data-qqq-id="assistant-record-chip"]').first()
|
||||
await expect(chip).toHaveAttribute('href', '/app/person/1')
|
||||
|
||||
// ui.navigate routed in place; the panel stayed open
|
||||
await expect(page).toHaveURL(/\/app\/person\/1$/)
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
// the inline table is shown once the panel is wide enough; widen with the keyboard
|
||||
const handle = panel.locator('[data-qqq-id="assistant-resize"]')
|
||||
await handle.focus()
|
||||
await page.keyboard.press('End')
|
||||
await expect(handle).toHaveAttribute('aria-valuenow', '640')
|
||||
await expect(panel).toHaveJSProperty('offsetWidth', 640)
|
||||
await panel.locator('[data-qqq-id="assistant-messages"]').evaluate((element) => { element.scrollTop = 0 })
|
||||
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/assistant-desktop.png` })
|
||||
|
||||
// chips open the record
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
await expect(panel).toBeVisible()
|
||||
})
|
||||
|
||||
test('one scroll container per region, no page or horizontal scrollbar, and axe passes', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
await mockAssistant(page)
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
await page.locator('body').click()
|
||||
await page.keyboard.press('Control+j')
|
||||
await ask(page, 'Who is active?')
|
||||
await expect(page.locator('[data-qqq-id="assistant-feedback"]')).toBeVisible()
|
||||
|
||||
const result = await scrollContainers(page)
|
||||
expect(result.document).toEqual({ vertical: false, horizontal: false })
|
||||
// the message list scrolls on its own; nothing else in the panel does (the page's data grid
|
||||
// keeps its own sideways scroller for its columns, as at any narrower window)
|
||||
expect(result.elements).toContain('assistant-messages:y')
|
||||
expect(result.elements.filter((entry) => !['assistant-messages:y', 'main-content:y'].includes(entry) && !/^grid-[^:]+:x$/.test(entry))).toEqual([])
|
||||
|
||||
await checkA11y(page)
|
||||
})
|
||||
|
||||
test('Stop cancels the running turn through the cancel endpoint', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
// the turn starts, the connection drops, the resumed stream waits until the cancel
|
||||
const mock = await mockAssistant(page, {
|
||||
hold: true,
|
||||
reply: sse(
|
||||
['message.start', { seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }],
|
||||
['text.delta', { seq: 2, messageId: 11, text: 'Counting people' }],
|
||||
),
|
||||
stored: [
|
||||
{ ...STORED[0], parts: [{ type: 'text', text: 'Count people' }] },
|
||||
{ ...STORED[1], status: 'CANCELLED', parts: [{ type: 'text', text: 'Counting people' }] },
|
||||
],
|
||||
})
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
await page.locator('body').click()
|
||||
await page.keyboard.press('Meta+j')
|
||||
await ask(page, 'Count people')
|
||||
await expect(page.locator('[data-qqq-id="assistant-markdown"]')).toContainText('Counting people')
|
||||
await page.locator('[data-qqq-id="assistant-stop"]').click()
|
||||
await expect(page.locator('[data-qqq-id="assistant-message"][data-role="assistant"]')).toContainText('Stopped.')
|
||||
expect(mock.cancels).toBe(1)
|
||||
await expect(page.locator('[data-qqq-id="assistant-send"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('the command palette hands typed text to the assistant', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
const mock = await mockAssistant(page)
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
await page.locator('body').click()
|
||||
await page.keyboard.press('Control+k')
|
||||
await page.locator('[data-qqq-id="command-menu-search"]').fill('who is active')
|
||||
await page.locator('[data-qqq-id="command-ask-assistant"]').click()
|
||||
await expect(page.locator('aside[data-qqq-id="assistant-panel"]')).toBeVisible()
|
||||
await expect.poll(() => mock.turns.length).toBe(1)
|
||||
expect(mock.turns[0]).toMatchObject({ text: 'who is active' })
|
||||
})
|
||||
|
||||
test('the deep link opens a new conversation with the prompt pre-filled and removes its parameters', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
const mock = await mockAssistant(page)
|
||||
await page.goto('/app/person/1?assistant=open&assistantPrompt=Tell%20me%20about%20this%20record.')
|
||||
await waitForAppReady(page)
|
||||
const composer = page.locator('[data-qqq-id="assistant-composer"]')
|
||||
await expect(composer).toHaveValue('Tell me about this record.')
|
||||
await expect(page).toHaveURL(/\/app\/person\/1$/)
|
||||
await expect(page.locator('[data-qqq-id="assistant-context-chip"]')).toContainText('People')
|
||||
expect(mock.turns).toEqual([])
|
||||
await composer.press('Enter')
|
||||
await expect.poll(() => mock.turns.length).toBe(1)
|
||||
expect(mock.turns[0]).toEqual({ text: 'Tell me about this record.', pageContext: { route: '/app/person/1', tableName: 'person', recordId: '1' } })
|
||||
})
|
||||
})
|
||||
|
||||
for (const [name, viewport] of Object.entries({ tablet: VIEWPORTS.tablet, phone: VIEWPORTS.phone, smallPhone: VIEWPORTS.smallPhone })) {
|
||||
test.describe(`Assistant panel: ${name} (sheet)`, () => {
|
||||
test.use({ viewport, hasTouch: name !== 'tablet' })
|
||||
|
||||
test(`opens as a sheet from the header button, stays readable without horizontal scroll, and Escape restores focus @mobile`, async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
await mockAssistant(page)
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
const button = page.locator('[data-qqq-id="button-assistant"]')
|
||||
await button.click()
|
||||
const sheet = page.locator('[role="dialog"][data-qqq-id="assistant-panel"]')
|
||||
await expect(sheet).toBeVisible()
|
||||
const box = await sheet.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width + 1)
|
||||
if (name !== 'tablet') expect(Math.round(box!.width)).toBe(viewport.width)
|
||||
|
||||
await ask(page, 'Who is active?')
|
||||
await expect(sheet.locator('[data-qqq-id="assistant-markdown"]').first()).toContainText('Two people are active.')
|
||||
await expect(sheet.locator('[data-qqq-id="assistant-records"]')).toBeVisible()
|
||||
// the composer stays at the bottom of the sheet, on screen
|
||||
const composerBox = await sheet.locator('[data-qqq-id="assistant-composer-form"]').boundingBox()
|
||||
expect(composerBox!.y + composerBox!.height).toBeLessThanOrEqual(viewport.height + 1)
|
||||
|
||||
const result = await scrollContainers(page)
|
||||
expect(result.document.horizontal).toBe(false)
|
||||
const inSheet = await sheet.evaluate((element) => {
|
||||
const found: string[] = []
|
||||
for (const node of Array.from(element.querySelectorAll<HTMLElement>('*'))) {
|
||||
const style = getComputedStyle(node)
|
||||
if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) found.push(node.dataset.qqqId ?? node.tagName)
|
||||
if (/(auto|scroll)/.test(style.overflowX) && node.scrollWidth > node.clientWidth + 1) found.push(`${node.dataset.qqqId ?? node.tagName}:x`)
|
||||
}
|
||||
return found
|
||||
})
|
||||
expect(inSheet).toEqual(['assistant-messages'])
|
||||
|
||||
if (SCREENSHOT_DIR) {
|
||||
await sheet.locator('[data-qqq-id="assistant-messages"]').evaluate((element) => { element.scrollTop = 0 })
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/assistant-${name}.png` })
|
||||
}
|
||||
await checkA11y(page)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(sheet).toHaveCount(0)
|
||||
await expect(button).toBeFocused()
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user