Compare commits

...
Author SHA1 Message Date
dependabot[bot] 90d0f93f75 build(deps): Bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [QRun-IO/qqq-orb/.github/workflows/next-ci.yml](https://github.com/qrun-io/qqq-orb), [QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml](https://github.com/qrun-io/qqq-orb) and [QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml](https://github.com/qrun-io/qqq-orb).


Updates `QRun-IO/qqq-orb/.github/workflows/next-ci.yml` from 0f33ddadf88b9a1b940b8883782fe2000ee1d2bd to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/0f33ddadf88b9a1b940b8883782fe2000ee1d2bd...1c389210e6dfda1630d053c07ae4dae523dd5576)

Updates `QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml` from 0f33ddadf88b9a1b940b8883782fe2000ee1d2bd to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/0f33ddadf88b9a1b940b8883782fe2000ee1d2bd...1c389210e6dfda1630d053c07ae4dae523dd5576)

Updates `QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml` from 0f33ddadf88b9a1b940b8883782fe2000ee1d2bd to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/0f33ddadf88b9a1b940b8883782fe2000ee1d2bd...1c389210e6dfda1630d053c07ae4dae523dd5576)

---
updated-dependencies:
- dependency-name: QRun-IO/qqq-orb/.github/workflows/next-ci.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-28 05:18:16 +00:00
james.maesandCodex 9756b9d93b ci: adopt managed orb 0.8 patch track (#193)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 16:20:56 -05:00
james.maesandCodex f205992878 ci: adopt shared orb 0.7.17 (#192)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:37:37 -05:00
james.maesandCodex 5f503fa7e9 test: disambiguate async help from process search results (#191)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 09:15:38 -05:00
james.maesandCodex 09828ab1cc ci: adopt central orb 0.7.12 and stable gates (#190)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 03:49:28 -05:00
james.maesandCodex c39748b1a0 test: stabilize widget layout and report reload acceptance (#189)
* test: assert widget layouts at container breakpoints

Co-Authored-By: Codex <noreply@openai.com>

* test: wait for saved report readiness before reloading

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 03:08:45 -05:00
james.maesandCodex 829ef6b384 ci: align canonical shared orb workers (#188)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:49:08 -05:00
james.maesandCodex a94e1e39c8 ci: adopt shared orb 0.7.11 (#187)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:12:27 -05:00
james.maesandCodex 9bea8cc63f ci: centralize workflows in QQQ orb (#185)
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 20:04:31 -05:00
james.maes ada9946eea Merge pull request #179 from QRun-IO/feature/assistant-conversation-list
feat(assistant): conversation row actions and thread state indicators
2026-10-19 08:16:52 -05:00
james.maesandCodex fa960580f6 fix(assistant): integrate attachment and conversation recovery
Preserve both configuration normalizers and restore the loading status accessible name required by logout acceptance.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 07:45:41 -05:00
james.maes 596bee88cc Merge pull request #178 from QRun-IO/feature/security-findings-2026-10
fix(security): harden plain-text sanitizers and pin source-map-js
2026-10-19 07:41:18 -05:00
james.maes df764362b8 Merge pull request #184 from QRun-IO/fix/assistant-attachment-recovery
fix(assistant): contain upload failures and isolate conversation files
2026-10-19 07:38:03 -05:00
james.maesandCodex 07b4a973de fix(assistant): isolate conversation action rollback
Co-Authored-By: Codex <noreply@openai.com>
2026-10-19 07:11:40 -05:00
james.maes e36afde222 Merge pull request #183 from QRun-IO/fix/startup-branding
fix: show the dashboard only after application branding is ready
2026-10-19 07:11:37 -05:00
james.maesandCodex 69836fc62f fix(security): parse help and widget text with the shared sanitizer
Co-Authored-By: Codex <noreply@openai.com>
2026-10-19 07:07:22 -05:00
james.maes 3cb3659a41 Merge branch 'main' into feature/assistant-conversation-list 2026-10-19 07:06:27 -05:00
james.maes b30ff4e916 Merge remote-tracking branch 'origin/feature/security-findings-2026-10' into fix/source-map-security 2026-10-19 07:03:16 -05:00
james.maesandCodex d558c1378d fix(assistant): contain upload failures and isolate conversation files
Co-Authored-By: Codex <noreply@openai.com>
2026-10-19 07:01:25 -05:00
james.maes f1c5f09b4a Merge pull request #182 from QRun-IO/feature/audit-fallback-fixture
test: exercise the audit table fallback with independent permissions
2026-10-19 06:55:22 -05:00
james.maesandCodex c9a56b0d9f fix: wait for application branding before painting the dashboard
Co-authored-by: Codex <noreply@openai.com>
2026-10-19 06:48:51 -05:00
james.maesandCodex 3bab5596ab test: exercise audit fallback with independent process permission
Co-authored-by: Codex <noreply@openai.com>
2026-10-19 06:29:02 -05:00
james.maesandClaude Opus 5.5 ae2498868d feat(assistant): conversation row actions, thread state indicators, unread correctness
Rail rows are now a list of buttons with Archive/Restore and Delete (confirm,
optimistic with rollback and toast), a Manage all conversations link to the
conversations table when the server names one, a spinner for running threads,
an unread dot and bold title, and a dot on the History toggle for activity in
other conversations. A reply that finishes in the open conversation is marked
read (PATCH read:true). Fields are optional so older servers keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:40:18 -05:00
james.maesandClaude Opus 5.5 ea21505e96 fix(security): harden plain-text sanitizers and pin source-map-js
- Strip HTML tags until stable and decode &amp; last in helpPlainText and
  widget plainText, so nested or split tags cannot survive.
- Match closing script tags with attributes or whitespace in the CSP
  inline-script hasher.
- Replace every newline in the child-record export aria-label.
- Override source-map-js to >=1.2.2 (GHSA advisory, dev dependency).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:36:24 -05:00
james.maes 1b37e5efa9 Merge pull request #176 from QRun-IO/feature/next-rc36-prep
chore(#712): prepare Next UI 1.0.0-RC.36
2026-10-16 10:39:55 -05:00
james.maesandClaude Opus 5.5 ba20307fa0 chore(#712): prepare Next UI 1.0.0-RC.36
PR175: accessibility fixes (Dashboard Wave 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:22:41 -05:00
james.maes 81a03eab2d Merge pull request #175 from QRun-IO/feature/home-a11y-contrast
fix(a11y): readable link text, 24px clear target, h2 widget titles, skip link focus
2026-10-16 10:19:17 -05:00
james.maesandClaude Sonnet 5.5 ab9cf837df fix(a11y): readable text on accent-filled surfaces
White text on a light brand accent (for example #059669, 3.8:1) fails
contrast on the active sidebar item, selected tabs and primary buttons.
applyBrandingTheme now sets --color-primary-foreground to white when that
reaches 4.5:1 on the accent and black otherwise, so the brand fill is kept.
The axe spec no longer excludes the sidebar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:24:32 -05:00
james.maesandClaude Sonnet 5.5 5c9307015c fix(a11y): readable link text, 24px clear target, h2 widget titles, skip link focus
App homes failed axe "serious" checks on 18 kof22 pages (A10/L10 = 0):

- Accent-colored text (the table widget link row, cell links, widget links)
  used text-primary, which fails 4.5:1 for light brand colors such as #059669.
  Add a --color-link token (text-link utility) derived per theme from the
  accent: applyBrandingTheme darkens (light theme) or lightens (dark theme)
  the accent until it reaches 4.5:1 on the card and muted surfaces; without
  branding the stylesheet derives it from --color-primary.
- The widget dropdown icon buttons (Clear range, previous/next) are at least
  24 x 24 px (WCAG 2.2 target size).
- Widget titles on an app home render as h2 under the page h1 (a nested
  widget stays h3); no visual change.
- main is focusable (tabIndex -1) and the skip link moves focus to the first
  widget of the page, else to main.

Adds unit tests and a Playwright axe spec (light and dark, 390 and 1440 px).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:20:21 -05:00
james.maes 55c150136b Merge pull request #174 from QRun-IO/feature/next-rc35-prep
chore(#712): prepare Next UI 1.0.0-RC.35
2026-10-15 22:13:38 -05:00
james.maesandClaude Opus 5.5 d644995c85 docs(release): RC35 notes state only the verified requirement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:04:28 -05:00
james.maesandClaude Opus 5.5 6f95554362 chore(#712): prepare Next UI 1.0.0-RC.35
PR164: profile photo uploader and avatars everywhere (UI API 1.8.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:04:15 -05:00
james.maes ba38367244 Merge pull request #164 from QRun-IO/feature/user-profile-avatars
feat(profile): profile photo uploader and avatars everywhere (UI API 1.8.0)
2026-10-15 22:03:46 -05:00
james.maes c623b3bc87 Merge remote-tracking branch 'origin/main' into feature/user-profile-avatars 2026-10-15 21:32:25 -05:00
james.maes 17f605261a Merge pull request #173 from QRun-IO/feature/next-rc34-prep
chore(#712): prepare Next UI 1.0.0-RC.34
2026-10-15 21:17:33 -05:00
james.maesandClaude Opus 5.5 0db8ab0bfa chore(#712): prepare Next UI 1.0.0-RC.34
PR172: fail soft (error boundaries, validated assistant payloads, unstuck streams).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:10:37 -05:00
james.maes 68a2b9de0c Merge pull request #172 from QRun-IO/feature/fail-soft-boundaries
feat: fail soft — layered error boundaries, validated assistant payloads, unstuck streams
2026-10-15 21:10:07 -05:00
james.maesandClaude Opus 5.5 af42c7613f chore(openapi): vendor the assistant UI API extension 1.5.0 (qbit-assistant#60)
Re-vendors extensions/assistant.json from qbit-assistant#60's assistant.yaml:
POST /assistant/client-errors (AssistantClientErrorReportV1) replaces the
provisional entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:46:00 -05:00
james.maesandClaude Opus 5.5 f5170ed130 Merge origin/main (IA phase 1): the avatar moves to the header AccountMenu
The user menu moved from the sidebar footer to the header AccountMenu
(#168). Take main's Sidebar; AccountMenu now draws UserAvatar from the
profile (photo, chosen Gravatar via the server, else initials) with the
profile's display name, and no longer builds Gravatar URLs from branding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:38:02 -05:00
james.maesandClaude Opus 5.5 77fc832f9e test(e2e): a crashing assistant result and a crashing widget leave the page usable
Injects a real render-time crash (no app test hook): an init script makes
Number.prototype.toLocaleString throw for one sentinel value, which the
count render and the table widget format. Asserts the failure stays in its
part/widget with details and Retry, the rest of the reply/dashboard and the
shell keep working, records renders without rows/columns and unknown render
types still show, and the client error report reaches the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:34:56 -05:00
james.maesandClaude Opus 5.5 627ad7ae34 feat(assistant): fail soft at panel, message, part and render level
- The panel sits behind its own boundary: a crash shows "The assistant
  hit a problem" with Retry, Copy details, Report a bug (QA capture) and
  Close, while the page keeps working; reopening retries.
- Each message, part and tool render has its own boundary with a small
  inline "couldn't be displayed" note and a details toggle; unknown parts
  and renders show as raw JSON.
- Failed replies offer Retry (asks again); running replies offer Stop
  inline. A second Stop, or a failed stop request, stops listening at once
  and keeps the reply shown as stopped, so the input is never locked.
  A reply left running with no live connection can be stopped too.
- A stream event the reducer cannot apply is skipped and reported instead
  of breaking the stream.
- Failures are reported (console with request id; POST
  /assistant/client-errors when the assistant extension is 1.5.0+, so they
  show in Admin). Vendors the operation into the assistant extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:26:58 -05:00
james.maesandClaude Opus 5.5 03f67def30 feat(assistant): validate payloads at the API boundary; streams never hang
- lib/api/assistant-normalize: zod validation of conversation messages,
  parts, renders, presentation blocks and stream events against the
  /qqq/v1 assistant shapes. Missing lists become [], missing text '',
  bad list items are dropped, and unknown part or render types become
  `unknown` (shown as raw JSON) instead of throwing.
- getAssistantConversation and the SSE reader apply it, so a stored or
  streamed malformed payload can no longer reach a renderer.
- A connection that delivers nothing (not even the 15 s ping) for 45 s is
  treated as lost and resumed; refused turns carry their X-Request-Id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:26:57 -05:00
james.maesandClaude Opus 5.5 cc64297d83 fix(avatars): read the profile from the preferences index (no 404 without profiles)
The real-backend acceptance apps advertise the avatars feature but install
no profile section, so GET /preferences/profile answered 404 on every page.
useMyProfile now finds the profile section in the shared preferences index
(which saving any section already invalidates); nothing is requested that
can 404, and other users' avatars are only looked up when profiles exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:15:42 -05:00
james.maesandClaude Opus 5.5 1d7c1a5ebf feat(errors): layered fail-soft boundaries with PII-safe client error reports
- lib/errors/client-errors: one reporter for every boundary; logs with the
  X-Request-Id correlation id, redacts and bounds text, dedupes, caps per
  session, and sends to a registered sink when one exists.
- ErrorBoundary gains scope reporting, resetKeys and fallbackRender
  (backward compatible); InlineFailure is the small in-place fallback with
  a details toggle.
- (dashboard)/error.tsx: a page that throws is replaced inside the app
  shell, so navigation, header and assistant keep working.
- Every ConnectedWidget, SeededWidget and record section renders behind its
  own boundary, so one failing widget or section cannot take down a page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:13:46 -05:00
james.maes 960538ebc0 Merge pull request #171 from QRun-IO/feature/next-rc33-prep
chore(#712): prepare Next UI 1.0.0-RC.33
2026-10-15 20:02:15 -05:00
james.maesandClaude Opus 5.5 67eca54900 chore(#712): prepare Next UI 1.0.0-RC.33
PR168 (navigation, IA phase 1) and PR170 (assistant empty-results crash fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:56:17 -05:00
james.maes b4b59cee6e Merge pull request #170 from QRun-IO/fix/assistant-empty-records
fix(assistant): empty query results no longer crash the page
2026-10-15 19:55:53 -05:00
james.maes 28fdb2a781 Merge pull request #168 from QRun-IO/feature/ia-phase1-nav
feat(nav): sidebar sections and favorites, one search, header Search/Ask/Jobs/Notifications/Help/Account, single-key shortcut switch
2026-10-15 19:48:09 -05:00
james.maes 524adff0d5 Merge remote-tracking branch 'origin/main' into feature/user-profile-avatars
# Conflicts:
#	docs/releases/unreleased.md
#	src/lib/api/openapi/qqq-v1.json
2026-10-15 19:42:02 -05:00
james.maesandClaude Opus 5.5 893bbf26df fix(assistant): empty query results no longer crash the page
A records render with no rows or columns (servers omit empty lists when a
query matches nothing) threw "Cannot read properties of undefined
(reading 'length')" and took down the whole page. Renders now treat
missing lists as empty and show "no rows match", and each reply part
renders inside an error boundary, so one malformed part cannot crash the
reply or the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:30:51 -05:00
james.maesandClaude Opus 5.5 1df522097f test(acceptance): NAV-031 checks that no record is listed (the search box always offers help and Search everything)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:25:24 -05:00
james.maesandClaude Opus 5.5 f5a56eaf7e test(keyboard): name the query client wrapper (react/display-name)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:00:10 -05:00
james.maesandClaude Opus 5.5 9b475797a0 chore(avatars): UI API 1.8.0 (QQQ 4.1.0-RC.17); re-vendor the 1.8.0 document
QQQ 4.1.0-RC.16 shipped UI API 1.7.0 without profiles; they are 1.8.0 in
RC.17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:59:27 -05:00
james.maesandClaude Opus 5.5 5283503e89 fix(keyboard): read the Keyboard section from the preferences index (no 404 on servers without it); NAV-032 on the search box
A server without the Keyboard preference section answered the per-page
GET /preferences/keyboard with 404, which acceptance rightly counts as a
failed request. The setting now comes from the preferences index (shared
with the account menu, skipped without the preferences feature), so no
request fails; the switch saves there and updates both caches.

NAV-032: the search box always offers Search everything once text is
typed, so the spec checks that and that no record is listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:48:41 -05:00
james.maesandClaude Opus 5.5 bb9e083539 chore(perf): drop the quickSearchPanel lazy-chunk budget (the inline header search and / dialog are retired)
Record results render in the search box (CommandRecordResults), which
is already in the palette's lazy chunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:36:33 -05:00
james.maesandClaude Opus 5.5 d5003ebdd5 Merge main (RC.32: What's new) into feature/ia-phase1-nav; What's new moves to the Help menu
The release-notes What's new item (#165) moves from the retired sidebar
user menu into the header Help menu, with the same extension probe,
path and unread summary; its tests move with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:34:26 -05:00
james.maes 9a63173efe Merge pull request #167 from QRun-IO/feature/next-rc32-prep
chore(#712): prepare Next UI 1.0.0-RC.32
2026-10-15 17:04:55 -05:00
james.maesandClaude Opus 5.5 93e0fd7303 chore(#712): prepare Next UI 1.0.0-RC.32
PR165: What's new page and user-menu entry (release-notes UI API 1.0.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:58:59 -05:00
james.maes b24b74e30d Merge pull request #165 from QRun-IO/feature/whats-new
feat(release-notes): What's new page and user-menu entry (release-notes UI API 1.0.0)
2026-10-15 16:58:08 -05:00
james.maesandClaude Opus 5.5 ea028dc86f docs(releases): avatars via lookup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:25:21 -05:00
james.maesandClaude Opus 5.5 3e5b097667 fix(avatars): opaque avatar URLs via batched POST /avatars/lookup
Follows the core security review (qqq#1238): other people's avatars come
from one POST /avatars/lookup per tick (user ids never in a URL), answers
are kept for the session, and every image URL is GET /avatars/{avatarId}.
Re-vendor the 1.7.0 document (lookupAvatars, opaque avatarId); e2e mocks
the lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:25:14 -05:00
james.maes 98229202f3 Merge remote-tracking branch 'origin/main' into feature/whats-new
# Conflicts:
#	docs/releases/unreleased.md
2026-10-15 16:19:09 -05:00
james.maesandClaude Opus 5.5 9df3324687 fix(nav): menu actions run after focus returns; drawer search; palette active option; specs for the new header
- HeaderMenu close(then): a chosen item's action (search help, keyboard
  shortcuts, Ask, sign out) runs once focus is back on the menu button,
  so the dialog it opens returns focus there.
- The drawer's search field opens search after the drawer has closed
  and focus is back on the menu button.
- The search box input names the highlighted option
  (aria-activedescendant), which cmdk does not track for a controlled
  value.
- e2e and acceptance specs move to the one search box, the Help and
  account menus, sidebar favorites, the 1024 px drawer breakpoint,
  Shift+D and the single-key switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:14:58 -05:00
james.maes ae6649f8e1 Merge pull request #166 from QRun-IO/feature/next-rc31-prep
chore(#712): prepare Next UI 1.0.0-RC.31
2026-10-15 16:10:23 -05:00
james.maesandClaude Opus 5.5 a504a21718 chore(#712): prepare Next UI 1.0.0-RC.31
PR162: adopt QQQ UI API 1.6.0. Requires QQQ 4.1.0-RC.15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:12 -05:00
james.maes 9aff198a60 Merge pull request #162 from QRun-IO/feature/ui-api-1.6
feat(ui-api): adopt QQQ UI API 1.6.0 - clientCapabilities, field validation, error schema, v1 branding
2026-10-15 15:49:54 -05:00
james.maesandClaude Opus 5.5 2d125e4f90 feat(nav): one search, header Search/Ask/Jobs/Notifications/Help/Account, sidebar sections and favorites
The IA review of 2026-10-15 (Phase 1), rendered generically from
metadata (no application names):

- Sidebar sections from app section roles (UI API 1.7.0, inferred for
  older servers): Primary without a heading, History and group headings
  as role=group labels, Settings collapsed with a count. A single-child
  app is one leaf. A section name used as a path (an old group app URL)
  redirects to the app that heads it.
- Sidebar Favorites (collaboration): collapsed, up to five.
- One search box: Cmd/Ctrl+K, /, the header field and the drawer field
  open the palette (pages with search aliases, records, actions, help,
  Ask). The inline header search and the / dialog are retired.
- Header, from general to personal: Search, Ask (labelled from 1440 px),
  Jobs (always shown, idle or with a count), Notifications (severity in
  the name), Help, Account avatar. 40 px targets, 44 px on touch.
  Compact screens fold Help and Ask into the avatar menu.
- Help opens a Help menu (WCAG 3.2.6): search help, keyboard shortcuts,
  Report a bug. The user menu moves from the sidebar footer to the avatar.
- The persistent sidebar starts at 1024 px; below that it is a drawer.
- Single-key shortcuts can be turned off (WCAG 2.1.4), via the core
  Keyboard preference section or this browser. Record delete moves
  from d to Shift+D (still confirmed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:32:53 -05:00
james.maesandClaude Opus 5.5 ec974fa121 fix(processes): keep the 403 permission refusal message; carry its traceId
A refused step (HTTP 403) again becomes the user-facing 'You do not have
permission to run this process.' even when the 1.6.0 body has a message
(PRC-042); the body's traceId is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:26:18 -05:00
james.maesandClaude Opus 5.5 4f1641cb72 fix(auth): abort and discard the old session's requests at logout
A /qqq/v1/preferences response landing after the logout response set the
sessionId cookie again (SEC-037, SEC-048). Logout now ends the API
client's session generation before calling /logout: requests in flight
are aborted through a session AbortController (also passed to the
realtime and assistant fetch streams), and any response of the ended
generation is discarded as a cancellation, so it can neither set the
cookie nor trigger the 401 flow. Cancelled requests raise no error toast.
Replaces the earlier wait-for-idle approach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:02:41 -05:00
james.maesandClaude Opus 5.5 154be4f827 feat(release-notes): What's new page and user-menu entry (release-notes UI API 1.0.0)
Vendored release-notes extension contract; hand-written client for releases,
summary and seen; /app/whats-new lists published releases newest first,
grouped by category, with New badges, deep link highlight (?release=id),
paging and safe links/screenshots, and marks them seen once loaded. The user
menu shows What's new with an unread count when the extension is installed.
The bell needs no change: release notifications link to /app/whats-new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:59:25 -05:00
james.maesandClaude Opus 5.5 04fc4d7692 fix(ui-api): keep legacy behaviour where 1.6.0 changed it; settle requests before logout
- error text keeps userFacingError, error, then message; process errors are
  user-facing only from userFacingError
- the full materialDashboardTheme wins over the uiSettings.theme subset
- uiSettings are read setting by setting, falling back to materialDashboard
- audits fall back to instance metadata when the table capability is
  unavailable (RC.15 marks unaudited tables unavailable)
- global error toasts unchanged from main (no trace description)
- logout waits for in-flight queries and mutations so a late response
  cannot refresh the expired sessionId cookie
- acceptance specs accept the 1.6.0 error and icon fields, and use a
  server-only uniqueness error now that exclusive bounds are published

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:54:30 -05:00
james.maesandClaude Opus 5.5 81fa126305 test(profile): photo uploader unit and e2e coverage, full-row image fields
- ImageUploadField and UserAvatar unit tests (crop, upload, save ref,
  remove, SVG refused, a11y; initials, misses, sizes).
- tests/e2e/profile-avatars.spec.ts: upload flow against mocks and the
  6-width light/dark screenshot matrix (SCREENSHOTS_DIR).
- Image fields span the whole form row so they never squeeze at 768.
- Release notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:39:21 -05:00
james.maes 1fc984a3c6 Merge remote-tracking branch 'origin/main' into feature/ui-api-1.6
# Conflicts:
#	src/lib/query-client.ts
2026-10-15 14:39:05 -05:00
james.maesandClaude Opus 5.5 a5fb8dd8d5 feat(profile): avatars everywhere and a photo uploader on the Profile preferences
- Profile (core 'profile' section, UI API 1.7.0) renders generically; any
  FILE_UPLOAD field with valueIsUploadRef gets ImageUploadField: drag and
  drop or choose, square crop with zoom (pointer and keyboard), preview,
  remove; the crop goes to POST /uploads and the ref is saved with the form.
- One UserAvatar (photo via GET /avatars/{userId}, else initials; misses
  remembered for the session) for the user menu, presence, comments,
  mentions, people picker, activity and the assistant's user messages.
- useMyProfile reads the profile section through the shared query, so a
  save updates the header at once.
- Privacy: branding gravatarDefault no longer sends the user's email hash
  to Gravatar; a Gravatar shows only when the user chose it (server side).
- Vendor the UI API 1.7.0 core document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:24 -05:00
james.maes 692d0cb166 Merge pull request #161 from QRun-IO/feature/next-rc30-prep
chore(#712): prepare Next UI 1.0.0-RC.30
2026-10-15 13:22:52 -05:00
james.maes e9208fc34c Merge branch 'main' into feature/next-rc30-prep 2026-10-15 13:14:31 -05:00
james.maes 2cbfb6d962 Merge pull request #163 from QRun-IO/feature/qa-capture-spec-sync
chore(qa-capture): re-vendor qa-capture.json from qbit-qa-capture 0.1.0-RC.1
2026-10-15 13:14:02 -05:00
james.maesandClaude Opus 5.5 b2a41b3eb9 chore(qa-capture): re-vendor qa-capture.json from qbit-qa-capture 0.1.0-RC.1
Replaces the hand-written document with the QBit's generated
openapi/qqq-v1/qa-capture.yaml. Same paths, operations and fields; the
document now has the QBit's schema names (QaCaptureReportResultV1),
descriptions and nullable markers. OFF_SPEC_CEILING stays 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:42:26 -05:00
james.maes 35d937ed30 Merge pull request #160 from QRun-IO/feature/qa-capture
feat(qa-capture): optional Jam QA capture on the qa-capture UI API extension
2026-10-15 12:41:30 -05:00
james.maesandClaude Opus 5.5 a00473c305 chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:09 -05:00
james.maes e76f0325a5 Merge branch 'feature/qa-capture' into feature/next-rc30-prep 2026-10-15 12:15:46 -05:00
james.maesandClaude Opus 5.5 de1d4a5295 test(standalone): include the qa backend prefix in staged-build fixtures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:15:44 -05:00
james.maesandClaude Opus 5.5 42ee6b87ce feat(ui-api): adopt QQQ UI API 1.6.0 (QQQ 4.1.0-RC.14)
- vendor core 1.6.0 and the extension documents (alerts 1.2.0, assistant
  1.3.0, collaboration 1.1.0, data-analysis 1.5.0, edi 1.1.0, esb 1.1.0,
  quick-search 1.1.0, secured-data 1.1.0, sql-workbench 1.1.0)
- saved views, audits, sharing and bulk processes come from the table's
  clientCapabilities (fallback to the old names only for pre-1.6.0 servers)
- record forms use field.validation (required, maxLength/tooLongBehavior,
  inclusive/exclusive min/max, pattern + patternMessage)
- errors prefer BasicErrorResponseV1.message and show the traceId
- standalone branding uses GET /qqq/v1/branding/{role} (falls back on 404)
- uiSettings preferred over materialDashboard(Theme)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:09:20 -05:00
james.maesandClaude Opus 5.5 a73e4e245c chore(#712): prepare Next UI 1.0.0-RC.30
PR160: optional Jam QA capture on the qbit-qa-capture UI API extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:04:31 -05:00
james.maesandClaude Opus 5.5 e0ca5b1bb4 perf(qa-capture): load the sanitizers with the Jam chunk
The page URL filter moves to page-url.ts; sanitizers.ts is now imported only
by the dynamic Jam import, so users without capture never download it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:02:59 -05:00
james.maesandClaude Opus 5.5 c9773cf45f docs(qa-capture): QA capture guide and acceptance matrix row SEC-050
SEC-050 is recorded as excluded (pending owner approval) until
qbit-qa-capture ships a real-backend fixture; Vitest covers it meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:00:51 -05:00
james.maesandClaude Opus 5.5 15cdffe7f3 feat(standalone): opt-in Jam CSP origins and forward /qa to the backend
QQQ_QA_CAPTURE_CSP=true adds js.jam.dev to script-src and *.jam.dev to
frame-src and connect-src. /qa/recorder (the qbit's Jam domain verification
page) is a backend route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:51 -05:00
james.maesandClaude Opus 5.5 41462cc4f6 feat(qa-capture): mark secure field values for Jam blur
Password, REVEAL and secured-data masked/revealable values and inputs carry
data-jam-blur and data-qqq-secure (inert when capture is off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:50 -05:00
james.maesandClaude Opus 5.5 039fa56a0b feat(qa-capture): load Jam for enabled users and add Report a bug
The root providers load the capability once after sign-in when /version lists
qa-capture. Only an enabled capability dynamic-imports the Jam SDKs, adds the
jam:team/jam:blur meta, initializes Jam once with the sanitizers and registers
jam.metadata (route context, versions, backend context, request ids). The user
menu gains Report a bug (POST report, then Recorder.open with jam-title and
jam-reference) and QA reports; the header shows QA mode on; saving the QA mode
preference section refetches the capability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:48 -05:00
james.maesandClaude Opus 5.5 f93a1d3c8f feat(qa-capture): keep Jam Recording Link parameters across sign-in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:38 -05:00
james.maesandClaude Opus 5.5 600af361f4 feat(qa-capture): keep the last 50 request ids while capture is enabled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:37 -05:00
james.maesandClaude Opus 5.5 0969231c95 feat(qa-capture): build Jam sanitizers from the server's rules
Header removal, JSON (any depth, case-insensitive) and form field redaction,
query parameter redaction, value patterns, same-origin body drops, plus a
credential/JWT baseline. Pure and synchronous; an invalid pattern fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:36 -05:00
james.maesandClaude Opus 5.5 99170dbff8 feat(qa-capture): vendor the qa-capture UI API extension and its client
Hand-written per the shared qa-capture 1.0.0 contract (qaCaptureCapability,
qaCaptureReportCreate) until qbit-qa-capture publishes its generated fragment.
The capability is normalized so anything incomplete reads as disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:35 -05:00
james.maesandClaude Opus 5.5 74cf6e88d7 build(deps): add Jam recording-links 0.4.0 and sdk 1.0.1 for QA capture
Pinned exactly. Electron (an optional peer of the recording-links electron
entry point) is ignored so installs stay small.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:41:11 -05:00
james.maes 0f6c37c998 Merge pull request #159 from QRun-IO/feature/next-rc29-prep
chore(#712): prepare Next UI 1.0.0-RC.29
2026-10-15 05:14:29 -05:00
james.maesandClaude Opus 5.5 4306da9cdd chore(#712): prepare Next UI 1.0.0-RC.29
PR136: EDI mapper page on the qbit-edi UI API extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 03:51:02 -05:00
james.maes abfbbb2e55 Merge pull request #136 from QRun-IO/feature/edi-mapper
feat(edi): EDI mapper page on the qbit-edi UI API extension
2026-10-15 03:50:37 -05:00
james.maesandClaude Opus 5.5 4139316789 test(edi): mock a UI API 1.4.0 server in the mapper e2e
Next main requires UI API 1.4.0 or later (sign-in refuses older servers),
so the mapper spec's version mock moves from 1.1.0 to 1.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 03:25:44 -05:00
james.maesandClaude Opus 5.5 aa0bc65774 Merge origin/main into feature/edi-mapper
Resolves src/lib/query-client.ts by keeping main's jobs, secured-data and
preference keys alongside the EDI mapper keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 03:25:42 -05:00
james.maes 86b54181f7 Merge pull request #158 from QRun-IO/feature/next-rc28-prep
chore(#712): prepare Next UI 1.0.0-RC.28
2026-10-15 01:33:47 -05:00
james.maesandClaude Opus 5.5 b32141cf65 chore(#712): prepare Next UI 1.0.0-RC.28
PR141: zero off-spec UI API calls (minimum UI API 1.4.0). Requires QQQ
4.1.0-RC.9 (RC.8's /table/{t}/apis was shadowed, qqq#1216).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:59:19 -05:00
james.maes 16699aedfa Merge pull request #141 from QRun-IO/feature/ui-api-zero-off-spec
feat(ui-api): zero off-spec calls (OFF_SPEC_CEILING 0, UI API >= 1.4.0)
2026-10-15 00:58:51 -05:00
james.maesandClaude Opus 5.5 e0ff899cb6 fix(developer): table spec URL ends in json so RapiDoc offers "View OpenAPI spec"
RapiDoc shows the View button only for a spec URL ending in "json"; the
v1 document operation is /openapi, so add ?format=json (ignored by the
server). Fixes acceptance REC-053.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:37:25 -05:00
james.maesandClaude Opus 5.5 e807d30209 test(acceptance): ESB-003 allows the refused table-APIs lookup
Without person READ, the table developer view's GET /table/person/apis
is refused (403) like the ESB section's request; /apis.json used to
answer 200 for any caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:10:07 -05:00
james.maesandClaude Opus 5.5 11d0f34639 test(e2e): the route mocks answer GET /version as a 1.4.0 server
With SUPPORTED_UI_API.minimum at 1.4.0, a mocked app without a /version
route falls back to the pre-semver 1.0.0 and refuses to load. Specs that
need an extension still register their own /version route on top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:57:08 -05:00
james.maesandClaude Opus 5.5 45928e8abf test(dev): give the associated-script card test room for its three 5 s waits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:14:32 -05:00
james.maesandClaude Opus 5.5 1a381c2a68 Merge origin/main; vendor the RC.8 documents (UI API 1.4.0)
Re-vendor qqq-v1.json and api-aware.json from the v4.1.0-RC.8 tag and
move main's new e2e /version mocks to 1.4.0 (SUPPORTED_UI_API.minimum).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:12:21 -05:00
james.maes ee3682e859 Merge pull request #157 from QRun-IO/feature/next-rc27-prep
chore(#712): prepare Next UI 1.0.0-RC.27
2026-10-14 21:08:42 -05:00
james.maesandClaude Opus 5.5 62ec386035 chore(#712): prepare Next UI 1.0.0-RC.27
PR124 (secured-data UI), PR155 (jobs tray and live job view), PR156.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:19:11 -05:00
james.maes 427a037d53 Merge pull request #155 from QRun-IO/feature/jobs-tray
feat(jobs): live job view, header Jobs tray and Jobs page
2026-10-14 20:18:29 -05:00
james.maes 2381579a52 Merge remote-tracking branch 'origin/main' into feature/jobs-tray
# Conflicts:
#	src/lib/query-client.ts
2026-10-14 19:49:22 -05:00
james.maes 112bbc557a Merge pull request #156 from QRun-IO/fix/large-table-default-columns
test(acceptance): prfWide declares its 40 default columns (PRF-001/002 after #142)
2026-10-14 19:45:22 -05:00
james.maesandClaude Opus 5.5 463e9cc8e9 test(acceptance): the 40-column performance table names all columns as its default
#142 made tables wider than twelve fields open with a short default column
set, so PRF-001/PRF-002 saw 6 of prfWide's 40 columns (and 250 x 6 = 1,500
cells instead of 10,000). The budgets measure the full 40-column grid, so
the fixture now declares every column through the nextUi.defaultColumns
supplemental hint, which is the metadata route #142 added for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:16:16 -05:00
james.maes 20fd1fa3d3 Merge pull request #124 from QRun-IO/feature/secured-data-ui
feat(secured-data): masked values, reveal, restricted filters and break-glass banner
2026-10-14 19:14:52 -05:00
james.maesandClaude Opus 5.5 4f685d200b test(acceptance): PRC-019 and PRF-005 read the live job view
The Working screen is replaced by the live job view, so the progress
assertions use its hooks: job-progress-message, job-progress-stats
(N / M and elapsed) and the Job progress bar. Feature matrix and
Material parity rows updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:14:20 -05:00
james.maes 9fc9cab48f Merge remote-tracking branch 'origin/main' into feature/secured-data-ui
# Conflicts:
#	src/app/(dashboard)/layout.tsx
#	src/components/forms/DynamicFormField.tsx
#	src/components/forms/EntityForm.tsx
#	src/components/query/DataGrid.tsx
#	src/components/query/FilterBuilder.tsx
#	src/lib/query-client.ts
#	src/lib/utils/filter-utils.ts
#	src/types/metadata.ts
2026-10-14 18:48:55 -05:00
james.maesandClaude Opus 5.5 aafeabfb33 fix(jobs): only call the jobs operations when the instance has the jobs tables
durableJobs is listed by every 1.3+ server, but the jobs operations exist
only where the instance defines jobs_job and jobs_log. Gate the tray,
page and inline tracking on both, so older servers and apps without
durable jobs never see a jobs request (acceptance flagged GET /jobs 404).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:47:33 -05:00
james.maes 4663557ca4 Merge pull request #154 from QRun-IO/feature/next-rc26-prep
chore(#712): prepare Next UI 1.0.0-RC.26
2026-10-14 18:08:31 -05:00
james.maesandClaude Opus 5.5 3cf64ce24c chore(#712): prepare Next UI 1.0.0-RC.26
PR142: tables open with a short default column set (audit High, #114).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:59:34 -05:00
james.maesandClaude Opus 5.5 b19d4c09b4 feat(jobs): live job view, header Jobs tray and Jobs page
- Header Jobs button next to the bell: active count badge, a subtle ring
  while jobs run, and a popover listing running jobs (eased mini bars,
  percent, ETA) and recently finished ones; clicking reopens a job
- Live job view replaces Working / Executing Process: phase stepper,
  eased determinate bar (shimmer when indeterminate), percent, counts,
  bytes, ETA, elapsed, message, collapsible live log with copy
- Run in background (POST /jobs/track for inline runs) and auto-detach
  for durableJobAlways processes after 5 s unless the user stays
- /app/jobs (Active, Recent, All for jobs_admin) and /app/jobs/{id}
- Realtime job.progress with since-cursor polling fallback; only
  /qqq/v1 operations from the re-vendored 1.4.0 document
- Motion on transform/opacity only, static under reduced motion;
  light and dark; Vitest, mocked e2e and screenshots at 390 and 1440

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:59:19 -05:00
james.maes caa89dc24f Merge pull request #142 from QRun-IO/feature/default-list-columns
feat(query): open tables with a short default column set
2026-10-14 17:56:20 -05:00
james.maes 5f3b7c697d Merge pull request #153 from QRun-IO/feature/next-rc25-prep
chore(#712): prepare Next UI 1.0.0-RC.25
2026-10-14 17:45:49 -05:00
james.maesandClaude Opus 5.5 bebb4f2444 chore(#712): prepare Next UI 1.0.0-RC.25
PR151 (assistant re-attach, never block asking, progress, Stop), paired
with qbit-assistant 0.1.0-RC.13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:35:13 -05:00
james.maesandClaude Opus 5.5 5afd785a22 test(search): wait for the first record suggestion to be selected
GlobalSearch's grouped-suggestions test asserted the selected option
synchronously after the list appeared. Under the full CI suite the
selection had not settled yet and the search-everything row was still
selected. The assertion now waits, as the next one already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:35:09 -05:00
james.maes 2d6a33ea9f Merge remote-tracking branch 'origin/main' into feature/default-list-columns 2026-10-14 17:33:00 -05:00
james.maes 70594c40ba Merge pull request #151 from QRun-IO/feature/assistant-reattach
feat(assistant): never block asking, re-attach after refresh, live progress and Stop
2026-10-14 17:20:08 -05:00
james.maes 9f3c48f4c7 Merge pull request #152 from QRun-IO/feature/next-rc24-prep
chore(#712): prepare Next UI 1.0.0-RC.24
2026-10-14 17:04:15 -05:00
james.maesandClaude Opus 5.5 38d2c62f3a chore(#712): prepare Next UI 1.0.0-RC.24
RC23's tag acceptance failed on tablet (REC-004, REC-019, NAV-015);
PR149 fixes it. Also includes PR150, PR148 and PR145.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:57:15 -05:00
james.maes 3ab4815526 Merge pull request #150 from QRun-IO/feature/empty-state-table-help
feat(query): empty tables explain what they are for and how rows get created
2026-10-14 16:56:28 -05:00
james.maes f0b4c775f0 Merge pull request #149 from QRun-IO/fix/record-tabs-tablet
fix(record): section tabs wrap instead of folding into More (tablet regression from #144)
2026-10-14 16:56:07 -05:00
james.maesandClaude Opus 5.5 f29bedd437 test(e2e): the turn POST now carries concurrency (supersede by default)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:51:32 -05:00
james.maesandClaude Opus 5.5 336affb9e2 chore(openapi): vendor the assistant UI API extension 1.2.0 (qbit-assistant#49)
turns/active, concurrency on a new turn, 503 SHUTTING_DOWN, turn.phase and
message.start.startedAt; the contract test now covers the new call.
Also: text buttons in the background-run banner, a check icon in the
concurrency menu, and the Enter hint kept to fine pointers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:37:57 -05:00
james.maesandClaude Opus 5.5 92b165eb9c feat(query): an empty, unfiltered table explains itself from its help slot
When a table has no records and no filter or search is active, the empty
state shows 'No <Table> yet' and the table's help (the 'help' slot: what the
table is for and how its rows get created) instead of 'Try adjusting your
filters'. Filtered lists and tables without help keep the existing text.
Adds helpPlainText (whole help as plain text); helpSummary reuses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:33:56 -05:00
james.maesandClaude Opus 5.5 42fa4c3c5f feat(assistant): never block asking; re-attach after refresh; live progress
Several runs per conversation: sending while an answer runs replaces it
(default) or runs both (panel menu choice). On load, focus and online the
panel re-attaches to running answers found through the UI API, shows a
banner with Open and Stop for answers in other conversations, and the
header button shows a busy state. Each running answer shows its phase
(thinking, tool, writing) with elapsed time; Stop is always available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:26:26 -05:00
james.maesandClaude Opus 5.5 72872f5ccd feat(assistant): find active turns, retry restarts, resume until the turn ends
listActiveAssistantTurns (GET /assistant/turns/active, [] on an older
server), the optional concurrency on a new turn, a retried 503
SHUTTING_DOWN start, the turn.phase event, an idempotent cancel (409 is
finished), and a resume that keeps going with capped backoff for a time
budget instead of giving up after three attempts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:26:23 -05:00
james.maesandClaude Opus 5.5 b89029c0ff test(record): import QRecord from the types barrel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:05:10 -05:00
james.maesandClaude Opus 5.5 bb81e3c9a4 test(acceptance): NAV-015 waits for each list before navigating on
WebKit reports an API read that a navigation cuts off as "due to access
control checks", which the diagnostics fixture counts as an error
(tablet failure on the RC.23 tag). The title test now lets the Person and
Nav Deep Item lists load before opening the next page, as NAV-016 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:04:01 -05:00
james.maesandClaude Opus 5.5 413c736050 fix(record): section tabs wrap onto rows instead of folding into More
#144 folded tabs that did not fit into a More menu. At tablet widths that
hid sections from the tab bar, so they could not be opened by touch or
reached by the arrow keys, and their panels were unnamed (acceptance
REC-004, plus REC-019, whose Time Zones tab was folded). Every section is
now a tab again. The tab bar wraps onto more rows and never scrolls
sideways (#116), and a label longer than the bar wraps inside its tab.
The More menu component is removed. Unit tests cover the wrapping, the
keyboard navigation and tapping the last section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:03:57 -05:00
james.maes c07fba1d4b Merge pull request #148 from QRun-IO/feature/policy-editor-full-access
feat(authentik): Full access checkbox in the policy editor
2026-10-14 16:03:44 -05:00
james.maesandClaude Opus 5.5 8437aeb0eb test(acceptance): REC-030 shows the two columns it measures
Record Lab has more than twelve fields, so it now opens with the short
default column set; the SIZE test turns on Short Code and Website before
measuring them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:48:24 -05:00
james.maes e39d4b847b Merge pull request #145 from QRun-IO/feature/home-no-duplicate-grids
fix(app-home): no automatic card grids on homes that declare widgets
2026-10-14 15:48:01 -05:00
james.maesandClaude Opus 5.5 9b596289af feat(authentik): Full access checkbox in the policy editor
A prominent "Full access (all current and future resources)" checkbox emits
the single {Action:["*"],Resource:["*"]} statement and disables the matrix.
Resource "*" with any other action is reported invalid. Opening a
full-access group shows it ticked; unticking returns an empty matrix. The
member preview says "Full access: everything in this app".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:27:30 -05:00
james.maesandClaude Opus 5.5 e434716cbd chore(ui-api): UI API 1.4.0 - re-vendor and require 1.4.0
RC.7 shipped UI API 1.3.0; the operations this PR uses ship in 1.4.0
(QRun-IO/qqq#1188, #1189, #1206). Re-vendor qqq-v1.json and
api-aware.json at 1.4.0, set SUPPORTED_UI_API.minimum to 1.4.0 and move
the mocked /version responses to 1.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:20:32 -05:00
james.maes dcc3a47522 Merge pull request #147 from QRun-IO/feature/next-rc23-prep
chore(#712): prepare Next UI 1.0.0-RC.23
2026-10-14 15:18:43 -05:00
james.maesandClaude Opus 5.5 90fc22327c chore(#712): prepare Next UI 1.0.0-RC.23
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:11:52 -05:00
james.maes 39b8f8b7ae Merge pull request #127 from QRun-IO/feature/help-everywhere
feat(help): help everywhere — icons, long-help panel, help search, author links
2026-10-14 15:09:41 -05:00
james.maesandClaude Opus 5.5 8f90df5ae9 fix(app-home): keep section grids by default; hint hides both
Hiding the section grids on every home with widgets removed the only way
to launch app-level processes on some homes (acceptance NAV-008/009,
PRC-004, SEC-008). By default only the Apps grid now gives way to widgets;
nextUi.showChildGrid=false hides both grids for homes whose widgets cover
them (kof22 sets it on its own and the qbit homes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:06:15 -05:00
james.maesandClaude Opus 5.5 e8bcb0bbe3 fix(query): keep every column on tables with at most twelve fields
Narrow tables do not have the wide-grid problem, so the short default set
applies only above twelve visible fields (or when the table names its own
set). Existing tables of that size, and the acceptance fixtures, open as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:05:16 -05:00
james.maes 28b2de402c Merge pull request #146 from QRun-IO/feature/nested-app-urls
fix(routing): redirect app-nested URLs to their canonical path
2026-10-14 15:01:23 -05:00
james.maes acd324e410 Merge pull request #144 from QRun-IO/feature/record-tabs-wrap
fix(record): section tabs fold into a More menu instead of scrolling
2026-10-14 15:01:14 -05:00
james.maes 4c9d0ab9e1 Merge pull request #139 from QRun-IO/fix/breadcrumb-no-hscroll
fix(layout): breadcrumb without inner horizontal scroll
2026-10-14 14:59:26 -05:00
james.maes cd2754b93c Merge pull request #138 from QRun-IO/fix/dark-mode-os-default
feat(theme): default to the OS color scheme (System / Light / Dark)
2026-10-14 14:59:19 -05:00
james.maes be72703bd7 Merge pull request #143 from QRun-IO/feature/quick-search-suggest
feat(search): typeahead suggestions from the quick-search extension in the global search box
2026-10-14 14:56:10 -05:00
james.maes 219db989ed Merge pull request #140 from QRun-IO/feature/policy-editor-features
feat(authentik): policy editor feature permissions and member preview
2026-10-14 14:56:00 -05:00
james.maes 4cfb03c6b7 Merge pull request #99 from QRun-IO/dependabot/github_actions/github-actions-56a18c2c80
build(deps): Bump the github-actions group across 1 directory with 4 updates
2026-10-14 13:54:23 -05:00
james.maes 496cb0a14c Merge pull request #68 from QRun-IO/dependabot/npm_and_yarn/rapidoc-10.1.0
build(deps): Bump rapidoc from 9.3.8 to 10.1.0
2026-10-14 13:54:14 -05:00
james.maesandClaude Opus 5.5 996b0271de fix(routing): redirect app-nested URLs to their canonical path (#117)
The breadcrumb shows app > table > record, but /app/{app}/{table} said
"Table metadata is unavailable" and /app/{app}/{table}/{id} was a 404.
When the first segment is an app (and not also a table) and the second is a
table, process or app, the page now replaces the URL with /app/{second}/...,
keeping the rest of the path, the query string and the hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:53:52 -05:00
james.maesandClaude Opus 5.5 2580cf95a6 fix(app-home): no automatic card grids on homes that declare widgets (#115)
App homes appended automatic section (Actions, Reports, Data) and child-app
grids under their own widgets, repeating the nav as cards (a screen or two
on a phone). Homes that declare widgets now skip both grids; homes without
widgets keep them. The app hint nextUi.showChildGrid overrides either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:36:16 -05:00
james.maesandClaude Opus 5.5 a07213f633 fix(record): section tabs fold into a More menu instead of scrolling (#116)
The record view's section tab strip scrolled sideways inside its box when a
record had many sections (13 on a blog post). Tabs that do not fit now move
into a More dropdown, measured with ResizeObserver; the active section always
stays visible, arrow-key navigation and tab roles are kept, and the phone
accordion is unchanged. Nothing in the strip scrolls horizontally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:36:13 -05:00
james.maesandClaude Sonnet 5.5 17203f9876 test(search): mocked e2e for typeahead suggestions at 390 and 1440 px, light and dark
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:30:51 -05:00
james.maesandClaude Sonnet 5.5 e30c23dcbf feat(search): header search, dialog and palette use typeahead suggestions when installed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:30:48 -05:00
james.maesandClaude Sonnet 5.5 3fb7002a52 feat(search): useRecordSearch suggest source with core-search fallback
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:30:46 -05:00
james.maesandClaude Sonnet 5.5 05ceeb1925 feat(search): quick-search suggest API client and vendored extension contract
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:30:44 -05:00
james.maesandClaude Opus 5.5 305d85bf61 feat(query): open tables with a short default column set (#114)
With no saved view, the query screen shows the primary key, then T1 and T2
section fields, skipping hidden, heavy and long-text fields, capped at six;
every other column starts unchecked. A table can name its own set with the
supplemental hint nextUi.defaultColumns. Saved and legacy views are kept;
reset goes back to the short set. Blog Posts no longer opens with 29 columns
and an inner horizontal scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:20:40 -05:00
james.maesandClaude Opus 5.5 4bd2c95d01 feat(ui-api): zero off-spec calls - every UI call is a spec'd /qqq/v1 operation
- Form adjusters: POST /table/{t}/formAdjuster/{id}/{event} (JSON) instead
  of the legacy multipart /material-dashboard-backend route.
- API discovery: GET /table/{t}/apis (versions included) instead of
  /apis.json and {api}/versions.json; the table document through
  GET /table/{t}/apis/{api}/{version}/openapi.
- Vendor the refreshed UI API document (1.3.0) and the API-aware
  document (extensions/api-aware.json) for the API-versioned
  metadata/query/count routes.
- OFF_SPEC_CALLS is empty and OFF_SPEC_CEILING is 0.
- SUPPORTED_UI_API.minimum is 1.3.0 (the operations above are new in it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:08:43 -05:00
james.maesandClaude Opus 5.5 863099b95f feat(authentik): policy editor feature permissions and member preview
- Table features (Analyze, Comment, ..., and READ_WRITE `write`) are
  selectable checkboxes per table, included in the row, column and app
  select-alls and in search; policy-document parses and generates them
  in canonical order and round-trips documents that use them.
- "Preview for a member" (qbit-authentik#29 payload): pick a member and
  see their effective permissions with this document, with added and
  removed markers against the saved document and a mapper-off note.
  Lazy-rendered; no extra network calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:03:19 -05:00
james.maesandClaude Opus 5.5 9d57861672 fix(layout): keep every breadcrumb a visible link; truncate and wrap instead of collapsing
The ellipsis menu removed middle crumbs from the DOM, breaking the
acceptance specs that click each crumb (NAV-014, 007, 018, 019, 028) and
the 44 px touch-target check on the header (NAV-038). Every crumb is a link
again, in order. Long labels truncate with an ellipsis and a title; the
trail wraps onto another line (the header grows by min-height) so it never
scrolls sideways and never drops below the touch-target width.

e2e still checks no overflow at 320, 390, 768 and 1440 px, and now that
all crumbs stay visible links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:00:04 -05:00
james.maesandClaude Opus 5.5 ce8da2d2a4 fix(theme): keep the appearance control's Light/Dark behavior; System is the unset state
NAV-056 acceptance failed because the control now reported the saved
preference instead of the active mode. Under an application theme the
Light radio is again the active one (Dark stays disabled, the explanation
still shows, the saved choice is kept). System remains a third radio that
means no saved key.

Spec changes, because the old assertions encode the retired "OS never
matters" default: with the OS dark and no saved choice the app is dark and
System is selected; Reset to Defaults removes the key (it no longer writes
"false") and follows the OS, including a live OS change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:55:50 -05:00
james.maes 4c40679023 Merge remote-tracking branch 'origin/main' into feature/help-everywhere
# Conflicts:
#	src/components/forms/DynamicForm.tsx
#	src/components/forms/DynamicFormField.tsx
2026-10-14 12:55:38 -05:00
james.maes 40cc60b1d4 Merge pull request #120 from QRun-IO/feature/contingent-possible-values
feat(forms): contingent possible values across forms, bulk edit, processes and filters
2026-10-14 12:42:43 -05:00
james.maes 292a2a9aea Merge remote-tracking branch 'origin/main' into feature/help-everywhere
# Conflicts:
#	tests/setup.ts
2026-10-14 12:26:20 -05:00
james.maesandClaude Opus 5.5 771e46ecc3 fix(layout): breadcrumb never scrolls sideways; middle crumbs collapse into a menu
Removes the breadcrumb's horizontal scroll. At narrow widths the middle
crumbs collapse into an ellipsis menu, the current page truncates with a
tooltip, and the header keeps one line. e2e checks no overflow at 320,
390, 768 and 1440 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:35 -05:00
james.maesandClaude Opus 5.5 51775ab0cb feat(theme): follow the OS color scheme unless the user picked light or dark
With no qqq-dark-mode preference the app follows prefers-color-scheme and
reacts to OS changes; an explicit choice still wins. Display preferences
offer System / Light / Dark, and a pre-hydration script applies the same
rule to avoid a flash of the wrong theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:29 -05:00
james.maesandClaude Opus 5.5 4b8226a4cc Merge origin/main into feature/contingent-possible-values
Picks up the realtime-singleton test isolation fix (#137).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:06:31 -05:00
james.maes db941a8cc5 Merge pull request #137 from QRun-IO/fix/association-metadata-dedupe
test: isolate the realtime client between tests (fixes the metadata dedupe flake)
2026-10-14 12:06:20 -05:00
james.maes 65db5355af Merge remote-tracking branch 'origin/main' into fix/association-metadata-dedupe
# Conflicts:
#	tests/setup.ts
2026-10-14 11:44:59 -05:00
james.maes 085430e17f Merge remote-tracking branch 'origin/main' into feature/help-everywhere
# Conflicts:
#	src/lib/api/openapi/qqq-v1.json
2026-10-14 11:38:28 -05:00
james.maesandClaude Opus 5.5 daf4005186 test: isolate the realtime singleton between tests; preload the lazy RecordView
"keeps arbitrary aliases distinct and deduplicates target metadata loads"
intermittently saw the pet metadata handler run twice under load. The
second request was not a product duplicate: it came from the realtime
tab singleton, which outlived each test. Once a test held a record view
past the 150 ms leader election, the singleton connected and kept the
stream cursor. The next test's first connection then resumed with
Last-Event-ID, the MSW realtime mock answered it with `resync` (a replay
miss), and invalidateRealtime refetched every active query in that
test's cache, including the already-loaded pet metadata.

Each test is a fresh page load, so the test setup now closes and forgets
the singleton after every test (new resetRealtimeClient). The page test
also preloads the lazy RecordView in beforeAll, as other page tests do,
so a single case run with -t no longer misses its first deadline on cold
compilation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:33:47 -05:00
james.maesandClaude Opus 5.5 de51fcbf83 Merge origin/main into feature/contingent-possible-values
Picks up the record-page association dedupe fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:27:44 -05:00
james.maes a521be57cc Merge pull request #121 from QRun-IO/feature/user-preferences
feat(preferences): Preferences page, notification settings drawer and section actions
2026-10-14 11:17:02 -05:00
james.maesandClaude Opus 5.5 4e6473aae2 test(help): WID-044 checks the summary tooltip and the help panel; HLP-003 is desktop-only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:11:22 -05:00
james.maesandClaude Opus 5.5 34efebb434 fix(edi): exact field labels in the preview pane; stabilize mapper e2e
- Sample and Guide revision selects get standalone labels, so their
  accessible names are exact.
- The overflow check ignores visually hidden (1px) elements, and the
  axe run disables color-contrast like the shared a11y helper (the
  mocked indigo accent fails repo-wide).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:08:38 -05:00
james.maesandClaude Opus 5.5 439c4b00e4 test(edi): open the guide segment through its summary element
The segment label is a span inside the summary, so pressing Enter on it
never toggled the details element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:49:44 -05:00
james.maesandClaude Opus 5.5 648d2d8bf8 Merge origin/main into feature/user-preferences
Preferences now use the shared UI API discovery (#133): a core-feature
check isUiApiFeatureSupported sits next to the extension check, replacing
the separate cache; the version mock helper announces preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:36:32 -05:00
james.maesandClaude Opus 5.5 f95baaf8c7 feat(edi): EDI mapper page on the qbit-edi UI API extension
- Vendor the edi extension document (qbit-edi 1.0.0) with generated
  types, API client and hooks; all calls are on spec.
- "Open Mapper" record action on edi_map (only when the edi extension is
  registered and the map is readable); route /app/edi_map/{id}/mapper.
- Guide tree, target fields and associations, keyboard and drag rule
  creation, transform argument forms, validation of unsaved rules,
  sandbox preview with rule overrides and a PHI-suppressed notice, and
  Save through the core table API for DRAFT maps only.
- Three panes at xl, two at lg, tabs below; saved rules collapsed.
- Unit and Playwright tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:27:01 -05:00
james.maes e78537ad36 Merge remote-tracking branch 'origin/main' into feature/help-everywhere
# Conflicts:
#	src/components/forms/DynamicFormField.tsx
#	src/components/forms/EntityForm.tsx
#	src/components/process/ProcessRun.tsx
#	src/components/process/ProcessStepScreen.tsx
#	src/components/query/RecordQuery.tsx
#	src/components/records/RecordViewHeader.tsx
#	src/components/records/RecordViewSection.tsx
#	src/lib/api/openapi/qqq-v1.json
#	src/types/metadata.ts
2026-10-14 10:26:06 -05:00
james.maesandClaude Opus 5.5 8478404802 Merge origin/main into feature/contingent-possible-values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:13:20 -05:00
james.maes 4ba1bb4cae Merge pull request #135 from QRun-IO/feature/next-rc22-prep
chore(#712): prepare Next UI 1.0.0-RC.22
2026-10-14 10:09:20 -05:00
james.maesandClaude Opus 5.5 c50675109a test: reset the preferences support cache synchronously in setup
The cache lives in an import-free module, so the global afterEach no longer
awaits a dynamic import between tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:08:20 -05:00
james.maes ab9245e66c Merge pull request #133 from QRun-IO/feature/108-qbit-presence-from-version
feat(ui-api): qbit presence from GET /version; vendor assistant extension (#108, off-spec 7)
2026-10-14 10:02:18 -05:00
james.maes be2d0fc66e Merge branch 'main' into feature/next-rc22-prep 2026-10-14 09:56:31 -05:00
james.maes 652ade887f Merge pull request #134 from QRun-IO/fix/collaboration-e2e-conflict-race
test(e2e): harden the collaboration conflict test against the mock stream attach race
2026-10-14 09:54:47 -05:00
james.maesandClaude Opus 5.5 3330f1bb63 Merge origin/main into feature/108-qbit-presence-from-version
Off-spec ceiling: main dropped POST /uploads (20); minus the 13
assistant calls is 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:31:25 -05:00
james.maes 1bfebd09fb Merge remote-tracking branch 'origin/main' into feature/user-preferences
# Conflicts:
#	src/lib/api/openapi/ui-api-calls.ts
2026-10-14 09:29:39 -05:00
james.maesandClaude Opus 5.5 fb8b4efa6a Merge origin/main into feature/contingent-possible-values
Kept main's ui-api-calls.ts (#128 carries the same off-spec fix); re-applied the one-line formMethods prop on main's EntityForm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:26:36 -05:00
james.maesandClaude Opus 5.5 d3629d72b1 chore(#712): prepare Next UI 1.0.0-RC.22
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:25:55 -05:00
james.maes 48e81b4d12 Merge pull request #128 from QRun-IO/feature/collaboration-presence-share-tasks
feat(collaboration): realtime presence, conflict review, sharing, tasks and My work
2026-10-14 09:24:02 -05:00
james.maesandClaude Opus 5.5 94494fc34f Merge origin/main into feature/user-preferences
Re-vendors the core document from qqq develop (1.3.0, with preferences)
plus main's vendored jobs and uploads operations; POST /uploads is now
spec'd, so its off-spec entry goes and the ceiling drops to 20.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:18:10 -05:00
james.maesandClaude Opus 5.5 2bdd7d980b test(e2e): resend the concurrent edit until the conflict banner shows (mock stream attach race on mobile)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:12:33 -05:00
james.maesandClaude Opus 5.5 ff1ea58cf1 fix(ui-api): drop the POST /uploads off-spec exception now that it is vendored
Main vendored /qqq/v1/uploads (#132) but kept the exception, which fails the contract test's 'only shrinks' check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:57:35 -05:00
james.maesandClaude Opus 5.5 58cca84fda Merge origin/main into feature/contingent-possible-values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:55:43 -05:00
james.maesandClaude Opus 5.5 91b335a42d fix(ui-api): extension discovery does not import the query client
The test setup resets discovery after each test; importing the query
client there pulled sonner into every test document and broke the
toast-observation tests. The version query key now lives beside the
discovery code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:53:38 -05:00
james.maesandClaude Opus 5.5 617bd1962d chore(ui-api): drop the POST /uploads off-spec exception now that the vendored document has it (ceiling 20)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:40:20 -05:00
james.maes cfba697339 Merge remote-tracking branch 'origin/main' into fix/collaboration-live-review
# Conflicts:
#	src/components/records/RecordView.tsx
#	src/lib/api/openapi/qqq-v1.json
2026-10-14 08:38:41 -05:00
james.maes 3db5a0507d Merge pull request #132 from QRun-IO/feature/vendor-alerts-sqlwb-ui-api
feat(ui-api): vendor the alerts and sql-workbench extension documents (off-spec 46 -> 21)
2026-10-14 08:30:26 -05:00
james.maes 36c07c7aa7 Merge pull request #123 from QRun-IO/feature/data-analysis-saved
feat(data-analysis): save, share, star, export and watch analyses
2026-10-14 08:29:50 -05:00
james.maesandClaude Opus 5.5 2ba63346f0 Merge origin/main into feature/contingent-possible-values
Picks up the off-spec ceiling fix (#131) that broke the UI API contract test on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:28:11 -05:00
james.maes a1c4a7de1a Merge pull request #126 from QRun-IO/feature/run-in-background
feat(processes): run in background, background-job banner, widget refresh
2026-10-14 08:28:03 -05:00
james.maes 4d4ba49e8f Merge pull request #129 from QRun-IO/fix/chart-sr-table-overflow
fix(widgets): chart data table overflow and doubled widget section headings
2026-10-14 08:27:50 -05:00
james.maesandClaude Opus 5.5 4f2cf6eb64 feat(ui-api): qbit presence from GET /version; no hard-coded qbit names (#108)
- ui-api-extensions: one shared /version discovery (isUiApiExtensionInstalled,
  useUiApiExtensionInstalled, UI_API_EXTENSION names); data-analysis
  delegates to it; reset on sign-out and user change.
- Alerts: presence from the "alerts" extension instead of the hard-coded
  alerts app (per-user access still from the inbox summary's 403/404);
  record-panel actions come from the panel payload (permission-aware
  hrefs) instead of hard-coded alertsAcknowledge / alertsSnooze /
  alerts_alert.
- Assistant: /assistant/config is probed only when the "assistant"
  extension is registered.
- Mocks announce their extensions from one /version handler; tests and
  mocked e2e routes updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:23:53 -05:00
james.maesandClaude Opus 5.5 4ad866286b feat(ui-api): vendor the assistant extension document
qbit-assistant#19 serves its /qqq/v1/assistant operations as the
assistant UI API extension (1.0.0). Vendor it and drop its 13 calls
from the off-spec list (ceiling 21 -> 8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:05:12 -05:00
james.maesandClaude Opus 5.5 dc6b1e2373 feat(ui-api): vendor the alerts and sql-workbench extension documents
The Alerts (qbit-alerts#19) and SQL Workbench (qbit-sql-workbench#21)
qbits now serve their /qqq/v1 operations as UI API extensions (1.0.0).
Vendor both documents and drop their 25 calls from the off-spec list
(ceiling 46 -> 21).

- Inbox actions call their literal operations (/alerts/inbox/read,
  /unread, /dismiss).
- The alerts extension removed the record-panel watch operation; the
  record panel drops its "Watch alerts for this record" button and
  keeps showing the deprecated watching state read-only. Following a
  record belongs to the collaboration panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:54:15 -05:00
james.maes 96b789088e Merge remote-tracking branch 'origin/main' into feature/data-analysis-saved 2026-10-14 07:53:09 -05:00
james.maes a75fd8b5f6 Merge remote-tracking branch 'origin/main' into fix/collaboration-live-review 2026-10-14 07:51:46 -05:00
james.maes 635def9ba8 Merge remote-tracking branch 'origin/main' into feature/data-analysis-saved 2026-10-14 07:49:51 -05:00
james.maesandClaude Opus 5.5 54a2db37b7 Merge origin/main into feature/contingent-possible-values
Resolved FilterBuilder.tsx: keep both the contingent dependencyField and main's supportedOperators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:44:30 -05:00
james.maes 3dfc93612f Merge remote-tracking branch 'origin/main' into fix/collaboration-live-review 2026-10-14 07:20:24 -05:00
james.maes 1e82e8696d Merge remote-tracking branch 'origin/feature/data-analysis-compare' into feature/data-analysis-saved 2026-10-14 07:09:07 -05:00
james.maesandClaude Opus 5.5 e2838496b6 test(api): cover directory search, presence beacon, my tasks and the realtime transport
Lifts src/lib/api function coverage above its 93% threshold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:08:46 -05:00
james.maesandClaude Opus 5.5 05be095a8d fix(realtime): stop the subscription sync loop when the server neither activates nor refuses a channel
The sync loop re-added a channel forever (never yielding to the event loop) when an update
response listed it as neither active nor refused, exhausting the heap of a test worker.
Channels a response leaves unresolved are now treated as refused until the next reconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:08:45 -05:00
james.maesandClaude Opus 5.5 3fcefdb6b4 fix(collaboration): keep the established edit payload and not-found copy outside collaboration tables
Versioned, dirty-only edit saves apply only to tables with PRESENCE or
FOLLOW (acceptance REC-009, REC-016, REC-023 expect the full payload and
defaults on other tables). The not-found view keeps its original wording
and Back button; the neutral wording and Request access appear only on
SHARING tables, below the Back button (REC-003).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:41:18 -05:00
james.maesandClaude Opus 5.5 a0a26b547b fix(collaboration): reset the touch minimum on tag chip controls with important
The global 44 px touch-target rule is unlayered CSS, so it outranks the
layered min-h-0/min-w-0 utilities whatever their specificity, and tag
chips on touch screens were still 50 px tall. The chip controls now use
the important modifier (min-h-0!, min-w-0!): chips measure 26 px on a
Pixel 7 while each control keeps a 44 x 44 px transparent touch overlay.
The record-panel screenshot test asserts the chip height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:28:57 -05:00
james.maesandClaude Opus 5.5 8408f6c445 fix(collaboration): hide empty activity details and word old audit summaries
- An activity entry shows "Details" only when at least one permitted
  detail has a message or a value change, so audits whose details are
  all empty no longer leave a Details header over nothing.
- Summaries are shown as the service sends them. Older core audit
  summaries of the form "Record was Edited" read "Edited", and "Record
  was Inserted" reads "Created" (other verbs in sentence case).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:16:32 -05:00
james.maesandClaude Opus 5.5 ebcd8aec30 feat(collaboration): show people by display name, falling back to the id
The service is adding display names beside user ids. The UI now renders
them when present and falls back to the id otherwise:

- Tasks: "Assigned to <assigneeDisplayName>", plus "Created by ..." and,
  for done tasks, "Completed by ...".
- Files: the uploader's createdByDisplayName.
- Comments already used authorDisplayName.
- Share results prefer the share's toDisplayName.

src/types/collaboration.ts gains assigneeDisplayName,
createdByDisplayName and completedByDisplayName (tasks),
createdByDisplayName (attachments, kept by the normalizer),
fromDisplayName/toDisplayName (shares) and requesterDisplayName (access
requests). The vendored extensions/collaboration.json is unchanged until
the service publishes them. The MSW fixtures carry the names, and the
mock task service records who completed a task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:15:54 -05:00
james.maesandClaude Opus 5.5 ed0f056c85 test(collaboration): type the self-exclusion people fixture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:15:04 -05:00
james.maesandClaude Opus 5.5 acfd28eea1 fix(collaboration): leave yourself out of share recipients and keep the dialog on screen
- The share picker drops the signed-in user from the people results,
  matching the app's user id (the session email) against each entry's
  user id and email, case-insensitively. The service is leaving the
  caller out too; this guards older services. The assign picker still
  lists you, since you may assign a record to yourself.
- Collaboration dialogs are a flex column no taller than the viewport
  less 2rem: the title bar stays put and only the body scrolls, and only
  when it does not fit, so there is no second page scroll.
- The link field shows the URL exactly as the service returns it.
- The MSW directory carries the mock session's own entry so the
  exclusion is exercised; unit and e2e tests cover it, and the e2e
  screenshot run checks the dialog fits a 1000 px viewport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:12:26 -05:00
james.maesandClaude Opus 5.5 365f57c21a fix(collaboration): agree counts with their nouns
"1 people viewing", "1 unread mentions" and "1 followers" now read
"1 person viewing", "1 unread mention" and "1 follower". A small
pluralize(count, singular, plural?) helper in string-utils (there was
none) formats each count; a paged follower count still reads "N+
followers".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:10:41 -05:00
james.maesandClaude Opus 5.5 b0e30cfe36 fix(collaboration): compact tag chips on touch screens and keep copy-id by the title
- On touch screens the global 44 px touch-target rule lifted each tag's
  remove button (and Add tag) to 44 x 44, so chips grew into ~50 px pills,
  two per row. The chip controls now keep their drawn size and carry a
  transparent overlay of at least 44 x 44 px as the touch target, so chips
  stay compact inline chips that wrap naturally at every width.
- The record collaboration header (presence, owner/assignee, Assign and
  Share) was mounted between the follow bell and the copy-record-id
  button; when it wrapped it left the copy button alone on its own row.
  The header now comes after the copy button and inline actions and takes
  its own full-width row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:09:53 -05:00
james.maesandClaude Opus 5.5 9aacbf0052 fix(records): one heading for a widget section titled like its card
A record section that shows a card widget drew the section label as a
heading and then the card's own title, so "Live Progress", "Chunks" and
other child lists read twice. When the card's label equals the section
label, the card's title stands alone and the section keeps the label as
its accessible name. Non-card widgets and differing labels are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:08:34 -05:00
james.maesandClaude Opus 5.5 3e140cb6c6 test(collaboration): expect the "@Name" mention chip in the record panel e2e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:07:27 -05:00
james.maesandClaude Opus 5.5 d7266d2951 fix(collaboration): render a mention as one "@Name" chip
A mention token `@[Name](user:id)` rendered the literal "@" outside the
chip, followed by a gap, and the chip could wrap across two lines. The
comment renderer now drops the token's "@" and the chip draws "@Name"
itself, with no wrapping inside the chip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:07:21 -05:00
james.maesandClaude Opus 5.5 b2a9e28ce0 fix(collaboration): separate Show and Reopen and keep disabled Post readable
- A collapsed resolved thread now reads "Resolved thread", then two
  separate actions, Show and Reopen, instead of "ShowReopen" run together.
  Resolve and Reopen sit in the thread's action row with Edit, Delete
  and Reply.
- The thread actions use the primary text token, which is lightened on
  dark surfaces (about 9.6:1 on the dark card, 5.2:1 on the light card).
- A disabled Post comment button uses the muted surface and muted
  foreground tokens with a border, instead of a 50% faded blue fill that
  was hard to read in dark mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:06:43 -05:00
james.maesandClaude Opus 5.5 c6f0fc87f6 fix(widgets): keep the hidden chart data table from widening the page
The screen-reader table of chart values had sr-only on the table itself.
A table cannot shrink to the 1px sr-only sets, so on chart pages it widened
main-content (915px at a 390px viewport), a horizontal scroll at phone widths.
Wrap it in an sr-only div instead; the table stays in the accessibility tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:37 -05:00
james.maesandClaude Opus 5.5 f1bff85157 fix(help): no RSC prefetch on dashboard create links; NAV-017 includes the Help Lab fixture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:51:57 -05:00
james.maesandClaude Opus 5.5 33f67fa8a8 feat(help): help everywhere — shared help icons, long-help panel, help search, author links
- HelpIcon (summary tooltip, keyboard, More help) and one lazy HelpPanel
- Help on app, table, section, field labels, list columns, widgets,
  processes, steps, reports and record/table actions
- Help search in the command palette and search page (GET /help/search,
  gated on the 'help' feature)
- Author mode: Add help / Edit help links for helpContent authors
- Vendored UI API 1.2.0 with typed HelpContentV1; acceptance HLP-001..006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:40:14 -05:00
james.maes 69aef5f05c Merge remote-tracking branch 'origin/main' into feature/collaboration-presence-share-tasks 2026-10-13 22:40:11 -05:00
james.maes d8bc9282db Merge branch 'feature/data-analysis-compare' into feature/data-analysis-saved 2026-10-13 22:31:44 -05:00
james.maes 67cf8ed57e Merge remote-tracking branch 'origin/feature/data-analysis-compare' into feature/data-analysis-saved 2026-10-13 22:30:11 -05:00
james.maesandClaude Opus 5.5 7c755d5741 test(acceptance): contingent specs accept trailing-slash record URLs and use field labels in the filter builder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:26:11 -05:00
james.maesandClaude Opus 5.5 38204cdacc feat(processes): run in background, background-job banner, widget refresh
Generic support for QQQ durable jobs (QRun-IO/qqq#1178, #1181); names no
qbit.

- Run in background: on a non-final step of a process whose metadata says
  durableJobEligible, a secondary button POSTs /processes/{name}/jobs with
  the values collected so far, the launch selection (filter or record ids)
  and startAfterStep, then opens the job record. Server errors (400, 403,
  404, 429) are shown.
- When a step's values carry durableJobId (a run over the server's
  threshold), the process shows "Running as a background job" with an Open
  job link, plus its summary lines.
- Widgets whose metadata sets defaultValues.refreshSeconds re-fetch on that
  interval (minimum 2s) while the page is visible, keeping the old data on
  screen; this is how the Jobs app home shows live progress.
- Types for durableJobEligible / durableJobAlways; the processJob client.
- Re-vendored the UI API 1.2.0 document with the jobs and uploads
  operations.

Bundle (report-only budget): total JS +1.8 KB gzip (1217.5 -> 1219.3 KB),
/app/_/_ first-load +0.9 KB (350.4 -> 351.3 KB), 126 chunks unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:24:59 -05:00
james.maesandClaude Opus 5.5 622ae28ceb fix(collaboration): keep record header controls together and readable at phone width
- Presence avatars, owner/assignee and the Assign/Share buttons are now
  three wrapping groups, so Share no longer drops onto its own line beside
  free space. Owner and Assignee render as muted label plus value.
- The avatar row is omitted when nobody else is viewing.
- The people search shows a "Search by name" placeholder.
- My work uses the page gutter instead of doubling it at 390 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:19:10 -05:00
james.maesandClaude Opus 5.5 9d2ece4228 test(secured-data): open the filter builder like the other specs; wait for the record before the banner shot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:09:14 -05:00
james.maesandClaude Opus 5.5 1bb7db1924 fix(tasks): show a task's done/open toggle immediately
The checkbox was bound to the server status, so a click snapped back
until the refetch landed: users saw no response and the Playwright check
failed on every browser. The toggled state now shows at once, the box is
disabled while the write is in flight, a failed write rolls it back, and
the entry clears when the server status agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:09:03 -05:00
james.maesandClaude Opus 5.5 cce505e5a5 fix(data-analysis): readable correlation heatmap in dark mode, repair text encoding
The heatmap uses translucent tints over the theme background with the
theme's text colour (AA checked over both themes) and aligned row
labels; restore the en and em dashes an earlier edit had mis-encoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:03:38 -05:00
james.maesandClaude Opus 5.5 2625355d88 fix(palette): list "Go to My work" after favorites
Favorites lead the command palette; the Work group came first and broke
the ordering test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:56:32 -05:00
james.maesandClaude Opus 5.5 6fa2ccf301 fix(realtime): hand over leadership on pagehide and never revive an ended session
- A closing or bfcache-frozen leader tab posts "bye" and drops its stream
  on pagehide, so a follower takes over at once (React cleanups never run
  on tab close; before, the handover waited for the 6.5 s lease expiry).
- An ended client stops leasing: a still-mounted surface could otherwise
  re-announce channels and make live tabs defer to a leader that never
  connects.
- A closed client drops its BroadcastChannel so late releases cannot
  post to a closed channel.
- realtimeClient() replaces an ended singleton, so signing in again as the
  same user restores realtime; the cache bridge is per client.
- The serialization test asserted against a transport mock that lost its
  state; it now checks the remove follows the in-flight add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:56:31 -05:00
james.maesandClaude Opus 5.5 ac5aa368d7 fix(collaboration): load the edit, presence and header layers only for tables that enable them
With the qbit installed, every edit form and record screen loaded the
collaboration edit layer and subscribed a record channel, whatever the
table's features. The edit layer then threw outside a QContextProvider,
which broke 26 EntityForm and RecordViewHeader tests.

- The edit layer loads for PRESENCE or FOLLOW tables, or once a save is
  rejected with 409; the header for realtime, sharing, assignment or
  ownership; table presence for realtime tables only.
- Record and table channels subscribe only where core publishes events.
- Realtime and presence hooks read the user id without requiring the
  provider.
- "Keep mine" closes the review before re-sending; a newer stream hint
  never replaces a pending 409 and its server version.
- The theirs/yours comparison stacks on phones.
- Test: a 409 sends nothing else and keeps the typed value; "Keep mine"
  re-sends the dirty values with the new expectedVersion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:56:30 -05:00
james.maesandClaude Opus 5.5 3509ff8452 feat(data-analysis): save, share, star, export and watch analyses
Every analysis view can be saved (optionally shared) as a
data_analysis_saved record, opened again with ?view=saved (re-run as the
viewer, with a star toggle), exported as CSV, XLSX or chart PNG, and
handed to SQL Workbench as a draft when it is installed. Profiles offer
Watch this metric, which creates a data_analysis_watch and previews its
series against the detector's baseline. Charts clip their tooltips so
nothing widens the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:56:20 -05:00
james.maesandClaude Opus 5.5 46436866be feat(secured-data): masked values, reveal, restricted filters and the break-glass banner
Renders fields the server marks MASKED or REVEALABLE as masked values with a
lock chip; offers Reveal (reason dialog, countdown, re-mask on expiry) when
the secured-data extension is present; filter builders, quick filters and
column menus offer only the allowed operators; restricted fields are
read-only and never sent in insert/update payloads; a break-glass banner
shows an active session with a countdown. Reveal dialog and banner are
lazy-loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:55:14 -05:00
james.maesandClaude Opus 5.5 1a7b8e371f wip(collaboration): realtime, presence, conflicts, sharing, tasks and My work (Codex draft, tests pending)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:46:44 -05:00
james.maesandClaude Opus 5.5 d011c546c0 test(acceptance): follow the sample rename to placeAddress / placeAddressPick
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:28:21 -05:00
james.maesandClaude Opus 5.5 236fa03923 feat(forms): contingent possible values across forms, bulk edit, processes and filters
- Contingent fields (possibleValueSourceDependsOn, or ${input.x} in a
  legacy filter) re-query only when their dependencies change, are
  disabled with "Choose <A> first" until set (DISABLE_UNTIL_SET), and
  clear with an inline notice when A changes and the value no longer
  fits; clears cascade A -> B -> C. Loading a saved record never clears.
- Server "not a valid choice" errors map to the dependent field.
- Bulk edit: B requires A enabled and set; process forms use the
  screen's metadata and values; quick-create gets sibling metadata.
- Filter builder: sends useCase filter (contingent fields showed no
  options before) and narrows B from A's EQUALS/IN criteria.
- Re-vendored UI API 1.2.0 document.
- Unit tests and real-backend acceptance specs (addressBook sample).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:40:14 -05:00
dependabot[bot] 68eb31d18d build(deps): Bump rapidoc from 9.3.8 to 10.1.0
Bumps [rapidoc](https://github.com/rapi-doc/RapiDoc) from 9.3.8 to 10.1.0.
- [Release notes](https://github.com/rapi-doc/RapiDoc/releases)
- [Commits](https://github.com/rapi-doc/RapiDoc/compare/v9.3.8...v10.1.0)

---
updated-dependencies:
- dependency-name: rapidoc
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 01:32:26 +00:00
dependabot[bot] 7a75f5762c build(deps): Bump the github-actions group across 1 directory with 4 updates
Bumps the github-actions group with 4 updates in the / directory: [actions/checkout](https://github.com/actions/checkout), [actions/setup-node](https://github.com/actions/setup-node), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/checkout` from 6.1.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6.1.0...3d3c42e5aac5ba805825da76410c181273ba90b1)

Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 01:16:56 +00:00
621 changed files with 81574 additions and 16156 deletions

No files matched your search

+10 -76
View File
@@ -1,80 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6
# ─── Jobs ────────────────────────────────────────────────────────────────────
# The dashboard jar (static export under next-dashboard/) is published to Maven
# Central on release tags, with the same orb commands and credentials as the
# Material Dashboard. QApplicationJavalinServer serves it at / when it is present.
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job; avoid the interactive needrestart prompt.
sudo sed -i 's|http://security.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
jobs:
publish_dashboard_jar:
executor: qqq-orb/java
steps:
- configure-package-install
- checkout
- qqq-orb/install_java:
install_node: true
node_version: "22"
- run:
name: Verify the immutable release version
command: |
release_version="${CIRCLE_TAG#v}"
frontend_version=$(node -p "require('./package.json').version")
maven_version=$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)
test "$frontend_version" = "$release_version"
test "$maven_version" = "$release_version"
- run:
name: Build the static export
command: |
corepack enable
pnpm install --frozen-lockfile
pnpm typecheck
pnpm lint
pnpm test
pnpm build:export
- qqq-orb/mvn_jar_deploy
# ─── Workflows ───────────────────────────────────────────────────────────────
# Trunk-based: main is the only long-lived branch (see CONTRIBUTING.md).
# test_only → every branch except main (the PR check ci/circleci: qqq-orb/node_app_test_only)
# publish_release → main builds; v* version tags publish the dashboard jar
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
setup:
jobs:
- qqq-orb/node_app_test_only:
filters:
branches:
ignore: main
tags:
only: []
publish_release:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ main ]
tags:
only: [ /v.*/ ]
- publish_dashboard_jar:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /.*/
tags:
only: /^v[0-9]+\.[0-9]+\.[0-9]+(-RC\.[0-9]+)?$/
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: /.*/
tags:
only: /.*/
-281
View File
@@ -1,281 +0,0 @@
name: Real-backend acceptance
# One production build feeds independent, serial-worker backends. Require "acceptance gate".
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
inputs:
qqq_ref:
description: QRun-IO/qqq branch or tag to build the sample backend from
required: false
permissions:
contents: read
concurrency:
group: acceptance-${{ github.ref }}
cancel-in-progress: true
env:
QQQ_REF: ${{ inputs.qqq_ref || vars.QQQ_ACCEPTANCE_REF || 'develop' }}
NEXT_TELEMETRY_DISABLED: '1'
MAVEN_ARGS: -Dmaven.wagon.http.retryHandler.count=5 -Daether.transport.http.retryHandler.count=5 -Daether.connector.http.retryHandler.count=5
jobs:
plan:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
mode: ${{ steps.select.outputs.mode }}
matrix: ${{ steps.select.outputs.matrix }}
specs: ${{ steps.select.outputs.specs }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Select projects, shards and changed areas
id: select
run: node scripts/acceptance-select.mjs
build:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: QRun-IO/qqq
ref: ${{ env.QQQ_REF }}
path: qqq-backend
- name: Resolve QQQ source revision
id: qqq
run: echo "sha=$(git -C qqq-backend rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'
cache: maven
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: qqq-cache
with:
path: ~/.m2/repository/com/kingsrook
key: qqq-source-${{ runner.os }}-java21-v1-${{ steps.qqq.outputs.sha }}
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Bundled UI API document matches the server's (src/lib/api/openapi/qqq-v1.json)
run: node scripts/check-ui-api-spec.mjs --server-spec qqq-backend/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml
- name: Build QQQ from source
if: steps.qqq-cache.outputs.cache-hit != 'true'
run: bash scripts/ci/retry.sh 4 mvn -B -q -f qqq-backend/pom.xml install -DskipTests -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true
- name: Build and install the dashboard jar (static export)
run: |
# Equivalent to build:jar, with retries around its Maven invocation too.
pnpm build:export
bash scripts/ci/retry.sh 4 mvn -B -q clean package
bash scripts/ci/retry.sh 4 mvn -B -q install
# Failed-attempt Maven stdout must not become part of the version value.
bash scripts/ci/retry.sh 4 mvn -B -q help:evaluate -Dexpression=project.version -Doutput="$RUNNER_TEMP/acceptance-next-version"
next_version=$(cat "$RUNNER_TEMP/acceptance-next-version")
echo "NEXT_JAR_VERSION=$next_version" >> "$GITHUB_ENV"
- name: Build the sample application jar
working-directory: qqq-backend
run: |
# QQQ checkstyle/pmd paths are relative to this checkout.
bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" 4 mvn -B -q -f qqq-sample-project/pom.xml package -DskipTests -Djacoco.skip=true \
-Dqqq.frontend.next.version="$NEXT_JAR_VERSION"
- name: Stage runtime artifacts
run: |
sample_jars=(qqq-backend/qqq-sample-project/target/*-jar-with-dependencies.jar)
test "${#sample_jars[@]}" -eq 1
sample_version="${sample_jars[0]##*/}"
sample_version="${sample_version#qqq-sample-project-}"
sample_version="${sample_version%-jar-with-dependencies.jar}"
mkdir -p test-results/acceptance-jars
cp "${sample_jars[0]}" test-results/acceptance-jars/
# javac and the security fixture need the API JAR in addition to the fat sample JAR.
cp "$HOME/.m2/repository/com/kingsrook/qqq/qqq-middleware-api/$sample_version/qqq-middleware-api-$sample_version.jar" \
test-results/acceptance-jars/qqq-middleware-api.jar
tar -cf test-results/acceptance-build.tar out test-results/acceptance-jars
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: acceptance-build
path: test-results/acceptance-build.tar
if-no-files-found: error
retention-days: 30
shards:
name: acceptance ${{ matrix.project }} ${{ matrix.shard }}/${{ matrix.total }}
needs: [plan, build]
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'
cache: maven
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Installed Playwright version
id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: browser-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-${{ matrix.browser }}
- run: pnpm exec playwright install-deps ${{ matrix.browser }}
- if: steps.browser-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install ${{ matrix.browser }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: acceptance-build
path: test-results
- name: Restore runtime artifacts
run: |
tar -xf test-results/acceptance-build.tar
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/test-results/acceptance-jars/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
echo "QQQ_MIDDLEWARE_API_JAR=$PWD/test-results/acceptance-jars/qqq-middleware-api.jar" >> "$GITHUB_ENV"
- name: Run shard
env:
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.project }}
QQQ_ACCEPTANCE_SPEC_PATHS: ${{ needs.plan.outputs.specs }}
QQQ_ACCEPTANCE_BLOB: '1'
# Environment-selected projects must not produce colliding default blob names.
QQQ_ACCEPTANCE_BLOB_NAME: report-${{ matrix.project }}-${{ matrix.shard }}.zip
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
QQQ_ACCEPTANCE_BROWSER_TRACE: ${{ (matrix.project == 'webkit' || matrix.project == 'mobile' || matrix.project == 'tablet') && 'navigation' || '0' }}
DEBUG: pw:browser
run: node scripts/acceptance.mjs --skip-build --shard=${{ matrix.shard }}/${{ matrix.total }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: acceptance-blob-${{ matrix.project }}-${{ matrix.shard }}
path: test-results/acceptance/blob-report/
if-no-files-found: error
retention-days: 30
gate:
name: acceptance gate
if: always()
needs: [plan, build, shards]
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: acceptance-blob-*
path: test-results/acceptance/blobs
merge-multiple: true
- name: Merge shard reports
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/acceptance/report.json
PLAYWRIGHT_HTML_OUTPUT_DIR: test-results/acceptance/html
PLAYWRIGHT_HTML_OPEN: never
run: npx playwright merge-reports --reporter=json,html test-results/acceptance/blobs
- name: Evaluate acceptance matrix
if: always()
env:
ACCEPTANCE_MODE: ${{ needs.plan.outputs.mode }}
run: |
args=()
if [[ "$ACCEPTANCE_MODE" == pr ]]; then args+=(--partial); fi
node scripts/acceptance-gate.mjs "${args[@]}"
- name: Require every planned shard and successful build
if: always()
env:
PLAN_RESULT: ${{ needs.plan.result }}
BUILD_RESULT: ${{ needs.build.result }}
SHARDS_RESULT: ${{ needs.shards.result }}
PLANNED_MATRIX: ${{ needs.plan.outputs.matrix }}
run: |
test "$PLAN_RESULT" = success
test "$BUILD_RESULT" = success
test "$SHARDS_RESULT" = success
node --input-type=module -e 'import { existsSync } from "node:fs"; for (const { project, shard } of JSON.parse(process.env.PLANNED_MATRIX).include) { const file = "test-results/acceptance/blobs/report-" + project + "-" + shard + ".zip"; if (!existsSync(file)) throw new Error("Missing shard report: " + file); }'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: acceptance
path: test-results/acceptance/
if-no-files-found: error
retention-days: 30
quarantine:
name: acceptance quarantine (chromium)
needs: [plan, build]
if: needs.plan.outputs.mode == 'full'
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'
cache: maven
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Installed Playwright version
id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: browser-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-chromium
- run: pnpm exec playwright install-deps chromium
- if: steps.browser-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: acceptance-build
path: test-results
- name: Restore runtime artifacts
run: |
tar -xf test-results/acceptance-build.tar
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/test-results/acceptance-jars/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
echo "QQQ_MIDDLEWARE_API_JAR=$PWD/test-results/acceptance-jars/qqq-middleware-api.jar" >> "$GITHUB_ENV"
- name: Run quarantined scenarios
env:
QQQ_ACCEPTANCE_BROWSERS: chromium
QQQ_ACCEPTANCE_QUARANTINE: only
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
QQQ_ACCEPTANCE_BROWSER_TRACE: '0'
DEBUG: pw:browser
run: node scripts/acceptance.mjs --skip-build
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: acceptance-quarantine
path: test-results/acceptance/
retention-days: 30
+36 -153
View File
@@ -1,158 +1,41 @@
name: CI
# Fast PR checks (QRun-IO/qqq#709, bundle budget #710). The real-backend gate, including the
# runtime performance budgets (specs/performance), runs in acceptance.yml.
# Each job installs on its own so they run in parallel; none needs a backend.
on:
name: Next centralized CI
'on':
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
push:
branches: [main]
branches:
- main
tags:
- v*
workflow_dispatch:
inputs:
qqq_ref:
description: QQQ source ref for acceptance
type: string
required: false
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NEXT_TELEMETRY_DISABLED: '1'
STORYBOOK_DISABLE_TELEMETRY: '1'
jobs:
unit:
name: Typecheck, lint, unit tests and coverage
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- name: Unit tests with src/lib coverage thresholds (vitest.config.ts)
run: pnpm test:coverage
e2e:
name: Mocked e2e (Playwright, chromium)
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- name: Installed Playwright version
id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: browser-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-chromium
- run: pnpm exec playwright install-deps chromium
- if: steps.browser-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- name: Production build
run: pnpm build
- name: Mocked e2e suite
env:
QQQ_E2E_SERVER: production
run: pnpm test:e2e
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-report
path: |
playwright-report/
test-results/
retention-days: 14
collaboration-e2e:
name: Collaboration e2e (MSW; chromium, firefox, webkit, mobile, tablet)
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- name: Installed Playwright version
id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: browser-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-all
- run: pnpm exec playwright install-deps chromium firefox webkit
- if: steps.browser-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium firefox webkit
- name: Collaboration suite (mock API, every browser project)
run: pnpm exec playwright test --config playwright.collaboration.config.ts
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: collaboration-e2e
path: |
test-results/collaboration/
test-results/collab-shots/
retention-days: 14
storybook:
name: Storybook build
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- run: pnpm build-storybook --quiet
performance:
name: Bundle budget (static export)
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- name: Static export
run: pnpm build:export
- name: First-load JS per route, total JS and largest chunk against perf-budget.json
run: pnpm perf:budget
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: bundle-budget
path: test-results/performance/bundle.json
retention-days: 30
ci:
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action)) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
acceptance:
if: github.event_name != 'pull_request' || !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action) || github.event.label.name == 'full-acceptance'
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
with:
qqq_ref: ${{ inputs.qqq_ref || '' }}
dashboard-image:
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action)) || (github.event_name == 'push' && startsWith(github.ref,
'refs/tags/v'))
permissions:
contents: read
packages: write
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
-75
View File
@@ -1,75 +0,0 @@
name: Dashboard image
on:
pull_request:
push:
tags: ['v*']
permissions:
contents: read
env:
IMAGE: ghcr.io/qrun-io/qqq-frontend-next
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
if: github.event_name == 'push'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
file: docker/quickstart/Dockerfile
platforms: linux/${{ matrix.arch }}
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
cache-from: type=gha,scope=dashboard-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=dashboard-${{ matrix.arch }}
- name: Verify runnable dashboard
if: github.event_name == 'pull_request'
env:
TAG: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
run: |
container=$(docker run -d --rm -p 127.0.0.1:3000:3000 "$TAG")
trap 'docker rm -f "$container"' EXIT
curl -fsS --max-time 3 --retry 30 --retry-delay 1 --retry-all-errors \
--retry-max-time 90 http://127.0.0.1:3000/app/person >/dev/null \
|| { docker logs "$container"; exit 1; }
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-24.04
permissions:
packages: write
steps:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish versioned multi-architecture image
run: |
docker buildx imagetools create \
--tag "$IMAGE:${GITHUB_REF_NAME#v}" \
"$IMAGE:$GITHUB_SHA-amd64" "$IMAGE:$GITHUB_SHA-arm64"
+3
View File
@@ -0,0 +1,3 @@
profile: frontend-next
orb_version: '0.8'
default_branch: main
+47 -3
View File
@@ -2,7 +2,7 @@
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
**This checkout targets Next UI 1.0.0-RC.19**, which adds the related-record picker (single, multi-child and link modes) and the assistant Read-only / Write switch to RC18. Confirm availability in the [RC19 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.19) before installing; see the [RC19 notes](docs/releases/1.0.0-RC.19.md) for the preparation checkpoint and verification scope. [RC18](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.18) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
**This checkout targets Next UI 1.0.0-RC.36**, which adds accessibility fixes: readable link text, 24px clear targets, h2 widget titles and skip-link focus to RC35. Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing; see the [RC36 notes](docs/releases/1.0.0-RC.36.md) for the preparation checkpoint and verification scope. [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
**Current roadmap and known issues:** [Next UI 1.0 tracker](https://github.com/QRun-IO/qqq/issues/713). GitHub issues are the source of truth for status and remaining work; see [issue tracking](docs/ISSUE-TRACKING.md).
@@ -14,11 +14,11 @@ The dashboard is the default admin UI for QQQ 4.1+. Add the jar (managed by `qqq
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.21</version>
<version>1.0.0-RC.36</version>
</dependency>
```
The explicit version overrides an older BOM-managed frontend. RC21 requires a published QQQ 4.1 candidate: 4.1.0-RC.1 or later; 4.1.0-RC.2 or later for standalone branding images (QQQ #1009); and **4.1.0-RC.3 or later** for FILE_PREVIEW thumbnails (QQQ #1056) and the instance home app (QQQ #1059); **4.1.0-RC.4 or later** for record developer panels (QQQ #1076); **4.1.0-RC.5 or later** for the record picker (QQQ #1099), form validation limits (QQQ #1100) and date filter presets (QQQ #1115). The Alerts bell appears only with qbit-alerts (0.1.0-RC.1 or later); the notification center needs qbit-alerts 0.1.0-RC.2 or later. The assistant panel appears only with the qbit-assistant backend (0.1.0-RC.3 or later). The SQL Workbench needs the qbit-sql-workbench QBit (1.0.0-RC.1 or later); see the release notes for the verified backend. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
The explicit version overrides an older BOM-managed frontend. RC36 requires a published QQQ 4.1 candidate: 4.1.0-RC.1 or later; 4.1.0-RC.2 or later for standalone branding images (QQQ #1009); and **4.1.0-RC.3 or later** for FILE_PREVIEW thumbnails (QQQ #1056) and the instance home app (QQQ #1059); **4.1.0-RC.4 or later** for record developer panels (QQQ #1076); **4.1.0-RC.5 or later** for the record picker (QQQ #1099), form validation limits (QQQ #1100) and date filter presets (QQQ #1115). The Alerts bell appears only with qbit-alerts (0.1.0-RC.1 or later); the notification center needs qbit-alerts 0.1.0-RC.2 or later. The assistant panel appears only with the qbit-assistant backend (0.1.0-RC.3 or later). The SQL Workbench needs the qbit-sql-workbench QBit (1.0.0-RC.1 or later); QA capture appears only with qbit-qa-capture (the `qa-capture` UI API extension); see the release notes for the verified backend. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
The jar holds the static export (`pnpm build:export`) under `next-dashboard/`. Deep links such as `/app/person/1` are served from placeholder pages and read their route from the browser path, so no Node.js server is involved. The Material Dashboard remains available: select it with `withServeFrontendMaterialDashboard(true)` or `-Dqqq.javalin.frontend=material`. Material routes are `/<app>/<table>/<id>`; Next routes are `/app/<table>/<id>`.
@@ -63,6 +63,18 @@ To upgrade between RCs, change only the `qqq-frontend-next` version and check th
Served from the jar there is nothing to configure in the frontend: it calls the API on the same origin at `/qqq/v1`, and the Javalin server sends the security headers and Content-Security-Policy. External embeds (for example QuickSight frames) need an allowed CSP source, not a disabled policy. The build-time variables under [Environment Variables](#environment-variables) apply only to frontend development and the standalone container image.
### App home navigation grids
App homes show automatic section ("Actions", "Reports", "Data") and child-app ("Apps")
card grids. When the app declares one or more widgets, the "Apps" grid is left out (even if
a declared widget is unavailable to the current user), so widget-based navigation is not
duplicated; the section grids stay.
Set `supplementalAppMetaData.nextUi.showChildGrid` on the app to override this default:
`true` shows both grids alongside widgets; `false` hides both grids, for a home whose
widgets already cover its sections and child apps.
Only boolean values override the default. Existing visibility and permission rules still apply.
### Known limitations
- Release candidates only; final 1.0 is held for application soak and owner approval ([#712](https://github.com/QRun-IO/qqq/issues/712), tracker [#713](https://github.com/QRun-IO/qqq/issues/713)).
@@ -178,3 +190,35 @@ src/
- **Charts:** Recharts
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
- **Package Manager:** pnpm
## Default query columns
A new query view shows at most six fields: the visible primary key first, then
eligible fields from T1 sections followed by T2 sections, preserving section and
field order within each tier. Tables without sections use metadata field order.
Hidden fields, heavy fields, TEXT/HTML/BLOB/JSON-like types, and CODE_EDITOR or
RENDER_HTML adornments are excluded from the automatic selection (the primary key
is retained unless hidden). Fields outside T1/T2 are initially unchecked.
Other available columns remain selectable in the column picker.
Applications can override this selection with a string-array hint:
```json
{
"supplementalMetaData": {
"nextUi": {
"defaultColumns": ["id", "title", "status"]
}
}
}
```
The legacy `supplementalTableMetaData.nextUi.defaultColumns` path is also accepted;
the modern `supplementalMetaData.nextUi` object takes precedence. A valid hint
selects exactly the named available columns, without the six-field cap or automatic
primary-key inclusion. Unknown or unavailable fields are ignored; an empty array
selects no columns, and malformed hints fall back to the automatic selection.
Saved views and legacy stored visibility (including `{}`, meaning all base columns)
are preserved. New View and Reset All Changes on an unsaved view restore these
metadata defaults. Quick search searches only visible string fields. Responsive
card/grid behavior is unchanged.
+1
View File
@@ -28,6 +28,7 @@ The ID may be on a layout-neutral wrapper around a native button or menu item. S
- Record view: `.recordView`, `record-sidebar`, `record-view-header-{table}`, `record-view-title-{table}`, `record-view-button-bar-{table}`, `record-view-actions-menu`, and `delete-confirmation-*` with `button-delete-yes`/`button-delete-no`.
- File previews: a `FILE_PREVIEW` value keeps its cell or value ID (`grid-cell-{field}`, `field-value-{field}`) on the thumbnail button with `data-preview-kind`; the box is `{id}-thumbnail`. The dialog uses `file-preview-dialog`, `file-preview-image`/`-pdf`/`-video`/`-audio`/`-unavailable`, `file-preview-details`, and `file-preview-{previous,next,zoom-in,zoom-out,zoom-fit,open,download,close}`.
- Forms: `.entityForm`, `record-create-{table}`/`record-edit-{table}`, `form-section-{section}`, `.field-wrapper.is-visible`, and `.stickyBottomButtonBar`. Default action buttons expose `data-button-variant` values such as `gradient`, `contained`, and `outlined`.
- Jobs: `button-jobs`, `jobs-active-badge`, `jobs-popover`, `link-job-{jobId}`, `link-all-jobs`, `jobs-page`, `tab-jobs-{active,recent,all}`, `job-progress-view`, `job-status`, `job-phases`, `job-progress-bar`, `job-progress-stats`, `job-progress-message`, `job-log-panel`, `job-log-scroll`, `button-job-log-{toggle,copy,latest}`, `job-background-countdown`, `button-job-{stay,background,cancel,retry,done}`, and `link-job-record`. Jobs motion is confined to `.qqq-job-{fill,shimmer,pulse,ring}` and is static under reduced motion.
The record view has a phone accordion and action sheet, a tablet tab list without the large-screen sidebar, and a large-screen tab list with the sidebar. A selector can be present in the DOM while its control is hidden at a narrower width. Test custom CSS at all three widths, including focus and contrast, before shipping it.
+21
View File
@@ -0,0 +1,21 @@
# Help everywhere — implementation and verification
Backend: QRun-IO/qqq#1177 (`docs/HELP-CONTENT.md`).
## Implementation
- Vendored the supplied help-core `openapi.yaml` as the existing one-space-indented JSON document. The prior vendoring commits are `01dd7e9` and `8a0fbbd`; neither contains a generator script. Conversion uses the installed ESLint dependency `js-yaml`, then `JSON.stringify(document, null, 1)` with a final newline. The full core document is retained, including its other changes within UI API 1.2.0.
- Added `summary` to `QHelpContent`, reused that type for widget slots, and added app/process/report slot maps. `src/lib/api/help.ts` uses the typed API client and checks the advertised `help` feature before calling search. No off-spec exception was added. Search and coverage are optional spec operations; coverage is not called.
- `src/components/help/HelpIcon.tsx` selects role-specific help, shows the plain summary, and opens the shared long-help panel by click, Enter, or Space. Existing customization IDs are retained on the corresponding control or tooltip. Input descriptions remain available while the tooltip is closed.
- `HelpPanelProvider` is mounted in the authenticated dashboard. One `next/dynamic` panel renders sanitized long content using the existing `HelpContent` renderer, with related help, safe navigation, Escape dismissal, focus trapping, and return focus. Help controls use theme tokens and coarse-pointer touch targets.
- Existing field, section, grid-column, widget-title, widget-column, and process-step help now uses the shared icon. New placements cover app, table query/view/edit/create/copy, process, report, and record/table action headers or labels. Action help is a sibling of the action control; Radix menu help participates in roving focus.
- `HelpSearchResults` provides 200 ms debouncing, a two-character minimum, feature gating, and loading/error/empty states. Both `CommandMenu` and the global `SearchPage` use it. The page's existing grouped-results area is a natural place for help; the compact header/search-dialog listbox retains its current record/navigation model. The palette also offers **Search help…**.
- Author mode retains key suffixes. **Add help** / **Edit help** links require the core table's insert permission and use `/app/helpContent/create#/defaultValues=…` with `key` and the most specific `role`. `HELP_CONTENT_TABLE` is the one production constant for that core-defined table.
## Tests and fixtures
- New Vitest tests cover the API gate, role precedence, summaries, keyboard controls, author URL presets and permission checks, panel sanitization/focus, and palette help search.
- `tests/acceptance/fixture/HelpFixtures.java` defines synthetic Help Lab metadata, Markdown bodies and distinct summaries, an owned data table, and the core authoring table. It is wired into the normal define/reset lifecycle.
- `tests/acceptance/specs/help/help.spec.ts` and `matrix/help.json` add HLP-001 through HLP-006, light/dark cases, mobile tags, and 320/1920-pixel panel checks. Column/table-menu coverage remains desktop-only because mobile query results use cards. The area is registered in `acceptance-select.mjs`.
- Existing REC-039/040, PRC-015, and widget-column assertions now focus the help button and inspect formatted long content in the panel. This is necessary because tooltips now contain plain summaries and touch opens the panel. Existing scenarios and matrix requirements remain; none was waived.
- Historical results in `docs/acceptance/feature-matrix.md` are preserved. The new help rows are appended as **not run**.
+106
View File
@@ -0,0 +1,106 @@
# QA capture (Jam)
Optional QA bug capture for the Next UI. The backend is the `qbit-qa-capture` QBit, which publishes the `qa-capture` UI API extension (1.0.0). [Jam](https://jam.dev) is the first provider. The UI calls only the two operations in [`src/lib/api/openapi/extensions/qa-capture.json`](../src/lib/api/openapi/extensions/qa-capture.json):
| Operation | Call | Used for |
|---|---|---|
| `qaCaptureCapability` | `GET /qqq/v1/qbit/qa-capture/capability` | Whether capture is on for the signed-in user, and the Jam team, sanitizer rules, blur selectors and backend context |
| `qaCaptureReportCreate` | `POST /qqq/v1/qbit/qa-capture/report` | "Report a bug": creates a report and returns the Recording Link `jam-*` parameters |
The vendored document is hand-written from the shared contract until the QBit publishes its generated `openapi/qqq-v1/qa-capture.yaml`. Re-vendor it from the QBit before release.
## When anything loads
Capture is off unless all of the following hold. When it is off, the UI loads no Jam chunk, adds no Jam meta tags and makes no Jam request:
1. `GET /qqq/v1/version` lists the `qa-capture` extension. Otherwise the capability is never requested.
2. The user is signed in. `QaCaptureLoader` in the root providers then requests the capability once (TanStack Query, `['qqq', 'qa-capture', 'capability']`).
3. The capability has `enabled: true`, provider `jam`, a team id and sanitizer rules. Anything else counts as disabled.
When capture is enabled, `src/lib/qa-capture/jam-loader.ts` (the only module that imports Jam, and only through `import()`) does the following:
- adds `<meta name="jam:team">` and `<meta name="jam:blur">` (the server's `blurSelectors` plus `[data-qqq-secure]`);
- dynamic-imports `@jam.dev/recording-links/sdk` (0.4.0) and `@jam.dev/sdk` (1.0.1);
- calls `jam.initialize({ teamId, blurSelectors, network, console })` once, with the sanitizers;
- registers `jam.metadata(...)`.
A Vitest check fails if any other file imports Jam at runtime.
What the SDKs fetch was checked in their published source. `initialize` loads `https://js.jam.dev/recorder.js` only in two cases: the page URL has `jam-recording` (checked on load and on every `pushState`, `replaceState` or `popstate`), or `loadRecorder()` is called. It loads `https://js.jam.dev/capture.js` only while some tab has an active recorder, which it tracks with the `jam:numRecorders` localStorage counter. `jam.metadata` only stores a callback on `window.__jam__`.
The browser cannot undo the Jam SDK, so turning capture off for a user (QA mode off) takes effect on the next page load. Request-id collection stops immediately.
## QA mode
When `capability.qaMode.preferenceSection` is set, the Preferences page renders that section like any other. Saving it refetches the capability, so turning QA mode on loads Jam without a reload. While `reason` is `QA_MODE`, the header shows a "QA mode on" badge that links to the section.
## Report a bug
The user menu shows **Report a bug** when `canReport` is true, and **QA reports** when `reportsHref` is an in-app path. The UI follows that link as given and never builds table paths itself. The Next UI has no help menu (the header help button opens the keyboard shortcuts), so both entries are in the user menu only.
Report a bug sends `POST /report` with:
- the page URL, with the sanitizer `queryParamNames`, the OAuth `code`, `state` and token parameters, and every `jam-*` parameter removed;
- the title `Bug: <page title>`;
- the app, table, record id or process, resolved from `/app/<slug>[/<id>[/<process>]]` against the instance metadata;
- the last request ids;
- the frontend version, user agent and viewport.
When the response has `recordingParams`, the UI opens the recorder with `await jam.loadRecorder({ openImmediately: false })` and then `Recorder.open(params['jam-recording'], { jamParams: { 'jam-title', 'jam-reference', ... } })`. The SDK puts those parameters on the URL while recording and removes them when the recorder closes, which lets the QBit's `jam.created` webhook match the capture to the report by `jam-reference`.
Without `recordingParams`, a toast explains that an administrator must set the Recording Link in the QA capture settings. It includes a QA reports action when `reportsHref` is present.
## Sanitizers
`src/lib/qa-capture/sanitizers.ts` builds the four Jam sanitizers from `capability.sanitizer`. They are pure, synchronous and deterministic, and they never log or fetch. The server's rules always get a baseline added: the `authorization`, `proxy-authorization`, `cookie` and `set-cookie` headers; the `password`, `sessionUUID`, `accessToken`, `refreshToken`, `idToken`, `clientSecret` and `code_verifier` fields; the `code`, `state`, `access_token` and `id_token` parameters; and JWTs.
| Data | Rule |
|---|---|
| Request and response headers | `headerNames` (lowercase) are deleted |
| JSON bodies | values of `bodyFieldNames` keys are replaced at any depth (case-insensitive); `valuePatterns` are applied to every string; the body stays valid JSON |
| Form-encoded bodies | the same, field by field |
| Other text bodies, WebSocket data | `valuePatterns` |
| Request URLs | `queryParamNames` values are redacted; `valuePatterns` are applied |
| Same-origin paths under `dropPathPrefixes` | request and response bodies are dropped |
| Console arguments | object keys and string patterns as for JSON |
A `valuePatterns` entry that is not a valid RegExp makes the affected sanitizers throw. Jam then redacts that data instead of sending it (fail closed).
## Secure fields
The UI knows a value is secure from its field metadata:
- the field type is `PASSWORD`;
- the field has the `REVEAL` adornment;
- secured data restricts the field (`accessTier` is `MASKED` or `REVEALABLE`).
Elements that show or edit such a value carry `data-jam-blur` and `data-qqq-secure`. This covers `MaskedValue`, a revealed restricted value, grid password and reveal cells, record view reveal and password values, password inputs (including while shown as text) and secure text inputs. The attributes are always present and have no effect when capture is off.
## Request ids
While capture is enabled, an axios interceptor keeps the last 50 `{ id, method, path, status, at }` entries from the response header named by `capability.requestIdHeader` (default `X-Request-Id`). The path is stored without its query string. They go into the Jam metadata and the report. Disabling capture clears the buffer.
## Jam metadata
At capture time Jam receives the following, kept under 10 KB by dropping the oldest request ids first:
- the app, table, record id and process;
- the frontend version (`NEXT_PUBLIC_APP_VERSION`);
- the backend `environment`, `backendVersion` and `node` from the capability;
- the request ids;
- the report reference;
- the page path, filtered as above.
## Recording Links and sign-in
A Recording Link can open a protected page as `?jam-recording=...&jam-title=...`. The login redirect, the OIDC/OAuth2 callback and the 401 redirect already carry the query in `returnTo`. Every page load also stashes the `jam-*` parameters in sessionStorage. After sign-in they are put back on the URL with `history.replaceState`, unless the page already has a `jam-recording`. The stash expires after 30 minutes. The Jam SDK watches `replaceState`, so the recorder then opens.
The public `/qa/recorder` page for Jam domain verification is served by the QBit, not by Next. The standalone server forwards `/qa/*` to the backend.
## Content-Security-Policy
Next adds no CSP of its own. In a jar deployment the dashboard CSP comes from the QQQ server (`NextDashboardSecurityHeaders`), and the QBit or application must add the Jam origins listed in `capability.cspOrigins`. The standalone container server (`standalone/qqq-server.mjs`) adds them only when `QQQ_QA_CAPTURE_CSP=true`:
- `script-src https://js.jam.dev`
- `frame-src https://*.jam.dev`
- `connect-src https://*.jam.dev`
+17
View File
@@ -0,0 +1,17 @@
# UI API 1.6 adoption
Adopts QQQ UI API 1.6.0 (QQQ 4.1.0-RC.15): table clientCapabilities, field validation, BasicErrorResponseV1, /qqq/v1/branding.
- Vendors the supplied core, api-aware, ESB, alerts, collaboration, data-analysis and EDI documents; regenerates EDI types. Assistant, quick-search, secured-data and sql-workbench documents are unchanged.
- Saved views, audits, sharing and bulk actions use per-user table capabilities. Legacy discovery is used only when `clientCapabilities` is absent; a missing or unavailable feature in a present capability object does not enable legacy access.
- Forms use `field.validation` for required values, length policies, inclusive/exclusive bounds and whole-value patterns. Existing record-write error mapping remains intact.
- Errors prefer `message` over legacy error fields. Global error toasts and process error details expose the trace identifier. Session-expiration handling remains in place.
- Standalone branding aliases call `/qqq/v1/branding/{role}` and retry the old public route only after a 404. Instance consumers prefer `supplementalInstanceMetaData.uiSettings`, with deprecated settings as a fallback.
The app version is unchanged. No commit, push, PR or deployment was made.
## Verification
The supplied server specification comparison passes at 1.6.0. `OFF_SPEC_CALLS` remains empty and `OFF_SPEC_CEILING` remains 0.
Vitest startup is blocked by this session's Windows sandbox (`spawn EPERM` launching esbuild), so no Vitest pass count is claimed. The production bundle compiles, but `pnpm build` stops at its TypeScript child-process launch with `spawn EPERM`; no completed build or bundle-budget delta is available. Standalone `pnpm typecheck` passes. `pnpm lint` passes with six pre-existing warnings. Twelve direct Node smoke checks pass: seven lossless JSON vendoring comparisons and five branding status/fallback cases. The focused contract/extension Vitest invocation and full `pnpm test` both stop before running tests.
+24 -2
View File
@@ -91,7 +91,7 @@ The additional ESB integration scenarios and their focused three-browser results
| PRC-016 | Back to the backend back step with entered values | Back restarts at the back step with values (possible-value label shown); resubmitting saves again | no Back without a backend backStep | prcComponents review, prcWizard longDetails, greetInteractive | pass (chromium) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#667](https://github.com/QRun-IO/qqq/issues/667) |
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | short route drops a screen from the stepper; values logged (SQL) | | prcWizard | pass (chromium) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | field relabelled and required on the later screen | empty relabelled required field blocks | prcWizard long route | pass (chromium) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-019 | Async job progress (message, N of M, progress bar, updated time) then continue | RUNNING progress shown while polling; finished screen and SQL log | | prcProgress | pass (chromium) | [#657](https://github.com/QRun-IO/qqq/issues/657) |
| PRC-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) then continue | RUNNING progress shown while polling; finished screen and SQL log | | prcProgress | pass (chromium) | [#657](https://github.com/QRun-IO/qqq/issues/657) |
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | confirm dialog; cancel step row (SQL) from a screen and while working | Stay on Page keeps the run | prcProgress cancelStep | pass (chromium) | |
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | user-facing message; internal detail toggle; expanded details meet contrast requirements in light, dark and application themes; simpleThrow "I always throw."; Close returns to the app | | prcFailures, prcComponents, sample simpleThrow | pass (chromium) | |
| PRC-022 | Retry after an error restarts with the original record selection | retry loads the selected records 2 and 4 | | prcFlaky (fails once) | pass (chromium) | [#658](https://github.com/QRun-IO/qqq/issues/658) |
@@ -190,7 +190,7 @@ The additional ESB integration scenarios and their focused three-browser results
| QRY-066 | Variant options come from the v1 variants route | the variant picker lists the stores from GET /qqq/v1/table/{table}/variants; the v1 route lists every option with its type and id; a table without variants lists none | an unknown table is refused (403) | qryStock (qryStore options) | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | v1 table metadata carries supplementalMetaData.materialDashboard.gotoFieldNames (API); Go To... on the query toolbar and the record view header offers the primary key, then each key, labelled by field labels; primary key, single-field and two-field keys each open the one matching record (EQUALS query, id from SQL); no match shows "Record not found."; a partial key matching two bins shows "More than 1 record was found..." (counts from SQL); Enter in an input submits its option; Escape closes and returns focus to the Go To button; a table with GET but no QUERY capability opens Go To automatically and it cannot be dismissed | a table without gotoFieldNames has no Go To button on its query or record screen; no navigation on no match or several matches | QueryFixtures.java qryBin (gotoFieldNames [[code], [aisle, shelf]]), qryLocker (GET without QUERY), qryItem (no Go To) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order (Material bySection order) | Headers read Id, Name, Code, Price, Quantity; Rows equal SQL select ordered by id desc | The metadata field-map order (price first) is not used | QueryFixtures.java qryOrdered (qry_item rows; fields declared price, quantity, code, id, name; sections identity [id, name, code], details [price], stock [quantity]) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | Page 5 at 250 per page reads Showing 1,001–1,234 of 1,234; The page shows exactly the last 234 ids by SQL | No unformatted range such as 1001–1234 | QueryFixtures.java qryManyRow (1,234 rows) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | Page 5 at 250 per page reads Showing 1,001–1,234 of 1,234; The page shows exactly the last 234 ids by SQL | No unformatted range such as 1001–1234 | QueryFixtures.java qryManyRow (1,234 rows) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | r sends a new query request and shows a value changed through the API (SQL confirms the change); f opens the Advanced Filters panel and a second f keeps it open; n opens the create form | keys typed in quick search are text (no filter panel, no navigation); viewer persona: n does nothing (f still works); the backend refuses the insert (403, SQL unchanged) | sample person | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and return to the query | Bulk Delete of the South store's full query result counts its one record; Every init and step request carries tableVariant {type: qryStore, id: 2}; Return comes back to the variant query, which is empty for South while North keeps its two rows | Without the variant the backend could not read the variant table's records | QueryFixtures.java qryStock (memory backend keyed by qryStore variants) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | First Name quick filter finds the SQL-matching records; Type-specific defaults gate Boolean until chosen, default possible values to any-of and date-time to after; A multi-value chip summarizes extra values; A saved Basic view restores its criterion and SQL rows | No application-specific field names are embedded in the UI; Cancel discards an un-applied quick-filter draft | sample person and QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
@@ -348,6 +348,7 @@ The additional ESB integration scenarios and their focused three-browser results
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | record edited through the API shows the new value on revisit | record deleted through the API shows not found | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#683](https://github.com/QRun-IO/qqq/issues/683) |
| INT-013 | Empty state: a query with no results explains it and offers the create action | no-match filter shows the empty message and Create | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | Try again after recovery signs in | network failure shows "Sign-in failed" | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-050 | Optional QA capture (Jam, qbit-qa-capture qa-capture extension): Jam loads only for enabled users; Report a bug opens the recorder; secure values blurred; captures sanitized | extension absent or capability disabled: no Jam chunk, meta or request; enabled: jam:team meta, one initialize with sanitizers, jam.metadata; Report a bug: POST report then Recorder.open with jam-title and jam-reference; Recording Link jam-* parameters survive the OIDC sign-in; QA mode preference refetches the capability | no Recording Link configured: toast, no recorder; Authorization/Cookie headers, secure body fields and JWTs redacted | qbit-qa-capture (not yet released); Vitest: src/lib/qa-capture/*.test.ts, src/components/qa-capture/QaCapture.test.tsx | excluded (Pending: not yet approved by the owner. No real-backend fixture until qbit-qa-capture is released; the row is covered by Vitest until then (QA capture PR, 2026-10-15).) | |
## Dashboards, widgets, reports, saved and scheduled reports, and sharing
@@ -442,3 +443,24 @@ The additional ESB integration scenarios and their focused three-browser results
| RPT-020 | Report files download from the v1 download and report routes | a process report links its file at /qqq/v1/download/... and the file has every row; a report without a process streams from /qqq/v1/reports/{name}?format=csv with every row | an unregistered server file or storage reference is refused (403); a restricted report is 403, an unknown one 404, a missing format 400 | accPersonReport, accStreamedReport, accRestrictedReport | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| WID-074 | Standalone composite scalar controls and metadata editors | Seeded text, number, date, boolean and native file controls; currency prefix, named possible values and multiline code editor; Choice width and center-click behavior with system and monospace fonts, including a 320px viewport; Backend uppercase/lowercase metadata preserves middle-of-value edits and caret position in standalone blocks; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Desktop wheel scrolling preserves plain and shared numeric block values; intentional ArrowUp edits still work and text inputs retain focus; Native standalone TIME controls increment by one second and retain second-level values. | Code-editor Enter leaves the widget on the same page; Multiline Enter does not submit or navigate away | widgetInputEditors / accTypedInputs | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | UTC timestamp displays the correct local date, time and seconds | Do not slice UTC digits into a local-time field | accTypedInputs in America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
## Contextual help, help search and authoring
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| HLP-001 | App and widget help | summary tooltip; keyboard panel; sanitized long markdown; focus restoration; light and dark; desktop and mobile | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-002 | Table, field and section help | view screen; edit screen; create screen; role selection | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-003 | Query columns and table action help | column summary; query table header; action is not invoked by help | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-004 | Process, step and record action help | process header; step header; record menu keyboard control | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-005 | Help search | static Search help command; debounced help query; result summary and panel; Go to link | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-006 | Help author affordance | key and specific role URL presets; standard create form; read-only users cannot author | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
The help rows above have not been executed locally; historical results above predate this change.
## Release notes UI API extension
`release-notes` 1.0.0 (qbit-release-notes 0.1.0-RC.1+) provides What's new at
`/app/whats-new`: category groups, new badges, append paging, safe links/screenshots,
release deep links, seen tracking and the user-menu unread count. Extension discovery
hides the menu and prevents requests on unsupported servers. Covered by the release-notes
client/page and Sidebar Vitest suites; real-backend acceptance remains pending.
+1 -1
View File
@@ -545,7 +545,7 @@ Process and report launch, the step lifecycle, process form fields, step compone
| Load instance/process/table metadata, with load-error messages | `material:src/qqq/pages/processes/ProcessRun.tsx:1858-1897` | `src/app/(dashboard)/app/[slug]/page.tsx` ("Failed to load process metadata.") | none | Done |
| Init, then handle JobComplete / Started / Running / Error | `material:src/qqq/pages/processes/ProcessRun.tsx:1595-1740` | `src/lib/hooks/use-process.ts:handle` | PRC-001, PRC-019 | Done |
| Async polling: 1.5 s, ×1.5 backoff to 12 s on 5xx, then "Could not connect to server" | `material:src/qqq/pages/processes/ProcessRun.tsx:98-100, 1745-1795` | `src/lib/hooks/use-process.ts` pollRef / POLL_* | PRC-019 | Done |
| Working screen: spinner, message, "N of M", progress bar, "Updated at" | `material:src/qqq/pages/processes/ProcessRun.tsx:631-670` | `src/components/process/ProcessRun.tsx` working block | PRC-019 | Done |
| Working screen: spinner, message, "N of M", progress bar, "Updated at" | `material:src/qqq/pages/processes/ProcessRun.tsx:631-670` | `src/components/jobs/InlineJobProgress.tsx` (live job view: phases, message, N / M, eased bar, ETA, elapsed and live log in place of "Updated at") | PRC-019 | Done |
| Loading shown before the first step | `material:src/qqq/pages/processes/ProcessRun.tsx:631, 2079` | `src/components/process/ProcessRun.tsx` (phase ≠ step) | PRC-040 | Done |
| userFacingError vs internal error | `material:src/qqq/pages/processes/ProcessRun.tsx:1720-1734` | `src/lib/hooks/use-process.ts` ERROR case | PRC-021 | Done |
| Error screen: "An error occurred…", show/hide detail toggle, Close/Cancel | `material:src/qqq/pages/processes/ProcessRun.tsx:589-629` | `src/components/process/ProcessErrorState.tsx` | PRC-021, PRC-022 | Done: Next adds Retry |
+1 -1
View File
@@ -1,6 +1,6 @@
# Next UI agent guide
> **This checkout targets RC21.** Confirm availability in the [RC21 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.21) before installing. [RC21 notes](../releases/1.0.0-RC.21.md) record the preparation checkpoint and verification scope; RC20 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
> **This checkout targets RC36.** Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing. [RC36 notes](../releases/1.0.0-RC.36.md) record the preparation checkpoint and verification scope; RC35 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
+3 -3
View File
@@ -1,12 +1,12 @@
# Next UI developer guide
> **This checkout targets RC21.** Confirm availability in the [RC21 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.21) before installing. [RC21 notes](../releases/1.0.0-RC.21.md) record the preparation checkpoint and verification scope; RC20 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
> **This checkout targets RC36.** Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing. [RC36 notes](../releases/1.0.0-RC.36.md) record the preparation checkpoint and verification scope; RC35 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
> **RC9:** application code is unchanged from RC8. See [RC9 notes](../releases/1.0.0-RC.9.md) and [published releases](https://github.com/QRun-IO/qqq-frontend-next/releases) for versioned verification and public delivery status. Earlier RC8 receipts below remain historical evidence.
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The previously verified public candidate at the RC21 preparation checkpoint was [1.0.0-RC.20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20) (commit `60d0b0f`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The previously verified public candidate at the RC36 preparation checkpoint was [1.0.0-RC.35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) (commit `55c1501`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
The visual baseline is the September 25 Next UI design (`e42ad2b2`). Material remains the reference for backend contracts and functional behavior. The layout defaults below describe current source; they do not certify a completed visual review.
@@ -35,7 +35,7 @@ The preferred QQQ-hosted distribution is the `com.kingsrook.qqq:qqq-frontend-nex
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.21</version>
<version>1.0.0-RC.36</version>
</dependency>
```
+2 -2
View File
@@ -10,11 +10,11 @@ RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-nex
The owner authorizes publishing new RCs at verified stability or daily milestones. Each RC gets a new immutable version and notes describing its fixes, backend prerequisites, verification, and known gaps. RC publication does not authorize final 1.0.
Previously verified public candidate at the RC21 preparation checkpoint: **[1.0.0-RC.20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC19 remain available and unchanged.
Previously verified public candidate at the RC36 preparation checkpoint: **[1.0.0-RC.35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC20 and RC22 through RC35 remain available and unchanged (RC21 was never published).
## Fixes during testing
This checkout targets [RC21](1.0.0-RC.21.md); confirm availability in the [RC21 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.21) before installing. RC21 adds file attach in the assistant panel (bulk load) ([PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125) (attach a file in the assistant panel for bulk load)). RC20 added readable assistant answers (GFM and presentation blocks), data-analysis profile panels, and favorites and recents ([PR92](https://github.com/QRun-IO/qqq-frontend-next/pull/92) (UI API version contract), [PR104](https://github.com/QRun-IO/qqq-frontend-next/pull/104) (UI API contract picker paths), [PR109](https://github.com/QRun-IO/qqq-frontend-next/pull/109) (acceptance ESB extension fix), [PR103](https://github.com/QRun-IO/qqq-frontend-next/pull/103) (data-analysis column and table profile panels), [PR105](https://github.com/QRun-IO/qqq-frontend-next/pull/105) (favorites, server recents and command-palette favorites), [PR111](https://github.com/QRun-IO/qqq-frontend-next/pull/111) (readable assistant answers: GFM markdown and presentation blocks)). RC19 added the related-record picker (single, multi-child and link modes) and the assistant Read-only / Write switch ([PR96](https://github.com/QRun-IO/qqq-frontend-next/pull/96) (related-record picker: single, multi-child and link modes), [PR101](https://github.com/QRun-IO/qqq-frontend-next/pull/101) (assistant Read-only / Write switch per conversation)). RC18 added the Authentik policy editor widget and the assistant pending-action card with persona ([PR91](https://github.com/QRun-IO/qqq-frontend-next/pull/91) (bundle budget report-only (owner decision)), [PR97](https://github.com/QRun-IO/qqq-frontend-next/pull/97) (faster sharded acceptance CI), [PR98](https://github.com/QRun-IO/qqq-frontend-next/pull/98) (budget script honors "enforce": false), [PR95](https://github.com/QRun-IO/qqq-frontend-next/pull/95) (Authentik policy editor widget), [PR94](https://github.com/QRun-IO/qqq-frontend-next/pull/94) (assistant pending-action card, persona and refresh after a write)). RC17 added the notification center (bell inbox, notification list and preferences for qbit-alerts user notifications) ([PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89) (notification center)). RC16 added the record picker for large possible-value fields, metadata-driven form validation, date-first filters with presets, the Alerts bell and record panel, resizable navigation and search preview thumbnails ([PR79](https://github.com/QRun-IO/qqq-frontend-next/pull/79) (resizable desktop navigation), [PR84](https://github.com/QRun-IO/qqq-frontend-next/pull/84) (search preview thumbnails), [PR86](https://github.com/QRun-IO/qqq-frontend-next/pull/86) (CSS budget 24 KB), [PR87](https://github.com/QRun-IO/qqq-frontend-next/pull/87) (range-error wording follows the backend), [PR85](https://github.com/QRun-IO/qqq-frontend-next/pull/85) (Alerts bell and record panel), [PR82](https://github.com/QRun-IO/qqq-frontend-next/pull/82) (date-first filters), [PR81](https://github.com/QRun-IO/qqq-frontend-next/pull/81) (quiet, metadata-driven form validation), [PR80](https://github.com/QRun-IO/qqq-frontend-next/pull/80) (record picker for large possible-value fields)). RC15 added the QQQ Assistant panel, the record developer view, search-as-you-type quick search and a search page, readable dashboard widget widths, one page scroll container and SQL Workbench dark mode ([PR58](https://github.com/QRun-IO/qqq-frontend-next/pull/58) (README adoption guide), [PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55) (readable dashboard widget widths), [PR54](https://github.com/QRun-IO/qqq-frontend-next/pull/54) (one page scroll container), [PR77](https://github.com/QRun-IO/qqq-frontend-next/pull/77) (Dependabot limited to three open PRs per ecosystem), [PR78](https://github.com/QRun-IO/qqq-frontend-next/pull/78) (SQL Workbench dark mode and standard controls), [PR61](https://github.com/QRun-IO/qqq-frontend-next/pull/61) (record developer view), [PR60](https://github.com/QRun-IO/qqq-frontend-next/pull/60) (QQQ Assistant panel, phase 1), [PR59](https://github.com/QRun-IO/qqq-frontend-next/pull/59) (search-as-you-type quick search, richer results and a search page)). RC14 was an urgent fix: record search no longer fails when one result has no label ([PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57)). RC13 added the SQL Workbench ([PR47](https://github.com/QRun-IO/qqq-frontend-next/pull/47)), FILE_PREVIEW thumbnails and lightbox ([PR49](https://github.com/QRun-IO/qqq-frontend-next/pull/49), QQQ #1056), responsive layouts with readable child lists and compact dates ([PR50](https://github.com/QRun-IO/qqq-frontend-next/pull/50)) and the instance home app ([PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53), QQQ #1059), and the other changes listed in the RC13 notes. These changes are absent from immutable RC12; the RC14 search fix isLine truncated
This checkout targets [RC36](1.0.0-RC.36.md); confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing. RC36 adds accessibility fixes: readable link text, 24px clear targets, h2 widget titles and skip-link focus ([PR175](https://github.com/QRun-IO/qqq-frontend-next/pull/175) (a11y: readable link text, 24px clear target, h2 widget titles, skip link focus)). RC35 added a profile photo uploader and avatars everywhere (UI API 1.8.0) ([PR164](https://github.com/QRun-IO/qqq-frontend-next/pull/164) (profile photo uploader and avatars everywhere, UI API 1.8.0)). RC34 added fail-soft error handling: layered error boundaries, validated assistant payloads and streams that can no longer lock the panel ([PR172](https://github.com/QRun-IO/qqq-frontend-next/pull/172) (layered error boundaries, zod payload repair, streaming can no longer lock the assistant panel)). RC33 added the new navigation (sidebar sections and favorites, one search, a new header, single-key shortcuts) and an assistant crash fix ([PR168](https://github.com/QRun-IO/qqq-frontend-next/pull/168) (sidebar sections and favorites, one search, new header, single-key shortcut switch), [PR170](https://github.com/QRun-IO/qqq-frontend-next/pull/170) (empty assistant query results no longer crash the page)). RC32 added a What's new page and user-menu entry for release notes ([PR165](https://github.com/QRun-IO/qqq-frontend-next/pull/165) (What's new page and user-menu entry, release-notes UI API 1.0.0)). RC31 added UI API 1.6.0 adoption: client capabilities, field validation, one error schema and v1 branding ([PR162](https://github.com/QRun-IO/qqq-frontend-next/pull/162) (adopt QQQ UI API 1.6.0: clientCapabilities, field validation, error schema, v1 branding)). RC30 added optional Jam QA capture on the qbit-qa-capture UI API extension ([PR160](https://github.com/QRun-IO/qqq-frontend-next/pull/160) (optional Jam QA capture on the qbit-qa-capture qa-capture extension)). RC29 added the EDI mapper page on the qbit-edi UI API extension ([PR136](https://github.com/QRun-IO/qqq-frontend-next/pull/136) (EDI mapper page on the qbit-edi edi extension, all calls on spec)). RC28 added zero off-spec UI API calls, so every backend call is a /qqq/v1 operation in the spec (minimum UI API 1.4.0) ([PR141](https://github.com/QRun-IO/qqq-frontend-next/pull/141) (zero off-spec UI API calls; OFF_SPEC_CEILING 0; minimum UI API 1.4.0)). RC27 added the secured-data UI (masked values, reveal, restricted filters, break-glass banner) and the live job view with a header Jobs tray and Jobs page ([PR124](https://github.com/QRun-IO/qqq-frontend-next/pull/124) (secured-data UI: masked values, reveal, restricted filters, break-glass banner), [PR155](https://github.com/QRun-IO/qqq-frontend-next/pull/155) (live job view, header Jobs tray and Jobs page), [PR156](https://github.com/QRun-IO/qqq-frontend-next/pull/156) (large-table acceptance fixture declares its default columns)). RC26 added tables that open with a short default column set instead of every field ([PR142](https://github.com/QRun-IO/qqq-frontend-next/pull/142) (tables open with a short default column set)). RC25 added an assistant panel that never blocks asking, re-attaches after refresh, shows live progress and can always Stop ([PR151](https://github.com/QRun-IO/qqq-frontend-next/pull/151) (assistant: never block asking, re-attach after refresh, live progress and Stop)). RC24 added the tablet record-tab fix, empty tables that explain themselves, a Full access checkbox in the policy editor and homes without duplicate card grids ([PR149](https://github.com/QRun-IO/qqq-frontend-next/pull/149) (record section tabs wrap instead of folding into More; tablet regression from PR144), [PR150](https://github.com/QRun-IO/qqq-frontend-next/pull/150) (empty tables explain what they are for and how rows get created), [PR148](https://github.com/QRun-IO/qqq-frontend-next/pull/148) (Full access checkbox in the authentik policy editor), [PR145](https://github.com/QRun-IO/qqq-frontend-next/pull/145) (no automatic card grids on homes that declare widgets)). RC23 added help everywhere, the Preferences page, contingent possible values in forms, search typeahead and System/Light/Dark theming ([PR127](https://github.com/QRun-IO/qqq-frontend-next/pull/127) (help everywhere: help icons, long-help panel, help search, author links), [PR121](https://github.com/QRun-IO/qqq-frontend-next/pull/121) (Preferences page, notification settings drawer and section actions), [PR120](https://github.com/QRun-IO/qqq-frontend-next/pull/120) (contingent possible values across forms, bulk edit, processes and filters), [PR143](https://github.com/QRun-IO/qqq-frontend-next/pull/143) (typeahead suggestions from qbit-quick-search in the global search box), [PR140](https://github.com/QRun-IO/qqq-frontend-next/pull/140) (authentik policy editor feature permissions and member preview), [PR138](https://github.cLine truncated
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
+2
View File
@@ -1,5 +1,7 @@
# Next UI 1.0.0-RC.21 — preparation
> **RC21 was never published.** Its tag build failed the UI API off-spec ceiling test, so the Maven Central and GHCR publishes were cancelled. Its changes ship in [RC22](1.0.0-RC.22.md).
At this preparation checkpoint RC21 **is not yet published**. [RC20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.21` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.21`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
+38
View File
@@ -0,0 +1,38 @@
# Next UI 1.0.0-RC.22 — preparation
At this preparation checkpoint RC22 **is not yet published**. [RC20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20) remains the latest verified public candidate and is immutable. RC21 was tagged but never published (its tag build failed the off-spec ceiling test and publishing was cancelled); its content is included here. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.22` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.22`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `48e81b4` (merge of [PR128](https://github.com/QRun-IO/qqq-frontend-next/pull/128)). Merged since RC20: [PR113](https://github.com/QRun-IO/qqq-frontend-next/pull/113) (collaboration record panel: comments, files, activity, tags and follow), [PR128](https://github.com/QRun-IO/qqq-frontend-next/pull/128) (collaboration: realtime presence, conflict review, sharing, tasks and My work), [PR106](https://github.com/QRun-IO/qqq-frontend-next/pull/106) (data-analysis pivot / group-by explorer), [PR118](https://github.com/QRun-IO/qqq-frontend-next/pull/118) (data-analysis compare, correlation, crosstab and shift explorers), [PR123](https://github.com/QRun-IO/qqq-frontend-next/pull/123) (data-analysis save, share, star, export and watch), [PR126](https://github.com/QRun-IO/qqq-frontend-next/pull/126) (run processes in background, background-job banner, widget refresh), [PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125) (attach a file in the assistant panel for bulk load), [PR132](https://github.com/QRun-IO/qqq-frontend-next/pull/132) (vendored alerts and sql-workbench extension documents, off-spec 46 to 21), [PR131](https://github.com/QRun-IO/qqq-frontend-next/pull/131) (off-spec ceiling for POST /uploads), [PR129](https://github.com/QRun-IO/qqq-frontend-next/pull/129) (chart data table overflow and doubled widget section headings).
## Backend requirements
QQQ **4.1.0-RC.6** or later. The collaboration screens need qbit-collaboration **0.1.0-RC.1** or later. The data-analysis explorers need qbit-data-analysis **0.1.0-RC.1** or later (extension 1.4.0). Assistant file attach uses the bulk-load UI API operations in QQQ **4.1.0-RC.7** and the tools in qbit-assistant **0.1.0-RC.10**; the upload endpoint (qqq#1181) is not yet released, so an attach attempt fails with a toast until it ships. Background process runs appear only when the server advertises `durableJobEligible` (qqq#1178 and qqq#1181); against older servers nothing new shows. RC22 does not publish QQQ 4.1.
## Features
- **Collaboration** ([PR113](https://github.com/QRun-IO/qqq-frontend-next/pull/113), [PR128](https://github.com/QRun-IO/qqq-frontend-next/pull/128)): record panel (comments, files, activity), tags and follow; realtime presence, conflict review, sharing, tasks and My work.
- **Data analysis** ([PR106](https://github.com/QRun-IO/qqq-frontend-next/pull/106), [PR118](https://github.com/QRun-IO/qqq-frontend-next/pull/118), [PR123](https://github.com/QRun-IO/qqq-frontend-next/pull/123)): pivot / group-by, compare, correlation, crosstab and shift explorers; save, share, star, export and watch.
- **Background process runs** ([PR126](https://github.com/QRun-IO/qqq-frontend-next/pull/126)): run in background, background-job banner, widget refresh.
- **Assistant file attach** ([PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125)) for assisted bulk load.
## Product fixes
- Chart data table overflow and doubled widget section headings ([PR129](https://github.com/QRun-IO/qqq-frontend-next/pull/129)).
## UI API contract
- Vendored alerts and sql-workbench extension documents; the off-spec list drops from 46 to 21 ([PR132](https://github.com/QRun-IO/qqq-frontend-next/pull/132), [PR131](https://github.com/QRun-IO/qqq-frontend-next/pull/131)).
## Bundle budget
Report-only (owner decision 2026-10-13): sizes are measured and reported; structural problems still fail.
## Verification checkpoint
PR checks on the RC22 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC22 GitHub release after delivery.
## Remaining RC limits
The RC20 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). Collaboration task assignees show the user id until qbit-collaboration adds a display name.
+43
View File
@@ -0,0 +1,43 @@
# Next UI 1.0.0-RC.23 — preparation
At this preparation checkpoint RC23 **is not yet published**. [RC22](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.22) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.23` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.23`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `39b8f8b` (merge of [PR127](https://github.com/QRun-IO/qqq-frontend-next/pull/127)). Merged since RC22: [PR127](https://github.com/QRun-IO/qqq-frontend-next/pull/127) (help everywhere: help icons, long-help panel, help search, author links), [PR121](https://github.com/QRun-IO/qqq-frontend-next/pull/121) (Preferences page, notification settings drawer and section actions), [PR120](https://github.com/QRun-IO/qqq-frontend-next/pull/120) (contingent possible values across forms, bulk edit, processes and filters), [PR143](https://github.com/QRun-IO/qqq-frontend-next/pull/143) (typeahead suggestions from qbit-quick-search in the global search box), [PR140](https://github.com/QRun-IO/qqq-frontend-next/pull/140) (authentik policy editor feature permissions and member preview), [PR138](https://github.com/QRun-IO/qqq-frontend-next/pull/138) (theme defaults to the OS color scheme), [PR144](https://github.com/QRun-IO/qqq-frontend-next/pull/144) (record section tabs fold into a More menu), [PR139](https://github.com/QRun-IO/qqq-frontend-next/pull/139) (breadcrumb without inner horizontal scroll), [PR146](https://github.com/QRun-IO/qqq-frontend-next/pull/146) (app-nested URLs redirect to their canonical path), [PR137](https://github.com/QRun-IO/qqq-frontend-next/pull/137) (realtime client isolated between tests), [PR68](https://github.com/QRun-IO/qqq-frontend-next/pull/68) (rapidoc 10.1.0), [PR99](https://github.com/QRun-IO/qqq-frontend-next/pull/99) (GitHub Actions updates).
## Backend requirements
Everything in RC22 needs QQQ **4.1.0-RC.6** or later. The new screens use QQQ **4.1.0-RC.7** features: help everywhere needs core typed help and help bundles (qqq#1177, qqq#1198); the Preferences page needs `/qqq/v1/preferences` (qqq#1162); contingent possible values need qqq#1175. Against RC.6 these screens stay hidden or fall back to the previous behaviour. Search typeahead needs qbit-quick-search **1.1.0-RC.8** or later (the `suggest` endpoint); without it the global search box behaves as before. The authentik policy editor additions need qbit-authentik 0.1.0-RC.4 or later. RC23 does not publish QQQ 4.1.
## Features
- **Help everywhere** ([PR127](https://github.com/QRun-IO/qqq-frontend-next/pull/127)): help icons, a long-help panel, help search and author links, driven by core typed help.
- **Preferences** ([PR121](https://github.com/QRun-IO/qqq-frontend-next/pull/121)): Preferences page, notification settings drawer and section actions.
- **Contingent possible values** ([PR120](https://github.com/QRun-IO/qqq-frontend-next/pull/120)) across forms, bulk edit, processes and filters.
- **Search typeahead** ([PR143](https://github.com/QRun-IO/qqq-frontend-next/pull/143)) from the qbit-quick-search `suggest` endpoint.
- **Theme** ([PR138](https://github.com/QRun-IO/qqq-frontend-next/pull/138)): defaults to the OS color scheme (System / Light / Dark).
- **Authentik** ([PR140](https://github.com/QRun-IO/qqq-frontend-next/pull/140)): policy editor feature permissions and member preview.
## Product fixes
- Record section tabs fold into a More menu instead of scrolling ([PR144](https://github.com/QRun-IO/qqq-frontend-next/pull/144)).
- Breadcrumb without inner horizontal scroll ([PR139](https://github.com/QRun-IO/qqq-frontend-next/pull/139)).
- App-nested URLs redirect to their canonical path ([PR146](https://github.com/QRun-IO/qqq-frontend-next/pull/146)).
## Browser testing and infrastructure
- The realtime client is isolated between tests, which fixes the metadata dedupe flake ([PR137](https://github.com/QRun-IO/qqq-frontend-next/pull/137)).
- Dependency updates: rapidoc 10.1.0 ([PR68](https://github.com/QRun-IO/qqq-frontend-next/pull/68)), GitHub Actions ([PR99](https://github.com/QRun-IO/qqq-frontend-next/pull/99)).
## Bundle budget
Report-only (owner decision 2026-10-13): sizes are measured and reported; structural problems still fail.
## Verification checkpoint
PR checks on the RC23 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC23 GitHub release after delivery.
## Remaining RC limits
The RC22 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+37
View File
@@ -0,0 +1,37 @@
# Next UI 1.0.0-RC.24 — preparation
At this preparation checkpoint RC24 **is not yet published**. [RC23](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.23) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.24` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.24`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `3ab4815` (merge of [PR150](https://github.com/QRun-IO/qqq-frontend-next/pull/150)). Merged since RC23: [PR149](https://github.com/QRun-IO/qqq-frontend-next/pull/149) (record section tabs wrap instead of folding into More; tablet regression from PR144), [PR150](https://github.com/QRun-IO/qqq-frontend-next/pull/150) (empty tables explain what they are for and how rows get created), [PR148](https://github.com/QRun-IO/qqq-frontend-next/pull/148) (Full access checkbox in the authentik policy editor), [PR145](https://github.com/QRun-IO/qqq-frontend-next/pull/145) (no automatic card grids on homes that declare widgets).
## Why RC24
RC23's tag real-backend acceptance failed on two tablet shards in both attempts (REC-004 record sections by touch and keyboard, REC-019 zoned date-times, NAV-015 document title). [PR149](https://github.com/QRun-IO/qqq-frontend-next/pull/149) restores wrapping section tabs (the More menu from PR144 is removed), deflakes NAV-015 and adds tablet to the full-acceptance label.
## Backend requirements
Unchanged from RC23: QQQ **4.1.0-RC.6** or later for everything in RC22, and **4.1.0-RC.7** for help everywhere, Preferences and contingent possible values. The Full access checkbox needs qbit-authentik **0.1.0-RC.5** or later (full-access `*` statement). Empty-table help uses core typed help (QQQ 4.1.0-RC.7); against older cores the table shows the previous empty state. RC24 does not publish QQQ 4.1.
## Features
- **Empty tables explain themselves** ([PR150](https://github.com/QRun-IO/qqq-frontend-next/pull/150)): what the table is for and how rows get created, from core help.
- **Full access** ([PR148](https://github.com/QRun-IO/qqq-frontend-next/pull/148)): a Full access checkbox in the authentik policy editor.
## Product fixes
- Record section tabs wrap instead of folding into a More menu; fixes the RC23 tablet regression ([PR149](https://github.com/QRun-IO/qqq-frontend-next/pull/149)).
- Homes that declare widgets no longer get automatic card grids ([PR145](https://github.com/QRun-IO/qqq-frontend-next/pull/145)).
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC24 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC24 GitHub release after delivery.
## Remaining RC limits
The RC23 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.25 — preparation
At this preparation checkpoint RC25 **is not yet published**. [RC24](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.24) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.25` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.25`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `70594c4` (merge of [PR151](https://github.com/QRun-IO/qqq-frontend-next/pull/151)). Merged since RC24: [PR151](https://github.com/QRun-IO/qqq-frontend-next/pull/151) (assistant: never block asking, re-attach after refresh, live progress and Stop).
## Backend requirements
Unchanged from RC24. The assistant changes pair with qbit-assistant **0.1.0-RC.13** (durable turns: lease, heartbeat, cross-pod Stop); with older assistants the panel behaves as before. RC25 does not publish QQQ 4.1.
## Features
- **Assistant panel** ([PR151](https://github.com/QRun-IO/qqq-frontend-next/pull/151)): asking is never blocked while a turn runs, the panel re-attaches to a running turn after refresh, shows live progress, and Stop is always available.
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC25 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC25 GitHub release after delivery.
## Remaining RC limits
The RC24 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.26 — preparation
At this preparation checkpoint RC26 **is not yet published**. [RC25](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.25) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.26` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.26`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `caa89dc` (merge of [PR142](https://github.com/QRun-IO/qqq-frontend-next/pull/142)). Merged since RC25: [PR142](https://github.com/QRun-IO/qqq-frontend-next/pull/142) (tables open with a short default column set).
## Backend requirements
Unchanged from RC25. RC26 does not publish QQQ 4.1.
## Features
- **Default list columns** ([PR142](https://github.com/QRun-IO/qqq-frontend-next/pull/142)): with no saved view, a table opens with a short default column set (primary key, then the first-tier section fields, capped at 6, skipping heavy, text, HTML, BLOB and JSON fields) instead of every field, so the grid no longer scrolls sideways. Fixes [#114](https://github.com/QRun-IO/qqq-frontend-next/issues/114) (kof22 admin UX audit, High).
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC26 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC26 GitHub release after delivery.
## Remaining RC limits
The RC25 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+29
View File
@@ -0,0 +1,29 @@
# Next UI 1.0.0-RC.27 — preparation
At this preparation checkpoint RC27 **is not yet published**. [RC26](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.26) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.27` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.27`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `427a037` (merge of [PR155](https://github.com/QRun-IO/qqq-frontend-next/pull/155)). Merged since RC26: [PR124](https://github.com/QRun-IO/qqq-frontend-next/pull/124) (secured-data UI: masked values, reveal, restricted filters, break-glass banner), [PR155](https://github.com/QRun-IO/qqq-frontend-next/pull/155) (live job view, header Jobs tray and Jobs page), [PR156](https://github.com/QRun-IO/qqq-frontend-next/pull/156) (large-table acceptance fixture declares its default columns).
## Backend requirements
Unchanged from RC26 for existing screens. The Jobs tray and live job view need QQQ **4.1.0-RC.8** (jobs UI API, UI API 1.4.0); against older cores the Jobs button stays hidden. The secured-data UI renders what QQQ **4.1.0-RC.7** field access tiers enforce and needs qbit-secured-data **0.1.0-RC.1** for reveal and break-glass. RC27 does not publish QQQ 4.1.
## Features
- **Secured-data UI** ([PR124](https://github.com/QRun-IO/qqq-frontend-next/pull/124)): masked values shown with a lock chip, reveal with reason, restricted filters, and a break-glass banner. The server masks values; the client never receives the real value.
- **Jobs** ([PR155](https://github.com/QRun-IO/qqq-frontend-next/pull/155)): a header Jobs button next to alerts, a live job view with progress, phases and log, the ability to leave a job running, and a Jobs page.
- Acceptance: the large-table fixture declares its 40 default columns after PR142 ([PR156](https://github.com/QRun-IO/qqq-frontend-next/pull/156)).
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC27 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC27 GitHub release after delivery.
## Remaining RC limits
The RC26 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.28 — preparation
At this preparation checkpoint RC28 **is not yet published**. [RC27](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.27) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.28` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.28`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `16699ae` (merge of [PR141](https://github.com/QRun-IO/qqq-frontend-next/pull/141)). Merged since RC27: [PR141](https://github.com/QRun-IO/qqq-frontend-next/pull/141) (zero off-spec UI API calls; OFF_SPEC_CEILING 0; minimum UI API 1.4.0).
## Backend requirements
**Requires QQQ 4.1.0-RC.9 or later** (UI API 1.4.0 operations; RC.8's `GET /table/{t}/apis` was shadowed by the record route, fixed in qqq#1216). RC28 does not publish QQQ 4.1.
## Features
- **Zero off-spec calls** ([PR141](https://github.com/QRun-IO/qqq-frontend-next/pull/141)): the form adjuster, table API discovery, table API documents and the remaining legacy routes now use spec'd `/qqq/v1` operations. `OFF_SPEC_CEILING` is 0.
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC28 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC28 GitHub release after delivery.
## Remaining RC limits
The RC27 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.29 — preparation
At this preparation checkpoint RC29 **is not yet published**. [RC28](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.28) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.29` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.29`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `abfbbb2` (merge of [PR136](https://github.com/QRun-IO/qqq-frontend-next/pull/136)). Merged since RC28: [PR136](https://github.com/QRun-IO/qqq-frontend-next/pull/136) (EDI mapper page on the qbit-edi edi extension, all calls on spec).
## Backend requirements
Requires QQQ **4.1.0-RC.9** or later (as RC28). The EDI mapper needs qbit-edi with the `edi` UI API extension (qbit-edi#15); without it the Open Mapper action stays hidden. RC29 does not publish QQQ 4.1.
## Features
- **EDI mapper** ([PR136](https://github.com/QRun-IO/qqq-frontend-next/pull/136)): an Open Mapper action on `edi_map` records opens a mapper page built on the vendored qbit-edi `edi` UI API extension, with generated types and an API client. Every call is on spec.
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC29 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC29 GitHub release after delivery.
## Remaining RC limits
The RC28 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+34
View File
@@ -0,0 +1,34 @@
# Next UI 1.0.0-RC.30 — preparation
At this preparation checkpoint RC30 **is not yet published**. [RC29](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.29) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.30` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.30`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main after [PR160](https://github.com/QRun-IO/qqq-frontend-next/pull/160), which this preparation depends on. Merged since RC29: [PR160](https://github.com/QRun-IO/qqq-frontend-next/pull/160) (optional Jam QA capture on the qbit-qa-capture `qa-capture` extension, all calls on spec).
## Backend requirements
RC30 needs QQQ **4.1.0-RC.9** or later, the same as RC29. QA capture also needs qbit-qa-capture with the `qa-capture` 1.0.0 UI API extension. Without it the UI makes no QA capture call and loads no Jam code. The vendored `qa-capture.json` must match the published QBit fragment before tagging. RC30 does not publish QQQ 4.1.
## Features
- **QA capture (Jam)** ([PR160](https://github.com/QRun-IO/qqq-frontend-next/pull/160)), shown only to users the QBit enables:
- Jam's Recording Links SDK is loaded on demand and initialized with sanitizers built from the server's rules (headers, JSON and form fields, query parameters, value patterns, dropped paths, fail closed).
- Jam receives metadata with the page context and the last request ids.
- "Report a bug" and "QA reports" are in the user menu, and a "QA mode on" badge appears in the header.
- Password, reveal and secured-data values are blurred in recordings.
- Recording Link `jam-*` parameters are kept through sign-in.
- The standalone image adds the Jam CSP origins when `QQQ_QA_CAPTURE_CSP=true`.
- Dependencies: `@jam.dev/recording-links` 0.4.0 and `@jam.dev/sdk` 1.0.1, pinned. See [QA capture](../QA-CAPTURE.md).
## Bundle budget
Report-only (owner decision 2026-10-13). PR160 measured +3.4 to +4.2 KB gzip first-load JS per route and +9.2 KB gzip total JS. The Jam chunks load only for enabled users.
## Verification checkpoint
PR checks on the RC30 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC30 GitHub release after delivery. Acceptance row SEC-050 (QA capture) is excluded pending owner approval until qbit-qa-capture has a real-backend fixture; Vitest covers it meanwhile.
## Remaining RC limits
The RC29 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.31 — preparation
At this preparation checkpoint RC31 **is not yet published**. [RC30](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.30) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.31` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.31`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `9aff198` (merge of [PR162](https://github.com/QRun-IO/qqq-frontend-next/pull/162)). Merged since RC30: [PR162](https://github.com/QRun-IO/qqq-frontend-next/pull/162) (adopt QQQ UI API 1.6.0: clientCapabilities, field validation, error schema, v1 branding).
## Backend requirements
**Requires QQQ 4.1.0-RC.15 or later** (UI API 1.6.0). RC31 does not publish QQQ 4.1.
## Features
- **UI API 1.6.0** ([PR162](https://github.com/QRun-IO/qqq-frontend-next/pull/162)): vendors the core 1.6.0 and API-aware documents and the updated qbit extensions (alerts 1.2.0, assistant 1.3.0, collaboration 1.1.0, data-analysis 1.5.0, edi 1.1.0, esb 1.1.0, quick-search 1.1.0, secured-data 1.1.0, sql-workbench 1.1.0); uses table client capabilities, server field validation, the single error schema and v1 branding. The off-spec ceiling stays 0.
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC31 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC31 GitHub release after delivery.
## Remaining RC limits
The RC30 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.32 — preparation
At this preparation checkpoint RC32 **is not yet published**. [RC31](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.31) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.32` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.32`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `b24b74e` (merge of [PR165](https://github.com/QRun-IO/qqq-frontend-next/pull/165)). Merged since RC31: [PR165](https://github.com/QRun-IO/qqq-frontend-next/pull/165) (What's new page and user-menu entry, release-notes UI API 1.0.0).
## Backend requirements
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC31). What's new needs qbit-release-notes **0.1.0-RC.1** (release-notes UI API extension 1.0.0); without it the feature hides itself. RC32 does not publish QQQ 4.1.
## Features
- **What's new** ([PR165](https://github.com/QRun-IO/qqq-frontend-next/pull/165)): an `/app/whats-new` page and a user-menu entry with an unread count, on the release-notes UI API extension 1.0.0 (vendored). The page is a separate lazy route; off-spec calls stay at 0.
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC32 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC32 GitHub release after delivery.
## Remaining RC limits
The RC31 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+31
View File
@@ -0,0 +1,31 @@
# Next UI 1.0.0-RC.33 — preparation
At this preparation checkpoint RC33 **is not yet published**. [RC32](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.32) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.33` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.33`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `b4b59ce` (merge of [PR170](https://github.com/QRun-IO/qqq-frontend-next/pull/170)). Merged since RC32: [PR168](https://github.com/QRun-IO/qqq-frontend-next/pull/168) (sidebar sections and favorites, one search, new header, single-key shortcut switch), [PR170](https://github.com/QRun-IO/qqq-frontend-next/pull/170) (empty assistant query results no longer crash the page).
## Backend requirements
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC32). The navigation works against any 1.x server (it infers section roles when absent); QQQ **4.1.0-RC.16** (UI API 1.7.0) adds the server-declared section roles, section apps and search keywords. RC33 does not publish QQQ 4.1.
## Features
- **Navigation** ([PR168](https://github.com/QRun-IO/qqq-frontend-next/pull/168)): sidebar sections and favorites, one search, a header with Search, Ask, Jobs, Notifications, Help and Account, a single-key shortcut switch and Shift+D.
## Product fixes
- Empty assistant query results no longer crash the page ([PR170](https://github.com/QRun-IO/qqq-frontend-next/pull/170)).
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC33 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC33 GitHub release after delivery.
## Remaining RC limits
The RC32 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.34 — preparation
At this preparation checkpoint RC34 **is not yet published**. [RC33](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.33) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.34` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.34`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `68a2b9d` (merge of [PR172](https://github.com/QRun-IO/qqq-frontend-next/pull/172)). Merged since RC33: [PR172](https://github.com/QRun-IO/qqq-frontend-next/pull/172) (layered error boundaries, zod payload repair, streaming can no longer lock the assistant panel).
## Backend requirements
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC33). The navigation works against any 1.x server (it infers section roles when absent); QQQ **4.1.0-RC.16** (UI API 1.7.0) adds the server-declared section roles, section apps and search keywords. RC34 does not publish QQQ 4.1.
## Product fixes
- **Fail soft** ([PR172](https://github.com/QRun-IO/qqq-frontend-next/pull/172)): layered error boundaries keep a failing widget, section or panel from taking down the page; assistant payloads are validated and repaired with zod; a stream can no longer lock the assistant panel. Pairs with qbit-assistant 0.1.0-RC.22 (fail-soft server, extension 1.5.0).
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC34 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC34 GitHub release after delivery.
## Remaining RC limits
The RC33 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.35 — preparation
At this preparation checkpoint RC35 **is not yet published**. [RC34](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.34) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.35` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.35`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `ba38367` (merge of [PR164](https://github.com/QRun-IO/qqq-frontend-next/pull/164)). Merged since RC34: [PR164](https://github.com/QRun-IO/qqq-frontend-next/pull/164) (profile photo uploader and avatars everywhere, UI API 1.8.0).
## Backend requirements
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC34). Profiles and avatars need QQQ **4.1.0-RC.17** (UI API 1.8.0). RC35 does not publish QQQ 4.1.
## Features
- **Profiles and avatars** ([PR164](https://github.com/QRun-IO/qqq-frontend-next/pull/164)): a profile photo uploader on the Profile section and user avatars shown throughout the UI.
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC35 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC35 GitHub release after delivery.
## Remaining RC limits
The RC34 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.36 — preparation
At this preparation checkpoint RC36 **is not yet published**. [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.36` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.36`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `81a03ea` (merge of [PR175](https://github.com/QRun-IO/qqq-frontend-next/pull/175)). Merged since RC35: [PR175](https://github.com/QRun-IO/qqq-frontend-next/pull/175) (a11y: readable link text, 24px clear target, h2 widget titles, skip link focus).
## Backend requirements
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC35). Profiles and avatars need QQQ **4.1.0-RC.17** (UI API 1.8.0); against older servers avatars fall back to initials and the uploader stays hidden. RC36 does not publish QQQ 4.1.
## Product fixes
- **Accessibility** ([PR175](https://github.com/QRun-IO/qqq-frontend-next/pull/175)): link text meets 4.5:1 contrast through a new derived link color, clear buttons have 24px targets, widget titles are h2 headings, and the skip link takes focus correctly.
## Bundle budget
Report-only (owner decision 2026-10-13).
## Verification checkpoint
PR checks on the RC36 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC36 GitHub release after delivery.
## Remaining RC limits
The RC35 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
+9 -3
View File
@@ -1,14 +1,20 @@
# Next UI — unreleased
[RC21 preparation](1.0.0-RC.21.md) collects every change merged since [RC20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20). RC20 is published and immutable. Changes merged after the RC21 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).
[RC36 preparation](1.0.0-RC.36.md) collects every change merged since [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35). RC35 is published and immutable. Changes merged after the RC36 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
None yet.
- **UI API 1.6.0 (QQQ 4.1.0-RC.15).** Next reads the typed `clientCapabilities` of table metadata for saved views, sharing, bulk processes and audits, applies `field.validation` (required, length, inclusive and exclusive bounds, patterns) before a request, reads client-neutral `uiSettings` setting by setting with the deprecated `materialDashboard` values as fallback, and shows the process error `traceId`. The full `materialDashboardTheme` still wins over the `uiSettings.theme` subset. Error messages keep their order (`userFacingError`, `error`, then the new `message`). Audits fall back to the permission-filtered instance metadata when a table's capability is unavailable, since RC.15 marks unaudited tables unavailable although explicit audits can exist. Off-spec calls stay at 0.
- **What's new:** a paged release history with new badges, safe links and screenshots, notification deep links, and an unread count in the user menu. Uses UI API extension `release-notes` 1.0.0; requires qbit-release-notes 0.1.0-RC.1+.
- **Profile photo and avatars everywhere** (QQQ 4.1.0-RC.17, UI API 1.8.0 `avatars`):
- The core **Profile** preferences section renders generically. Its photo is a `FILE_UPLOAD` field with `valueIsUploadRef`, so you can drag and drop or choose a picture, crop it square (drag, arrow keys or the zoom slider), preview it and remove it. The cropped PNG goes to `POST /uploads`, and the ref saves with the form.
- One `UserAvatar` (photo via one batched `POST /avatars/lookup` and `GET /avatars/{avatarId}`, otherwise initials) shows people in the user menu, presence, comments, mention candidates, the people picker, activity and your own assistant messages. A missing avatar is remembered for the session, so it isn't requested again.
- **Privacy:** branding `gravatarDefault` no longer sends your email hash to Gravatar. A Gravatar appears only if you turn it on in your Profile, and the server serves it.
- Image fields take a full form row at every width. e2e coverage is `tests/e2e/profile-avatars.spec.ts`, including the 6-width light/dark screenshot matrix.
## Product fixes
None yet.
- **Logout no longer races an in-flight request.** Logout ends the API client's session generation first: every request still in flight (axios and the realtime and assistant streams) is aborted, and any response of the old generation that still arrives is discarded, so none can refresh the `sessionId` cookie the logout expires or change client auth state (SEC-037, SEC-048).
## Browser testing and infrastructure
+22
View File
@@ -0,0 +1,22 @@
# Jobs screenshots
The mocked browser spec is `tests/e2e/jobs-tray.spec.ts`. Capture the actual UI with:
```powershell
$env:QQQ_E2E_SERVER='production'
$env:QQQ_JOBS_SCREENSHOTS='docs/screenshots/jobs-tray'
$env:QQQ_E2E_PORT='3117'
pnpm exec playwright test tests/e2e/jobs-tray.spec.ts --workers=1 --reporter=list
```
Captured 2026-10-14 from the production build (`QQQ_E2E_SERVER=production`), 1000 px tall:
| View | 390 light | 390 dark | 1440 light | 1440 dark |
| --- | --- | --- | --- | --- |
| Live job | ![](live-390-light.png) | ![](live-390-dark.png) | ![](live-1440-light.png) | ![](live-1440-dark.png) |
| Open tray | ![](tray-390-light.png) | ![](tray-390-dark.png) | ![](tray-1440-light.png) | ![](tray-1440-dark.png) |
| Jobs page | ![](page-390-light.png) | ![](page-390-dark.png) | ![](page-1440-light.png) | ![](page-1440-dark.png) |
The spec also checks horizontal overflow, popover bounds, keyboard focus restoration, axe at 1440
light, and reduced motion; its lifecycle case runs an inline process, detaches it, watches it
finish in the tray and reopens the finished view.
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+10 -4
View File
@@ -1,6 +1,6 @@
{
"name": "qqq-frontend-next",
"version": "1.0.0-RC.21",
"version": "1.0.0-RC.36",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
@@ -36,6 +36,8 @@
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.1",
"@hookform/resolvers": "^5.2.2",
"@jam.dev/recording-links": "0.4.0",
"@jam.dev/sdk": "1.0.1",
"@lezer/highlight": "1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
@@ -56,7 +58,7 @@
"lodash": "^4.18.1",
"lucide-react": "^0.485.0",
"next": "16.3.8",
"rapidoc": "9.3.8",
"rapidoc": "10.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
@@ -113,7 +115,11 @@
"overrides": {
"serialize-javascript": ">=7.0.3",
"@humanfs/node": ">=0.16.8",
"@babel/core": ">=7.29.6 <8"
}
"@babel/core": ">=7.29.6 <8",
"source-map-js": ">=1.2.2"
},
"ignoredOptionalDependencies": [
"electron"
]
}
}
-5
View File
@@ -53,11 +53,6 @@
"marker": "search-result-card",
"gzipKB": 14.5
},
"quickSearchPanel": {
"$comment": "The quick search results panel of the header box and the '/' dialog (groups, highlights, completions, failure and fallback notices), loaded when the panel first opens: import() in GlobalSearch and SearchDialog. Measured 9.4 KB gzip at introduction (#59); budget about 8% above.",
"marker": "search-results-status",
"gzipKB": 10.2
},
"commandRecordResults": {
"$comment": "The command palette's record groups, loaded when the palette searches records: import() in CommandMenu. Measured 7.5 KB gzip at introduction (#59); budget about 8% above.",
"marker": "command-menu-searching",
+1 -1
View File
@@ -10,7 +10,7 @@ const port = Number(process.env.QQQ_E2E_PORT || 3107)
const baseURL = `http://localhost:${port}`
export default defineConfig({
testDir: './tests/e2e',
testMatch: ['collaboration-favorites.spec.ts', 'collaboration-record-panel.spec.ts'],
testMatch: ['collaboration-favorites.spec.ts', 'collaboration-record-panel.spec.ts', 'collaboration-presence-share-tasks.spec.ts'],
outputDir: './test-results/collaboration/artifacts',
fullyParallel: false,
workers: 1,
+353 -1032
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -47,7 +47,7 @@
</developers>
<properties>
<revision>1.0.0-RC.21</revision>
<revision>1.0.0-RC.36</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
+2 -1
View File
@@ -9,7 +9,7 @@ import { execFileSync } from 'node:child_process'
import { appendFileSync, readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
export const AREAS = ['esb', 'navigation', 'performance', 'processes', 'query', 'records', 'security', 'widgets']
export const AREAS = ['esb', 'help', 'navigation', 'performance', 'processes', 'query', 'records', 'security', 'widgets']
export const SMOKE = [
'navigation/landing.spec.ts',
'navigation/navigation.spec.ts',
@@ -19,6 +19,7 @@ export const SMOKE = [
].map((file) => `tests/acceptance/specs/${file}`)
const sourceAreas = [
[/help|commandmenu/, 'help'],
[/widget|dashboard|report|chart|pivot/, 'widgets'],
[/query|grid|table|filter|search/, 'query'],
[/record|form|field|input|validation|attachment|preview/, 'records'],
+1 -1
View File
@@ -20,7 +20,7 @@ import path from 'node:path'
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']
// operations the app works without (it checks for them), so an older server may lack them
const OPTIONAL_OPERATIONS = new Set(['GET /qqq/v1/version'])
const OPTIONAL_OPERATIONS = new Set(['GET /qqq/v1/version', 'GET /qqq/v1/help/search', 'GET /qqq/v1/help/coverage'])
const bundled = JSON.parse(readFileSync(path.resolve('src/lib/api/openapi/qqq-v1.json'), 'utf-8'))
/** @param {string} version @returns {number[] | null} */
+25
View File
@@ -0,0 +1,25 @@
/*
* 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 https://www.apache.org/licenses/LICENSE-2.0
*/
// Vendor the qbit YAML as lossless JSON, like the other extension documents, then run
// node scripts/generate-edi-types.mjs. No runtime schema or generator dependency ships.
import { readFileSync, writeFileSync } from 'node:fs'
const document = JSON.parse(readFileSync(new URL('../src/lib/api/openapi/extensions/edi.json', import.meta.url), 'utf8'))
const header = readFileSync(new URL('../src/lib/api/tables.ts', import.meta.url), 'utf8').split('/**')[0]
function typeOf(schema) {
let type = schema.$ref ? schema.$ref.split('/').at(-1)
: schema.enum ? schema.enum.map(JSON.stringify).join(' | ')
: schema.type === 'array' ? `Array<${typeOf(schema.items)}>`
: schema.type === 'object' ? schema.properties
? `{\n${Object.entries(schema.properties).map(([name, value]) => ` ${name}${schema.required?.includes(name) ? '' : '?'}: ${typeOf(value)}`).join('\n')}\n}`
: `Record<string, ${typeof schema.additionalProperties === 'object' ? typeOf(schema.additionalProperties) : 'unknown'}>`
: ['integer', 'number'].includes(schema.type) ? 'number'
: ['string', 'boolean'].includes(schema.type) ? schema.type : 'unknown'
return type + (schema.nullable && type !== 'unknown' ? ' | null' : '')
}
writeFileSync(new URL('../src/types/edi.ts', import.meta.url), header + '/** @file Generated from the vendored edi.json. Run scripts/generate-edi-types.mjs; do not edit. */\n\n' + Object.entries(document.components.schemas).map(([name, schema]) => `/** ${schema.description ?? name} */\nexport type ${name} = ${typeOf(schema)}\n`).join('\n'))
@@ -22,6 +22,7 @@
'use client'
import React from 'react'
import dynamic from 'next/dynamic'
import { useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
@@ -32,6 +33,10 @@ import { launchTableName } from '@/lib/utils/process-utils'
import { NotFoundState } from '@/components/layout/NotFoundState'
import { RouteRedirect } from '@/components/layout/RouteRedirect'
const EdiMapperPage = dynamic(() => import('@/components/edi-mapper/EdiMapperPage'), {
loading: () => <p role="status">Loading mapper…</p>,
})
/**
* Opens the named process for the record, returning to the record afterwards, or shows the
* in-shell not-found state when the segment is not one of the table's processes.
@@ -42,6 +47,8 @@ export default function RecordProcessPage() {
const { slug, recordId, action } = useRouteParams<{ slug: string; recordId: string; action: string }>()
const { data: metaData, isError } = useQuery({ queryKey: queryKeys.metadataAll(), queryFn: loadMetaData, staleTime: 1000 * 60 * 30 })
if (slug === 'edi_map' && action === 'mapper') return <EdiMapperPage key={recordId} mapId={Number(recordId)} />
if (isError) {
return <div role="alert" className="py-12 text-center text-destructive">Failed to load application metadata.</div>
}
@@ -55,7 +55,7 @@ vi.mock('@/lib/api/developer', () => ({
getTableApis: vi.fn(async () => []),
getApiVersions: vi.fn(),
getApiTableSpec: vi.fn(),
apiTableSpecUrl: () => '/api/v1/person/openapi.json',
apiTableSpecUrl: () => '/qqq/v1/table/person/apis/siteApi/v1/openapi',
apiMiddlewareBaseURL: () => '',
}))
@@ -140,7 +140,7 @@ describe('RecordDeveloperViewPage', () => {
expect(within(card).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Code', 'Logs', 'Test', 'Docs'])
expect(screen.getByTestId('page-header')).toHaveTextContent('Ada Lovelace Developer Mode')
expect(getRecordDeveloperData).toHaveBeenCalledWith('person', '1')
})
}, 15_000) // three waits of up to 5 s each: the 5 s default could expire before they do
it('shows the raw values of the developer-mode record, not a separate record read (Material getRecordDeveloperMode)', async () => {
serveInstance([])
@@ -17,7 +17,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { deserializeFilter } from '@/lib/utils/filter-utils'
import apiClient from '@/lib/api/client'
@@ -61,6 +61,12 @@ function renderPage() {
}
describe('RecordViewPage independent association loading', () => {
beforeAll(async () => {
// Keep cold compilation of the lazy RecordView outside the first assertion's deadline,
// so any case passes alone (-t) and under load.
await import('@/components/records/RecordView')
}, 30_000)
beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); navigation.search = 'tab=related' })
it('keeps the base and child Add, and requests no expansion, when the child table is listed without read permission (QRun-IO/qqq#671)', async () => {
@@ -59,7 +59,7 @@ const RecordView = lazy(() => import('@/components/records/RecordView').then((mo
*
* Loads the base record and optional whole-association expansion independently. Once
* the record loads it is registered in the recently-viewed history (via
* `addRecentRecord`) so it surfaces in the GlobalSearch and SearchDialog.
* `addRecentRecord`) so it surfaces in the search box (recently viewed records).
* The page header in QContext is updated to the record label.
*
* @returns A composed view that assembles:
@@ -202,6 +202,7 @@ export default function RecordViewPage() {
)}
<AlertsRecordPanelSlot metaData={metaData} tableName={slug} recordId={recordId} />
<Suspense fallback={<div role="status" aria-label="Loading record view" className="py-12 text-center">Loading record…</div>}><RecordView
requestedPrimaryKey={recordId}
instance={metaData}
tableMetaData={tableMetaData}
record={displayRecord}
@@ -217,7 +218,7 @@ export default function RecordViewPage() {
allTables={metaData?.tables}
widgetMetaDataMap={metaData?.widgets}
associationTables={associationTables}
auditSource={auditSource(metaData)}
auditSource={auditSource(metaData, tableMetaData.clientCapabilities)}
/></Suspense>
</>
)
@@ -88,7 +88,7 @@ describe('TableDeveloperViewPage API Docs & Playground', () => {
window.localStorage.clear()
getTableApisMock.mockReset()
getApiVersionsMock.mockReset()
getApiVersionsMock.mockImplementation(async (path: string) => versions[path])
getApiVersionsMock.mockImplementation((api) => versions[api.path])
})
it('selects the first API and its current version and embeds RapiDoc for the table spec', async () => {
@@ -108,7 +108,7 @@ describe('TableDeveloperViewPage API Docs & Playground', () => {
await waitFor(() => expect(container.querySelector('rapi-doc')).not.toBeNull())
const rapiDoc = container.querySelector('[data-qqq-id="table-dev-api-reference"] rapi-doc')!
expect(rapiDoc).toHaveAttribute('spec-url', '/person-api/2026.Q2/person/openapi.json')
expect(rapiDoc).toHaveAttribute('spec-url', '/qqq/v1/table/person/apis/person-api/2026.Q2/openapi?format=json')
expect(rapiDoc).toHaveAttribute('primary-color', '#6366f1')
expect(rapiDoc).toHaveAttribute('show-header', 'false')
expect(rapiDoc).toHaveAttribute('render-style', 'view')
@@ -128,7 +128,7 @@ describe('TableDeveloperViewPage API Docs & Playground', () => {
expect(await screen.findByLabelText('API')).toHaveValue('admin-api')
expect(await screen.findByLabelText('Version')).toHaveValue('v2')
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/admin-api/v2/person/openapi.json'))
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/qqq/v1/table/person/apis/admin-api/v2/openapi?format=json'))
})
it('falls back to the first API and the current version when the remembered ones are gone', async () => {
@@ -151,7 +151,7 @@ describe('TableDeveloperViewPage API Docs & Playground', () => {
expect(await screen.findByLabelText('Version')).toHaveValue('v1')
await user.selectOptions(screen.getByLabelText('Version'), 'v2')
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/admin-api/v2/person/openapi.json'))
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/qqq/v1/table/person/apis/admin-api/v2/openapi?format=json'))
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiName')).toBe('admin-api')
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiVersion')).toBe('v2')
})
+4 -3
View File
@@ -11,6 +11,7 @@
*/
import type { ReactNode } from 'react'
import { NestedAppRedirect } from '@/components/layout/NestedAppRedirect'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/**
@@ -23,12 +24,12 @@ export function generateStaticParams() {
}
/**
* Pass-through layout that exists to carry generateStaticParams.
* Resolves app-prefixed URLs before rendering the resource route.
*
* @param props - Layout props.
* @param props.children - Route content.
* @returns The children unchanged.
* @returns The route content or its canonical redirect.
*/
export default function Layout({ children }: { children: ReactNode }) {
return children
return <NestedAppRedirect>{children}</NestedAppRedirect>
}
@@ -0,0 +1,34 @@
/*
* 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 Shared access-request deep link; never fetches the referenced record. */
'use client'
import dynamic from 'next/dynamic'
import { Suspense } from 'react'
const Request = dynamic(
() => import('@/components/collaboration/RequestAccess').then((m) => m.RequestAccessPage),
{ ssr: false }
)
/** Renders the opaque request form.
* @returns Access page.
*/
export default function AccessRequestRoute() {
return (
<Suspense>
<Request />
</Suspense>
)
}
@@ -0,0 +1,30 @@
/*
* 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 Static export placeholder for job detail routes. */
import type { ReactNode } from 'react'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/** Generates the export placeholder.
* @returns One job path.
*/
export function generateStaticParams() { return [{ jobId: EXPORT_PLACEHOLDER }] }
/** Pass-through layout.
* @param props - Children.
* @returns Children unchanged.
*/
export default function Layout({ children }: { children: ReactNode }) { return children }
@@ -0,0 +1,30 @@
/*
* 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 Live job route, separate from metadata table records. */
'use client'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { JobDetailView } from '@/components/jobs/JobDetailView'
/** Renders a job by id.
* @returns Job view or invalid-id message.
*/
export default function JobRoute() {
const { jobId } = useRouteParams<{ jobId: string }>()
const id = Number(jobId)
if (!Number.isSafeInteger(id) || id <= 0) return <p role="alert">Invalid job identifier.</p>
return <div className="w-full min-w-0 rounded-xl border border-border bg-card"><JobDetailView key={id} jobId={id} /></div>
}
+25
View File
@@ -0,0 +1,25 @@
/*
* 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 Static Jobs page, taking precedence over the metadata slug route. */
'use client'
import dynamic from 'next/dynamic'
const JobsPage = dynamic(() => import('@/components/jobs/JobsPage').then((module) => module.JobsPage))
/** Renders the Jobs page.
* @returns Page content.
*/
export default function JobsRoute() { return <JobsPage /> }
+28
View File
@@ -0,0 +1,28 @@
/*
* 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 Static My work route, loaded like the notifications page. */
'use client'
import dynamic from 'next/dynamic'
const MyWork = dynamic(() => import('@/components/collaboration/MyWork').then((m) => m.MyWork), {
ssr: false,
})
/** Renders personal work.
* @returns Lazy page.
*/
export default function MyWorkRoute() {
return <MyWork />
}
+1
View File
@@ -240,6 +240,7 @@ export default function DashboardPage() {
<Link
key={target.key}
href={`${target.path}/create`}
prefetch={false}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
data-qqq-id={`dashboard-create-${target.key}`}
>
@@ -0,0 +1,43 @@
/*
* 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 What's new route, split from the dashboard's first load. */
'use client'
import React from 'react'
import dynamic from 'next/dynamic'
import { Loader2 } from 'lucide-react'
/** The page loads on demand, so its code stays out of every route's first load. */
const WhatsNewPage = dynamic(() => import('@/components/release-notes/WhatsNewPage').then((module) => module.WhatsNewPage), {
ssr: false,
loading: () => (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="whats-new-page-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading release notes...
</p>
),
})
/**
* Renders the whats-new page at `/app/whats-new`.
*
* @returns The page.
*/
export default function WhatsNewRoute() {
return <WhatsNewPage />
}
+42
View File
@@ -0,0 +1,42 @@
/*
* 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.
*/
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { resetClientErrorsForTesting, setClientErrorSink } from '@/lib/errors/client-errors'
import PageError from './error'
describe('page error boundary', () => {
afterEach(() => {
resetClientErrorsForTesting()
vi.restoreAllMocks()
})
it('reports the failure and offers Try again inside the shell', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const sink = vi.fn().mockResolvedValue(undefined)
setClientErrorSink(sink)
const reset = vi.fn()
render(<PageError error={new Error("Cannot read properties of undefined (reading 'length')")} reset={reset} />)
expect(screen.getByText('This page hit a problem')).toBeVisible()
expect(sink).toHaveBeenCalledWith(expect.objectContaining({ scope: 'page' }))
await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(reset).toHaveBeenCalledOnce()
})
})
+89
View File
@@ -0,0 +1,89 @@
/*
* 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 Page error boundary — a page that throws is replaced inside the app shell, so the
* navigation, header and assistant keep working.
*/
'use client'
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { clientErrorDetails, reportClientError, type ClientErrorReport } from '@/lib/errors/client-errors'
/**
* Whether the error means the session ended (the root boundary sends the user to log in).
*
* @param error - The thrown error.
* @returns True for 401 / unauthorized / session expired.
*/
function isSessionError(error: Error): boolean {
const message = error.message?.toLowerCase() ?? ''
return message.includes('401') || message.includes('unauthorized') || message.includes('session expired')
}
/**
* The page-level fallback, rendered by Next.js inside the dashboard layout.
*
* @param props - The Next.js error boundary props.
* @param props.error - The thrown error.
* @param props.reset - Renders the page again.
* @returns A card with Try again, Go home and the error's details.
*/
export default function PageError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const [report, setReport] = useState<ClientErrorReport | null>(null)
const sessionExpired = isSessionError(error)
useEffect(() => {
if (!sessionExpired) setReport(reportClientError(error, { scope: 'page' }))
}, [error, sessionExpired])
// the root boundary owns the "Session Expired" screen
if (sessionExpired) throw error
return (
<div className="mx-auto max-w-lg rounded-xl border border-border bg-card p-6 text-center shadow-sm" role="alert" data-qqq-id="page-error">
<AlertCircle className="mx-auto mb-3 h-10 w-10 text-destructive" aria-hidden="true" />
<h1 className="text-lg font-semibold text-foreground">This page hit a problem</h1>
<p className="mt-1 text-sm text-muted-foreground">The rest of the app still works. Try again, or go to another page.</p>
<div className="mt-4 flex flex-wrap justify-center gap-3">
<button
type="button"
onClick={reset}
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
data-qqq-id="button-page-try-again"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Try again
</button>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
data-qqq-id="link-page-go-home"
>
Go home
</Link>
</div>
{report && (
<details className="mt-4 text-left text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">Details</summary>
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono" data-qqq-id="page-error-details">{clientErrorDetails(report)}</pre>
</details>
)}
</div>
)
}
+93 -68
View File
@@ -22,8 +22,10 @@
/** Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts. */
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { SecuredDataProvider, SecuredDataBannerSlot } from '@/components/secured-data/SecuredDataProvider'
import React, { useEffect, useLayoutEffect, useState, useCallback, useMemo, useRef } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '@/lib/auth/use-auth'
@@ -31,15 +33,17 @@ import { useQContext, QContextProvider } from '@/lib/context/q-context'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import { useDocumentTitle } from '@/lib/hooks/use-document-title'
import { loadMetaData } from '@/lib/api/metadata'
import { ApplicationLoading } from '@/components/layout/ApplicationLoading'
import { applyBrandingTheme } from '@/lib/theme/apply-branding'
import { skipToFirstZone } from '@/lib/utils/skip-link'
import { readMaterialTheme } from '@/lib/theme/material-theme'
import { useTheme } from '@/lib/theme/theme-provider'
import { recordAnalytics } from '@/lib/analytics'
import { useAnalytics } from '@/lib/analytics/use-analytics'
import { queryKeys } from '@/lib/query-client'
import { searchableTables } from '@/lib/utils/record-search'
import { scrollPageToTop } from '@/lib/utils/scroll-to-top'
import dynamic from 'next/dynamic'
import { Sparkles } from 'lucide-react'
import Sidebar from '@/components/layout/Sidebar'
import Header from '@/components/layout/Header'
import BannerComponent from '@/components/layout/Banner'
@@ -47,18 +51,19 @@ import BrandedHeaderBar from '@/components/layout/BrandedHeaderBar'
import CompanyFooter from '@/components/layout/CompanyFooter'
import { buildBreadcrumbs, buildDocumentTitle } from '@/components/layout/Breadcrumbs'
import { CommandMenu } from '@/components/feedback/CommandMenu'
import { SearchDialog } from '@/components/feedback/SearchDialog'
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
import { AccountMenu } from '@/components/layout/AccountMenu'
import { HelpMenu, HelpMenuItems } from '@/components/layout/HelpMenu'
import { HEADER_MENU_ITEM_CLASS } from '@/components/layout/HeaderMenu'
import { singleKeyShortcutsEnabled } from '@/lib/keyboard/single-key-shortcuts'
import { useSingleKeyShortcutsSync } from '@/lib/keyboard/use-single-key-shortcuts-sync'
import { HelpPanelProvider } from '@/components/help/HelpPanelProvider'
import { AssistantProvider, useAssistantController } from '@/components/assistant/AssistantProvider'
import { AssistantHeaderButton } from '@/components/assistant/AssistantHeaderButton'
import { AssistantMount } from '@/components/assistant/AssistantMount'
import { AlertsBell, isAlertsInstalled } from '@/components/alerts/AlertsBell'
import { AlertsBell, useAlertsInstalled } from '@/components/alerts/AlertsBell'
import { NotificationBannerSlot } from '@/components/alerts/NotificationBannerSlot'
// The favorites menu checks for collaboration and loads after hydration, so neither it nor the
// collaboration hooks are in the dashboard's first load (the slot stays empty until then).
const LazyFavoritesMenu = dynamic(() => import('@/components/collaboration/FavoritesMenuSlot').then((module) => module.LazyFavoritesMenu), { ssr: false })
/**
* Inner layout that renders the full dashboard chrome.
*
@@ -70,7 +75,8 @@ const LazyFavoritesMenu = dynamic(() => import('@/components/collaboration/Favor
* - Applies the application theme (tokens, rules, customCss) and the branded header bar.
* - Configures analytics and records a page view per screen.
* - Redirects unauthenticated users to `/login`.
* - Registers global keyboard shortcuts: Cmd+K (command palette), `/` (search), `?` (help).
* - Registers global keyboard shortcuts: Cmd+K and `/` (search), `?` (keyboard shortcuts); the
* single-key ones only while the user has not turned single-key shortcuts off (WCAG 2.1.4).
* - Renders the desktop sidebar, mobile sidebar drawer, banners, header, and main content slot.
*
* @param children - The authenticated page content to render in the main slot.
@@ -85,15 +91,17 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
// Mobile sidebar drawer state
const [sidebarOpen, setSidebarOpen] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const helpButtonRef = useRef<HTMLButtonElement>(null)
// Command palette state
// The one search box (pages, records, actions, help, Ask): Cmd/Ctrl+K, `/`, the header field
const [commandOpen, setCommandOpen] = useState(false)
// opened from Help > Search help: scoped to help articles
const [commandHelpOnly, setCommandHelpOnly] = useState(false)
const openSearch = useCallback((helpOnly = false) => {
setCommandHelpOnly(helpOnly)
setCommandOpen(true)
}, [])
// Search dialog state (/ key)
const [searchOpen, setSearchOpen] = useState(false)
// Keyboard shortcuts help dialog state
// Keyboard shortcuts sheet (`?`, or Help > Keyboard shortcuts)
const [helpOpen, setHelpOpen] = useState(false)
// Load full application metadata
@@ -101,6 +109,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
data: metaData,
isLoading: metaLoading,
error: metaError,
refetch: reloadMetaData,
} = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
@@ -109,19 +118,28 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
})
// QQQ Assistant (qbit-assistant): hidden and inert unless its /assistant/config probe enables it
const alertsInstalled = useAlertsInstalled()
const assistant = useAssistantController(metaData, isAuthenticated)
const { enabled: assistantEnabled, toggle: toggleAssistant } = assistant
// The user's single-key shortcut setting (server Keyboard section, or this browser's choice)
useSingleKeyShortcutsSync(isAuthenticated)
// Generate sidebar routes and path map from app tree
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, homePath } = useAppTreeRoutes(metaData)
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, homePath, sectionRedirects } = useAppTreeRoutes(metaData)
// A section heading is not a page: its name as a path (an old link to a group that used to be an
// app) goes to the app that heads it, at the section
useEffect(() => {
const target = sectionRedirects[(pathname ?? '').replace(/\/+$/, '')]
if (target) router.replace(target)
}, [pathname, sectionRedirects, router])
// Every route change starts at the top of the page (Material App scrolls to 0 on each pathname)
useEffect(() => {
scrollPageToTop()
}, [pathname])
// Tables the backend record search covers for this user (none: search stays local)
const searchTables = useMemo(() => searchableTables(metaData), [metaData])
// Sync pathToLabelMap to QContext
useEffect(() => {
@@ -130,8 +148,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
}
}, [pathToLabelMap, setPathToLabelMap])
// Sync branding to QContext and inject theme
useEffect(() => {
// Install branding before the first branded shell is painted.
useLayoutEffect(() => {
if (metaData?.branding) {
setBranding(metaData.branding)
// accent colors (validated, MED-3) and the favicon
@@ -154,7 +172,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
// customCss; removed again when the dashboard unmounts (sign-out)
const { theme, setTheme } = useTheme()
const applicationTheme = useMemo(() => readMaterialTheme(metaData), [metaData])
useEffect(() => { setTheme(applicationTheme) }, [applicationTheme, setTheme])
useLayoutEffect(() => { setTheme(applicationTheme) }, [applicationTheme, setTheme])
useEffect(() => () => setTheme(null), [setTheme])
// Analytics (QRun-IO/qqq#730): off unless configured; one page view per screen
@@ -193,18 +211,19 @@ 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+K / Ctrl+K — toggles the search box.
* - 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).
* - `?` — opens the keyboard shortcuts dialog (only when not in a text field).
* - Escape — closes all overlays (search box, shortcuts sheet, mobile sidebar).
* - `/` — opens the search box (not in a text field; off with single-key shortcuts).
* - `.` — the same, kept quietly for Material habits (off with single-key shortcuts).
* - `?` — opens the keyboard shortcuts sheet (not in a text field; off with single-key shortcuts).
*
* @param e - The native DOM keyboard event.
*/
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setCommandHelpOnly(false)
setCommandOpen((prev) => !prev)
}
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'j' && assistantEnabled) {
@@ -214,46 +233,35 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
}
if (e.key === 'Escape') {
setCommandOpen(false)
setSearchOpen(false)
setHelpOpen(false)
// The navigation drawer is a modal dialog and handles its own Escape
}
// Single-key shortcuts — only when not focused in a text field
if (!isInputFocused() && !e.metaKey && !e.ctrlKey && !e.altKey) {
// Single-key shortcuts — only when not focused in a text field, and not turned off (WCAG 2.1.4)
if (!isInputFocused() && !e.metaKey && !e.ctrlKey && !e.altKey && singleKeyShortcutsEnabled()) {
if (e.key === '.') {
e.preventDefault()
recordAnalytics({ category: 'globalEvents', action: 'dotMenuKeyboardShortcut' })
setCommandOpen(true)
openSearch()
}
if (e.key === '/') {
e.preventDefault()
setSearchOpen(true)
openSearch()
}
if (e.key === '?') {
e.preventDefault()
setHelpOpen(true)
}
}
}, [isInputFocused, assistantEnabled, toggleAssistant])
}, [isInputFocused, assistantEnabled, toggleAssistant, openSearch])
useEffect(() => {
document.addEventListener('keydown', handleGlobalKeyDown)
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
}, [handleGlobalKeyDown])
// Show loading state while auth initializes
if (authLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
role="status"
aria-label="Loading"
/>
</div>
)
}
// Use the same neutral screen through authentication and metadata loading.
if (authLoading) return <ApplicationLoading />
// Auth loaded but not authenticated — redirect handled above
if (!isAuthenticated) {
@@ -265,23 +273,28 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<p className="text-destructive">Failed to load application metadata.</p>
<p role="alert" className="text-destructive">Unable to load your workspace.</p>
<p className="mt-1 text-sm text-muted-foreground">
{metaError instanceof Error ? metaError.message : 'Unknown error'}
</p>
<button type="button" className="mt-4 rounded-md border px-4 py-2" onClick={() => { void reloadMetaData() }}>Try again</button>
</div>
</div>
)
}
if (!metaData) return <ApplicationLoading />
// One scroll container: the shell is exactly the viewport and only <main> scrolls. Both are
// positioned (`relative`) so they contain absolutely positioned descendants (`sr-only` text,
// tooltips): otherwise such an element's containing block is the viewport, `overflow-hidden`
// 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}>
<HelpPanelProvider instance={metaData}>
<SecuredDataProvider enabled={isAuthenticated}><AssistantProvider value={assistant}>
<div className="relative flex h-screen flex-col overflow-hidden bg-background" data-qqq-id="app-shell">
<SecuredDataBannerSlot />
{/* 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" />
<BrandedHeaderBar theme={theme} />
@@ -291,6 +304,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:inline-flex focus:items-center pointer-coarse:focus:min-h-11 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
data-qqq-id="skip-to-content"
onClick={(event) => { if (skipToFirstZone()) event.preventDefault() }}
>
Skip to main content
</a>
@@ -300,9 +314,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
routes={sidebarRoutes}
branding={metaData?.branding}
homePath={homePath}
logout={logout}
userName={user?.name}
userEmail={user?.email}
showFavorites={isAuthenticated}
data-qqq-id="sidebar-container"
/>
@@ -311,9 +323,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
routes={sidebarRoutes}
branding={metaData?.branding}
homePath={homePath}
logout={logout}
userName={user?.name}
userEmail={user?.email}
showFavorites={isAuthenticated}
onSearchOpen={() => openSearch()}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
returnFocusRef={menuButtonRef}
@@ -328,22 +339,37 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
onMenuOpen={() => setSidebarOpen(true)}
menuOpen={sidebarOpen}
menuButtonRef={menuButtonRef}
onSearchOpen={() => setSearchOpen(true)}
onCommandOpen={() => setCommandOpen(true)}
onHelpOpen={() => setHelpOpen(true)}
helpButtonRef={helpButtonRef}
onSearchOpen={() => openSearch()}
pathToLabelMap={pathToLabelMap}
ancestorAppMap={ancestorAppMap}
homePath={homePath}
navTargets={navTargets}
searchTables={searchTables}
assistantButton={<AssistantHeaderButton />}
favoritesButton={isAuthenticated ? <LazyFavoritesMenu /> : null}
notificationsButton={isAuthenticated && isAlertsInstalled(metaData) ? <AlertsBell metaData={metaData} /> : null}
notificationsButton={isAuthenticated && alertsInstalled ? <AlertsBell metaData={metaData} /> : null}
helpMenu={<HelpMenu onSearchHelp={() => openSearch(true)} onShortcuts={() => setHelpOpen(true)} />}
accountMenu={(
<AccountMenu
userName={user?.name}
userEmail={user?.email}
logout={logout}
compactItems={(close) => (
<>
{/* the compact header has no room for Help and Ask: they lead this menu (help stays in one place) */}
<HelpMenuItems close={close} onSearchHelp={() => openSearch(true)} onShortcuts={() => setHelpOpen(true)} />
{assistantEnabled && (
<button type="button" role="menuitem" className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-ask"
onClick={() => close(toggleAssistant)}>
<Sparkles className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
Ask the assistant
</button>
)}
</>
)}
/>
)}
/>
{/* Page content */}
<main className="relative flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
<main className="relative flex-1 overflow-y-auto p-6 focus:outline-none" id="main-content" tabIndex={-1} data-qqq-id="main-content">
{metaLoading ? (
<div
className="flex items-center justify-center py-12"
@@ -374,16 +400,15 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
onClose={() => setCommandOpen(false)}
navTargets={navTargets}
metaData={metaData}
helpOnly={commandHelpOnly}
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} assistantEnabled={assistantEnabled} />
{/* Keyboard Shortcuts Sheet */}
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} assistantEnabled={assistantEnabled} />
</div>
</AssistantProvider>
</AssistantProvider></SecuredDataProvider>
</HelpPanelProvider>
)
}
+2
View File
@@ -29,6 +29,7 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Providers from './providers'
import { themeScript } from '@/lib/theme/theme-script'
import '@/styles/globals.css'
/** Inter font configuration with the CSS variable `--font-inter`. */
@@ -71,6 +72,7 @@ export default function RootLayout({
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="font-sans antialiased" suppressHydrationWarning>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<Providers>{children}</Providers>
</body>
</html>
+22 -19
View File
@@ -29,6 +29,7 @@
import Link from 'next/link'
import { SearchX } from 'lucide-react'
import { NestedAppRedirect } from '@/components/layout/NestedAppRedirect'
import { LegacyPathRedirect } from '@/components/layout/LegacyPathRedirect'
/**
@@ -39,26 +40,28 @@ import { LegacyPathRedirect } from '@/components/layout/LegacyPathRedirect'
export default function NotFound() {
return (
// A Material dashboard URL (nested under its apps) opens at its path here instead
<LegacyPathRedirect>
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="flex flex-col items-center gap-6 text-center">
<SearchX className="h-16 w-16 text-muted-foreground" aria-hidden="true" />
<div>
<h1 className="text-6xl font-bold text-foreground">404</h1>
<p className="mt-2 text-xl text-muted-foreground">Page not found</p>
<p className="mt-1 text-sm text-muted-foreground">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<NestedAppRedirect>
<LegacyPathRedirect>
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="flex flex-col items-center gap-6 text-center">
<SearchX className="h-16 w-16 text-muted-foreground" aria-hidden="true" />
<div>
<h1 className="text-6xl font-bold text-foreground">404</h1>
<p className="mt-2 text-xl text-muted-foreground">Page not found</p>
<p className="mt-1 text-sm text-muted-foreground">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
</div>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 pointer-coarse:min-h-11 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-qqq-id="link-go-home"
>
Go Home
</Link>
</div>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 pointer-coarse:min-h-11 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-qqq-id="link-go-home"
>
Go Home
</Link>
</div>
</div>
</LegacyPathRedirect>
</LegacyPathRedirect>
</NestedAppRedirect>
)
}
+6 -1
View File
@@ -31,6 +31,7 @@ import { AuthProvider } from '@/lib/auth/auth-provider'
import { ThemeProvider } from '@/lib/theme/theme-provider'
import { injectThemeTokens } from '@/lib/theme/tokens'
import { AppToaster } from '@/components/feedback/Toast'
import { QaCaptureLoader } from '@/components/qa-capture/QaCaptureLoader'
// Inject default theme tokens on first render
if (typeof window !== 'undefined') {
@@ -79,7 +80,11 @@ export default function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
{/* QA capture (qa-capture extension): nothing is loaded unless the server enables it for this user */}
<QaCaptureLoader />
{children}
</AuthProvider>
</ThemeProvider>
<AppToaster />
</QueryClientProvider>
+23 -28
View File
@@ -14,22 +14,21 @@
* limitations under the License.
*/
// Tests for the Alerts record panel: metadata gating (no request without the qbit's app) and the
// Tests for the Alerts record panel: extension gating (no request without the extension) and the
// panel's hidden / open / watch states. The bell, popover and banners are in
// NotificationCenter.test.tsx.
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render, screen, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { uiApiVersionHandler } from '@/mocks/ui-api-version'
import { server } from '@/mocks/node'
import type { AlertsRecordPanel } from '@/types/alerts'
import type { QInstance } from '@/types/metadata'
import { isAlertsInstalled } from './AlertsBell'
import { AlertsRecordPanelSlot } from './AlertsRecordPanelSlot'
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
@@ -45,10 +44,10 @@ const SUMMARY = { unreadCount: 0, highestUnreadSeverity: null, notificationsHref
const PANEL: AlertsRecordPanel = {
visible: true,
openCount: 1,
open: [{ alertId: 9, title: 'Post has no excerpt', severity: 'WARNING', status: 'OPEN', group: 'Content', age: '1 d', href: '/app/alerts_alert/9' }],
open: [{ alertId: 9, title: 'Post has no excerpt', severity: 'WARNING', status: 'OPEN', group: 'Content', age: '1 d', href: '/app/alerts_alert/9', actions: [{ name: 'ack', label: 'Acknowledge', href: '/app/customAck?record=9' }, { name: 'later', label: 'Snooze', href: '/app/customSnooze/9' }, { name: 'api', label: 'API only', operationId: 'customOperation' }] }],
resolvedLast30Days: 2,
viewAllHref: '/app/alerts_alert?filter=x',
canManage: true,
canManage: false,
watching: false,
}
@@ -69,16 +68,11 @@ function renderWithClient(ui: React.ReactElement) {
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
describe('isAlertsInstalled', () => {
it('is true only when the metadata lists the alerts app', () => {
expect(isAlertsInstalled(WITH_ALERTS)).toBe(true)
expect(isAlertsInstalled(WITHOUT_ALERTS)).toBe(false)
expect(isAlertsInstalled(undefined)).toBe(false)
})
})
beforeEach(() => server.use(uiApiVersionHandler('alerts')))
describe('AlertsRecordPanelSlot', () => {
it('sends no request when the metadata does not announce Alerts', async () => {
it('sends no request when the version does not announce Alerts', async () => {
server.use(uiApiVersionHandler())
let calls = 0
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => {
@@ -123,8 +117,7 @@ describe('AlertsRecordPanelSlot', () => {
expect(none.container).toBeEmptyDOMElement()
})
it('shows open alerts with actions, recent resolutions, and watches the record', async () => {
let watched: unknown = null
it('shows open alerts with actions and recent resolutions; watching is read-only', async () => {
let panel = PANEL
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
@@ -133,24 +126,26 @@ describe('AlertsRecordPanelSlot', () => {
expect(url.searchParams.get('tableName')).toBe('blog_post')
expect(url.searchParams.get('recordId')).toBe('5')
return HttpResponse.json(panel)
}),
http.post('/qqq/v1/alerts/record-panel/watch', async ({ request }) => {
watched = await request.json()
panel = { ...PANEL, watching: true }
return HttpResponse.json({ watching: true, created: true })
})
)
const user = userEvent.setup()
renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="5" />)
const view = renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="5" />)
const section = await screen.findByRole('region', { name: 'Open alerts (1)' })
expect(await axe(section)).toHaveNoViolations()
expect(within(section).getByRole('link', { name: 'Post has no excerpt' })).toHaveAttribute('href', '/app/alerts_alert/9')
expect(within(section).getByRole('link', { name: 'Acknowledge' })).toHaveAttribute('href', '/app/alertsAcknowledge?recordsParam=recordIds&recordIds=9&tableName=alerts_alert')
expect(within(section).getByRole('link', { name: 'Snooze' })).toBeInTheDocument()
expect(within(section).getByRole('link', { name: 'Acknowledge' })).toHaveAttribute('href', '/app/customAck?record=9')
expect(within(section).getByRole('link', { name: 'Snooze' })).toHaveAttribute('href', '/app/customSnooze/9')
expect(within(section).queryByRole('link', { name: 'API only' })).not.toBeInTheDocument()
expect(within(section).getByText('2 resolved in the last 30 days')).toBeInTheDocument()
await user.click(within(section).getByRole('button', { name: 'Watch alerts for this record' }))
await waitFor(() => expect(watched).toEqual({ tableName: 'blog_post', recordId: '5' }))
expect(await within(section).findByText("You watch this record's alerts")).toBeInTheDocument()
// the alerts extension (1.0.0) removed the watch operation: no button, only the legacy state
expect(within(section).queryByRole('button')).not.toBeInTheDocument()
expect(within(section).queryByText("You watch this record's alerts")).not.toBeInTheDocument()
view.unmount()
panel = { ...PANEL, watching: true, open: PANEL.open.map(({ actions: _actions, ...alert }) => alert) }
renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="5" />)
expect(await screen.findByText("You watch this record's alerts")).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Acknowledge' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Snooze' })).not.toBeInTheDocument()
})
})
+18 -25
View File
@@ -16,10 +16,9 @@
/**
* @file AlertsBell — the header bell of the notification center (qbit-alerts). Like the QQQ
* Assistant, it is gated on instance metadata: unless the metadata lists the qbit's `alerts`
* app (which QQQ includes only when qbit-alerts is installed and the user holds `alerts.view`,
* the same permission its `/alerts/*` routes require), it renders nothing and sends no request
* at all. When enabled it polls the inbox summary every `pollSeconds` (60) while the tab is
* Assistant, it is gated on the server's registered UI API extensions. Per-user access is
* checked by the inbox summary (403/404 stops polling). It sends no inbox request
* when the extension is absent. When enabled it polls the inbox summary every `pollSeconds` (60) while the tab is
* visible (TanStack Query pauses interval refetches in a hidden tab and refetches when it shows
* again), backing off exponentially to 5 minutes after errors. The popover's code loads on
* first open, so the shell stays small.
@@ -31,28 +30,20 @@ import React, { Suspense, lazy, useRef, useState } from 'react'
import { Bell } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useUiApiExtensionInstalled, UI_API_EXTENSION } from '@/lib/api/ui-api-extensions'
import { getAlertsInboxSummary } from '@/lib/api/alerts'
import { queryKeys } from '@/lib/query-client'
import type { AlertsInboxSummary } from '@/types/alerts'
import type { QInstance } from '@/types/metadata'
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
const AlertsBellPopover = lazy(() => import('./AlertsBellPopover'))
/** The app qbit-alerts publishes (`AlertsQBitConfig.APP_NAME`), gated on `alerts.view`. */
export const ALERTS_APP_NAME = 'alerts'
/**
* Whether the instance metadata announces qbit-alerts to this user: its `alerts` app is
* present. QQQ leaves an app out of the metadata when the qbit is not installed or the user
* lacks its permission (`alerts.view`, which the `/alerts/*` routes also require), so this is
* the precondition for any Alerts request.
*
* @param metaData - Instance metadata.
* @returns True when the Alerts app is present.
/** Whether the server registers the Alerts extension.
* @returns True when Alerts is installed.
*/
export function isAlertsInstalled(metaData: QInstance | undefined): boolean {
const app = metaData?.apps?.[ALERTS_APP_NAME]
return Boolean(app) && typeof app === 'object'
export function useAlertsInstalled(): boolean {
return useUiApiExtensionInstalled(UI_API_EXTENSION.alerts)
}
/** The longest wait between polls after errors (5 minutes). */
@@ -96,7 +87,7 @@ export function pollDelay(data: AlertsInboxSummary | null | undefined, failed: n
* Shared with the record panel slot and the banner slot. It never fetches (nor polls) unless
* `enabled`.
*
* @param enabled - Whether to load it: authenticated and {@link isAlertsInstalled}.
* @param enabled - Whether to load it: authenticated and {@link useAlertsInstalled}.
* @returns The TanStack Query result.
*/
export function useAlertsInbox(enabled: boolean) {
@@ -115,31 +106,33 @@ export function useAlertsInbox(enabled: boolean) {
/**
* The header bell with its unread badge (red when a CRITICAL alert is unread).
*
* @param props - `metaData`: instance metadata, which must announce Alerts ({@link isAlertsInstalled}).
* @param _props - `metaData`: retained for caller compatibility; registration comes from the version endpoint.
* @returns The bell, or null when Alerts is not available.
*/
export function AlertsBell({ metaData }: { metaData: QInstance | undefined }) {
const { data } = useAlertsInbox(isAlertsInstalled(metaData))
export function AlertsBell(_props: { metaData: QInstance | undefined }) {
const { data } = useAlertsInbox(useAlertsInstalled())
const [open, setOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
if (!data) return null
const unread = data.unreadCount
const label = unread > 0 ? `Notifications, ${unread} unread` : 'Notifications, none unread'
const critical = data.highestUnreadSeverity === 'CRITICAL'
// severity is in the name, not only in the badge's color
const label = unread > 0 ? `Notifications, ${unread} unread${critical ? ', critical' : ''}` : 'Notifications, none unread'
return (
<div className="relative">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((value) => !value)}
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"
className={HEADER_BUTTON_CLASS}
aria-label={label}
title="Notifications"
aria-haspopup="dialog"
aria-expanded={open}
data-qqq-id="button-notifications"
>
<Bell className="h-5 w-5 text-foreground/60" aria-hidden="true" />
<Bell className="h-5 w-5" aria-hidden="true" />
{unread > 0 && (
<span
className={`absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
+10 -32
View File
@@ -24,11 +24,11 @@
'use client'
import React, { useState } from 'react'
import React from 'react'
import Link from 'next/link'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { getAlertsRecordPanel, watchAlertsRecord } from '@/lib/api/alerts'
import { getAlertsRecordPanel } from '@/lib/api/alerts'
import { queryKeys } from '@/lib/query-client'
import { SeverityDot } from './SeverityDot'
@@ -47,8 +47,6 @@ export interface AlertsRecordPanelProps {
* @returns The panel, or null when there is nothing to show.
*/
export default function AlertsRecordPanel({ tableName, recordId }: AlertsRecordPanelProps) {
const queryClient = useQueryClient()
const [watchError, setWatchError] = useState<string | null>(null)
const { data } = useQuery({
queryKey: queryKeys.alertsRecordPanel(tableName, recordId),
queryFn: () => getAlertsRecordPanel(tableName, recordId),
@@ -57,9 +55,6 @@ export default function AlertsRecordPanel({ tableName, recordId }: AlertsRecordP
})
if (!data || !data.visible) return null
const actionHref = (processName: string, alertId: number) =>
`/app/${processName}?recordsParam=recordIds&recordIds=${alertId}&tableName=alerts_alert`
return (
<section
aria-labelledby="alerts-record-panel-title"
@@ -70,28 +65,12 @@ export default function AlertsRecordPanel({ tableName, recordId }: AlertsRecordP
<h2 id="alerts-record-panel-title" className="text-sm font-semibold text-foreground">
{data.openCount > 0 ? `Open alerts (${data.openCount})` : 'Alerts'}
</h2>
{data.watching ? (
{/* Watching is the Alerts qbit's deprecated record subscription (read-only since alerts 1.0.0);
following a record belongs to the collaboration panel. */}
{data.watching && (
<span className="text-xs text-muted-foreground" data-qqq-id="alerts-record-watching">You watch this record&apos;s alerts</span>
) : (
<button
type="button"
onClick={async () => {
setWatchError(null)
try {
await watchAlertsRecord(tableName, recordId)
await queryClient.invalidateQueries({ queryKey: queryKeys.alertsRecordPanel(tableName, recordId) })
} catch {
setWatchError('Could not subscribe. Try again.')
}
}}
className="rounded-md px-2 py-1 text-xs font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="alerts-record-watch"
>
Watch alerts for this record
</button>
)}
</div>
{watchError && <p role="alert" className="mt-2 text-xs text-destructive">{watchError}</p>}
{data.open.length > 0 && (
<ul className="mt-3 divide-y divide-border">
@@ -103,12 +82,11 @@ export default function AlertsRecordPanel({ tableName, recordId }: AlertsRecordP
<span className="block text-xs text-muted-foreground">
{alert.status.charAt(0) + alert.status.slice(1).toLowerCase()} · {alert.age === 'just now' ? 'just now' : `${alert.age} old`}
</span>
{data.canManage && alert.status !== 'RESOLVED' && (
{alert.actions?.some((action) => action.href) && (
<span className="mt-1 flex flex-wrap gap-3 text-xs">
{alert.status === 'OPEN' && (
<Link href={actionHref('alertsAcknowledge', alert.alertId)} className="text-primary hover:underline">Acknowledge</Link>
)}
<Link href={actionHref('alertsSnooze', alert.alertId)} className="text-primary hover:underline">Snooze</Link>
{alert.actions.map((action) => action.href ? (
<Link key={action.name} href={action.href} className="text-primary hover:underline">{action.label}</Link>
) : null)}
</span>
)}
</span>
@@ -16,7 +16,7 @@
/**
* @file AlertsRecordPanelSlot — where a record view's "Open alerts" panel goes. It renders
* nothing (sends no request and loads no panel code) unless the instance metadata announces
* nothing (sends no request and loads no panel code) unless the version endpoint announces
* Alerts and the bell's inbox summary loads, and never on the Alerts qbit's own tables.
*/
@@ -25,18 +25,18 @@
import React, { Suspense, lazy } from 'react'
import type { QInstance } from '@/types/metadata'
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
import { useAlertsInstalled, useAlertsInbox } from './AlertsBell'
const AlertsRecordPanel = lazy(() => import('./AlertsRecordPanel'))
/**
* The slot.
*
* @param props - Instance metadata (which must announce Alerts), and the record's table and primary key.
* @param props - The record's table and primary key; metadata is retained for caller compatibility.
* @returns The lazily loaded panel, or null.
*/
export function AlertsRecordPanelSlot({ metaData, tableName, recordId }: { metaData: QInstance | undefined; tableName: string; recordId: string | undefined }) {
const { data } = useAlertsInbox(isAlertsInstalled(metaData))
export function AlertsRecordPanelSlot({ tableName, recordId }: { metaData: QInstance | undefined; tableName: string; recordId: string | undefined }) {
const { data } = useAlertsInbox(useAlertsInstalled())
if (!data || !recordId || tableName.startsWith('alerts_')) return null
return (
<Suspense fallback={null}>
@@ -17,7 +17,7 @@
/**
* @file NotificationBannerSlot — shows sticky notifications (the summary's `banners`) above the
* page until dismissed. It shares the bell's summary query, so it sends nothing of its own and
* nothing at all unless the metadata announces Alerts; the banner UI loads only when there is
* nothing at all unless the version announces Alerts; the banner UI loads only when there is
* a banner to show.
*/
@@ -26,7 +26,7 @@
import React, { Suspense, lazy } from 'react'
import type { QInstance } from '@/types/metadata'
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
import { useAlertsInstalled, useAlertsInbox } from './AlertsBell'
const NotificationBanners = lazy(() => import('./NotificationBanners'))
@@ -36,8 +36,9 @@ const NotificationBanners = lazy(() => import('./NotificationBanners'))
* @param props - `metaData`: instance metadata; `enabled`: the user is signed in.
* @returns The banners, or null.
*/
export function NotificationBannerSlot({ metaData, enabled }: { metaData: QInstance | undefined; enabled: boolean }) {
const { data } = useAlertsInbox(enabled && isAlertsInstalled(metaData))
export function NotificationBannerSlot({ enabled }: { metaData: QInstance | undefined; enabled: boolean }) {
const installed = useAlertsInstalled()
const { data } = useAlertsInbox(enabled && installed)
if (!data?.banners.length) return null
return (
<Suspense fallback={null}>
@@ -14,7 +14,7 @@
* limitations under the License.
*/
// Tests for the notification center's bell, popover and banner slot: metadata gating (no request
// Tests for the notification center's bell, popover and banner slot: extension gating (no request
// without the qbit's app), polling with backoff and the hidden-tab pause, badge, a11y, per-item
// and bulk actions (optimistic, rolled back on failure), and older backends.
@@ -26,6 +26,7 @@ import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { uiApiVersionHandler } from '@/mocks/ui-api-version'
import { server } from '@/mocks/node'
import type { QInstance } from '@/types/metadata'
import { AlertsBell, MAX_POLL_MS, pollDelay } from './AlertsBell'
@@ -115,6 +116,7 @@ describe('pollDelay', () => {
describe('AlertsBell', () => {
beforeEach(() => {
server.use(uiApiVersionHandler('alerts'))
router.push.mockReset()
})
@@ -122,7 +124,8 @@ describe('AlertsBell', () => {
focusManager.setFocused(undefined)
})
it('sends no request and renders nothing when the metadata does not announce Alerts', async () => {
it('sends no request and renders nothing when the version does not announce Alerts', async () => {
server.use(uiApiVersionHandler())
let calls = 0
server.use(http.get('/qqq/v1/alerts/*', () => {
calls++
@@ -138,14 +141,14 @@ describe('AlertsBell', () => {
expect(banners.container).toBeEmptyDOMElement()
})
it('renders and polls the inbox summary when the metadata announces Alerts', async () => {
it('renders and polls the inbox summary when the version announces Alerts', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
}))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /^Notifications, 2 unread/ })).toBeInTheDocument()
await waitFor(() => expect(calls).toBeGreaterThanOrEqual(3))
})
@@ -156,7 +159,7 @@ describe('AlertsBell', () => {
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
}))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await screen.findByRole('button', { name: 'Notifications, 2 unread' })
await screen.findByRole('button', { name: /^Notifications, 2 unread/ })
act(() => focusManager.setFocused(false))
await sleep(100)
const hidden = calls
@@ -191,15 +194,17 @@ describe('AlertsBell', () => {
it('shows the unread count, red for critical, capped at 99+, with an accessible label', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const first = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
const bell = await screen.findByRole('button', { name: /^Notifications, 2 unread/ })
expect(bell).toHaveAttribute('aria-expanded', 'false')
// severity is in the name, not only in the badge color
expect(bell).toHaveAccessibleName('Notifications, 2 unread, critical')
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
first.unmount()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 140, highestUnreadSeverity: 'WARNING' })))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await screen.findByRole('button', { name: 'Notifications, 140 unread' })
await screen.findByRole('button', { name: /^Notifications, 140 unread/ })
expect(screen.getByText('99+', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-primary')
})
@@ -208,7 +213,7 @@ describe('AlertsBell', () => {
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
const bell = await screen.findByRole('button', { name: /^Notifications, 2 unread/ })
await user.click(bell)
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
expect(await axe(dialog)).toHaveNoViolations()
@@ -238,7 +243,7 @@ describe('AlertsBell', () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: /^Notifications, 2 unread/ }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
const first = within(dialog).getAllByRole('link')[0]
await waitFor(() => expect(first).toHaveFocus())
@@ -255,7 +260,7 @@ describe('AlertsBell', () => {
const dismissals = captureAction('dismiss')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: /^Notifications, 2 unread/ }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
await user.click(within(dialog).getByRole('button', { name: 'Mark read: Nightly backup did not run' }))
@@ -280,11 +285,11 @@ describe('AlertsBell', () => {
)
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: /^Notifications, 2 unread/ }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Nightly backup did not run' }))
expect(within(dialog).queryByText('Nightly backup did not run')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Notifications, 1 unread' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Notifications, 1 unread/ })).toBeInTheDocument()
})
it('rolls back when an action fails', async () => {
@@ -294,9 +299,9 @@ describe('AlertsBell', () => {
)
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: /^Notifications, 2 unread/ }))
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /^Notifications, 2 unread/ })).toBeInTheDocument()
})
it('marks all read and opens the notifications page', async () => {
@@ -304,7 +309,7 @@ describe('AlertsBell', () => {
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: /^Notifications, 2 unread/ }))
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
await waitFor(() => expect(reads).toEqual([{ all: true }]))
await user.click(screen.getByRole('link', { name: 'View all' }))
@@ -316,7 +321,7 @@ describe('AlertsBell', () => {
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 1 unread' }))
await user.click(await screen.findByRole('button', { name: /^Notifications, 1 unread/ }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
expect(dialog).toHaveTextContent('CDN · 20 min ago')
expect(within(dialog).queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument()
@@ -336,6 +341,7 @@ describe('AlertsBell', () => {
})
describe('NotificationBannerSlot', () => {
beforeEach(() => server.use(uiApiVersionHandler('alerts')))
it('shows sticky notifications until dismissed', async () => {
const dismissals: unknown[] = []
let summary = {
@@ -18,9 +18,10 @@ import { 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 { expect, it, vi } from 'vitest'
import { beforeEach, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import { uiApiVersionHandler } from '@/mocks/ui-api-version'
import { NotificationSettingsButton } from './NotificationSettingsButton'
import AlertsBellPopover from './AlertsBellPopover'
import { AlertsBell } from './AlertsBell'
@@ -29,6 +30,8 @@ import type { QInstance } from '@/types/metadata'
// The drawer and form are lazy chunks; give the first import time on a busy runner.
vi.setConfig({ testTimeout: 15_000 })
beforeEach(() => server.use(uiApiVersionHandler('alerts')))
const summary = {
unreadCount: 0,
highestUnreadSeverity: null,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
// Tests for the notifications page: metadata gating, filters and paging in the URL, the inbox
// Tests for the notifications page: extension gating, filters and paging in the URL, the inbox
// query, safe markdown bodies, actions, and empty and error states.
import React from 'react'
@@ -25,6 +25,7 @@ import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { uiApiVersionHandler } from '@/mocks/ui-api-version'
import { server } from '@/mocks/node'
const nav = vi.hoisted(() => ({ params: new URLSearchParams(), push: vi.fn(), replace: vi.fn() }))
@@ -34,8 +35,6 @@ vi.mock('next/navigation', () => ({
useSearchParams: () => nav.params,
}))
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
const meta = vi.hoisted(() => ({ apps: { alerts: { name: 'alerts', label: 'Alerts' } } as Record<string, unknown> }))
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn(async () => ({ apps: meta.apps, tables: {}, processes: {} })) }))
import { NotificationsPage, filterFromParams } from './NotificationsPage'
@@ -82,14 +81,14 @@ describe('filterFromParams', () => {
describe('NotificationsPage', () => {
beforeEach(() => {
server.use(uiApiVersionHandler('alerts'))
nav.params = new URLSearchParams()
nav.push.mockReset()
nav.replace.mockReset()
meta.apps = { alerts: { name: 'alerts', label: 'Alerts' } }
})
it('sends no inbox request without the Alerts app', async () => {
meta.apps = {}
it('sends no inbox request without the Alerts extension', async () => {
server.use(uiApiVersionHandler())
const queries = serveInbox({ items: [], total: 0 })
renderPage()
expect(await screen.findByText('Notifications are not available.')).toBeInTheDocument()
+3 -5
View File
@@ -31,14 +31,13 @@ import { Loader2 } from 'lucide-react'
import { getAlertsInbox } from '@/lib/api/alerts'
import { useQContext } from '@/lib/context/q-context'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { queryKeys } from '@/lib/query-client'
import type { InboxFilter, InboxItem } from '@/types/alerts'
import { NOTIFICATION_CATEGORIES, NOTIFICATION_SEVERITIES } from '@/types/alerts'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
import { NotificationSettingsButton } from './NotificationSettingsButton'
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
import { useAlertsInbox, useAlertsInstalled } from './AlertsBell'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest, severityLabel } from './NotificationParts'
/** Items per page. */
@@ -109,9 +108,8 @@ function Loading() {
export function NotificationsPage() {
const { setPageHeader } = useQContext()
useEffect(() => setPageHeader('Notifications'), [setPageHeader])
const { data: metaData, isLoading } = useMetaData()
if (isLoading) return <Loading />
if (!isAlertsInstalled(metaData)) {
const installed = useAlertsInstalled()
if (!installed) {
return (
<p className="p-6 text-sm text-muted-foreground" data-qqq-id="notifications-unavailable">
Notifications are not available.
@@ -20,6 +20,7 @@ import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { attachmentPolicy, ATTACHMENTS_ACCEPT, BULK_LOAD_ACCEPT } from '@/lib/utils/assistant-attachments'
import { AssistantComposer } from './AssistantComposer'
/** Composer props with no page context. */
@@ -66,4 +67,40 @@ describe('AssistantComposer attachments', () => {
expect(screen.getByRole('status')).toHaveTextContent('Uploading')
expect(screen.getByRole('button', { name: 'Attach a file' })).toBeDisabled()
})
it('offers CSV or Excel for a bulk load by default', () => {
const { container } = render(<AssistantComposer {...BASE} onAttach={() => {}} />)
expect(screen.getByRole('button', { name: 'Attach a file' })).toHaveAttribute('title', 'Attach a file (CSV or Excel) for a bulk load')
expect((container.querySelector('[data-qqq-id="assistant-attach-input"]') as HTMLInputElement).accept).toBe(BULK_LOAD_ACCEPT)
})
it('offers any readable file when the server reads attachments, and describes the limits', () => {
const policy = attachmentPolicy({ enabled: true, features: { attachments: true }, limits: { maxAttachmentBytes: 10 * 1024 * 1024, attachmentTypes: ['Text', 'PDF', 'Images'] } }, false)
const { container } = render(<AssistantComposer {...BASE} onAttach={() => {}} attachPolicy={policy} />)
const button = screen.getByRole('button', { name: 'Attach a file' })
expect(button).toHaveAttribute('title', 'Attach a file')
expect(button).toHaveAccessibleDescription('Reads Text, PDF, Images, up to 10 MB per file')
expect((container.querySelector('[data-qqq-id="assistant-attach-input"]') as HTMLInputElement).accept).toBe(ATTACHMENTS_ACCEPT)
})
it('shows each chip with its name, size and type, and an attach problem inline; passes axe', async () => {
const onDismiss = vi.fn()
const { container } = render(
<AssistantComposer {...BASE} onAttach={() => {}} onRemoveAttachment={() => {}} onDismissAttachError={onDismiss}
attachError="scan.pdf is 12 MB. The assistant reads files up to 10 MB."
attachments={[
{ uploadRef: 'up_1', fileName: 'notes.txt', sizeBytes: 524_288, contentType: 'text/plain' },
{ uploadRef: 'up_2', fileName: 'screenshot.png', sizeBytes: 1_572_864, contentType: 'image/png' },
{ uploadRef: 'up_3', fileName: 'rows.xlsx', sizeBytes: 2048 },
]} />
)
const chips = container.querySelectorAll('[data-qqq-id="assistant-attachment-chip"]')
expect(Array.from(chips).map((chip) => chip.getAttribute('data-kind'))).toEqual(['text', 'image', 'spreadsheet'])
expect(chips[0]).toHaveTextContent('notes.txt, text file512 KB')
expect(chips[1]).toHaveTextContent('1.5 MB')
expect(screen.getByRole('alert')).toHaveTextContent('scan.pdf is 12 MB. The assistant reads files up to 10 MB.')
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(onDismiss).toHaveBeenCalled()
expect(await axe(container)).toHaveNoViolations()
})
})
+85 -32
View File
@@ -22,10 +22,11 @@
'use client'
import React, { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { FileText, Loader2, MapPin, Paperclip, SendHorizontal, Square, X } from 'lucide-react'
import { AlertTriangle, File as FileIcon, FileImage, FileJson, FileSpreadsheet, FileText, Loader2, MapPin, Paperclip, SendHorizontal, Square, X } from 'lucide-react'
import type { AssistantAttachment } from '@/types/assistant'
import type { AssistantPageInfo } from '@/lib/utils/assistant-links'
import { attachmentKind, BULK_LOAD_ACCEPT, formatBytes, type AttachmentKind } from '@/lib/utils/assistant-attachments'
/** Props of {@link AssistantComposer}. */
interface AssistantComposerProps {
@@ -39,6 +40,8 @@ interface AssistantComposerProps {
onRefreshContext: () => void
/** Whether a reply is streaming in this conversation. */
streaming: boolean
/** Sending during an answer replaces it unless concurrent is selected. */
concurrency?: 'supersede' | 'concurrent'
/** Whether a stop was requested. */
stopping: boolean
/**
@@ -67,10 +70,31 @@ interface AssistantComposerProps {
onRemoveAttachment?: (uploadRef: string) => void
/** Whether an upload is in progress. */
attaching?: boolean
/** The file picker hint, tooltip and limits (defaults: CSV or Excel for a bulk load). */
attachPolicy?: { accept: string; title: string; maxBytes?: number; types?: string[] }
/** A problem with the last file (too large, upload failed), shown inline. */
attachError?: string | null
/** Clears {@link AssistantComposerProps.attachError}. */
onDismissAttachError?: () => void
}
/** File types the attach button offers (bulk load files). */
const ATTACH_ACCEPT = '.csv,.tsv,.txt,.xlsx'
/** Chip icon per attachment kind. */
const KIND_ICON: Record<AttachmentKind, typeof FileText> = {
image: FileImage,
spreadsheet: FileSpreadsheet,
json: FileJson,
pdf: FileText,
text: FileText,
file: FileIcon,
}
/** What a chip says about its type, for screen readers. */
const KIND_LABEL: Record<AttachmentKind, string> = {
image: 'image', spreadsheet: 'data file', json: 'JSON file', pdf: 'PDF', text: 'text file', file: 'file',
}
/** Default attach policy: the bulk-load files of servers that do not read attachments. */
const BULK_LOAD_POLICY = { accept: BULK_LOAD_ACCEPT, title: 'Attach a file (CSV or Excel) for a bulk load' }
/** Tallest the textarea grows before it scrolls, in pixels. */
const MAX_TEXTAREA_HEIGHT = 160
@@ -83,7 +107,7 @@ const MAX_TEXTAREA_HEIGHT = 160
* @returns The composer form.
*/
function AssistantComposerInner(
{ pageInfo, contextDismissed, onDismissContext, onRefreshContext, streaming, stopping, onSend, onStop, prefill, attachments = [], onAttach, onRemoveAttachment, attaching = false }: AssistantComposerProps,
{ pageInfo, contextDismissed, onDismissContext, onRefreshContext, streaming, stopping, concurrency = 'supersede', onSend, onStop, prefill, attachments = [], onAttach, onRemoveAttachment, attaching = false, attachPolicy = BULK_LOAD_POLICY, attachError = null, onDismissAttachError }: AssistantComposerProps,
ref: React.ForwardedRef<HTMLTextAreaElement>
) {
const [text, setText] = useState('')
@@ -91,6 +115,11 @@ function AssistantComposerInner(
const hintId = useId()
const localRef = useRef<HTMLTextAreaElement | null>(null)
const fileRef = useRef<HTMLInputElement | null>(null)
const attachHintId = useId()
const attachHint = [
attachPolicy.types?.length ? `Reads ${attachPolicy.types.join(', ')}` : '',
attachPolicy.maxBytes ? `up to ${formatBytes(attachPolicy.maxBytes)} per file` : '',
].filter(Boolean).join(', ')
// A deep link pre-fills the box; the user reviews and sends it
useEffect(() => {
@@ -111,9 +140,9 @@ function AssistantComposerInner(
element.style.height = `${Math.min(element.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`
}, [text])
/** Sends the text unless empty or a reply is streaming. */
/** Sends the text unless empty. */
const submit = () => {
if (!text.trim() || streaming) return
if (!text.trim()) return
onSend(text)
setText('')
}
@@ -147,23 +176,30 @@ function AssistantComposerInner(
)}
{(attachments.length > 0 || attaching) && (
<ul className="mb-2 flex min-w-0 flex-wrap gap-1.5" aria-label="Attached files" data-qqq-id="assistant-attachments">
{attachments.map((attachment) => (
<li key={attachment.uploadRef} 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-attachment-chip">
<FileText className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate">{attachment.fileName ?? 'Attached file'}</span>
{onRemoveAttachment && (
<button
type="button"
onClick={() => onRemoveAttachment(attachment.uploadRef)}
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={`Remove ${attachment.fileName ?? 'the attached file'}`}
data-qqq-id="assistant-attachment-remove"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
)}
</li>
))}
{attachments.map((attachment) => {
const kind = attachmentKind(attachment.fileName, attachment.contentType)
const Icon = KIND_ICON[kind]
const size = typeof attachment.sizeBytes === 'number' ? formatBytes(attachment.sizeBytes) : ''
return (
<li key={attachment.uploadRef} 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-attachment-chip" data-kind={kind}>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate text-foreground">{attachment.fileName || 'Attached file'}</span>
<span className="sr-only">, {KIND_LABEL[kind]}</span>
{size && <span className="shrink-0 tabular-nums" data-qqq-id="assistant-attachment-size">{size}</span>}
{onRemoveAttachment && (
<button
type="button"
onClick={() => onRemoveAttachment(attachment.uploadRef)}
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={`Remove ${attachment.fileName || 'the attached file'}`}
data-qqq-id="assistant-attachment-remove"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
)}
</li>
)
})}
{attaching && (
<li className="inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-2 pr-2 text-xs text-muted-foreground" role="status" data-qqq-id="assistant-attaching">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
@@ -172,13 +208,30 @@ function AssistantComposerInner(
)}
</ul>
)}
{attachError && (
<div className="mb-2 flex min-w-0 items-start gap-1.5 rounded-md border border-destructive/40 px-2 py-1.5 text-xs text-destructive" role="alert" data-qqq-id="assistant-attach-error">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 break-words">{attachError}</span>
{onDismissAttachError && (
<button
type="button"
onClick={onDismissAttachError}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-8 pointer-coarse:w-8"
aria-label="Dismiss"
data-qqq-id="assistant-attach-error-dismiss"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
)}
</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">
{onAttach && (
<>
<input
ref={fileRef}
type="file"
accept={ATTACH_ACCEPT}
accept={attachPolicy.accept}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
@@ -195,11 +248,13 @@ function AssistantComposerInner(
disabled={attaching}
className="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 disabled:opacity-50 pointer-coarse:h-11 pointer-coarse:w-11"
aria-label="Attach a file"
title="Attach a file (CSV or Excel) for a bulk load"
aria-describedby={attachHint ? attachHintId : undefined}
title={attachPolicy.title}
data-qqq-id="assistant-attach"
>
<Paperclip className="h-4 w-4" aria-hidden="true" />
</button>
{attachHint && <span id={attachHintId} className="sr-only">{attachHint}</span>}
</>
)}
<label htmlFor={inputId} className="sr-only">Message the assistant</label>
@@ -220,23 +275,22 @@ function AssistantComposerInner(
submit()
}
}}
placeholder="Ask about your data..."
placeholder={streaming ? 'Ask something else...' : '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 ? (
{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'}
aria-label={stopping ? 'Stopping - select to stop now' : '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()}
@@ -246,9 +300,8 @@ function AssistantComposerInner(
>
<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>
<p id={hintId} className={`mt-1 text-[11px] text-muted-foreground ${streaming ? '' : 'hidden pointer-fine:block'}`}>{streaming ? (concurrency === 'concurrent' ? 'Both answers will continue' : 'Sending stops the current answer') : 'Enter to send, Shift+Enter for a new line'}</p>
</form>
)
}
@@ -0,0 +1,427 @@
/*
* 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 conversation list: row actions (archive, restore, delete with confirmation and
// rollback), the manage link, running and unread indicators, and marking read when a reply ends
import React from 'react'
import { 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 { toast } from 'sonner'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import type { QInstance } from '@/types'
import type { AssistantConversation } from '@/types/assistant'
import { server } from '@/mocks/node'
import { uiApiVersionHandler } from '@/mocks/ui-api-version'
import { AssistantProvider, useAssistantController } from './AssistantProvider'
import { AssistantHeaderButton } from './AssistantHeaderButton'
import { AssistantMount } from './AssistantMount'
import { ConversationRail } from './ConversationRail'
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' }, assistantConversation: { name: 'assistantConversation', label: 'Conversations' } },
supplementalInstanceMetaData: { assistant: {} },
} as unknown as QInstance
/**
* Builds a conversation.
*
* @param id - The id.
* @param title - The title.
* @param extra - Other fields.
* @returns The conversation.
*/
function conversation(id: number, title: string, extra: Partial<AssistantConversation> = {}): AssistantConversation {
return { id, title, status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z', ...extra }
}
/** The accessible name of the unread row (the spacing around the screen-reader text varies by engine). */
const UNREAD_ROW = /^Open invoices\s*,\s*unread$/
const LIST = [
conversation(1, 'Sales report', { running: true }),
conversation(2, 'Open invoices', { unreadCount: 2 }),
conversation(3, 'Weekly summary', { pinned: true }),
conversation(4, 'Quiet one'),
]
/**
* Props of the rail, with spies.
*
* @param overrides - Props to change.
* @returns The props.
*/
function railProps(overrides: Partial<React.ComponentProps<typeof ConversationRail>> = {}): React.ComponentProps<typeof ConversationRail> {
return {
conversations: LIST, loading: false, activeId: null, status: 'ACTIVE',
onStatusChange: vi.fn(), onSelect: vi.fn(), onArchive: vi.fn(), onDelete: vi.fn(),
...overrides,
}
}
describe('ConversationRail', () => {
it('has Archive and Delete on every row, and Restore in the archived list', async () => {
const props = railProps()
const user = userEvent.setup()
const { rerender } = render(<ConversationRail {...props} />)
for (const item of LIST) {
expect(screen.getByRole('button', { name: `Archive ${item.title}` })).toBeInTheDocument()
expect(screen.getByRole('button', { name: `Delete ${item.title}` })).toBeInTheDocument()
}
await user.click(screen.getByRole('button', { name: 'Archive Quiet one' }))
expect(props.onArchive).toHaveBeenCalledWith(LIST[3])
await user.click(screen.getByRole('button', { name: 'Delete Open invoices' }))
expect(props.onDelete).toHaveBeenCalledWith(LIST[1])
const archived = LIST.map((item) => ({ ...item, status: 'ARCHIVED' as const }))
rerender(<ConversationRail {...props} conversations={archived} status="ARCHIVED" />)
expect(screen.getByRole('button', { name: 'Restore Quiet one' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Archive / })).toBeNull()
})
it('moves between rows with the arrow keys and reaches a row\'s actions with Right', async () => {
const user = userEvent.setup()
render(<ConversationRail {...railProps()} />)
// pinned first, then recent in order
const names: Array<string | RegExp> = ['Weekly summary', 'Working: Sales report', UNREAD_ROW, 'Quiet one']
const mains = names.map((name) => screen.getByRole('button', { name }))
mains[0].focus()
await user.keyboard('{ArrowDown}')
expect(mains[1]).toHaveFocus()
await user.keyboard('{End}')
expect(mains[3]).toHaveFocus()
await user.keyboard('{Home}')
expect(mains[0]).toHaveFocus()
await user.keyboard('{ArrowRight}')
expect(screen.getByRole('button', { name: 'Archive Weekly summary' })).toHaveFocus()
await user.keyboard('{ArrowRight}')
expect(screen.getByRole('button', { name: 'Delete Weekly summary' })).toHaveFocus()
await user.keyboard('{ArrowLeft}{ArrowLeft}')
expect(mains[0]).toHaveFocus()
// one tab stop per list: only the current row's buttons are tabbable
expect(mains[1]).toHaveAttribute('tabindex', '-1')
expect(screen.getByRole('button', { name: 'Delete Quiet one' })).toHaveAttribute('tabindex', '-1')
})
it('marks running conversations with a spinner and unread ones with a dot and a bold title', () => {
const { container } = render(<ConversationRail {...railProps()} />)
const running = screen.getByRole('button', { name: 'Working: Sales report' })
expect(running.querySelector('[data-qqq-id="assistant-conversation-running"]')).not.toBeNull()
expect(running.querySelector('.motion-safe\\:animate-spin')).not.toBeNull()
const unread = screen.getByRole('button', { name: UNREAD_ROW })
expect(unread.querySelector('[data-qqq-id="assistant-conversation-unread"]')).not.toBeNull()
expect(unread.querySelector('.font-semibold')).toHaveTextContent('Open invoices')
expect(container.querySelectorAll('[data-qqq-id="assistant-conversation-running"]')).toHaveLength(1)
expect(container.querySelectorAll('[data-qqq-id="assistant-conversation-unread"]')).toHaveLength(1)
})
it('shows both indicators on a conversation that is running and unread', () => {
render(<ConversationRail {...railProps({ conversations: [conversation(7, 'Busy', { running: true, unreadCount: 1 })] })} />)
const row = screen.getByRole('button', { name: /^Working: Busy/ })
expect(row.querySelector('[data-qqq-id="assistant-conversation-running"]')).not.toBeNull()
expect(row.querySelector('[data-qqq-id="assistant-conversation-unread"]')).not.toBeNull()
})
it('shows the manage link only with a path', () => {
const { rerender } = render(<ConversationRail {...railProps({ manageHref: '/app/assistantConversation' })} />)
expect(screen.getByRole('link', { name: 'Manage all conversations' })).toHaveAttribute('href', '/app/assistantConversation')
rerender(<ConversationRail {...railProps({ manageHref: null })} />)
expect(screen.queryByRole('link', { name: 'Manage all conversations' })).toBeNull()
})
it.each(['ACTIVE', 'ARCHIVED'] as const)('passes axe with running and unread rows (%s)', async (status) => {
const conversations = LIST.map((item) => ({ ...item, status }))
const { container } = render(<ConversationRail {...railProps({ conversations, status, activeId: 4, manageHref: '/app/assistantConversation' })} />)
expect(await axe(container)).toHaveNoViolations()
})
})
// ─── Inside the panel ────────────────────────────────────────────────────────
/** Records the traffic of one test. */
const traffic: { patches: Array<{ id: string; body: unknown }>; deletes: string[] } = { patches: [], deletes: [] }
/**
* Harness: the dashboard's assistant wiring.
*
* @param props - Component properties.
* @param props.metaData - Instance metadata.
* @returns The shell.
*/
function Shell({ metaData }: { metaData: QInstance }) {
const assistant = useAssistantController(metaData, true)
return (
<AssistantProvider value={assistant}>
<header><AssistantHeaderButton /></header>
<main>page</main>
<AssistantMount />
</AssistantProvider>
)
}
/**
* Renders the shell.
*
* @param metaData - Instance metadata.
* @returns The render result.
*/
function renderShell(metaData: QInstance = META) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}><Shell metaData={metaData} /></QueryClientProvider>)
}
/**
* Registers the assistant routes with a mutable conversation list.
*
* @param options - `config` extra probe fields; `list` the conversations; `archived` the archived ones.
* @param options.config - Extra probe fields.
* @param options.list - The active conversations.
* @param options.archived - The archived conversations.
*/
function mockAssistant(options: { config?: Record<string, unknown>; list?: AssistantConversation[]; archived?: AssistantConversation[] } = {}) {
let list = options.list ?? LIST
let archived = options.archived ?? []
traffic.patches = []
traffic.deletes = []
server.use(
uiApiVersionHandler('assistant'),
http.get(`${BASE}/turns/active`, () => HttpResponse.json({ error: 'Not found' }, { status: 404 })),
http.get(`${BASE}/config`, () => HttpResponse.json({ enabled: true, ...options.config })),
http.get(`${BASE}/conversations`, ({ request }) =>
HttpResponse.json({ conversations: new URL(request.url).searchParams.get('status') === 'ARCHIVED' ? archived : list })),
http.patch(`${BASE}/conversations/:id`, async ({ params, request }) => {
const body = await request.json() as { status?: 'ACTIVE' | 'ARCHIVED'; read?: boolean }
traffic.patches.push({ id: String(params.id), body })
const id = Number(params.id)
if (body.read) list = list.map((item) => (item.id === id ? { ...item, unreadCount: 0 } : item))
if (body.status === 'ARCHIVED') { archived = [...archived, ...list.filter((item) => item.id === id).map((item) => ({ ...item, status: 'ARCHIVED' as const }))]; list = list.filter((item) => item.id !== id) }
if (body.status === 'ACTIVE') { list = [...list, ...archived.filter((item) => item.id === id).map((item) => ({ ...item, status: 'ACTIVE' as const }))]; archived = archived.filter((item) => item.id !== id) }
return HttpResponse.json(conversation(id, 'x'))
}),
http.delete(`${BASE}/conversations/:id`, ({ params }) => {
traffic.deletes.push(String(params.id))
list = list.filter((item) => item.id !== Number(params.id))
return new HttpResponse(null, { status: 204 })
}),
)
}
/**
* Opens the panel and its conversation list.
*
* @param user - The user.
*/
async function openRail(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await screen.findByRole('button', { name: UNREAD_ROW })
}
beforeAll(async () => {
await import('./AssistantPanel')
}, 30_000)
beforeEach(() => {
router.push.mockReset()
window.history.replaceState(null, '', '/app/person')
window.localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
window.history.replaceState(null, '', '/')
})
describe('conversation list in the panel', () => {
it('archives a row at once and asks the server', async () => {
mockAssistant()
const user = userEvent.setup()
renderShell()
await openRail(user)
await user.click(screen.getByRole('button', { name: 'Archive Quiet one' }))
await waitFor(() => expect(screen.queryByRole('button', { name: 'Quiet one' })).toBeNull())
expect(traffic.patches).toContainEqual({ id: '4', body: { status: 'ARCHIVED' } })
await user.click(screen.getByRole('button', { name: 'Archived' }))
expect(await screen.findByRole('button', { name: 'Restore Quiet one' })).toBeInTheDocument()
})
it('confirms a delete naming the conversation; Cancel keeps it, Delete removes it', async () => {
mockAssistant()
const user = userEvent.setup()
renderShell()
await openRail(user)
await user.click(screen.getByRole('button', { name: 'Delete Quiet one' }))
const dialog = await screen.findByRole('alertdialog')
expect(within(dialog).getByText(/Quiet one and its messages are deleted/)).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
expect(traffic.deletes).toEqual([])
expect(screen.getByRole('button', { name: 'Quiet one' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Delete Quiet one' }))
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(traffic.deletes).toEqual(['4']))
await waitFor(() => expect(screen.queryByRole('button', { name: 'Quiet one' })).toBeNull())
})
it('puts the row back and shows a toast when the server refuses', async () => {
mockAssistant()
server.use(http.patch(`${BASE}/conversations/:id`, () => HttpResponse.json({ error: 'nope' }, { status: 500 })))
const error = vi.spyOn(toast, 'error')
const user = userEvent.setup()
renderShell()
await openRail(user)
await user.click(screen.getByRole('button', { name: 'Archive Quiet one' }))
await waitFor(() => expect(error).toHaveBeenCalled())
expect(error.mock.calls[0][0]).toContain('Quiet one')
expect(await screen.findByRole('button', { name: 'Quiet one' })).toBeInTheDocument()
})
it('rolls a failed delete back too', async () => {
mockAssistant()
server.use(http.delete(`${BASE}/conversations/:id`, () => HttpResponse.json({ error: 'nope' }, { status: 500 })))
const error = vi.spyOn(toast, 'error')
const user = userEvent.setup()
renderShell()
await openRail(user)
await user.click(screen.getByRole('button', { name: 'Delete Quiet one' }))
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(error).toHaveBeenCalled())
expect(await screen.findByRole('button', { name: 'Quiet one' })).toBeInTheDocument()
})
it('links to the conversations table only when the server names one that is in the metadata', async () => {
mockAssistant({ config: { conversationsTable: 'assistantConversation' } })
const user = userEvent.setup()
const first = renderShell()
await openRail(user)
expect(await screen.findByRole('link', { name: 'Manage all conversations' })).toHaveAttribute('href', '/app/assistantConversation')
first.unmount()
mockAssistant({ config: { conversationsTable: 'notInMetadata' } })
const second = renderShell()
await openRail(user)
expect(screen.queryByRole('link', { name: 'Manage all conversations' })).toBeNull()
second.unmount()
mockAssistant()
renderShell()
await openRail(user)
expect(screen.queryByRole('link', { name: 'Manage all conversations' })).toBeNull()
})
it('works with an old server that reports neither running nor a table', async () => {
mockAssistant({ list: [{ ...conversation(2, 'Open invoices'), unreadCount: 1 }] })
const user = userEvent.setup()
renderShell()
await openRail(user)
expect(document.querySelector('[data-qqq-id="assistant-conversation-running"]')).toBeNull()
})
it('dots the History toggle while another conversation is working or unread, with an accessible label', async () => {
mockAssistant()
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const toggle = await screen.findByRole('button', { name: 'Show conversations' })
await waitFor(() => expect(toggle.querySelector('[data-qqq-id="assistant-rail-toggle-dot"]')).not.toBeNull())
expect(toggle).toHaveAccessibleDescription('Other conversations are working or have unread replies')
})
it('does not dot the History toggle when nothing else is working or unread', async () => {
mockAssistant({ list: [conversation(4, 'Quiet one')] })
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const toggle = await screen.findByRole('button', { name: 'Show conversations' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(toggle.querySelector('[data-qqq-id="assistant-rail-toggle-dot"]')).toBeNull()
})
it('passes axe with the rail open (Recent and Archived)', async () => {
mockAssistant({ config: { conversationsTable: 'assistantConversation' }, archived: [conversation(9, 'Old thread', { status: 'ARCHIVED', unreadCount: 1 })] })
const user = userEvent.setup()
renderShell()
await openRail(user)
const panel = screen.getByRole('dialog')
expect(await axe(panel)).toHaveNoViolations()
await user.click(screen.getByRole('button', { name: 'Archived' }))
await screen.findByRole('button', { name: 'Restore Old thread' })
expect(await axe(panel)).toHaveNoViolations()
})
})
describe('unread correctness', () => {
/**
* A reply that streams to its end in the open conversation, the server counting it unread.
*/
it('marks the open conversation read when its reply finishes, so it never counts as unread', async () => {
const open = conversation(4, 'Quiet one')
mockAssistant({ list: [open] })
let unread = 0
server.use(
http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [{ ...open, unreadCount: unread }] })),
http.get(`${BASE}/conversations/4`, () => HttpResponse.json({ conversation: open, messages: [] })),
http.post(`${BASE}/conversations/4/turns`, () => {
unread = 1 // the server counts the reply as unread until it is marked read
const frames = [
['message.start', { seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }],
['text.delta', { seq: 2, messageId: 11, text: 'Done.' }],
['message.end', { seq: 3, messageId: 11, status: 'COMPLETE' }],
] as Array<[string, Record<string, unknown>]>
return new HttpResponse(frames.map(([type, data]) => `id: ${data.seq}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`).join(''), { headers: { 'Content-Type': 'text/event-stream' } })
}),
http.patch(`${BASE}/conversations/4`, async ({ request }) => {
traffic.patches.push({ id: '4', body: await request.json() })
unread = 0
return HttpResponse.json(open)
}),
)
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: 'Quiet one' }))
const composer = await screen.findByRole('textbox', { name: 'Message the assistant' })
await user.type(composer, 'Hello{Enter}')
await waitFor(() => expect(traffic.patches).toContainEqual({ id: '4', body: { read: true } }))
// the header badge never shows the reply the user was watching
await new Promise((resolve) => setTimeout(resolve, 100))
expect(document.querySelector('[data-qqq-id="assistant-unread-badge"]')).toBeNull()
})
it('opening a conversation with unread replies marks it read on the server', async () => {
mockAssistant({ list: [conversation(2, 'Open invoices', { unreadCount: 2 })] })
server.use(http.get(`${BASE}/conversations/2`, () => HttpResponse.json({ conversation: conversation(2, 'Open invoices'), messages: [] })))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant, 2 unread/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: UNREAD_ROW }))
await waitFor(() => expect(traffic.patches).toContainEqual({ id: '2', body: { read: true } }))
})
})
@@ -22,20 +22,25 @@
'use client'
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { Sparkles } from 'lucide-react'
import { useAssistant } from './assistant-context'
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
import { cn } from '@/lib/utils/cn'
import { assistantKeys, useAssistant } from './assistant-context'
/**
* Toggles the assistant panel; the badge counts unread replies.
* Toggles the assistant panel ("Ask"); the badge counts unread replies. The sparkles icon alone is
* ambiguous, so from 1440 px it carries the text label "Ask" (its tooltip says so below that).
*
* @returns The button, or null when the assistant is unavailable.
*/
export function AssistantHeaderButton() {
const assistant = useAssistant()
const { data: busy = false } = useQuery({ queryKey: assistantKeys.busy(), queryFn: () => false, enabled: false })
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)'
const label = unreadCount > 0 ? `Ask the assistant, ${unreadCount} unread (Ctrl+J or ⌘J)` : 'Ask the assistant (Ctrl+J or ⌘J)'
return (
<button
ref={triggerRef}
@@ -45,12 +50,15 @@ export function AssistantHeaderButton() {
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"
className={cn(HEADER_BUTTON_CLASS, 'gap-1.5')}
aria-label={label}
title="Ask the assistant (Ctrl+J or ⌘J)"
aria-pressed={open}
data-qqq-id="button-assistant"
>
<Sparkles className="h-5 w-5 text-foreground/60" aria-hidden="true" />
<Sparkles className="h-5 w-5" aria-hidden="true" />
<span className="hidden text-sm font-medium min-[1440px]:inline" aria-hidden="true">Ask</span>
{busy && <span className="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-primary motion-safe:animate-pulse" role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
{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"
@@ -104,7 +104,8 @@ const COMPONENTS: Components = {
const COMMENT_COMPONENTS: Components = {
...COMPONENTS,
a: ({ href, children }) => {
if (href?.startsWith('user:')) return <span className="rounded-full bg-primary/10 px-1.5 py-0.5 font-medium text-primary" data-qqq-id="mention-chip">{children}</span>
// One unbreakable "@Name" chip; CollaborationMarkdown drops the token's leading "@".
if (href?.startsWith('user:')) return <span className="whitespace-nowrap rounded-full bg-primary/10 px-1.5 py-0.5 font-medium text-primary" data-qqq-id="mention-chip">@{children}</span>
const match = href?.match(/^record:([^/]+)\/(.+)$/)
const internal = internalAppHref(match ? `/app/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}` : href)
return internal ? <Link href={internal} className="text-primary underline" data-qqq-id="comment-record-link">{children}</Link> : <span>{children}</span>
@@ -0,0 +1,144 @@
/*
* 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.
*/
// Fail-soft rendering of assistant replies: a failed bulk-load tool step, malformed parts and a
// renderer that throws all stay local to their part; the rest of the reply keeps rendering.
import React from 'react'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantMessage, AssistantPart, AssistantStreamEvent } from '@/types/assistant'
import { toAssistantEvent } from '@/lib/api/assistant'
import { resetClientErrorsForTesting, setClientErrorSink } from '@/lib/errors/client-errors'
import { AssistantMessageView, PART_RENDERERS } from './AssistantMessageView'
import { optimisticTurnMessages, reduceStreamEvent, type TurnMessageIds } from './message-reducer'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), prefetch: vi.fn() }), usePathname: () => '/app/home' }))
/**
* Parses SSE frames the way the stream client does.
*
* @param frames - `[type, data]` pairs, as the server writes them.
* @returns The typed events.
*/
function events(...frames: Array<[string, Record<string, unknown>]>): AssistantStreamEvent[] {
return frames.flatMap(([type, data]) => {
const { event } = toAssistantEvent({ id: String(data.seq), event: type, data: JSON.stringify(data) })
return event ? [event] : []
})
}
/**
* Renders messages like the panel does.
*
* @param messages - The messages.
* @returns The render result.
*/
function show(messages: AssistantMessage[]) {
const client = new QueryClient()
return render(
<QueryClientProvider client={client}>
{messages.map((message) => <AssistantMessageView key={message.id} message={message} onSend={() => undefined} />)}
</QueryClientProvider>
)
}
// What qbit-assistant streams when a bulk-load tool rejects an attached file: a tool.result with
// INVALID_INPUT, the server's message as the summary and no render (BulkLoadTools.fail).
const BULK_LOAD_FAILURE: Array<[string, Record<string, unknown>]> = [
['message.start', { seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11, startedAt: '2026-10-16T19:50:00Z' }],
['turn.phase', { seq: 2, phase: 'thinking', label: 'thinking' }],
['tool.start', { seq: 3, messageId: 11, toolCallId: 'toolu_1', name: 'describe_table', summary: 'Describing a table' }],
['tool.result', { seq: 4, messageId: 11, toolCallId: 'toolu_1', name: 'describe_table', outcome: 'OK', summary: 'Described Blog Posts' }],
['tool.start', { seq: 5, messageId: 11, toolCallId: 'toolu_2', name: 'bulk_load_attach', summary: 'Reading the file' }],
['tool.result', { seq: 6, messageId: 11, toolCallId: 'toolu_2', name: 'bulk_load_attach', outcome: 'INVALID_INPUT', summary: 'Unrecognized file extension - expecting .csv or .xlsx' }],
['turn.phase', { seq: 7, phase: 'writing', label: 'writing' }],
['text.delta', { seq: 8, messageId: 11, text: 'I could not read that file.' }],
['usage', { seq: 9, turnId: 9, inputTokens: 10, outputTokens: 5 }],
['message.end', { seq: 10, messageId: 11, status: 'COMPLETE', stopReason: 'end_turn' }],
]
describe('assistant replies fail soft', () => {
afterEach(() => {
resetClientErrorsForTesting()
vi.restoreAllMocks()
})
it('shows a failed bulk-load step from the live stream as a failed step, not a crash', () => {
const ids: TurnMessageIds = { userId: -1, assistantId: -2 }
let messages = optimisticTurnMessages('can we post this as a blog?', { userId: -1, assistantId: -2 }, '2026-10-16T19:50:00Z')
const live = events(...BULK_LOAD_FAILURE)
for (const [index, event] of live.entries()) {
messages = reduceStreamEvent(messages, event, ids)
// every intermediate state renders (the panel renders after each event)
if (index < live.length - 1) show(messages).unmount()
}
show(messages)
expect(screen.getByText('Described Blog Posts')).toBeInTheDocument()
expect(screen.getByText('Unrecognized file extension - expecting .csv or .xlsx')).toBeInTheDocument()
expect(screen.getAllByText(', Invalid request')).toHaveLength(1)
expect(screen.getByText('I could not read that file.')).toBeInTheDocument()
expect(screen.queryByText(/could not be shown/)).toBeNull()
})
it('keeps a renderer that throws inside its part; the reply keeps rendering', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const sink = vi.fn().mockResolvedValue(true)
setClientErrorSink(sink)
const original = PART_RENDERERS.navigate
PART_RENDERERS.navigate = () => {
throw new TypeError("Cannot read properties of undefined (reading 'length')")
}
try {
show([{ id: 11, role: 'ASSISTANT', status: 'COMPLETE', turnId: 9, createDate: '', parts: [
{ type: 'navigate', href: '/app/person' },
{ type: 'text', text: 'Still here.' },
] }])
} finally {
PART_RENDERERS.navigate = original
}
expect(screen.getByText('This part could not be shown.')).toBeInTheDocument()
expect(screen.getByText('Still here.')).toBeInTheDocument()
expect(sink).toHaveBeenCalledWith(expect.objectContaining({ scope: 'assistant-part', partType: 'navigate', messageId: 11 }))
// the report names the part type and the error, never the reply's content
expect(JSON.stringify(sink.mock.calls)).not.toContain('Still here')
})
it('degrades malformed parts and a message without parts locally', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const malformed = [null, 42, { text: 'no type' }, { type: 'text', text: 'Readable part.' }] as unknown as AssistantPart[]
show([
{ id: 11, role: 'ASSISTANT', status: 'COMPLETE', turnId: 9, createDate: '', parts: malformed },
{ id: 12, role: 'ASSISTANT', status: 'COMPLETE', turnId: 9, createDate: '', parts: undefined as unknown as AssistantPart[] },
{ id: 13, role: 'USER', status: 'COMPLETE', turnId: 9, createDate: '', parts: [null, { type: 'text', text: 'My question' }] as unknown as AssistantPart[] },
])
expect(screen.getAllByText('This part could not be shown.')).toHaveLength(2)
expect(screen.getByText('Readable part.')).toBeInTheDocument()
expect(screen.getByText('My question')).toBeInTheDocument()
})
it('applies events to a malformed message instead of throwing', () => {
const ids: TurnMessageIds = { userId: null, assistantId: 11 }
const broken = [{ id: 11, role: 'ASSISTANT', status: 'STREAMING', turnId: 9, createDate: '' }] as unknown as AssistantMessage[]
const [text] = events(['text.delta', { seq: 2, messageId: 11, text: 'Hello' }])
const next = reduceStreamEvent(broken, text, ids)
expect(next[0].parts).toEqual([{ type: 'text', text: 'Hello' }])
const [presentation] = events(['presentation', { seq: 3, messageId: 11, part: 'not a part' }])
expect(presentation).toBeUndefined()
})
})
+102 -26
View File
@@ -23,7 +23,7 @@
'use client'
import React, { useId, useState } from 'react'
import { AlertTriangle, Check, ChevronRight, CircleX, Info, Loader2, Navigation, ThumbsDown, ThumbsUp } from 'lucide-react'
import { AlertTriangle, Check, ChevronRight, CircleX, Info, Loader2, Navigation, RefreshCw, Square, ThumbsDown, ThumbsUp } from 'lucide-react'
import type {
AssistantMessage,
@@ -34,16 +34,25 @@ import type {
AssistantTextPart,
AssistantToolOutcome,
AssistantToolPart,
AssistantUnknownPart,
} from '@/types/assistant'
import { sendAssistantFeedback } from '@/lib/api/assistant'
import { internalAppHref } from '@/lib/utils/assistant-links'
import { cn } from '@/lib/utils/cn'
import { MyAvatar } from '@/components/ui/UserAvatar'
import { AssistantRunStatus } from './AssistantRunStatus'
import { partsOf } from './message-reducer'
import type { AssistantRun } from './use-assistant-engine'
import { AssistantMarkdown } from './AssistantMarkdown'
import { AssistantPresentation, type AssistantPresentationActions } from './AssistantPresentation'
import { AssistantRenderView, RecordChips } from './AssistantRenders'
import { AssistantRenderView, RawJsonView, RecordChips } from './AssistantRenders'
import { ErrorBoundary } from '@/components/feedback/ErrorBoundary'
import { InlineFailure } from '@/components/feedback/InlineFailure'
import Link from 'next/link'
/** Plain-language outcome of a tool call. */
const INLINE_ACTION = 'inline-flex items-center gap-1 rounded px-1 text-xs font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 pointer-coarse:min-h-11'
const OUTCOME_LABEL: Record<AssistantToolOutcome, string> = {
OK: 'Done',
DENIED: 'Not permitted',
@@ -58,15 +67,17 @@ const OUTCOME_LABEL: Record<AssistantToolOutcome, string> = {
*
* @param props - Component properties.
* @param props.part - The tool part.
* @param props.finished - Whether the answer has ended, even if no tool result arrived.
* @returns The step.
*/
function ToolStep({ part }: { part: AssistantToolPart }) {
function ToolStep({ part, finished = false }: { part: AssistantToolPart; finished?: boolean }) {
const [expanded, setExpanded] = useState(false)
const detailsId = useId()
const running = !part.outcome
const running = !part.outcome && !finished
const outcome = part.outcome ? OUTCOME_LABEL[part.outcome] ?? part.outcome : 'Not completed'
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'}>
<div className="space-y-1.5" data-qqq-id="assistant-tool-step" data-outcome={part.outcome ?? (finished ? 'INCOMPLETE' : 'RUNNING')}>
<button
type="button"
onClick={() => setExpanded((value) => !value)}
@@ -82,16 +93,25 @@ function ToolStep({ part }: { part: AssistantToolPart }) {
<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>
<span className="sr-only">{running ? ', running' : `, ${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>
<dd>{running ? 'Running' : outcome}</dd>
</dl>
{part.render && <AssistantRenderView render={part.render} />}
{part.render && (
<ErrorBoundary
scope="assistant-render"
reportContext={{ renderType: part.render.type === 'unknown' ? part.render.originalType : part.render.type }}
resetKeys={[part.render]}
fallbackRender={({ report, error, reset }) => <InlineFailure message="This result could not be shown." report={report} error={error} onRetry={reset} qqqId="assistant-render-error" />}
>
<AssistantRenderView render={part.render} />
</ErrorBoundary>
)}
</div>
)
}
@@ -141,7 +161,7 @@ function NavigateView({ part }: { part: AssistantNavigatePart }) {
}
/** Renders one part type. */
type PartRenderer<T extends AssistantPart> = (props: { part: T } & AssistantPresentationActions) => React.ReactNode
type PartRenderer<T extends AssistantPart> = (props: { part: T; finished?: boolean } & AssistantPresentationActions) => React.ReactNode
/**
* Renderers by part type. Phase 2 adds `approval` here; anything without a renderer is skipped.
@@ -149,10 +169,11 @@ type PartRenderer<T extends AssistantPart> = (props: { part: T } & AssistantPres
export const PART_RENDERERS: { [K in AssistantPart['type']]: PartRenderer<Extract<AssistantPart, { type: K }>> } = {
presentation: ({ part, onSend, sending }) => <AssistantPresentation part={part} onSend={onSend} sending={sending} />,
text: ({ part }: { part: AssistantTextPart }) => (part.text ? <AssistantMarkdown text={part.text} /> : null),
tool: ({ part }: { part: AssistantToolPart }) => <ToolStep part={part} />,
tool: ({ part, finished }) => <ToolStep part={part} finished={finished} />,
record_refs: ({ part }: { part: AssistantRecordRefsPart }) => <RecordChips refs={part.refs} />,
navigate: ({ part }: { part: AssistantNavigatePart }) => <NavigateView part={part} />,
notice: ({ part }: { part: AssistantNoticePart }) => <NoticeView part={part} />,
unknown: ({ part }: { part: AssistantUnknownPart }) => <RawJsonView label={part.originalType ? `Unsupported content (${part.originalType})` : 'Unsupported content'} value={part.raw} />,
}
/**
@@ -160,11 +181,38 @@ export const PART_RENDERERS: { [K in AssistantPart['type']]: PartRenderer<Extrac
*
* @param props - Component properties.
* @param props.part - Any part, including types this version does not know.
* @param props.messageId - The message's id, for the failure report.
* @returns The rendered part, or null.
*/
function PartView({ part, onSend, sending }: { part: AssistantPart } & AssistantPresentationActions) {
const render = (PART_RENDERERS as Record<string, PartRenderer<AssistantPart> | undefined>)[part.type]
return render ? <>{render({ part, onSend, sending })}</> : null
function PartView({ part, onSend, sending, finished, messageId }: { part: AssistantPart; finished?: boolean; messageId?: number } & AssistantPresentationActions) {
if (!part || typeof part !== 'object') return <InlineFailure message="This part could not be shown." qqqId="assistant-part-error" />
const render = typeof part.type === 'string' ? (PART_RENDERERS as Record<string, PartRenderer<AssistantPart> | undefined>)[part.type] : undefined
if (!render) return null
// One part that fails to render (for example, a malformed tool payload) must
// not take down the reply - or the page - around it. A new payload (streaming) renders again.
// The renderer runs inside the boundary (as a component), so an error in it stays here too.
return (
<ErrorBoundary
scope="assistant-part"
reportContext={{ messageId, partType: part.type }}
resetKeys={[part]}
fallbackRender={({ report, error }) => <InlineFailure message="This part could not be shown." report={report} error={error} qqqId="assistant-part-error" />}
>
<RenderPart render={render} part={part} onSend={onSend} sending={sending} finished={finished} />
</ErrorBoundary>
)
}
/**
* Calls a part renderer during its own render, inside the part's error boundary.
*
* @param props - Component properties.
* @param props.render - The renderer.
* @param props.part - The part.
* @returns What the renderer returns.
*/
function RenderPart({ render, part, onSend, sending, finished }: { render: PartRenderer<AssistantPart>; part: AssistantPart; finished?: boolean } & AssistantPresentationActions) {
return <>{render({ part, onSend, sending, finished })}</>
}
/**
@@ -235,37 +283,65 @@ function FeedbackButtons({ messageId }: { messageId: number }) {
*
* @param props - Component properties.
* @param props.message - The message.
* @param props.onRetry - Asks the question again (offered when the reply failed).
* @param props.onStop - Stops the reply (offered while it is running, and for a reply left running).
* @returns The message article.
*/
export function AssistantMessageView({ message, onSend, sending }: { message: AssistantMessage } & AssistantPresentationActions) {
export function AssistantMessageView({ message, onSend, sending, run, onRetry, onStop }: { message: AssistantMessage; run?: AssistantRun; onRetry?: () => void; onStop?: () => void } & AssistantPresentationActions) {
if (message.role === 'USER') {
const text = message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
const text = partsOf(message).map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : '')).join('')
return (
<article className="flex justify-end" aria-label="You" data-qqq-id="assistant-message" data-role="user">
<article className="flex items-end justify-end gap-2" 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>
<MyAvatar size={28} data-qqq-id="assistant-user-avatar" />
</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} />)}
{partsOf(message).map((part, index) => <PartView key={index} part={part} messageId={message.id} />)}
</article>
)
}
const streaming = message.status === 'STREAMING'
const parts = partsOf(message)
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} onSend={onSend} sending={sending || streaming} />)}
{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>
<article className="min-w-0 space-y-2" aria-label="Assistant" data-qqq-id="assistant-message" data-role="assistant" data-status={message.status}>
{parts.length > 0 && (
<div className="space-y-2" aria-busy={streaming}>
{parts.map((part, index) => <PartView key={part?.type === 'tool' && part.toolCallId ? part.toolCallId : index} part={part} messageId={message.id} onSend={onSend} sending={sending || streaming} finished={!streaming} />)}
</div>
)}
{(run || streaming) && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{run ? <AssistantRunStatus run={run} /> : 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>
)}
{onStop && (
<button type="button" onClick={onStop} className={INLINE_ACTION} data-qqq-id="assistant-message-stop">
<Square className="h-3 w-3 fill-current" aria-hidden="true" />
{run?.status === 'cancelling' ? 'Stop now' : 'Stop'}
</button>
)}
</div>
)}
{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 === 'ERROR' && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1" data-qqq-id="assistant-reply-failed">
{!parts.some((part) => part?.type === 'notice' && part.level === 'error') && (
<p className="text-xs text-destructive" role="alert">The reply did not finish.</p>
)}
{onRetry && (
<button type="button" onClick={onRetry} disabled={sending} className={INLINE_ACTION} data-qqq-id="assistant-message-retry">
<RefreshCw className="h-3 w-3" aria-hidden="true" />
Retry
</button>
)}
</div>
)}
{message.status === 'COMPLETE' && message.id > 0 && <FeedbackButtons messageId={message.id} />}
</article>
@@ -0,0 +1,115 @@
/*
* 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.
*/
// A crashing assistant panel is replaced by a small card; the page around it keeps working
import React from 'react'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resetClientErrorsForTesting, setClientErrorSink } from '@/lib/errors/client-errors'
import { AssistantContext, assistantKeys, type AssistantController } from './assistant-context'
import { AssistantMount, setAssistantPanelLoaderForTesting } from './AssistantMount'
const panel = vi.hoisted(() => ({ broken: true }))
vi.mock('./AssistantPanel', () => ({
default: function AssistantPanel() {
if (panel.broken) throw new TypeError("Cannot read properties of undefined (reading 'length')")
return <aside data-qqq-id="assistant-panel">panel</aside>
},
}))
function renderMount(setOpen = vi.fn(), client = new QueryClient()) {
const controller = { enabled: true, open: true, setOpen, activeConversationId: 4 } as unknown as AssistantController
return render(
<QueryClientProvider client={client}>
<AssistantContext.Provider value={controller}>
<main><button type="button">page action</button></main>
<AssistantMount />
</AssistantContext.Provider>
</QueryClientProvider>
)
}
describe('AssistantMount', () => {
afterEach(() => {
resetClientErrorsForTesting()
setAssistantPanelLoaderForTesting(null)
vi.restoreAllMocks()
panel.broken = true
})
it('replaces a crashed panel with Retry, Copy details and Close, and reports it', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const sink = vi.fn().mockResolvedValue(true)
setClientErrorSink(sink)
const setOpen = vi.fn()
renderMount(setOpen)
expect(await screen.findByText('The assistant hit a problem')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'page action' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Copy details' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Report a bug' })).not.toBeInTheDocument() // QA capture off
expect(sink).toHaveBeenCalledWith(expect.objectContaining({ scope: 'assistant-panel', conversationId: 4 }))
panel.broken = false
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('panel')).toBeInTheDocument()
})
it('closes from the failure card', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const setOpen = vi.fn()
renderMount(setOpen)
await userEvent.click(await screen.findByRole('button', { name: 'Close assistant' }))
expect(setOpen).toHaveBeenCalledWith(false)
})
it('loads the panel again on Retry after its code failed to load (a chunk missing after a deploy)', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
panel.broken = false
const real = () => import('./AssistantPanel')
let attempts = 0
setAssistantPanelLoaderForTesting(() => {
attempts++
return attempts === 1 ? Promise.reject(new Error('Failed to fetch dynamically imported module')) : real()
})
renderMount()
expect(await screen.findByText('The assistant hit a problem')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('panel')).toBeInTheDocument()
expect(attempts).toBe(2)
})
it('recovers by itself once an assistant request succeeds again', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const client = new QueryClient()
const list = vi.fn().mockResolvedValue([])
// the header badge's list query, as the shell registers it
await client.prefetchQuery({ queryKey: assistantKeys.conversations(), queryFn: list })
renderMount(vi.fn(), client)
expect(await screen.findByText('The assistant hit a problem')).toBeInTheDocument()
// the next poll succeeds (the mount's own backoff check, a focus refetch or the badge)
panel.broken = false
await act(async () => {
await client.refetchQueries({ queryKey: assistantKeys.conversations(), type: 'all' })
})
expect(await screen.findByText('panel')).toBeInTheDocument()
expect(screen.queryByText('The assistant hit a problem')).toBeNull()
})
})
+128 -15
View File
@@ -16,34 +16,147 @@
/**
* @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.
* streaming, record renders) loads when the assistant becomes available. It stays mounted
* while closed so refresh discovery and the header's busy indicator keep working.
*/
'use client'
import React, { Suspense, lazy, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useAssistant } from './assistant-context'
import { ErrorBoundary } from '@/components/feedback/ErrorBoundary'
import { assistantKeys, useAssistant } from './assistant-context'
import { AssistantPanelFailure } from './AssistantPanelFailure'
const AssistantPanel = lazy(() => import('./AssistantPanel'))
/** The panel module. */
type PanelModule = { default: React.ComponentType }
/** First wait before a crashed panel is tried again on its own; doubles up to the cap. */
export const RECOVERY_FIRST_DELAY_MS = 5_000
/** Longest wait between two recovery checks. */
export const RECOVERY_MAX_DELAY_MS = 60_000
const importPanel = (): Promise<PanelModule> => import('./AssistantPanel')
let panelLoader: () => Promise<PanelModule> = importPanel
let loadedPanel: Promise<PanelModule> | null = null
let panelModule: PanelModule | null = null
/**
* Mounts the lazily loaded assistant panel once it has been opened.
* Loads the panel code once; a failed load (a chunk missing after a deploy, a network error) is
* forgotten so the next attempt fetches it again.
*
* @returns The panel, or null until the assistant is available and first opened.
* @returns The panel module.
*/
function loadPanel(): Promise<PanelModule> {
if (!loadedPanel) {
loadedPanel = panelLoader().then((module) => {
panelModule = module
return module
}, (error: unknown) => {
loadedPanel = null
throw error
})
}
return loadedPanel
}
/**
* Replaces how the panel code is loaded (tests: a chunk that fails to load).
*
* @param loader - The loader, or null for the real import.
*/
export function setAssistantPanelLoaderForTesting(loader: (() => Promise<PanelModule>) | null): void {
panelLoader = loader ?? importPanel
loadedPanel = null
panelModule = null
}
/**
* Mounts the lazily loaded assistant panel when the assistant is available. The panel sits
* behind its own error boundary: if it (or its code chunk) fails, a small card offers Retry,
* Copy details and Report a bug, and the page keeps working. The panel comes back on Retry, on
* reopening, and by itself once an assistant request succeeds again (checked with a backoff).
*
* @returns The panel, or null until the assistant is available.
*/
export function AssistantMount() {
const assistant = useAssistant()
const [loaded, setLoaded] = useState(false)
const open = Boolean(assistant?.open)
const queryClient = useQueryClient()
const [failed, setFailed] = useState(false)
const [recovery, setRecovery] = useState(0)
const onError = useCallback(() => setFailed(true), [])
const onLoaded = useCallback(() => setFailed(false), [])
// While crashed: try the assistant again with a backoff; the panel renders again as soon as an
// assistant request (this check, a focus refetch, the badge) succeeds.
useEffect(() => {
if (open) setLoaded(true)
}, [open])
if (!assistant?.enabled || (!loaded && !open)) return null
if (!failed) return
const cache = queryClient.getQueryCache()
const root = assistantKeys.all()
const unsubscribe = cache.subscribe((event) => {
if (event.type !== 'updated' || event.action.type !== 'success') return
const key = event.query.queryKey
if (root.every((part, index) => key[index] === part)) setRecovery((value) => value + 1)
})
let delay = RECOVERY_FIRST_DELAY_MS
let timer: ReturnType<typeof setTimeout>
const check = () => {
void queryClient.refetchQueries({ queryKey: assistantKeys.conversations(), type: 'all' }).catch(() => undefined)
delay = Math.min(delay * 2, RECOVERY_MAX_DELAY_MS)
timer = setTimeout(check, delay)
}
timer = setTimeout(check, delay)
return () => {
unsubscribe()
clearTimeout(timer)
}
}, [failed, queryClient])
if (!assistant?.enabled) return null
return (
<Suspense fallback={null}>
<AssistantPanel />
</Suspense>
<ErrorBoundary
scope="assistant-panel"
reportContext={{ conversationId: assistant.activeConversationId }}
resetKeys={[assistant.open, recovery]}
onError={onError}
onReset={() => setFailed(false)}
fallbackRender={({ report, error, reset }) => <AssistantPanelFailure report={report} error={error} onRetry={reset} />}
>
<PanelLoader onLoaded={failed ? onLoaded : undefined} />
</ErrorBoundary>
)
}
/**
* Loads the panel code and renders the panel. Unlike React.lazy, which keeps a failed load
* forever, a remount (Retry, reopening, recovery) loads it again. A load failure is thrown to
* the panel's error boundary like a render error.
*
* @param props - Component properties.
* @param props.onLoaded - Called once the panel rendered, when set (the mount was showing a failure).
* @returns The panel, or nothing while its code loads.
*/
function PanelLoader({ onLoaded }: { onLoaded?: () => void }) {
const [module, setModule] = useState<PanelModule | null>(panelModule)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (module) return
let active = true
loadPanel().then((loaded) => {
if (active) setModule(loaded)
}, (failure: unknown) => {
if (active) setError(failure ?? new Error('The assistant could not be loaded.'))
})
return () => {
active = false
}
}, [module])
useEffect(() => {
if (module) onLoaded?.()
}, [module, onLoaded])
if (error) throw error
if (!module) return null
const Panel = module.default
return <Panel />
}
+350 -12
View File
@@ -26,8 +26,10 @@ import { axe } from 'jest-axe'
import type { QInstance } from '@/types'
import { server } from '@/mocks/node'
import { uiApiVersionHandler } from '@/mocks/ui-api-version'
import { AssistantProvider, useAssistantController } from './AssistantProvider'
import { AssistantHeaderButton } from './AssistantHeaderButton'
import * as assistantApi from '@/lib/api/assistant'
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() }))
@@ -39,6 +41,9 @@ vi.mock('next/navigation', () => ({
const BASE = '/qqq/v1/assistant'
const META = { tables: { person: { name: 'person', label: 'People' } }, supplementalInstanceMetaData: { assistant: {} } } as unknown as QInstance
/** Matches rendered message text, not the screen-reader announcer that repeats it. */
const MESSAGE_TEXT = ':not([data-qqq-id="assistant-announcer"])'
const CONVERSATION = { id: 4, title: null, status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z' }
/**
@@ -124,6 +129,8 @@ function renderShell(metaData?: QInstance) {
function mockAssistant(config: Record<string, unknown> | number = { enabled: true }) {
persisted.messages = []
server.use(
uiApiVersionHandler('assistant'),
http.get(`${BASE}/turns/active`, () => HttpResponse.json({ error: 'Not found' }, { status: 404 })),
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 })),
@@ -144,6 +151,7 @@ beforeEach(() => {
})
afterEach(() => {
vi.restoreAllMocks()
window.history.replaceState(null, '', '/')
})
@@ -179,7 +187,7 @@ describe('assistant gating', () => {
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()
expect(await screen.findByRole('button', { name: 'Ask the assistant, 3 unread (Ctrl+J or ⌘J)' })).toBeInTheDocument()
})
})
@@ -196,13 +204,13 @@ describe('asking a question', () => {
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const composer = await screen.findByRole('textbox', { name: 'Message the assistant' })
await user.type(composer, 'Count images{Enter}')
const action = await screen.findByRole('button', { name: 'Show PNG' })
await waitFor(() => expect(action).toBeEnabled())
await user.click(action)
await waitFor(() => expect(requests[1]).toEqual({ text: 'Show me PNG files', pageContext: { route: '/app/person', tableName: 'person' } }))
await waitFor(() => expect(requests[1]).toEqual({ concurrency: 'supersede', text: 'Show me PNG files', pageContext: { route: '/app/person', tableName: 'person' } }))
})
it('streams the reply with a tool step and record chip, follows navigation, and passes axe', async () => {
@@ -215,13 +223,13 @@ describe('asking a question', () => {
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
await user.click(await screen.findByRole('button', { name: /^Ask the 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' } }))
await waitFor(() => expect(turnBody).toEqual({ concurrency: 'supersede', 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()
@@ -266,7 +274,7 @@ describe('asking a question', () => {
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
expect(within(panel).getAllByText('Ada').length).toBeGreaterThan(0)
expect(within(panel).getByText('Hi, I am Ada.')).toBeInTheDocument()
@@ -290,7 +298,7 @@ describe('asking a question', () => {
)
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'hi{Enter}')
act(() => stream.close())
@@ -317,7 +325,7 @@ describe('asking a question', () => {
)
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
await user.click(await screen.findByRole('button', { name: /^Ask the 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' }])))
@@ -327,12 +335,67 @@ describe('asking a question', () => {
expect(await within(panel).findByText('Stopped.')).toBeInTheDocument()
})
it('still stops when the stop request fails, and the composer stays usable', async () => {
mockAssistant()
const stream = controllableStream()
server.use(
http.post(`${BASE}/conversations/4/turns`, () => stream.response()),
http.post(`${BASE}/turns/9/cancel`, () => HttpResponse.json({ error: 'Gateway down' }, { status: 502 })),
)
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the 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' }))
expect(await within(panel).findByText('Stopped.')).toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: /^Stop/ })).not.toBeInTheDocument()
expect(within(panel).getByRole('textbox', { name: 'Message the assistant' })).toBeEnabled()
})
it('stops at once on a second Stop while the server is still stopping', async () => {
mockAssistant()
const stream = controllableStream()
server.use(
http.post(`${BASE}/conversations/4/turns`, () => stream.response()),
http.post(`${BASE}/turns/9/cancel`, () => HttpResponse.json({ status: 'CANCELLING' }, { status: 202 })),
)
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the 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)))
await user.click(await within(panel).findByRole('button', { name: 'Stop the reply' }))
await user.click(await within(panel).findByRole('button', { name: 'Stop now' }))
expect(await within(panel).findByText('Stopped.', { selector: MESSAGE_TEXT })).toBeInTheDocument()
})
it('offers Retry on a failed reply, which asks the question again', async () => {
mockAssistant()
let posts = 0
server.use(http.post(`${BASE}/conversations/4/turns`, () => {
posts++
return HttpResponse.json({ error: 'Provider unavailable' }, { status: 503 })
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the 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('Provider unavailable')).toBeInTheDocument()
await user.click(within(panel).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(posts).toBe(2))
})
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/ }))
await user.click(await screen.findByRole('button', { name: /^Ask the 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()
@@ -350,11 +413,11 @@ describe('asking a question', () => {
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
await user.click(await screen.findByRole('button', { name: /^Ask the 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' }))
await waitFor(() => expect(turnBody).toEqual({ concurrency: 'supersede', text: 'Hi' }))
})
})
@@ -375,7 +438,7 @@ describe('markdown safety', () => {
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
await user.click(await screen.findByRole('button', { name: /^Ask the 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' })
@@ -417,3 +480,278 @@ describe('deep link', () => {
expect(router.replace).not.toHaveBeenCalled()
})
})
/** A discovered turn with a seven-second head start. */
function activeTurn() {
return { turnId: 9, conversationId: 4, assistantMessageId: 11, userMessageId: 10, status: 'RUNNING', phase: 'thinking', startedAt: new Date(Date.now() - 7000).toISOString(), heartbeatAt: new Date().toISOString(), elapsedMs: 7000, live: true, concurrencyMode: 'supersede' }
}
/** Opens the panel and returns its composer. */
async function openComposer(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
return screen.findByRole('textbox', { name: 'Message the assistant' })
}
describe('answer lifecycle and recovery', () => {
it('keeps partial text and the error notice when a running answer fails', async () => {
mockAssistant()
const stream = controllableStream()
server.use(http.post(`${BASE}/conversations/4/turns`, () => stream.response()))
const user = userEvent.setup()
renderShell()
const composer = await openComposer(user)
await user.type(composer, 'Explain this{Enter}')
act(() => stream.write(sse(START, ['tool.start', { seq: 2, messageId: 11, toolCallId: 'search', name: 'query_records', summary: 'Search records' }], ['text.delta', { seq: 3, messageId: 11, text: 'Partial explanation' }])))
expect(await screen.findByText('Partial explanation')).toBeInTheDocument()
persisted.messages = storedTurn('Explain this', [{ type: 'tool', toolCallId: 'search', name: 'query_records', summary: 'Search records' }, { type: 'text', text: 'Partial explanation' }, { type: 'notice', level: 'error', text: 'The server restarted.' }], 'ERROR')
act(() => {
stream.write(sse(['error', { seq: 4, messageId: 11, message: 'The server restarted.' }], ['message.end', { seq: 5, messageId: 11, status: 'ERROR' }]))
stream.close()
})
expect(await screen.findByText('The server restarted.')).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('button', { name: 'Stop the reply' })).not.toBeInTheDocument())
expect(screen.getByText('Partial explanation')).toBeInTheDocument()
expect(document.querySelector('[data-qqq-id="assistant-tool-step"]')).toHaveAttribute('data-outcome', 'INCOMPLETE')
await user.type(composer, 'Next question')
expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled()
})
it('shows a busy header on refresh while closed, then a background banner with Open and Stop', async () => {
mockAssistant()
const turn = activeTurn()
const stream = controllableStream()
const events = vi.fn(() => stream.response())
let active = true
persisted.messages = storedTurn('Find records', [], 'STREAMING')
server.use(
http.get(`${BASE}/turns/active`, () => HttpResponse.json({ turns: active ? [turn] : [] })),
http.get(`${BASE}/conversations/4`, () => HttpResponse.json({ conversation: { ...CONVERSATION, title: 'People search' }, messages: persisted.messages })),
http.get(`${BASE}/turns/9/events`, events),
http.post(`${BASE}/turns/9/cancel`, () => {
active = false
persisted.messages = storedTurn('Find records', [], 'CANCELLED')
stream.write(sse(['message.end', { seq: 2, messageId: 11, status: 'CANCELLED' }]))
stream.close()
return HttpResponse.json({ status: 'CANCELLING' })
}),
)
const user = userEvent.setup()
renderShell()
expect(await screen.findByRole('status', { name: 'Assistant is answering' })).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await openComposer(user)
const banner = document.querySelector('[data-qqq-id="assistant-background-run"]') as HTMLElement
expect(banner).toHaveTextContent('Answering in People search - 0:0')
expect(within(banner).getByRole('button', { name: 'Stop' })).toBeEnabled()
await user.click(within(banner).getByRole('button', { name: 'Open' }))
expect(await screen.findByText('Thinking...')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Stop the reply' })).toBeEnabled()
expect(screen.getByPlaceholderText('Ask something else...')).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Stop the reply' }))
expect(await screen.findByText('Stopped.', { selector: MESSAGE_TEXT })).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('status', { name: 'Assistant is answering' })).not.toBeInTheDocument())
expect(events).toHaveBeenCalledTimes(1)
})
it('stops a background conversation without opening it', async () => {
mockAssistant()
const stream = controllableStream()
const cancel = vi.fn(() => {
persisted.messages = storedTurn('Find records', [], 'CANCELLED')
stream.write(sse(['message.end', { seq: 2, messageId: 11, status: 'CANCELLED' }]))
stream.close()
return HttpResponse.json({ status: 'CANCELLING' })
})
server.use(
http.get(`${BASE}/turns/active`, () => HttpResponse.json({ turns: [activeTurn()] })),
http.get(`${BASE}/turns/9/events`, () => stream.response()),
http.post(`${BASE}/turns/9/cancel`, cancel),
)
const user = userEvent.setup()
renderShell()
await openComposer(user)
await user.click(await screen.findByRole('button', { name: 'Stop' }))
await waitFor(() => expect(cancel).toHaveBeenCalledTimes(1))
await waitFor(() => expect(document.querySelector('[data-qqq-id="assistant-background-run"]')).toBeNull())
})
it.each(['supersede', 'concurrent'] as const)('sends while running with %s and retains both replies', async (mode) => {
mockAssistant()
const first = controllableStream()
const second = controllableStream()
const bodies: Array<{ text: string; concurrency: string }> = []
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
bodies.push(await request.json() as { text: string; concurrency: string })
return bodies.length === 1 ? first.response() : second.response()
}))
const user = userEvent.setup()
renderShell()
const composer = await openComposer(user)
await user.type(composer, 'First question{Enter}')
act(() => first.write(sse(START, ['text.delta', { seq: 2, messageId: 11, text: 'First answer' }])))
expect(await screen.findByText('First answer', { selector: MESSAGE_TEXT })).toBeInTheDocument()
if (mode === 'concurrent') {
await user.click(screen.getByRole('button', { name: 'Conversation actions' }))
await user.click(await screen.findByRole('menuitemradio', { name: 'Answer both' }))
expect(localStorage.getItem('qqq.assistant.concurrency')).toBe('concurrent')
}
expect(screen.getByText(mode === 'concurrent' ? 'Both answers will continue' : 'Sending stops the current answer')).toBeInTheDocument()
expect(composer).toBeEnabled()
await user.type(composer, 'Second question')
expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Stop the reply' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(bodies).toHaveLength(2))
expect(bodies[0].concurrency).toBe('supersede')
expect(bodies[1].concurrency).toBe(mode)
act(() => second.write(sse(
['message.start', { seq: 1, turnId: 12, conversationId: 4, userMessageId: 13, messageId: 14 }],
['text.delta', { seq: 2, messageId: 14, text: 'Second answer' }],
)))
expect(await screen.findByText('Second answer', { selector: MESSAGE_TEXT })).toBeInTheDocument()
// The newer answer finishes first: it must not overwrite the older stream in the cache.
act(() => {
second.write(sse(['message.end', { seq: 3, messageId: 14, status: 'COMPLETE' }]))
second.close()
})
await waitFor(() => expect(document.querySelector('[data-status="COMPLETE"][data-role="assistant"]')).not.toBeNull())
expect(screen.getByRole('button', { name: 'Stop the reply' })).toBeInTheDocument()
const notice = 'Stopped because you asked something else.'
persisted.messages = [
...storedTurn('First question', [{ type: 'text', text: 'First answer' }, ...(mode === 'supersede' ? [{ type: 'notice', level: 'info', text: notice }] : [])], mode === 'supersede' ? 'CANCELLED' : 'COMPLETE'),
{ id: 13, role: 'USER', status: 'COMPLETE', turnId: 12, createDate: new Date().toISOString(), parts: [{ type: 'text', text: 'Second question' }] },
{ id: 14, role: 'ASSISTANT', status: 'COMPLETE', turnId: 12, createDate: new Date().toISOString(), parts: [{ type: 'text', text: 'Second answer' }] },
]
act(() => {
if (mode === 'supersede') first.write(sse(['notice', { seq: 3, level: 'info', text: notice }]))
first.write(sse(['message.end', { seq: 4, messageId: 11, status: mode === 'supersede' ? 'CANCELLED' : 'COMPLETE' }]))
first.close()
})
await waitFor(() => expect(screen.queryByRole('button', { name: 'Stop the reply' })).not.toBeInTheDocument())
expect(screen.getByText('First answer', { selector: MESSAGE_TEXT })).toBeInTheDocument()
expect(screen.getByText('Second answer', { selector: MESSAGE_TEXT })).toBeInTheDocument()
if (mode === 'supersede') expect(screen.getByText(notice)).toBeInTheDocument()
expect(screen.queryByRole('status', { name: 'Assistant is answering' })).not.toBeInTheDocument()
})
it('falls back to STREAMING messages on an old server and reconciles cancel 409', async () => {
mockAssistant()
const stream = controllableStream()
persisted.messages = storedTurn('Find records', [{ type: 'text', text: 'Old partial' }], 'STREAMING')
server.use(
http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [{ ...CONVERSATION, title: 'Saved conversation' }] })),
http.get(`${BASE}/turns/9/events`, () => stream.response()),
http.post(`${BASE}/turns/9/cancel`, () => {
persisted.messages = storedTurn('Find records', [{ type: 'text', text: 'Finished on another pod' }])
return HttpResponse.json({ error: 'already finished' }, { status: 409 })
}),
)
const user = userEvent.setup()
renderShell()
await openComposer(user)
await user.click(screen.getByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: 'Saved conversation' }))
await user.click(await screen.findByRole('button', { name: 'Stop the reply' }))
expect(await screen.findByText('Finished on another pod', { selector: MESSAGE_TEXT })).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('button', { name: 'Stop the reply' })).not.toBeInTheDocument())
})
it('ends a stuck run, then Retry check discovers its finished answer', async () => {
mockAssistant()
const actualStart = assistantApi.streamAssistantTurn
vi.spyOn(assistantApi, 'streamAssistantTurn').mockImplementation((id, body, options) => actualStart(id, body, { ...options, resumeDelayMs: 1, resumeBudgetMs: 100 }))
server.use(
http.post(`${BASE}/conversations/4/turns`, () => new HttpResponse(sse(START, ['ping', {}]), { headers: { 'Content-Type': 'text/event-stream' } })),
http.get(`${BASE}/turns/9/events`, () => new HttpResponse(sse(['ping', {}]), { headers: { 'Content-Type': 'text/event-stream' } })),
)
const user = userEvent.setup()
renderShell()
const composer = await openComposer(user)
await user.type(composer, 'Find records{Enter}')
expect(await screen.findByText('Lost connection to the assistant. It may still be answering - reopen the panel to check.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Stop the reply' })).not.toBeInTheDocument()
persisted.messages = storedTurn('Find records', [{ type: 'text', text: 'Recovered finished answer' }])
await user.click(screen.getByRole('button', { name: 'Retry check' }))
expect(await screen.findByText('Recovered finished answer')).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('button', { name: 'Retry check' })).not.toBeInTheDocument())
})
it('rechecks on online and after being hidden longer than thirty seconds', async () => {
mockAssistant()
const active = vi.fn(() => HttpResponse.json({ turns: [] }))
server.use(http.get(`${BASE}/turns/active`, active))
renderShell()
await waitFor(() => expect(active).toHaveBeenCalledTimes(1))
act(() => window.dispatchEvent(new Event('online')))
await waitFor(() => expect(active).toHaveBeenCalledTimes(2))
const hidden = vi.spyOn(document, 'hidden', 'get')
const now = vi.spyOn(Date, 'now')
const time = Date.now()
now.mockReturnValue(time)
hidden.mockReturnValue(true)
act(() => document.dispatchEvent(new Event('visibilitychange')))
now.mockReturnValue(time + 31000)
hidden.mockReturnValue(false)
act(() => window.dispatchEvent(new Event('focus')))
await waitFor(() => expect(active).toHaveBeenCalledTimes(3))
act(() => window.dispatchEvent(new Event('focus')))
expect(active).toHaveBeenCalledTimes(3)
})
})
describe('multiple run controls', () => {
it('Stop cancels every known run in the conversation', async () => {
mockAssistant()
const streams = [controllableStream(), controllableStream()]
let posts = 0
const cancelled: string[] = []
server.use(
http.post(`${BASE}/conversations/4/turns`, () => streams[posts++].response()),
http.post(`${BASE}/turns/:id/cancel`, ({ params }) => {
cancelled.push(String(params.id))
const index = params.id === '9' ? 0 : 1
streams[index].write(sse(['message.end', { seq: 3, messageId: index === 0 ? 11 : 14, status: 'CANCELLED' }]))
streams[index].close()
return HttpResponse.json({ status: 'CANCELLING' })
}),
)
const user = userEvent.setup()
renderShell()
const composer = await openComposer(user)
await user.type(composer, 'First{Enter}')
act(() => streams[0].write(sse(START)))
await screen.findByText('Thinking...')
await user.type(composer, 'Second{Enter}')
act(() => streams[1].write(sse(['message.start', { seq: 1, turnId: 12, conversationId: 4, userMessageId: 13, messageId: 14 }])))
await waitFor(() => expect(screen.getAllByText('Thinking...')).toHaveLength(2))
await user.click(screen.getByRole('button', { name: 'Stop the reply' }))
await waitFor(() => expect(cancelled.sort()).toEqual(['12', '9']))
await waitFor(() => expect(screen.queryByRole('button', { name: 'Stop the reply' })).not.toBeInTheDocument())
})
it('renders persisted phase events and the server start time', async () => {
mockAssistant()
const stream = controllableStream()
server.use(http.post(`${BASE}/conversations/4/turns`, () => stream.response()))
const user = userEvent.setup()
renderShell()
const composer = await openComposer(user)
await user.type(composer, 'Find records{Enter}')
expect(await screen.findByText('Starting...')).toBeInTheDocument()
const startedAt = new Date(Date.now() - 30000).toISOString()
act(() => stream.write(sse(['message.start', { ...START[1], startedAt }], ['turn.phase', { seq: 2, phase: 'thinking' }])))
expect(await screen.findByText('Thinking...')).toBeInTheDocument()
expect(document.querySelector('[data-qqq-id="assistant-run-status"]')).toHaveTextContent(/0:3[0-9]/)
act(() => stream.write(sse(['turn.phase', { seq: 3, phase: 'tool', toolName: 'query_records', label: 'Search records' }])))
expect(await screen.findByText('Using Search records...')).toBeInTheDocument()
act(() => stream.write(sse(['turn.phase', { seq: 4, phase: 'writing' }])))
expect(await screen.findByText('Writing...')).toBeInTheDocument()
act(() => {
stream.write(sse(['message.end', { seq: 5, messageId: 11, status: 'COMPLETE' }]))
stream.close()
})
await waitFor(() => expect(screen.queryByText('Writing...')).not.toBeInTheDocument())
})
})
+150 -71
View File
@@ -28,12 +28,11 @@ 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, Eye, MoreHorizontal, PanelLeft, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { Archive, ArchiveRestore, Check, Eye, MoreHorizontal, PanelLeft, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import type { QInstance } from '@/types'
import type { AssistantAttachment, AssistantConversation, AssistantConversationDetail, AssistantConversationStatus, AssistantConversationUpdate } from '@/types/assistant'
import { uploadFile } from '@/lib/api/uploads'
import type { AssistantConcurrency, AssistantConversation, AssistantConversationDetail, AssistantConversationStatus, AssistantConversationUpdate, AssistantMessage } from '@/types/assistant'
import {
deleteAssistantConversation,
getAssistantConversation,
@@ -47,11 +46,17 @@ import { assistantPageInfo, type AssistantPageInfo } from '@/lib/utils/assistant
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 { AssistantElapsed } from './AssistantRunStatus'
import { AssistantComposer } from './AssistantComposer'
import { AssistantMessageView } from './AssistantMessageView'
import { ConversationRail, conversationTitle } from './ConversationRail'
import { markConversationRead } from './mark-read'
import { useRailActions } from './use-rail-actions'
import { useAssistantEngine, type AssistantEngine } from './use-assistant-engine'
import { useAssistantAttachments } from './use-assistant-attachments'
import { useStreamAnnouncer } from './use-stream-announcer'
import { ErrorBoundary } from '@/components/feedback/ErrorBoundary'
import { InlineFailure } from '@/components/feedback/InlineFailure'
/** Docked width limits, in pixels. */
export const PANEL_MIN_WIDTH = 360
@@ -145,37 +150,28 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
const queryClient = useQueryClient()
const { activeConversationId, setActiveConversationId, pendingRequest, consumeRequest, metaData } = assistant
const pathname = usePathname()
// STAND-IN(core=QRun-IO/qbit-assistant#21, removeBy=RC.9): move to core Preferences
const [concurrency, setConcurrency] = useState<AssistantConcurrency>(() => {
try { return localStorage.getItem('qqq.assistant.concurrency') === 'concurrent' ? 'concurrent' : 'supersede' }
catch { return 'supersede' }
})
const changeConcurrency = (value: string) => {
const next = value === 'concurrent' ? 'concurrent' : 'supersede'
setConcurrency(next)
try { localStorage.setItem('qqq.assistant.concurrency', next) } catch { /* Storage is optional. */ }
}
const [railOpen, setRailOpen] = useState(false)
const [railStatus, setRailStatus] = useState<AssistantConversationStatus>('ACTIVE')
const [renaming, setRenaming] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
/** The rail row whose delete is being confirmed; the open conversation when null. */
const [rowDelete, setRowDelete] = useState<AssistantConversation | null>(null)
const [dismissedRoute, setDismissedRoute] = useState<string | null>(null)
const [pageInfo, setPageInfo] = useState(() => readPageInfo(metaData))
const [prefill, setPrefill] = useState<{ id: number; text: string } | null>(null)
const [attachments, setAttachments] = useState<AssistantAttachment[]>([])
const [attaching, setAttaching] = useState(false)
// attaching is offered where saved bulk load profiles are (the bulk-load tools use the files)
const canAttach = Boolean(metaData?.processes?.storeSavedBulkLoadProfile)
// attachments belong to one conversation
useEffect(() => setAttachments([]), [activeConversationId])
/**
* Uploads a picked file (POST /uploads, as the user) and attaches its ref.
*
* @param file - The file.
*/
const attach = async (file: File) => {
setAttaching(true)
try {
const uploaded = await uploadFile(file)
setAttachments((current) => [...current.filter((a) => a.uploadRef !== uploaded.ref), { uploadRef: uploaded.ref, fileName: uploaded.fileName }].slice(-5))
} catch (error) {
toast.error(`Could not attach ${file.name}: ${getErrorMessage(error)}`)
} finally {
setAttaching(false)
}
}
// attaching is offered when the server reads attachments, else where saved bulk load profiles are
const files = useAssistantAttachments(assistant.config, Boolean(metaData?.processes?.storeSavedBulkLoadProfile), activeConversationId)
const clearAttachments = files.clear
const listId = useId()
const scrollRef = useRef<HTMLDivElement>(null)
const stickToBottom = useRef(true)
@@ -187,7 +183,7 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
queryFn: () => listAssistantConversations(railStatus),
staleTime: 1000 * 60,
})
const run = activeConversationId !== null ? engine.runs[activeConversationId] : undefined
const run = activeConversationId !== null ? engine.runs[activeConversationId]?.[0] : undefined
const detailQuery = useQuery({
queryKey: assistantKeys.conversation(activeConversationId ?? 0),
queryFn: () => getAssistantConversation(activeConversationId as number),
@@ -201,24 +197,41 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
?? conversationsQuery.data?.find((item) => item.id === activeConversationId)
const announcement = useStreamAnnouncer(messages)
// Opening a conversation reads it: the badge drops its unread replies
// Opening a conversation reads it: the badge drops its unread replies, here and on the server
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)))
const unread = queryClient.getQueriesData<AssistantConversation[]>({ queryKey: [...assistantKeys.all(), 'conversations'] })
.some(([, list]) => list?.some((item) => item.id === activeConversationId && item.unreadCount > 0))
if (unread) markConversationRead(queryClient, activeConversationId)
}, [activeConversationId, assistant.open, queryClient, messages.length])
// Anything else working or unread in the active list, for the History toggle's dot
const activeList = useQuery({
queryKey: assistantKeys.conversations(),
queryFn: () => listAssistantConversations(),
staleTime: 1000 * 60,
}).data
const othersBusy = (activeList ?? []).some((item) => item.id !== activeConversationId && (item.running === true || item.unreadCount > 0))
|| Object.keys(engine.runs).some((id) => Number(id) !== activeConversationId)
const railConversations = useMemo(() => conversationsQuery.data?.map((item) => (
item.running || !engine.runs[item.id] ? item : { ...item, running: true }
)), [conversationsQuery.data, engine.runs])
const railActions = useRailActions({ activeConversationId, setActiveConversationId })
const manageTable = assistant.config?.conversationsTable
const manageHref = manageTable && metaData?.tables?.[manageTable] ? `/app/${encodeURIComponent(manageTable)}` : null
// 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)
if (activeConversationId === null) return
const streaming = messages.filter((message) => message.role === 'ASSISTANT' && message.status === 'STREAMING' && message.id > 0 && message.turnId !== null)
streaming.forEach((message) => engine.attach(activeConversationId, message))
}, [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)
clearAttachments()
setActiveConversationId(null)
setRailOpen(false)
if (pendingRequest.mode === 'prefill') {
@@ -226,8 +239,8 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
return
}
const context = pendingRequest.context === undefined ? readPageInfo(metaData)?.context ?? null : pendingRequest.context
void engine.send(pendingRequest.prompt, null, context)
}, [pendingRequest, consumeRequest, setActiveConversationId, metaData, engine])
void engine.send(pendingRequest.prompt, null, context, concurrency)
}, [pendingRequest, consumeRequest, setActiveConversationId, metaData, engine, concurrency, clearAttachments])
// Keep the newest message in view while the user has not scrolled up
useLayoutEffect(() => {
@@ -240,6 +253,19 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
const contextDismissed = pageInfo !== null && dismissedRoute === pageInfo.context.route
/**
* Retry for a failed reply: asks the question before it again.
*
* @param list - The conversation's messages.
* @param index - The failed reply's index.
* @returns The retry action, or undefined when no question precedes it.
*/
const retryFor = (list: AssistantMessage[], index: number): (() => void) | undefined => {
const question = list.slice(0, index).reverse().find((item) => item.role === 'USER')
const text = question?.parts.map((part) => (part.type === 'text' ? part.text : '')).join('').trim()
return text ? () => send(text) : undefined
}
/**
* Sends from the composer, with the page context unless dismissed.
*
@@ -248,9 +274,10 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
const send = (text: string) => {
const info = readPageInfo(metaData)
const pageContext = info && dismissedRoute !== info.context.route ? info.context : null
const context = attachments.length ? { ...(pageContext ?? {}), attachments } : pageContext
const attachments = files.forTurn()
const context = attachments ? { ...(pageContext ?? {}), attachments } : pageContext
stickToBottom.current = true
void engine.send(text, activeConversationId, context)
void engine.send(text, activeConversationId, context, concurrency)
}
/**
@@ -270,18 +297,11 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
}
}
/** Deletes the active conversation. */
/** Deletes the conversation being confirmed (a rail row, or the open one). */
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 target = rowDelete ?? conversation
if (!target) return
await railActions.remove(target)
}
const showMessages = wide || !railOpen
@@ -290,20 +310,28 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
const title = activeConversationId === null ? assistantName : 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'
const textButton = 'inline-flex h-8 shrink-0 items-center justify-center rounded-md px-2 text-xs font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse: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}
className={cn(iconButton, 'relative')}
onClick={() => setRailOpen((value) => !value)}
aria-expanded={railOpen}
aria-controls={listId}
aria-label={railOpen ? 'Hide conversations' : 'Show conversations'}
aria-describedby={othersBusy ? `${listId}-busy` : undefined}
data-qqq-id="assistant-rail-toggle"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
{othersBusy && (
<>
<span className="absolute right-0.5 top-0.5 h-2 w-2 rounded-full bg-primary" aria-hidden="true" data-qqq-id="assistant-rail-toggle-dot" />
<span id={`${listId}-busy`} className="sr-only">Other conversations are working or have unread replies</span>
</>
)}
</button>
{renaming && conversation ? (
<form
@@ -364,6 +392,12 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
</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.Label className="px-2 py-1 text-xs text-muted-foreground">When I ask during an answer</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={concurrency} onValueChange={changeConcurrency}>
<DropdownMenu.RadioItem value="supersede" className={menuItem} data-qqq-id="assistant-concurrency-supersede"><span className="flex h-4 w-4 items-center justify-center"><DropdownMenu.ItemIndicator><Check className="h-4 w-4" aria-hidden="true" /></DropdownMenu.ItemIndicator></span>Replace it</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="concurrent" className={menuItem} data-qqq-id="assistant-concurrency-concurrent"><span className="flex h-4 w-4 items-center justify-center"><DropdownMenu.ItemIndicator><Check className="h-4 w-4" aria-hidden="true" /></DropdownMenu.ItemIndicator></span>Answer both</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator className="my-1 h-px bg-border" />
<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>
@@ -390,6 +424,7 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
type="button"
className={iconButton}
onClick={() => {
clearAttachments()
setActiveConversationId(null)
setRailOpen(false)
requestAnimationFrame(() => composerRef.current?.focus())
@@ -404,17 +439,33 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
</button>
</div>
{Object.entries(engine.runs).filter(([id]) => (Number(id) !== activeConversationId || !showMessages)).map(([id, runs]) => {
const other = queryClient.getQueryData<AssistantConversationDetail>(assistantKeys.conversation(Number(id)))?.conversation
return (
<div key={id} className="flex items-center gap-2 border-b border-border px-3 py-2 text-xs" data-qqq-id="assistant-background-run">
<span className="min-w-0 flex-1">Answering in {conversationTitle(other)} - <AssistantElapsed startedAt={runs[0].startedAt} /></span>
<button type="button" className={textButton} onClick={() => { clearAttachments(); setActiveConversationId(Number(id)); setRailOpen(false) }} data-qqq-id="assistant-background-open">Open</button>
<button type="button" className={textButton} onClick={() => engine.cancel(Number(id))} data-qqq-id="assistant-background-stop">Stop</button>
</div>
)
})}
{engine.lostConversations.length > 0 && <button type="button" className="px-3 py-2 text-left text-xs text-primary hover:underline" onClick={() => void engine.reconcile()} data-qqq-id="assistant-retry-check">Retry check</button>}
<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}
conversations={railConversations}
loading={conversationsQuery.isLoading}
activeId={activeConversationId}
status={railStatus}
onStatusChange={setRailStatus}
onArchive={(item) => void railActions.archive(item)}
onDelete={(item) => { setRowDelete(item); setConfirmDelete(true) }}
manageHref={manageHref}
onManage={() => { if (!wide) assistant.setOpen(false) }}
onSelect={(id) => {
if (id !== activeConversationId) clearAttachments()
setActiveConversationId(id)
if (!engine.runs[id]) void queryClient.invalidateQueries({ queryKey: assistantKeys.conversation(id) })
if (!wide) setRailOpen(false)
@@ -448,7 +499,6 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
<button
type="button"
onClick={() => send(prompt)}
disabled={Boolean(run)}
className="max-w-full rounded-full border border-border bg-background px-3 py-1.5 text-left text-xs text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
>
{prompt}
@@ -463,35 +513,61 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
{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} onSend={send} sending={Boolean(run)} />)}
{messages.map((message, index) => (
<ErrorBoundary
key={message.id}
scope="assistant-message"
reportContext={{ conversationId: activeConversationId, messageId: message.id, turnId: message.turnId }}
resetKeys={[message]}
fallbackRender={({ report, error }) => <InlineFailure message="This message could not be shown." report={report} error={error} qqqId="assistant-message-error" />}
>
<AssistantMessageView
message={message}
onSend={send}
run={activeConversationId === null ? undefined : engine.runs[activeConversationId]?.find((entry) => entry.assistantMessageId === message.id)}
onRetry={message.status === 'ERROR' ? retryFor(messages, index) : undefined}
onStop={message.status === 'STREAMING' && activeConversationId !== null ? () => engine.stopMessage(activeConversationId, message) : undefined}
/>
</ErrorBoundary>
))}
</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}
attachments={attachments}
onAttach={canAttach ? (file) => void attach(file) : undefined}
onRemoveAttachment={(uploadRef) => setAttachments((current) => current.filter((a) => a.uploadRef !== uploadRef))}
attaching={attaching}
/>
<ErrorBoundary
scope="assistant-composer"
reportContext={{ conversationId: activeConversationId }}
fallbackRender={({ report, error, reset }) => <InlineFailure message="The message box could not be shown." report={report} error={error} onRetry={reset} qqqId="assistant-composer-error" className="m-3" />}
>
<AssistantComposer
ref={composerRef}
pageInfo={pageInfo}
contextDismissed={contextDismissed}
onDismissContext={() => setDismissedRoute(pageInfo?.context.route ?? null)}
onRefreshContext={() => setPageInfo(readPageInfo(metaData))}
concurrency={concurrency}
streaming={Boolean(run)}
stopping={Boolean(run) && engine.runs[activeConversationId as number]?.every((entry) => entry.status === 'cancelling')}
onSend={send}
onStop={() => activeConversationId !== null && engine.cancel(activeConversationId)}
prefill={prefill}
attachments={files.attachments}
onAttach={files.policy.enabled ? (file) => void files.attach(file) : undefined}
onRemoveAttachment={files.remove}
attaching={files.attaching}
attachPolicy={files.policy}
attachError={files.error}
onDismissAttachError={files.dismissError}
/>
</ErrorBoundary>
</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.Root open={confirmDelete} onOpenChange={(open) => { setConfirmDelete(open); if (!open) setRowDelete(null) }}>
<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>
<AlertDialog.Description className="mt-1 text-sm text-muted-foreground">{conversationTitle(rowDelete ?? 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
@@ -626,6 +702,9 @@ export default function AssistantPanel() {
const assistant = useAssistant()
const docked = useMediaQuery(ASSISTANT_DOCKED_MEDIA_QUERY)
const engine = useAssistantEngine()
const reconcile = engine.reconcile
const open = assistant?.open
useEffect(() => { if (open) void reconcile() }, [open, reconcile])
if (!assistant) return null
if (docked) return assistant.open ? <DockedPanel assistant={assistant} engine={engine} /> : null
return <SheetPanel assistant={assistant} engine={engine} />
@@ -0,0 +1,133 @@
/*
* 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 AssistantPanelFailure — what the assistant panel shows when it crashes: the page keeps
* working, and the user can retry, copy the details or report a bug.
*/
'use client'
import React, { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Bug, Copy, RefreshCw, X } from 'lucide-react'
import { toast } from 'sonner'
import { clientErrorDetails, type ClientErrorReport } from '@/lib/errors/client-errors'
import { useQaCaptureCapability } from '@/lib/hooks/use-qa-capture'
import type { RouteMetaData } from '@/lib/qa-capture/route-context'
import { queryKeys } from '@/lib/query-client'
import { useAssistant } from './assistant-context'
const BUTTON = 'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60 pointer-coarse:min-h-11'
/**
* Starts a QA capture bug report, when QA capture is enabled for this user.
*
* @returns The action, or null when reporting is unavailable.
*/
function useReportBug(): { busy: boolean; report: () => Promise<void> } | null {
const capability = useQaCaptureCapability()
const client = useQueryClient()
const [busy, setBusy] = useState(false)
if (!capability?.enabled || !capability.canReport) return null
return {
busy,
report: async () => {
if (busy) return
setBusy(true)
try {
// loaded on demand: this card ships with the app shell
const { startBugReport } = await import('@/lib/qa-capture/report')
await startBugReport({ capability, metaData: client.getQueryData<RouteMetaData>(queryKeys.metadataAll()) })
} catch (error) {
toast.error(error instanceof Error && error.message ? `Could not start the bug report: ${error.message}` : 'Could not start the bug report.')
} finally {
setBusy(false)
}
},
}
}
/**
* The crashed panel's replacement: a small card with Retry, Copy details, Report a bug and Close.
* Shown only while the panel is open; the page around it is untouched.
*
* @param props - The failure.
* @param props.report - The report built for the crash (null until it is built).
* @param props.error - The error.
* @param props.onRetry - Mounts the panel again.
* @returns The card, or null when the panel is closed.
*/
export function AssistantPanelFailure({ report, error, onRetry }: { report: ClientErrorReport | null; error: Error; onRetry: () => void }) {
const assistant = useAssistant()
const bug = useReportBug()
if (!assistant?.open) return null
const details = report ? clientErrorDetails(report) : `${error.name}: ${error.message}`
/** Copies the details for a bug report. */
const copy = async () => {
try {
await navigator.clipboard.writeText(details)
toast.success('Details copied')
} catch {
toast.error('Could not copy the details.')
}
}
return (
<aside
className="fixed bottom-4 right-4 z-[var(--qqq-z-modal,1000)] w-[min(24rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-4 shadow-lg"
role="alert"
aria-label="Assistant"
data-qqq-id="assistant-panel-error"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">The assistant hit a problem</p>
<p className="mt-0.5 text-xs text-muted-foreground">The rest of the page still works. Retry to reopen the assistant; your conversations are kept.</p>
</div>
<button
type="button"
onClick={() => assistant.setOpen(false)}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11 pointer-coarse:w-11"
aria-label="Close assistant"
data-qqq-id="assistant-panel-error-close"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={onRetry} className={BUTTON} data-qqq-id="assistant-panel-error-retry">
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />Retry
</button>
<button type="button" onClick={() => void copy()} className={BUTTON} data-qqq-id="assistant-panel-error-copy">
<Copy className="h-3.5 w-3.5" aria-hidden="true" />Copy details
</button>
{bug && (
<button type="button" onClick={() => void bug.report()} disabled={bug.busy} className={BUTTON} data-qqq-id="assistant-panel-error-report">
<Bug className="h-3.5 w-3.5" aria-hidden="true" />{bug.busy ? 'Starting...' : 'Report a bug'}
</button>
)}
</div>
<details className="mt-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">Details</summary>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted p-1.5 font-mono text-[11px]" data-qqq-id="assistant-panel-error-details">{details}</pre>
</details>
</aside>
)
}
+19 -1
View File
@@ -27,7 +27,8 @@ 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 { getAssistantConfig, listAssistantConversations, reportAssistantClientError, supportsAssistantClientErrors } from '@/lib/api/assistant-probe'
import { setClientErrorSink } from '@/lib/errors/client-errors'
import { readAssistantDeepLink } from '@/lib/utils/assistant-deep-link'
import { ASSISTANT_DOCKED_MEDIA_QUERY, AssistantContext, assistantKeys, type AssistantController, type AssistantRequest } from './assistant-context'
@@ -97,11 +98,28 @@ export function useAssistantController(metaData: QInstance | undefined, isAuthen
})
const enabled = installed && Boolean(config?.enabled)
// UI failures (error boundaries, stream errors) go to Admin through the assistant's
// client-error operation when the server has it; otherwise they are only logged.
useEffect(() => {
if (!enabled) return
let active = true
void supportsAssistantClientErrors().then((supported) => {
if (active && supported) setClientErrorSink(reportAssistantClientError)
})
return () => {
active = false
setClientErrorSink(null)
}
}, [enabled])
const { data: conversations } = useQuery({
queryKey: assistantKeys.conversations(),
queryFn: () => listAssistantConversations(),
enabled,
staleTime: 1000 * 60,
// Replies landing in background threads (another tab, a long turn): refetch while the server
// reports one running, so the badge and rail catch up; idle, only the usual refetch on focus
refetchInterval: (query) => ((query.state.data ?? []).some((conversation) => conversation.running) ? 8000 : false),
})
const unreadCount = useMemo(() => (conversations ?? []).reduce((total, conversation) => total + (conversation.unreadCount || 0), 0), [conversations])
@@ -0,0 +1,119 @@
/*
* 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.
*/
// Tool result renders tolerate payloads with missing lists, and one bad part
// cannot take down the reply around it.
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { AssistantMessage, AssistantRender } from '@/types/assistant'
import { AssistantRenderView } from './AssistantRenders'
import { AssistantMessageView } from './AssistantMessageView'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
// The exact payload kof22 stored for a query that matched nothing (2026-10-16):
// the server omitted the empty rows and columns lists.
const emptyRecords = {
type: 'records',
tableName: 'db_perf_finding',
tableLabel: 'Findings',
openHref: '/app/db_perf_finding?filter=eyJib29sZWFuT3BlcmF0b3IiOiJBTkQifQ%3D%3D',
totalCount: 0,
truncated: false,
} as unknown as AssistantRender
describe('AssistantRenderView', () => {
it('renders a records payload without rows or columns as "no rows match"', () => {
render(<AssistantRenderView render={emptyRecords} />)
expect(screen.getByText('Findings: no rows match')).toBeInTheDocument()
expect(screen.getByText('Open in Query screen')).toBeInTheDocument()
})
it('renders history and record payloads without their lists', () => {
render(<AssistantRenderView render={{ type: 'history', tableName: 't', tableLabel: 'T', id: 1 } as unknown as AssistantRender} />)
expect(screen.getByText('No changes recorded.')).toBeInTheDocument()
const { container } = render(<AssistantRenderView render={{ type: 'record', tableName: 't', tableLabel: 'T', id: 7, href: '/app/t/7' } as unknown as AssistantRender} />)
expect(container.querySelector('[data-qqq-id="assistant-record"]')).not.toBeNull()
})
})
describe('AssistantMessageView', () => {
it('shows the reply when a tool part carries the empty records payload', () => {
const message: AssistantMessage = {
id: 84, role: 'ASSISTANT', status: 'COMPLETE', turnId: 5, createDate: '2026-10-16T00:24:26Z',
parts: [
{ type: 'tool', toolCallId: 'toolu_x', name: 'query_records', summary: 'Queried Findings', outcome: 'OK', render: emptyRecords },
{ type: 'text', text: 'Here are the main issues.' },
],
} as unknown as AssistantMessage
render(<AssistantMessageView message={message} onSend={vi.fn()} sending={false} />)
expect(screen.getByText('Here are the main issues.')).toBeInTheDocument()
expect(screen.getByText('Findings: no rows match')).toBeInTheDocument()
})
it('keeps the rest of the reply when one part throws while rendering', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const broken = { type: 'count', tableName: 't', tableLabel: 'T', openHref: '/app/t' } as unknown as AssistantRender // no count: toLocaleString throws
const message: AssistantMessage = {
id: 85, role: 'ASSISTANT', status: 'COMPLETE', turnId: 6, createDate: '2026-10-16T00:30:00Z',
parts: [
{ type: 'tool', toolCallId: 'toolu_y', name: 'count_records', summary: 'Counted', outcome: 'OK', render: broken },
{ type: 'text', text: 'Still readable.' },
],
} as unknown as AssistantMessage
render(<AssistantMessageView message={message} onSend={vi.fn()} sending={false} />)
expect(screen.getByText('Still readable.')).toBeInTheDocument()
// the tool step (its label) stays; only its result is replaced
expect(screen.getByText('Counted')).toBeInTheDocument()
expect(screen.getByText("This result could not be shown.")).toBeInTheDocument()
consoleError.mockRestore()
})
it('isolates a part that throws outside a render, and shows unknown parts and renders as JSON', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const message = {
id: 86, role: 'ASSISTANT', status: 'COMPLETE', turnId: 7, createDate: '2026-10-16T00:30:00Z',
parts: [
{ type: 'record_refs', refs: null }, // refs.length throws in RecordChips
{ type: 'tool', toolCallId: 'toolu_z', name: 'future_tool', summary: 'Mapped', outcome: 'OK', render: { type: 'unknown', originalType: 'map', raw: { type: 'map', pins: 2 } } },
{ type: 'unknown', originalType: 'approval', raw: { type: 'approval', id: 9 } },
{ type: 'text', text: 'After the bad parts.' },
],
} as unknown as AssistantMessage
render(<AssistantMessageView message={message} onSend={vi.fn()} sending={false} />)
expect(screen.getByText('After the bad parts.')).toBeInTheDocument()
expect(screen.getByText("This part could not be shown.")).toBeInTheDocument()
expect(screen.getByText('Unsupported result (map)')).toBeInTheDocument()
expect(screen.getByText('Unsupported content (approval)')).toBeInTheDocument()
consoleError.mockRestore()
})
it('offers Retry on a failed reply and Stop on a running one', async () => {
const onRetry = vi.fn()
const onStop = vi.fn()
const failed = { id: 87, role: 'ASSISTANT', status: 'ERROR', turnId: 8, createDate: '2026-10-16T00:30:00Z', parts: [] } as unknown as AssistantMessage
const { rerender } = render(<AssistantMessageView message={failed} onSend={vi.fn()} sending={false} onRetry={onRetry} />)
expect(screen.getByText('The reply did not finish.')).toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(onRetry).toHaveBeenCalledOnce()
const running = { ...failed, status: 'STREAMING' } as AssistantMessage
rerender(<AssistantMessageView message={running} onSend={vi.fn()} sending={false} onStop={onStop} />)
screen.getByRole('button', { name: 'Stop' }).click()
expect(onStop).toHaveBeenCalledOnce()
})
})
+56 -14
View File
@@ -113,10 +113,23 @@ export function RecordChips({ refs }: { refs: AssistantRecordRef[] }) {
* @returns The records view.
*/
function RecordsView({ render }: { render: AssistantRecordsRender }) {
const shown = render.rows.length
// Older servers omit empty lists (a query that matched nothing): treat as [].
const rows = Array.isArray(render.rows) ? render.rows : []
const columns = Array.isArray(render.columns) ? render.columns : []
const shown = 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
const [firstColumn, ...otherColumns] = columns
if (shown === 0) {
return (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-records">
<span className="truncate text-xs text-muted-foreground">{render.tableLabel}: no rows match</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>
)
}
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">
@@ -130,17 +143,17 @@ function RecordsView({ render }: { render: AssistantRecordsRender }) {
<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>)}
{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) => (
{rows.map((row) => (
<tr key={String(row.id)} className="border-b border-border last:border-0">
{render.columns.map((column, index) => (
{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] ?? '')}
<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>
@@ -150,18 +163,18 @@ function RecordsView({ render }: { render: AssistantRecordsRender }) {
</div>
{/* narrow: one card per row */}
<ul className="divide-y divide-border @lg:hidden">
{render.rows.map((row) => (
{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)}
{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>
<dd className="truncate text-foreground">{row.values?.[column.name] ?? ''}</dd>
</React.Fragment>
))}
</dl>
@@ -188,7 +201,7 @@ function RecordView({ render }: { render: AssistantRecordRender }) {
<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) => (
{(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>
@@ -228,16 +241,16 @@ function CountView({ render }: { render: AssistantCountRender }) {
* @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>
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 && (
{(entry.details?.length ?? 0) > 0 && (
<ul className="mt-0.5 space-y-0.5 pl-3">
{entry.details.map((detail, detailIndex) => (
{(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>
@@ -310,7 +323,36 @@ export function AssistantRenderView({ render }: { render: AssistantRender }) {
<PendingActionCard render={render} />
</Suspense>
)
case 'unknown':
return <RawJsonView label={render.originalType ? `Unsupported result (${render.originalType})` : 'Unsupported result'} value={render.raw} />
default:
return null
}
}
/** Longest JSON shown for an unsupported payload. */
const RAW_JSON_LIMIT = 20_000
/**
* Shows a payload this UI cannot render as formatted JSON behind a toggle, instead of failing.
*
* @param props - The label and the payload.
* @param props.label - What the payload is.
* @param props.value - The payload.
* @returns A collapsed JSON view.
*/
export function RawJsonView({ label, value }: { label: string; value: unknown }) {
let json: string
try {
json = JSON.stringify(value, null, 2) ?? String(value)
} catch {
json = String(value)
}
if (json.length > RAW_JSON_LIMIT) json = `${json.slice(0, RAW_JSON_LIMIT)}\n…`
return (
<details className="rounded-lg border border-border bg-card px-3 py-2 text-xs" data-qqq-id="assistant-raw-json">
<summary className="cursor-pointer select-none text-muted-foreground">{label}</summary>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground">{json}</pre>
</details>
)
}
@@ -0,0 +1,47 @@
/*
* 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.
*/
import { act, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantRun } from './use-assistant-engine'
import { AssistantRunStatus } from './AssistantRunStatus'
afterEach(() => vi.useRealTimers())
describe('AssistantRunStatus', () => {
it('ticks elapsed time without changing the polite announcement until the phase changes', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-14T12:00:00Z'))
const run: AssistantRun = { key: 1, conversationId: 4, assistantMessageId: 11, turnId: null, status: 'running', phase: 'starting', startedAt: new Date().toISOString() }
const { rerender, container } = render(<AssistantRunStatus run={run} />)
expect(screen.getByText('Starting...')).toBeInTheDocument()
rerender(<AssistantRunStatus run={{ ...run, phase: 'thinking' }} />)
act(() => vi.advanceTimersByTime(7000))
expect(screen.getByText('Thinking...')).toBeInTheDocument()
expect(screen.getByText('0:07')).toBeInTheDocument()
expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(/^Thinking\.\.\.$/)
rerender(<AssistantRunStatus run={{ ...run, phase: 'tool', toolLabel: 'Search records' }} />)
act(() => vi.advanceTimersByTime(14000))
expect(screen.getByText('Using Search records...')).toBeInTheDocument()
expect(screen.getByText('0:21')).toBeInTheDocument()
rerender(<AssistantRunStatus run={{ ...run, phase: 'writing' }} />)
act(() => vi.advanceTimersByTime(9000))
expect(screen.getByText('Writing...')).toBeInTheDocument()
expect(screen.getByText('0:30')).toBeInTheDocument()
rerender(<AssistantRunStatus run={{ ...run, status: 'cancelling' }} />)
expect(screen.getByText('Stopping...')).toBeInTheDocument()
})
})
@@ -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 Progress labels with a silent, once-per-second elapsed clock. */
'use client'
import { useEffect, useState } from 'react'
import type { AssistantRun } from './use-assistant-engine'
/**
* Displays elapsed time without repeatedly announcing ticks to screen readers.
*
* @param props - The start time.
* @param props.startedAt - ISO timestamp of the turn's start.
* @returns The elapsed clock.
*/
export function AssistantElapsed({ startedAt }: { startedAt: string }) {
const [now, setNow] = useState(Date.now)
useEffect(() => {
setNow(Date.now())
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [startedAt])
const seconds = Math.max(0, Math.floor((now - Date.parse(startedAt)) / 1000)) || 0
return <span aria-live="off">{Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, '0')}</span>
}
/**
* Shows the current phase; only phase changes enter the polite live region.
*
* @param props - The running answer.
* @param props.run - Current progress and start time.
* @returns The progress line.
*/
export function AssistantRunStatus({ run }: { run: AssistantRun }) {
const label = run.status === 'cancelling' ? 'Stopping...'
: run.phase === 'starting' ? 'Starting...'
: run.phase === 'tool' ? `Using ${run.toolLabel || 'tool'}...`
: run.phase === 'writing' ? 'Writing...' : 'Thinking...'
return (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" data-qqq-id="assistant-run-status">
<span aria-live="polite" aria-atomic="true">{label}</span>
<AssistantElapsed startedAt={run.startedAt} />
</p>
)
}
+151 -55
View File
@@ -15,14 +15,18 @@
*/
/**
* @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.
* @file ConversationRail — the panel's conversation list (pinned, recent, archived). A list of
* rows, each a main button that opens the conversation plus Archive/Restore and Delete buttons.
* (A listbox cannot hold buttons, so the rows are plain buttons with arrow-key navigation:
* Up/Down/Home/End move between rows, Right reaches a row's actions, Left returns.) Running
* conversations show a spinner, unread ones a dot and a bold title.
*/
'use client'
import React, { useId, useRef } from 'react'
import { Pin } from 'lucide-react'
import React, { useEffect, useId, useRef, useState } from 'react'
import Link from 'next/link'
import { Archive, ArchiveRestore, ExternalLink, Loader2, Pin, Trash2 } from 'lucide-react'
import type { AssistantConversation, AssistantConversationStatus } from '@/types/assistant'
import { cn } from '@/lib/utils/cn'
@@ -46,6 +50,22 @@ interface ConversationRailProps {
* @param id - The conversation id.
*/
onSelect: (id: number) => void
/**
* Archives a conversation (or restores it, in the archived list).
*
* @param conversation - The conversation.
*/
onArchive: (conversation: AssistantConversation) => void
/**
* Asks to delete a conversation (the panel confirms first).
*
* @param conversation - The conversation.
*/
onDelete: (conversation: AssistantConversation) => void
/** Path of the standard table screen listing all conversations; the link is hidden without it. */
manageHref?: string | null
/** Called when the manage link is followed. */
onManage?: () => void
className?: string
}
@@ -59,79 +79,143 @@ export function conversationTitle(conversation: AssistantConversation | undefine
return conversation?.title?.trim() || 'New conversation'
}
/** Classes of a row's small icon buttons. */
const ACTION_BUTTON = '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'
/**
* The conversation list.
*
* @param props - Component properties.
* @returns The rail.
*/
export function ConversationRail({ conversations, loading, activeId, status, onStatusChange, onSelect, className }: ConversationRailProps) {
export function ConversationRail({ conversations, loading, activeId, status, onStatusChange, onSelect, onArchive, onDelete, manageHref, onManage, 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))
const [focusedId, setFocusedId] = useState<number | null>(null)
const tabId = [focusedId, activeId].find((id) => id !== null && ordered.some((conversation) => conversation.id === id)) ?? ordered[0]?.id
const lastIndex = useRef(0)
lastIndex.current = Math.max(0, ordered.findIndex((conversation) => conversation.id === focusedId))
// A row that disappears under the keyboard (archived, deleted) hands focus to its neighbour
const count = ordered.length
const previousCount = useRef(count)
useEffect(() => {
const shrank = count < previousCount.current
previousCount.current = count
if (!shrank || count === 0) return
const active = document.activeElement
if (active && active !== document.body && active.isConnected) return
const mains = listRef.current?.querySelectorAll<HTMLElement>('[data-conversation-main]')
mains?.[Math.min(lastIndex.current, count - 1)]?.focus()
}, [count])
/**
* Arrow-key navigation between options.
* Arrow-key navigation between rows and from a row to its actions.
*
* @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) {
const target = event.target as HTMLElement
const rowElement = target.closest<HTMLElement>('[data-conversation-row]')
if (!rowElement) return
const mains = Array.from(listRef.current?.querySelectorAll<HTMLElement>('[data-conversation-main]') ?? [])
const actions = Array.from(rowElement.querySelectorAll<HTMLElement>('[data-conversation-action]'))
const onMain = target.hasAttribute('data-conversation-main')
const main = rowElement.querySelector<HTMLElement>('[data-conversation-main]') as HTMLElement
const current = mains.indexOf(main)
let next: HTMLElement | undefined
if (event.key === 'ArrowDown') next = mains[Math.min(mains.length - 1, current + 1)]
else if (event.key === 'ArrowUp') next = mains[Math.max(0, current - 1)]
else if (event.key === 'Home') next = mains[0]
else if (event.key === 'End') next = mains[mains.length - 1]
else if (event.key === 'ArrowRight') next = onMain ? actions[0] : actions[actions.indexOf(target) + 1]
else if (event.key === 'ArrowLeft' && !onMain) next = actions[actions.indexOf(target) - 1] ?? main
if (next) {
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()
next.focus()
}
}
/**
* One option.
* One row.
*
* @param conversation - The conversation.
* @param index - Its position in keyboard order.
* @returns The option.
* @returns The row.
*/
const option = (conversation: AssistantConversation, index: number) => {
const row = (conversation: AssistantConversation) => {
const selected = conversation.id === activeId
const unread = conversation.unreadCount > 0 && !selected
const running = conversation.running === true
const title = conversationTitle(conversation)
const tabIndex = conversation.id === tabId ? 0 : -1
const archived = conversation.status === 'ARCHIVED'
return (
<div
<li
key={conversation.id}
role="option"
aria-selected={selected}
tabIndex={index === focusIndex ? 0 : -1}
data-conversation-row
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"
onFocus={() => setFocusedId(conversation.id)}
className={cn('group relative flex items-center rounded-md', selected ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent')}
>
{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>
<button
type="button"
data-conversation-main
aria-current={selected ? 'true' : undefined}
tabIndex={tabIndex}
onClick={() => onSelect(conversation.id)}
className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
data-qqq-id="assistant-conversation"
>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 text-primary motion-safe:animate-spin" aria-hidden="true" data-qqq-id="assistant-conversation-running" />
<span className="sr-only">Working: </span>
</>
) : null}
{!running && unread ? <span className="h-2 w-2 rounded-full bg-primary" aria-hidden="true" data-qqq-id="assistant-conversation-unread" /> : null}
</span>
{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')}>
{title}
{unread && <span className="sr-only">, unread</span>}
</span>
{running && unread && <span className="h-2 w-2 shrink-0 rounded-full bg-primary" aria-hidden="true" data-qqq-id="assistant-conversation-unread" />}
</button>
<div
className="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-sidebar opacity-0 shadow-sm focus-within:opacity-100 group-hover:opacity-100 pointer-coarse:static pointer-coarse:translate-y-0 pointer-coarse:bg-transparent pointer-coarse:opacity-100 pointer-coarse:shadow-none"
data-qqq-id="assistant-conversation-actions"
>
<button
type="button"
data-conversation-action
tabIndex={tabIndex}
aria-label={`${archived ? 'Restore' : 'Archive'} ${title}`}
title={archived ? 'Restore' : 'Archive'}
onClick={() => onArchive(conversation)}
className={ACTION_BUTTON}
data-qqq-id="assistant-conversation-archive"
>
{archived ? <ArchiveRestore className="h-3.5 w-3.5" aria-hidden="true" /> : <Archive className="h-3.5 w-3.5" aria-hidden="true" />}
</button>
<button
type="button"
data-conversation-action
tabIndex={tabIndex}
aria-label={`Delete ${title}`}
title="Delete"
onClick={() => onDelete(conversation)}
className={cn(ACTION_BUTTON, 'hover:text-destructive')}
data-qqq-id="assistant-conversation-delete"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</li>
)
}
@@ -150,25 +234,37 @@ export function ConversationRail({ conversations, loading, activeId, status, onS
</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">
<div ref={listRef} 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>
<section aria-labelledby={`${labelId}-pinned`}>
<h3 id={`${labelId}-pinned`} className="px-2 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Pinned</h3>
<ul className="space-y-0.5">{pinned.map(row)}</ul>
</section>
)}
{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>
<section aria-labelledby={`${labelId}-recent`}>
<h3 id={`${labelId}-recent`} className={cn('px-2 pb-0.5 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground', pinned.length === 0 && 'sr-only')}>
{status === 'ACTIVE' ? 'Recent' : 'Archived'}
</h3>
<ul className="space-y-0.5">{recent.map(row)}</ul>
</section>
)}
</div>
{manageHref && (
<Link
href={manageHref}
onClick={onManage}
className="flex shrink-0 items-center gap-1.5 border-t border-border px-3 py-2 text-xs font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
data-qqq-id="assistant-manage-conversations"
>
Manage all conversations
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</Link>
)}
</nav>
)
}
@@ -38,6 +38,14 @@ export const assistantKeys = {
* @returns The key.
*/
all: () => ['qqq', 'assistant'] as const,
/** Active-turn discovery.
* @returns The query key.
*/
activeTurns: () => [...assistantKeys.all(), 'active-turns'] as const,
/** Local stream activity.
* @returns The query key.
*/
busy: () => [...assistantKeys.all(), 'busy'] as const,
/**
* The feature probe.
*
Loaded 100 of 621 files, more files were not shown because too many files have changed in this diff. Show more