The Next UI side of the QQQ Assistant (qbit-assistant), Phase 1: read-only questions and navigation. Spec: QRun-IO/qqq docs/specs/2026-10-11-qqq-assistant-qbit-design.md (§5, §2.6, §3.5). Shipped in Next core and gated by the /qqq/v1/assistant/config probe (owner decision D5). The probe is made only when instance metadata announces the qbit (supplementalInstanceMetaData.assistant, the spec's recommended metadata signal). An application without the qbit therefore makes no assistant request at all: no 404s, no console errors. Without the backend nothing renders and Ctrl/⌘+J is inert, so this is safe to merge before the qbit exists.
Backend dependency: the panel appears only once core's v1 SupplementalInstanceMetaData allow-list carries an assistant entry and qbit-assistant publishes it. This is tracked in the coordinator contract.
What's added
src/lib/api/assistant.ts: a typed client for every contract route. Turns stream over fetch (with credentials: 'include'), read through response.body, and are parsed by an incremental SSE parser (sse-parser.ts). The parser handles chunk boundaries anywhere (including CR/LF splits), multi-line data, id/event/retry, comments and a BOM. Events are deduplicated by seq. A dropped connection resumes through /turns/{id}/events?after=seq, and Stop calls POST /turns/{id}/cancel. A 401 from a stream starts the normal sign-in flow (apiClient.notifyUnauthorized()).
src/lib/api/assistant-probe.ts: getAssistantConfig() follows the ESB getOrNull pattern. It is called only when supplementalInstanceMetaData.assistant is present. A 403, a 404 or a 2xx body that isn't a config (the root-SPA not-found page) returns null and hides everything. Kept separate so the shell doesn't load the streaming client.
src/components/assistant/*:
The controller is created in (dashboard)/layout.tsx and provides: open state, active conversation, unread count and openWith(prompt).
A header button with an unread badge, Ctrl/⌘+J (works from text fields; listed in the shortcuts dialog when enabled) and an "Ask Assistant: " command palette item.
The lazily loaded panel:
≥ 1024 px: docked as a flex sibling of the main column, with a complementary landmark. It is resizable from 360 to 640 px by pointer or keyboard, and the width is kept in localStorage. Its message list scrolls on its own.
< 1024 px: a full-height Radix Dialog sheet that follows visualViewport, with a sticky composer. It is full width on phones and a 30rem side sheet on tablets.
Conversation rail (a listbox: pinned/recent/archived, unread dots), rename/pin/archive/delete, a dismissible context chip ("On: People (filtered)" / "On: Alice (People)"), Stop, and aria-live announcements throttled to whole sentences.
Rich parts are rendered only from structured payloads: tool steps as collapsible rows, record chips with RecordHoverCard, records tables (cards when the panel is narrow) with "Open in Query screen", record, count and history renders, and chart/statistics widgets through WidgetRenderer. Thumbs up/down feedback.
Parts render through a PART_RENDERERS registry, so Phase 2 approval cards slot in by adding an entry. Unknown part and event types are skipped.
Markdown (react-markdown + rehype-sanitize): raw HTML is skipped, images never load, and only same-origin /app paths become links. Other URLs are plain text with a copy button. ui.navigate routes in place with router.push, and the panel stays open.
Deep link?assistant=open&assistantPrompt=<text> (contract §5.3, for the record Developer screen): opens a new conversation seeded with the page context, pre-fills the composer without sending, and removes both parameters with router.replace. Inert when the probe returns null or enabled: false.
Layout rules
One scroll container per region. Docked, the panel's message list scrolls independently of <main>. The document never scrolls and the page never scrolls sideways (checked by Playwright).
Readable at every width. The docked panel's max width is capped to leave room for the sidebar and a readable page.
No shell positioning changes. The main column only gains min-w-0. It composes with #54 (relative shell/main).
Bundle (static export, pnpm perf:budget)
First-load JS: the shell adds +1.7 KB (/app/_ 335.5 → 337.2 KB, /app/_/_/dev 355.0 → 356.7 KB, both under their caps).
Panel chunk:50.8 KB gzip, loaded on first open. It is budgeted as lazyChunks.assistant (55 KB), so total JS is 1028.3 KB against the 1050 KB budget.
CSS: shared CSS is 21.3 KB against the 21 KB cap (+0.9 KB of utilities), so cssGzipKB is raised to 22, with the reason recorded in perf-budget.json.
Test plan
pnpm typecheck, pnpm lint (0 errors; the 5 warnings are pre-existing, in untouched files)
Vitest:
sse-parser.test.ts: every split point, one character at a time, CRLF/CR, multi-line data, ids, retry, BOM, comments, unterminated events, split unicode.
assistant-links.test.ts: same-origin /app allowlist and attack strings, page context, deep link.
message-reducer.test.ts.
AssistantPanel.test.tsx (MSW streams): gating on 404/403/disabled, unread badge, streamed text + tool step + record chip + navigation + jest-axe, cancel through the endpoint, refused turn, dismissed context, markdown safety, and the deep link (pre-filled, parameters stripped, not sent; inert without the probe).
Full suite: everything passes except the same Windows-only path-separator failures that also fail on main (tests/standalone/acceptance-stage.test.ts, tests/acceptance/support/standalone-variant.test.ts). Under full-suite load locally, two existing record-page tests in src/app/(dashboard)/app/[slug]/[recordId]/page.test.tsx and one WidgetRenderer test can time out at 5 s; they pass when run alone.
Playwright (QQQ_E2E_SERVER=production), new tests/e2e/assistant.spec.ts with mocked /assistant routes:
Gating.
Ctrl+J from a text field opens the docked panel with the composer focused.
Ask → streamed text, tool step and record chip; ui.navigate changes the route and the panel stays open.
Keyboard resize to 640 px.
One scroll container per region with no page or horizontal scrollbar, plus axe.
Stop → cancel endpoint → "Stopped.".
Command palette hand-over.
Deep link.
Tablet (820), phone (390) and small phone (360) sheets: no horizontal scroll, composer on screen, the only scroller is the message list, axe, and Escape returns focus to the header button.
Full mocked e2e suite: 122 passed, including a new test that makes sure no assistant request goes out without the metadata entry.
Real-backend acceptance: the first run failed because the probe's 404 counted as an unexplained failed request on every page. The second commit gates the probe on metadata, so the sample application (which has no qbit) makes no assistant requests.
pnpm build-storybook (new Assistant/AssistantMessageView stories)
Contract notes
These are also recorded in the coordinator's contract file under "Deviations (next)":
Docking starts at 1024 px, not the spec's 768 px (md). Below 1024 the 256 px sidebar plus a 360 px panel would leave the page unreadable, which breaks the owner's "readable at all widths" rule. 768-1023 px uses a side sheet instead.
Inline records tables render the server's display strings directly.DataCell/FieldValue need field metadata and raw values, which the records render payload doesn't carry.
The UI assumes the server marks a conversation read when it is loaded. The UI clears the badge optimistically.
The docked panel mounts in the layout's flex row (needed for docking), not literally next to CommandMenu.
## Summary
The Next UI side of the QQQ Assistant (`qbit-assistant`), Phase 1: read-only questions and navigation. Spec: QRun-IO/qqq `docs/specs/2026-10-11-qqq-assistant-qbit-design.md` (§5, §2.6, §3.5). Shipped in Next core and gated by the `/qqq/v1/assistant/config` probe (owner decision D5). The probe is made only when instance metadata announces the qbit (`supplementalInstanceMetaData.assistant`, the spec's recommended metadata signal). An application without the qbit therefore makes **no assistant request at all**: no 404s, no console errors. **Without the backend nothing renders and Ctrl/⌘+J is inert**, so this is safe to merge before the qbit exists.
> **Backend dependency:** the panel appears only once core's v1 `SupplementalInstanceMetaData` allow-list carries an `assistant` entry and qbit-assistant publishes it. This is tracked in the coordinator contract.
### What's added
- **`src/lib/api/assistant.ts`**: a typed client for every contract route. Turns stream over `fetch` (with `credentials: 'include'`), read through `response.body`, and are parsed by an incremental SSE parser (`sse-parser.ts`). The parser handles chunk boundaries anywhere (including CR/LF splits), multi-line data, `id`/`event`/`retry`, comments and a BOM. Events are deduplicated by `seq`. A dropped connection resumes through `/turns/{id}/events?after=seq`, and Stop calls `POST /turns/{id}/cancel`. A 401 from a stream starts the normal sign-in flow (`apiClient.notifyUnauthorized()`).
- **`src/lib/api/assistant-probe.ts`**: `getAssistantConfig()` follows the ESB `getOrNull` pattern. It is called only when `supplementalInstanceMetaData.assistant` is present. A 403, a 404 or a 2xx body that isn't a config (the root-SPA not-found page) returns `null` and hides everything. Kept separate so the shell doesn't load the streaming client.
- **`src/components/assistant/*`**:
- The controller is created in `(dashboard)/layout.tsx` and provides: open state, active conversation, unread count and `openWith(prompt)`.
- A header button with an unread badge, Ctrl/⌘+J (works from text fields; listed in the shortcuts dialog when enabled) and an "Ask Assistant: <text>" command palette item.
- The **lazily loaded panel**:
- **≥ 1024 px**: docked as a flex sibling of the main column, with a `complementary` landmark. It is resizable from 360 to 640 px by pointer or keyboard, and the width is kept in localStorage. Its message list scrolls on its own.
- **< 1024 px**: a full-height Radix Dialog sheet that follows `visualViewport`, with a sticky composer. It is full width on phones and a 30rem side sheet on tablets.
- Conversation rail (a listbox: pinned/recent/archived, unread dots), rename/pin/archive/delete, a dismissible context chip ("On: People (filtered)" / "On: Alice (People)"), Stop, and `aria-live` announcements throttled to whole sentences.
- Rich parts are rendered only from structured payloads: tool steps as collapsible rows, record chips with `RecordHoverCard`, records tables (cards when the panel is narrow) with "Open in Query screen", record, count and history renders, and chart/statistics widgets through `WidgetRenderer`. Thumbs up/down feedback.
- Parts render through a `PART_RENDERERS` registry, so Phase 2 approval cards slot in by adding an entry. Unknown part and event types are skipped.
- **Markdown** (`react-markdown` + `rehype-sanitize`): raw HTML is skipped, images never load, and only same-origin `/app` paths become links. Other URLs are plain text with a copy button. `ui.navigate` routes in place with `router.push`, and the panel stays open.
- **Deep link** `?assistant=open&assistantPrompt=<text>` (contract §5.3, for the record Developer screen): opens a new conversation seeded with the page context, pre-fills the composer without sending, and removes both parameters with `router.replace`. Inert when the probe returns null or `enabled: false`.
### Layout rules
- **One scroll container per region.** Docked, the panel's message list scrolls independently of `<main>`. The document never scrolls and the page never scrolls sideways (checked by Playwright).
- **Readable at every width.** The docked panel's max width is capped to leave room for the sidebar and a readable page.
- **No shell positioning changes.** The main column only gains `min-w-0`. It composes with #54 (`relative` shell/main).
### Bundle (static export, `pnpm perf:budget`)
- **First-load JS:** the shell adds **+1.7 KB** (`/app/_` 335.5 → 337.2 KB, `/app/_/_/dev` 355.0 → 356.7 KB, both under their caps).
- **Panel chunk:** **50.8 KB** gzip, loaded on first open. It is budgeted as `lazyChunks.assistant` (55 KB), so total JS is 1028.3 KB against the 1050 KB budget.
- **CSS:** shared CSS is 21.3 KB against the 21 KB cap (+0.9 KB of utilities), so `cssGzipKB` is raised to 22, with the reason recorded in `perf-budget.json`.
## Test plan
- [x] `pnpm typecheck`, `pnpm lint` (0 errors; the 5 warnings are pre-existing, in untouched files)
- [x] Vitest:
- `sse-parser.test.ts`: every split point, one character at a time, CRLF/CR, multi-line data, ids, retry, BOM, comments, unterminated events, split unicode.
- `assistant.test.ts`: probe cases, all routes, streaming order, pings, 401/429/non-JSON errors, resume with replay dedupe, retry exhaustion, gone turn, aborts, cancelled end.
- `assistant-links.test.ts`: same-origin `/app` allowlist and attack strings, page context, deep link.
- `message-reducer.test.ts`.
- `AssistantPanel.test.tsx` (MSW streams): gating on 404/403/disabled, unread badge, streamed text + tool step + record chip + navigation + jest-axe, cancel through the endpoint, refused turn, dismissed context, markdown safety, and the deep link (pre-filled, parameters stripped, not sent; inert without the probe).
- Full suite: everything passes except the same Windows-only path-separator failures that also fail on `main` (`tests/standalone/acceptance-stage.test.ts`, `tests/acceptance/support/standalone-variant.test.ts`). Under full-suite load locally, two existing record-page tests in `src/app/(dashboard)/app/[slug]/[recordId]/page.test.tsx` and one `WidgetRenderer` test can time out at 5 s; they pass when run alone.
- [x] Playwright (`QQQ_E2E_SERVER=production`), new `tests/e2e/assistant.spec.ts` with mocked `/assistant` routes:
- Gating.
- Ctrl+J from a text field opens the docked panel with the composer focused.
- Ask → streamed text, tool step and record chip; `ui.navigate` changes the route and the panel stays open.
- Keyboard resize to 640 px.
- One scroll container per region with no page or horizontal scrollbar, plus axe.
- Stop → cancel endpoint → "Stopped.".
- Command palette hand-over.
- Deep link.
- Tablet (820), phone (390) and small phone (360) sheets: no horizontal scroll, composer on screen, the only scroller is the message list, axe, and Escape returns focus to the header button.
- [x] Full mocked e2e suite: **122 passed**, including a new test that makes sure no assistant request goes out without the metadata entry.
- Real-backend acceptance: the first run failed because the probe's 404 counted as an unexplained failed request on every page. The second commit gates the probe on metadata, so the sample application (which has no qbit) makes no assistant requests.
- [x] `pnpm build-storybook` (new `Assistant/AssistantMessageView` stories)
## Contract notes
These are also recorded in the coordinator's contract file under "Deviations (next)":
- **Docking starts at 1024 px, not the spec's 768 px (`md`).** Below 1024 the 256 px sidebar plus a 360 px panel would leave the page unreadable, which breaks the owner's "readable at all widths" rule. 768-1023 px uses a side sheet instead.
- **Inline records tables render the server's display strings directly.** `DataCell`/`FieldValue` need field metadata and raw values, which the `records` render payload doesn't carry.
- **The UI assumes the server marks a conversation read when it is loaded.** The UI clears the badge optimistically.
- **The docked panel mounts in the layout's flex row** (needed for docking), not literally next to `CommandMenu`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Release coordination note (QQQ Assistant coordinator): this PR targets Next 1.0.0-RC.15, not RC.14 (#64 is a narrow urgent-fix candidate). It is green and ready to merge whenever the RC.15 owner sequences it, alongside #61 (record developer view, which renders the assistant's developer panel from qbit-assistant via qqq#1076).
The panel is inert until the backend announces itself: Next only probes /qqq/v1/assistant/config when /metaData carries supplementalInstanceMetaData.assistant, which needs QQQ 4.1.0-RC.4+ (qqq#1078, javalin allow-list entry) and qbit-assistant installed and enabled. Apps without the qbit see no change and no extra requests.
Release coordination note (QQQ Assistant coordinator): this PR targets **Next 1.0.0-RC.15**, not RC.14 (#64 is a narrow urgent-fix candidate). It is green and ready to merge whenever the RC.15 owner sequences it, alongside #61 (record developer view, which renders the assistant's developer panel from qbit-assistant via qqq#1076).
The panel is inert until the backend announces itself: Next only probes `/qqq/v1/assistant/config` when `/metaData` carries `supplementalInstanceMetaData.assistant`, which needs QQQ 4.1.0-RC.4+ (qqq#1078, javalin allow-list entry) and qbit-assistant installed and enabled. Apps without the qbit see no change and no extra requests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
The Next UI side of the QQQ Assistant (
qbit-assistant), Phase 1: read-only questions and navigation. Spec: QRun-IO/qqqdocs/specs/2026-10-11-qqq-assistant-qbit-design.md(§5, §2.6, §3.5). Shipped in Next core and gated by the/qqq/v1/assistant/configprobe (owner decision D5). The probe is made only when instance metadata announces the qbit (supplementalInstanceMetaData.assistant, the spec's recommended metadata signal). An application without the qbit therefore makes no assistant request at all: no 404s, no console errors. Without the backend nothing renders and Ctrl/⌘+J is inert, so this is safe to merge before the qbit exists.What's added
src/lib/api/assistant.ts: a typed client for every contract route. Turns stream overfetch(withcredentials: 'include'), read throughresponse.body, and are parsed by an incremental SSE parser (sse-parser.ts). The parser handles chunk boundaries anywhere (including CR/LF splits), multi-line data,id/event/retry, comments and a BOM. Events are deduplicated byseq. A dropped connection resumes through/turns/{id}/events?after=seq, and Stop callsPOST /turns/{id}/cancel. A 401 from a stream starts the normal sign-in flow (apiClient.notifyUnauthorized()).src/lib/api/assistant-probe.ts:getAssistantConfig()follows the ESBgetOrNullpattern. It is called only whensupplementalInstanceMetaData.assistantis present. A 403, a 404 or a 2xx body that isn't a config (the root-SPA not-found page) returnsnulland hides everything. Kept separate so the shell doesn't load the streaming client.src/components/assistant/*:(dashboard)/layout.tsxand provides: open state, active conversation, unread count andopenWith(prompt).complementarylandmark. It is resizable from 360 to 640 px by pointer or keyboard, and the width is kept in localStorage. Its message list scrolls on its own.visualViewport, with a sticky composer. It is full width on phones and a 30rem side sheet on tablets.aria-liveannouncements throttled to whole sentences.RecordHoverCard, records tables (cards when the panel is narrow) with "Open in Query screen", record, count and history renders, and chart/statistics widgets throughWidgetRenderer. Thumbs up/down feedback.PART_RENDERERSregistry, so Phase 2 approval cards slot in by adding an entry. Unknown part and event types are skipped.react-markdown+rehype-sanitize): raw HTML is skipped, images never load, and only same-origin/apppaths become links. Other URLs are plain text with a copy button.ui.navigateroutes in place withrouter.push, and the panel stays open.?assistant=open&assistantPrompt=<text>(contract §5.3, for the record Developer screen): opens a new conversation seeded with the page context, pre-fills the composer without sending, and removes both parameters withrouter.replace. Inert when the probe returns null orenabled: false.Layout rules
<main>. The document never scrolls and the page never scrolls sideways (checked by Playwright).min-w-0. It composes with #54 (relativeshell/main).Bundle (static export,
pnpm perf:budget)/app/_335.5 → 337.2 KB,/app/_/_/dev355.0 → 356.7 KB, both under their caps).lazyChunks.assistant(55 KB), so total JS is 1028.3 KB against the 1050 KB budget.cssGzipKBis raised to 22, with the reason recorded inperf-budget.json.Test plan
pnpm typecheck,pnpm lint(0 errors; the 5 warnings are pre-existing, in untouched files)sse-parser.test.ts: every split point, one character at a time, CRLF/CR, multi-line data, ids, retry, BOM, comments, unterminated events, split unicode.assistant.test.ts: probe cases, all routes, streaming order, pings, 401/429/non-JSON errors, resume with replay dedupe, retry exhaustion, gone turn, aborts, cancelled end.assistant-links.test.ts: same-origin/appallowlist and attack strings, page context, deep link.message-reducer.test.ts.AssistantPanel.test.tsx(MSW streams): gating on 404/403/disabled, unread badge, streamed text + tool step + record chip + navigation + jest-axe, cancel through the endpoint, refused turn, dismissed context, markdown safety, and the deep link (pre-filled, parameters stripped, not sent; inert without the probe).main(tests/standalone/acceptance-stage.test.ts,tests/acceptance/support/standalone-variant.test.ts). Under full-suite load locally, two existing record-page tests insrc/app/(dashboard)/app/[slug]/[recordId]/page.test.tsxand oneWidgetRenderertest can time out at 5 s; they pass when run alone.QQQ_E2E_SERVER=production), newtests/e2e/assistant.spec.tswith mocked/assistantroutes:ui.navigatechanges the route and the panel stays open.pnpm build-storybook(newAssistant/AssistantMessageViewstories)Contract notes
These are also recorded in the coordinator's contract file under "Deviations (next)":
md). Below 1024 the 256 px sidebar plus a 360 px panel would leave the page unreadable, which breaks the owner's "readable at all widths" rule. 768-1023 px uses a side sheet instead.DataCell/FieldValueneed field metadata and raw values, which therecordsrender payload doesn't carry.CommandMenu.🤖 Generated with Claude Code
Release coordination note (QQQ Assistant coordinator): this PR targets Next 1.0.0-RC.15, not RC.14 (#64 is a narrow urgent-fix candidate). It is green and ready to merge whenever the RC.15 owner sequences it, alongside #61 (record developer view, which renders the assistant's developer panel from qbit-assistant via qqq#1076).
The panel is inert until the backend announces itself: Next only probes
/qqq/v1/assistant/configwhen/metaDatacarriessupplementalInstanceMetaData.assistant, which needs QQQ 4.1.0-RC.4+ (qqq#1078, javalin allow-list entry) and qbit-assistant installed and enabled. Apps without the qbit see no change and no extra requests.🤖 Generated with Claude Code