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:
james.maes authored and GitHub committed 2026-10-12 14:48:48 -05:00
commit b32a29d25b
36 files changed
+5920 -15

No files matched your search

+4
View File
@@ -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).
+2
View File
@@ -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
View File
@@ -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
}
}
}
+698
View File
File diff suppressed because it is too large. Load diff
+30 -5
View File
@@ -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>',
'![tracker](https://evil.example.com/t.png) [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()
})
})
+565
View File
@@ -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)
})
})
+183
View File
@@ -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
}
+26 -3
View File
@@ -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}
+5 -1
View File
@@ -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}
+60
View File
@@ -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 : []
}
+334
View File
@@ -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' })
})
})
+426
View File
@@ -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 })
})
}
+8
View File
@@ -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.
*
+107
View File
@@ -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 }])
})
})
+149
View File
@@ -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
}
}
+37
View File
@@ -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}` : '' }
}
+118
View File
@@ -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()
})
})
+111
View File
@@ -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
}
}
+223
View File
@@ -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 }
+3 -2
View File
@@ -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. */
+378
View File
@@ -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()
})
})
}