diff --git a/docs/design/FRONTEND_LAYER_POLICY.md b/docs/design/FRONTEND_LAYER_POLICY.md new file mode 100644 index 00000000..845d8116 --- /dev/null +++ b/docs/design/FRONTEND_LAYER_POLICY.md @@ -0,0 +1,87 @@ +# Frontend Layer Policy + +Captured: 2026-07-16 + +## NodeRoom Standard + +```text +Convex Auth + identity and production session enforcement + +shadcn + Radix + accessible interaction primitives + +AI Elements + agent interaction primitives + +CSS + ordinary state transitions + +Reviewed visual recipes + bounded public-page and empty-state treatments + +Optional centralized motion engine + only meaningful multi-step choreography + +Convex + NodeAgent + authoritative state, tools, jobs, policy, and proof +``` + +Radix owns control behavior. Product code composes those controls through +shadcn-generated wrappers. Visual and motion layers may change appearance and +timing but may not reimplement focus, keyboard, dismissal, selection, or ARIA +behavior. + +## Adaptations From The Proposal + +- NodeRoom uses Convex Auth today. WorkOS is not installed and is not an + assumed dependency. A future identity-provider change must preserve the same + fail-closed production boundary. +- The `motion` package is installed and currently owned by the AI Elements + thinking-state shimmer. Keep CSS as the default elsewhere and do not add GSAP + until a real sequence requires a timeline. +- React Bits, GSAP, Lenis, Vanta, and Three.js are not installed for this work. + They remain approved categories with explicit boundaries, not default + dependencies. +- The authenticated NodeRoom workspace, spreadsheet grid, notebook editor, + graph inspectors, chat, and trace surfaces retain native scrolling. Lenis is + limited to future public storytelling routes. +- Vanta is not approved for the application shell. Any future experiment must + be lazy, isolated, reduced-motion aware, pointer inert, and backed by a static + fallback. + +## Import Ownership + +| Dependency | Allowed owner | +|---|---| +| `radix-ui`, `@radix-ui/*` | `src/components/ui/**`; recorded AI Elements controllable-state exceptions only | +| AI Elements | `src/components/ai-elements/**`, consumed by product compositions | +| React Bits recipes | `src/components/effects/react-bits/**` | +| `motion` / future GSAP | `src/motion/**`; AI Elements and reviewed React Bits source may use `motion` | +| Lenis | route-scoped provider under `src/motion/**` | +| Vanta and Three.js | lazy wrapper under `src/components/backgrounds/vanta/**` | + +Vendor UI and motion files may not import Convex, NodeAgent, or the Room store. +They receive consequential state and callbacks through props. + +## Motion Ladder + +1. No animation for deterministic capture or when motion adds no meaning. +2. CSS for hover, focus, open/close, opacity, and simple transforms. +3. A reviewed visual recipe for one bounded decorative treatment. +4. A centralized timeline engine for causal multi-element sequences. +5. Route-scoped smooth scrolling for public editorial pages only. +6. An isolated WebGL background only after performance and accessibility proof. + +Do not stack multiple owners on the same transform or scroll behavior. + +## Enforcement + +- `npm run ui:primitive-audit` checks Radix ownership. +- `npm run ui:motion-audit` checks motion/effect ownership. +- `npm run ui:layer-audit` checks both plus the business-logic boundary. +- `npm run design:audit` includes the complete layer audit. + +The raw-control migration remains tracked in +`docs/design/RADIX_PRIMITIVE_AUDIT.md`; the import gate does not pretend that +the region-by-region migration is already complete. diff --git a/docs/design/RADIX_PRIMITIVE_AUDIT.md b/docs/design/RADIX_PRIMITIVE_AUDIT.md new file mode 100644 index 00000000..a2fdc76d --- /dev/null +++ b/docs/design/RADIX_PRIMITIVE_AUDIT.md @@ -0,0 +1,86 @@ +# Radix Primitive Audit + +Captured: 2026-07-16 + +Layering and motion ownership are defined in +`docs/design/FRONTEND_LAYER_POLICY.md`. Radix remains the behavioral base; +visual recipes and motion systems may decorate a control but may not replace +its accessibility mechanics. + +Scope: interactive controls under `src/ui`, `src/app`, and shared component +layers. The rule is behavioral equivalence first: keep data flow, callbacks, +keyboard contracts, proof selectors, and visual tokens while moving generic +overlay/focus/selection mechanics to shadcn-generated Radix primitives. + +## Migrated + +| Surface | Handwritten behavior removed | Shared primitive | Files | +|---|---|---|---| +| Chat model route picker | Absolute positioning, outside-click listener, Escape listener, custom listbox navigation, ancestor-clipped scrolling | `Popover`, `Command`, `CommandInput`, `CommandList`, `CommandItem`, `Button` | `src/ui/Chat.tsx`, `src/components/ui/popover.tsx`, `src/app/styles.css` | +| Chat work-context picker | Absolute positioning, custom search field/listbox, manual dismissal | `Popover`, `Command`, `CommandInput`, `CommandList`, `CommandItem` | `src/ui/Chat.tsx`, `src/app/styles.css` | +| Chat `@` typeahead | Absolute positioning and handwritten option elements | controlled `Popover` + `Command` anchored to the composer | `src/ui/Chat.tsx`, `src/app/styles.css` | +| Notifications and passive intelligence | Outside-click and Escape listeners, clipped scrolling, duplicated dialog semantics | `Popover` + `ScrollArea` | `src/ui/NotificationsInbox.tsx`, `src/ui/insights/PassiveAgentChip.tsx`, `src/ui/insights/NoteworthyInbox.tsx` | +| People panel | Handwritten dismissal and viewport scrolling | non-modal `Dialog` + `ScrollArea` | `src/ui/PeoplePanel.tsx`, `src/ui/people-panel.css` | +| Landing create/join dialogs | Scrim dismissal, focus trap, Escape handling, focus restoration | shared `FocusTrapDialog` backed by shadcn `Dialog` | `src/ui/Landing.tsx`, `src/ui/primitives/FocusTrapDialog.tsx` | +| Command palette | Backdrop, focus trap, listbox keyboard loop, selected-row scrolling | `CommandDialog`, `CommandInput`, `CommandList`, `CommandItem` | `src/ui/CommandPalette.tsx`, `src/components/ui/command.tsx` | +| Shared modal layer | Handwritten tab loop and focus restoration | shadcn `Dialog`; the design-system `Modal` adapts through `FocusTrapDialog` | `src/ui/primitives/FocusTrapDialog.tsx`, `src/ui/primitives/designSystem.tsx`, `src/components/ui/dialog.tsx` | +| Mobile overflow and action menus | Absolute menus, manual open state, outside/Escape dismissal | `DropdownMenu` | `src/ui/mobile/shell/MobileHeader.tsx`, `src/ui/mobile/MobileApp.tsx` | +| Mobile bottom sheets and nested readers | Scrims, ARIA mutation, tab loop, Escape handling, focus restoration | container-scoped shadcn `Dialog` adapter | `src/ui/mobile/MobileApp.tsx`, `src/components/ui/dialog.tsx`, `src/ui/mobile/mobile.css` | +| Mobile tooltip | Handwritten hover/focus bubble placement | `Tooltip` with bounded long-press adapter | `src/ui/mobile/MobileTooltip.tsx`, `src/ui/mobile/mobileFrame.css` | +| Cell history | Outside/Escape dismissal and clipped scrolling | `Popover` + `ScrollArea` | `src/ui/panels/Artifact.tsx`, `src/ui/panels/artifact-receipts.css` | +| Notebook patch overlay | Manual viewport coordinates and fixed positioning | collision-aware `Popover` | `src/ui/panels/Artifact.tsx`, `src/ui/panels/notebook-paper.css` | +| Evidence citation preview | Manual viewport placement and hover/focus dismissal | `HoverCard` | `src/ui/panels/Artifact.tsx`, `src/app/styles.css` | + +The picker content now renders in a Radix portal, uses collision-aware top/end +placement, and sizes from `--radix-popover-content-available-width` and +`--radix-popover-content-available-height`. Existing model-routing callbacks and +legacy proof controls remain unchanged. + +The context surfaces use the same portal and collision contract. Context search +still covers artifacts, deck slides, proposals, and traces; the `@` path retains +its leading `@nodeagent` and artifact-reference behavior. The former slash-menu +component was unreachable because its option list was permanently empty, so its +handwritten overlay state was retired. `/ask` and `/free` remain parser-level +compatibility aliases and are intentionally not advertised as a second command UI. + +## Replace Next + +No P1 or P2 generic primitive migrations remain in this audit. New generic +dialogs, menus, listboxes, popovers, hover previews, tooltips, and scrollable +overlay regions must start from the shared shadcn-generated Radix layer. + +The listeners that remain in audited owners are product behavior rather than +overlay plumbing: global command/watch shortcuts, camera-follow cancellation, +spreadsheet drag and scroll measurement, and notebook editor measurement. + +## Keep Specialized + +These are not generic primitive replacements: + +- `GuidedTour` uses measured anchored coaching geometry and is non-modal. +- `TraceLensPanel` is a persistent application region, not a dialog despite its + current ARIA role; fix semantics with the trace-region migration. +- Spreadsheet cells, graph nodes, notebook blocks, and deck objects retain + their domain interaction engines. Radix may wrap menus and dialogs around + them but must not replace editing, drag, selection, or realtime logic. +- Compatibility-only hidden model controls stay until dependent proof scripts + migrate to visible semantic selectors. + +## Completion Gate + +For each migration: focused behavior tests, Escape/focus restoration, keyboard +selection, outside interaction, narrow desktop and phone screenshots, zero +horizontal overflow, and unchanged store/Convex callbacks. + +## Verification + +Verified 2026-07-16: + +- Focused primitive suite: 122 tests passed across 10 files. +- Repository floor: TypeScript app + Convex projects passed; 2,216 tests passed + across 328 files. +- `design:audit`: passed. Its existing token-drift inventory remains advisory. +- `ui:layer-audit`: passed across 577 source files. +- Live local browser: 390x844 mobile sheet and 900x800 command palette both had + zero horizontal overflow, correct focus containment/restoration, working + Escape dismissal, and zero console errors. diff --git a/docs/design/ui-contract/20260717-radix-primitives/README.md b/docs/design/ui-contract/20260717-radix-primitives/README.md new file mode 100644 index 00000000..da0beb4e --- /dev/null +++ b/docs/design/ui-contract/20260717-radix-primitives/README.md @@ -0,0 +1,74 @@ +# Radix primitive migration — before/after live capture + +Captured: 2026-07-17 + +| | ref | +| --- | --- | +| **before** | `origin/main` @ `786eef64` | +| **after** | `feat/radix-primitives` @ `db34032c` (= `origin/main` + the migration) | + +Both trees were built with `vite build` and served with `vite preview` +(`before` :5291, `after` :5292), then driven with Playwright against the built +preview per `docs/qa/BROWSER_VERIFY.md`. Browser-pane screenshots were NOT used — +preview tabs run with `document.visibilityState === "hidden"`, which pauses capture. + +## Screenshots + +| surface | before | after | +| --- | --- | --- | +| landing, 1280x860 | `before-01-landing-desktop.png` | `after-01-landing-desktop.png` | +| demo room, 1280x860 | `before-02-room-desktop.png` | `after-02-room-desktop.png` | +| command palette | `before-03-command-palette.png` | `after-03-command-palette.png` | +| landing, 390x844 | `before-04-mobile-390x844.png` | `after-04-mobile-390x844.png` | +| demo room, 390x844 | `before-05-mobile-room.png` | `after-05-mobile-room.png` | + +`before-02` and `after-02` are byte-identical in size (167,469) — the room surface +is visually unchanged. + +## What actually differs + +A primitive migration is behavioural, not visual, so near-identical images are the +intended outcome. The only visible delta is in the command palette: + +- Row height is slightly tighter (~36.6px vs ~39.4px) from shadcn's `CommandItem` + padding, so one additional row ("Open Today's Brief") fits inside the same + height-capped list. Same commands on both sides — `Today's Brief` is a demo-room + fixture present in both trees, not new content. + +## Behavioural probe (Playwright, both trees) + +| check | before | after | +| --- | --- | --- | +| palette opens on Ctrl+K | yes | yes | +| focus starts inside dialog | yes | yes | +| focus stays inside after 12 Tabs | yes | yes | +| Escape dismisses | yes | yes | +| focus restored to trigger | yes | yes | +| outside click dismisses | yes | yes | +| horizontal overflow @1280x860 | 0px | 0px | +| horizontal overflow @390x844 | 0px | 0px | +| console errors | none | none | + +**The palette was already correct before this migration.** Focus trapping, Escape +handling, outside dismissal and focus restoration all worked on `origin/main`. This +change does not fix them; it moves them onto a shared primitive so every surface +inherits one implementation instead of re-deriving it. + +## Structural delta + +- **before**: dialog renders inline inside `#root`; `aria-modal="true"`; custom + `.r-cmdk-backdrop`. +- **after**: dialog is portaled to `document.body` (`#radix-_r_2_`) with Radix focus + guards; `data-state="open"`; no `aria-modal`. + +## Open a11y question (NOT fixed by this change) + +Neither implementation marks `#root` `aria-hidden` while the modal is open, so +background content stays in the accessibility tree on both sides. `before` carried +`aria-modal="true"`; `after` does not (Radix drops it deliberately in favour of +`hideOthers`, but `hideOthers` is not marking `#root` here). + +The aria snapshot shrinks 185 -> 49 lines on `after` vs 185 -> 202 on `before`, but +that is most plausibly Radix's scroll-lock unmounting virtualized binder rows — it is +**not** evidence of aria-hiding and is not claimed as an a11y win. Worth one focused +follow-up. diff --git a/docs/design/ui-contract/20260717-radix-primitives/after-01-landing-desktop.png b/docs/design/ui-contract/20260717-radix-primitives/after-01-landing-desktop.png new file mode 100644 index 00000000..75e81185 Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/after-01-landing-desktop.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/after-02-room-desktop.png b/docs/design/ui-contract/20260717-radix-primitives/after-02-room-desktop.png new file mode 100644 index 00000000..51a85ce9 Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/after-02-room-desktop.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/after-03-command-palette.png b/docs/design/ui-contract/20260717-radix-primitives/after-03-command-palette.png new file mode 100644 index 00000000..1d7a5e92 Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/after-03-command-palette.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/after-04-mobile-390x844.png b/docs/design/ui-contract/20260717-radix-primitives/after-04-mobile-390x844.png new file mode 100644 index 00000000..3ad4a95b Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/after-04-mobile-390x844.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/after-05-mobile-room.png b/docs/design/ui-contract/20260717-radix-primitives/after-05-mobile-room.png new file mode 100644 index 00000000..a6848c8d Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/after-05-mobile-room.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/before-01-landing-desktop.png b/docs/design/ui-contract/20260717-radix-primitives/before-01-landing-desktop.png new file mode 100644 index 00000000..db591d22 Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/before-01-landing-desktop.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/before-02-room-desktop.png b/docs/design/ui-contract/20260717-radix-primitives/before-02-room-desktop.png new file mode 100644 index 00000000..51a85ce9 Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/before-02-room-desktop.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/before-03-command-palette.png b/docs/design/ui-contract/20260717-radix-primitives/before-03-command-palette.png new file mode 100644 index 00000000..9d26f442 Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/before-03-command-palette.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/before-04-mobile-390x844.png b/docs/design/ui-contract/20260717-radix-primitives/before-04-mobile-390x844.png new file mode 100644 index 00000000..d98fb61e Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/before-04-mobile-390x844.png differ diff --git a/docs/design/ui-contract/20260717-radix-primitives/before-05-mobile-room.png b/docs/design/ui-contract/20260717-radix-primitives/before-05-mobile-room.png new file mode 100644 index 00000000..634247f1 Binary files /dev/null and b/docs/design/ui-contract/20260717-radix-primitives/before-05-mobile-room.png differ diff --git a/e2e/full-modern-ux-bar.spec.ts b/e2e/full-modern-ux-bar.spec.ts index 60866dfd..540fed73 100644 --- a/e2e/full-modern-ux-bar.spec.ts +++ b/e2e/full-modern-ux-bar.spec.ts @@ -339,7 +339,8 @@ test.describe("full modern UX release bar", () => { await page.getByRole("button", { name: "Quick actions" }).click(); await expect(page.locator(".na-fab-fan")).toBeVisible(); - await page.getByRole("button", { name: "Ask NodeAgent" }).click(); + // Fan actions are Radix DropdownMenuItems (role=menuitem), not buttons. + await page.getByRole("menuitem", { name: "Ask NodeAgent" }).click(); await expect(page.locator(".na-ask-wrap")).toHaveAttribute("data-open", "true"); await attachAndAssertHealth(page, testInfo, "mobile-terracotta", health, { assertPerf: false }); diff --git a/package.json b/package.json index c16b4312..bc38444a 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,10 @@ "qa:ui:virality": "tsx scripts/design-quality.ts virality", "qa:ui:scorecard": "tsx scripts/design-quality.ts scorecard", "design:manifest": "tsx scripts/design-system.ts manifest --json", - "design:audit": "tsx scripts/design-system.ts audit", + "design:audit": "tsx scripts/design-system.ts audit && npm run ui:layer-audit", + "ui:layer-audit": "tsx scripts/ui-layer-audit.ts", + "ui:primitive-audit": "tsx scripts/ui-layer-audit.ts --scope=primitives", + "ui:motion-audit": "tsx scripts/ui-layer-audit.ts --scope=motion", "design:parity": "tsx scripts/design-parity.ts", "workflow:trace-previews": "tsx scripts/render-workflow-preview.ts", "workflow:app-previews": "playwright test capture-previews", diff --git a/scripts/ui-layer-audit.ts b/scripts/ui-layer-audit.ts new file mode 100644 index 00000000..6c57932a --- /dev/null +++ b/scripts/ui-layer-audit.ts @@ -0,0 +1,38 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import { auditUiLayerImports, type UiLayerAuditScope } from "../src/design/uiLayerPolicy"; + +const root = resolve(import.meta.dirname, ".."); +const scope = parseScope(process.argv.slice(2)); +const files = readSourceFiles(resolve(root, "src")); +const result = auditUiLayerImports(files, scope); +const json = process.argv.includes("--json"); + +if (json) { + console.log(JSON.stringify({ scope, ...result }, null, 2)); +} else if (result.ok) { + console.log(`ui-layer audit: pass (${scope}, ${result.checkedFiles} source files)`); +} else { + console.error(`ui-layer audit: fail (${result.findings.length} finding(s))`); + for (const finding of result.findings) { + console.error(`- ${finding.file}: ${finding.packageName} - ${finding.reason}`); + } +} + +if (!result.ok) process.exitCode = 1; + +function readSourceFiles(directory: string): Record { + const output: Record = {}; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) Object.assign(output, readSourceFiles(path)); + else if (/\.(?:ts|tsx|js|jsx|mjs)$/.test(entry.name)) output[relative(root, path).replaceAll("\\", "/")] = readFileSync(path, "utf8"); + } + return output; +} + +function parseScope(args: string[]): UiLayerAuditScope { + const value = args.find((arg) => arg.startsWith("--scope="))?.split("=")[1]; + if (value === "primitives" || value === "motion") return value; + return "all"; +} diff --git a/src/app/styles.css b/src/app/styles.css index 325044e6..14b9bb2b 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -205,10 +205,12 @@ button.r-offline-pill:hover, button.r-offline-pill:focus-visible { border-color: .r-passive-chip[data-new="true"] { animation: passiveChipPulse .82s var(--ease-out-expo) both; } } @keyframes passiveChipPulse { 0% { box-shadow: 0 0 0 0 var(--accent-border); } 100% { box-shadow: 0 0 0 7px transparent; } } -.r-inbox { position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 40; width: min(340px, 92vw); max-height: min(60vh, 460px); display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 10px; background: var(--bg-primary); box-shadow: 0 12px 32px rgba(0,0,0,.18); overflow: hidden; } +.r-passive-popover { width: auto; max-width: none; padding: 0; border: 0; background: transparent; box-shadow: none; } +.r-inbox { position: static; z-index: 40; width: min(340px, calc(var(--radix-popover-content-available-width) - 8px)); max-height: min(60vh, 460px, var(--radix-popover-content-available-height)); display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 10px; background: var(--bg-primary); box-shadow: 0 12px 32px rgba(0,0,0,.18); overflow: hidden; } .r-inbox-head { display: flex; align-items: center; gap: 8px; padding: var(--space-2) 10px; border-bottom: 1px solid var(--line); } .r-inbox-title { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--text-secondary); font-weight: 600; } -.r-inbox-list { list-style: none; margin: 0; padding: 4px; overflow-y: auto; } +.r-inbox-scroll { min-height: 0; flex: 1; } +.r-inbox-list { list-style: none; margin: 0; padding: 4px; } .r-inbox-empty { padding: 16px 12px; color: var(--text-tertiary); font-size: 11.5px; text-align: center; } .r-inbox-item { padding: 8px var(--space-2); border-radius: 8px; border: 1px solid transparent; margin-bottom: 4px; background: var(--bg-secondary); } .r-inbox-item[data-tone="failed"] { border-color: var(--danger-border); background: var(--danger-tint); } @@ -675,19 +677,20 @@ button.r-offline-pill:hover, button.r-offline-pill:focus-visible { border-color: .r-model-trigger svg { flex: none; color: var(--accent-ink); } .r-model-trigger-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .r-model-trigger-badge { flex: none; max-width: 78px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 1px solid var(--accent-border); border-radius: 999px; background: var(--accent-tint); color: var(--accent-ink); font-family: var(--font-mono); font-size: 9.5px; font-weight: 850; padding: 1px 5px; } -.r-model-popover { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 30; width: min(460px, calc(100vw - 28px)); max-height: min(560px, calc(100vh - 120px)); display: flex; flex-direction: column; gap: 8px; padding: 8px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-primary); box-shadow: var(--shadow-lg); animation: panelIn .14s var(--ease-out-expo) both; } -.r-model-search { height: 32px; display: flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-secondary); color: var(--text-tertiary); } -.r-model-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 12px; } +.r-model-popover { z-index: 80; width: min(460px, var(--radix-popover-content-available-width)); max-height: min(560px, var(--radix-popover-content-available-height)); overflow: hidden; display: flex; flex-direction: column; padding: 8px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-primary); color: var(--text-primary); box-shadow: var(--shadow-lg); transform-origin: var(--radix-popover-content-transform-origin); } +.r-model-command { min-height: 0; gap: 8px; border-radius: 0; background: transparent; color: inherit; } +.r-model-command [data-slot="command-input-wrapper"] { flex: none; height: 32px; gap: 7px; padding: 0 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-secondary); color: var(--text-tertiary); } +.r-model-command [data-slot="command-input"] { height: 30px; padding: 0; color: var(--text-primary); font: inherit; font-size: 12px; } .r-model-presets { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } -.r-model-preset { min-width: 0; min-height: 58px; display: flex; align-items: flex-start; justify-content: space-between; gap: 7px; text-align: left; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); padding: 7px; cursor: pointer; transition: background var(--motion-fast), border-color var(--motion-fast), color var(--motion-fast); } +.r-model-preset { min-width: 0; min-height: 58px; display: flex; align-items: flex-start; justify-content: space-between; gap: 7px; text-align: left; white-space: normal; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); padding: 7px; cursor: pointer; transition: background var(--motion-fast), border-color var(--motion-fast), color var(--motion-fast); } .r-model-preset:hover, .r-model-preset[data-selected="true"] { border-color: var(--accent-border); background: var(--accent-tint); color: var(--text-primary); } .r-model-preset span { min-width: 0; display: flex; flex-direction: column; gap: 2px; } .r-model-preset b { font-size: 11.5px; line-height: 1.1; } .r-model-preset small { color: var(--text-muted); font-size: 10.5px; line-height: 1.25; } .r-model-preset em { flex: none; font-style: normal; border: 1px solid var(--line); border-radius: 999px; padding: 1px 5px; color: var(--accent-ink); background: var(--bg-primary); font-family: var(--font-mono); font-size: 9px; font-weight: 850; } -.r-model-list { overflow: auto; display: flex; flex-direction: column; gap: 3px; padding-right: 2px; } +.r-model-list { min-height: 0; max-height: min(300px, calc(var(--radix-popover-content-available-height) - 170px)); overflow-x: hidden; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; padding-right: 2px; } .r-model-option { width: 100%; min-height: 46px; display: flex; align-items: center; justify-content: space-between; gap: 10px; text-align: left; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text-secondary); padding: 6px 7px; cursor: pointer; } -.r-model-option:hover, .r-model-option[data-selected="true"] { border-color: var(--accent-border); background: var(--bg-hover); color: var(--text-primary); } +.r-model-option:hover, .r-model-option[data-selected="true"], .r-model-option[data-current="true"] { border-color: var(--accent-border); background: var(--bg-hover); color: var(--text-primary); } .r-model-option-main { min-width: 0; display: flex; flex-direction: column; gap: 2px; } .r-model-option-main b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; line-height: 1.15; } .r-model-option-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-tertiary); font-size: 10.5px; } @@ -722,22 +725,19 @@ button.r-offline-pill:hover, button.r-offline-pill:focus-visible { border-color: .r-context-btn[data-active="true"] { color: var(--accent-ink); border-color: var(--accent-border); background: var(--accent-tint); } .r-composer { position: relative; } .r-composer-kbd { font-size: 10px; color: var(--text-tertiary); margin-left: auto; align-self: center; } -/* slash-command popover (assistant-ui trigger menu) */ -.r-slash { position: absolute; left: 12px; right: 12px; bottom: calc(100% - 4px); z-index: 10; background: var(--bg-primary); border: 1px solid var(--line-strong); border-radius: 11px; box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; gap: 2px; animation: panelIn .16s var(--ease-out-expo) both; } -.r-slash-item { display: flex; flex-direction: column; gap: 1px; text-align: left; padding: var(--space-2) 10px; border-radius: 8px; border: none; background: transparent; cursor: pointer; } -.r-slash-item:hover, .r-slash-item[aria-selected="true"] { background: var(--accent-tint); } -.r-slash-item .cmd { font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--accent-ink); } -.r-slash-item .hint { font-size: 11px; color: var(--text-muted); } -.r-context-picker { position: absolute; left: 12px; right: 12px; bottom: calc(100% - 4px); z-index: 12; max-height: min(420px, 58vh); display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 6px; padding: 7px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-primary); box-shadow: var(--shadow-lg); } -.r-context-picker-search { height: 32px; display: flex; align-items: center; gap: 7px; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg-secondary); color: var(--text-muted); } -.r-context-picker-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 12px; } -.r-context-picker-search button { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 0; background: transparent; color: var(--text-muted); } -.r-context-picker-list { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 2px; } -.r-context-picker-list > button { width: 100%; min-width: 0; min-height: 38px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-secondary); text-align: left; } -.r-context-picker-list > button:hover, .r-context-picker-list > button:focus-visible { outline: none; border-color: var(--accent-border); background: var(--bg-hover); color: var(--text-primary); } -.r-context-picker-list > button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; font-weight: 750; } -.r-context-picker-list > button em { max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font: normal 9.5px/1.2 var(--font-mono); } -.r-context-picker-list > p { margin: 0; padding: 16px 8px; color: var(--text-muted); font-size: 11.5px; text-align: center; } +/* Composer context menus: Radix owns portal/collision/focus; these rules own NodeRoom density. */ +.r-composer-popover { width: min(360px, calc(var(--radix-popover-content-available-width) - 8px)); max-width: calc(100vw - 16px); max-height: min(420px, calc(var(--radix-popover-content-available-height) - 8px)); overflow: hidden; padding: 6px; border-color: var(--line-strong); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); box-shadow: var(--shadow-lg); } +.r-mention-popover { width: min(340px, calc(var(--radix-popover-content-available-width) - 8px)); } +.r-composer-command { max-height: inherit; background: transparent; color: inherit; } +.r-composer-command [data-slot="command-input-wrapper"] { height: 34px; padding: 0 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg-secondary); } +.r-composer-command [data-slot="command-input"] { height: 32px; padding: 0; font-size: 12px; color: var(--text-primary); } +.r-composer-command-list { max-height: min(350px, calc(var(--radix-popover-content-available-height) - 58px)); margin-top: 5px; } +.r-mention-popover .r-composer-command-list { margin-top: 0; } +.r-composer-command-item { min-width: 0; min-height: 38px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid transparent; border-radius: 6px; color: var(--text-secondary); } +.r-composer-command-item[data-selected="true"] { border-color: var(--accent-border); background: var(--bg-hover); color: var(--text-primary); } +.r-composer-command-item .cmd { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; font-weight: 750; } +.r-composer-command-item .hint { max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font: 400 9.5px/1.2 var(--font-mono); } +.r-composer-command-empty { padding: 16px 8px; color: var(--text-muted); font-size: 11.5px; text-align: center; } /* message action bar (assistant-ui) — revealed on hover/focus */ .r-msg-actions { display: flex; gap: 6px; margin-top: 5px; opacity: 0; transition: opacity var(--motion-fast); } .r-msg:hover .r-msg-actions, .r-msg:focus-within .r-msg-actions { opacity: 1; } @@ -2992,7 +2992,9 @@ body.nr-honesty-on [data-evidence-class="unsourced"] { background-color: col never intercepts the pointer, so cells underneath stay clickable/editable. Pinning the chip (click or keyboard focus) makes the source links live. */ .r-cite-popover.r-evidence-popover { - pointer-events: none; + display: block; + position: static; + pointer-events: auto; width: min(288px, calc(100vw - 34px)); max-height: 280px; overflow: hidden; @@ -3001,12 +3003,7 @@ body.nr-honesty-on [data-evidence-class="unsourced"] { background-color: col border-color: var(--line); font-size: 12px; } -.r-cite-wrap:focus-within .r-cite-popover.r-evidence-popover { pointer-events: auto; } - -/* Viewport-aware placement (data-* set by EvidenceReceipt): flip above when - near the bottom edge; anchor left when the chip hugs the left edge. */ -.r-cite-popover.r-evidence-popover[data-flip="up"] { top: auto; bottom: 22px; } -.r-cite-popover.r-evidence-popover[data-align="left"] { right: auto; left: 0; } +.r-cite-popover.r-evidence-popover[data-state="closed"] { display: none; } /* Receipt items: label + quoted snippet + host/confidence, hairline-separated. */ .r-cite-popover .r-evidence-items { margin: 0; } diff --git a/src/components/ai-elements/shimmer.tsx b/src/components/ai-elements/shimmer.tsx index 6b635d01..dad65a57 100644 --- a/src/components/ai-elements/shimmer.tsx +++ b/src/components/ai-elements/shimmer.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils"; import type { MotionProps } from "motion/react"; -import { motion } from "motion/react"; +import { motion, useReducedMotion } from "motion/react"; import type { CSSProperties, ElementType, JSX } from "react"; import { memo, useMemo } from "react"; @@ -38,6 +38,7 @@ const ShimmerComponent = ({ duration = 2, spread = 2, }: TextShimmerProps) => { + const reducedMotion = useReducedMotion(); const MotionComponent = getMotionComponent( Component as keyof JSX.IntrinsicElements ); @@ -47,6 +48,10 @@ const ShimmerComponent = ({ [children, spread] ); + if (reducedMotion) { + return {children}; + } + return ( & { title?: string description?: string className?: string showCloseButton?: boolean + contentTestId?: string + contentProps?: Omit, "children"> + commandProps?: React.ComponentProps }) { return ( - - {title} - {description} - - + + {title} + {description} + + {children} @@ -60,12 +71,14 @@ function CommandDialog({ function CommandInput({ className, + wrapperClassName, + trailing, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { wrapperClassName?: string; trailing?: React.ReactNode }) { return (
+ {trailing}
) } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 84bdef4b..96b6a6b7 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -51,16 +51,28 @@ function DialogContent({ className, children, showCloseButton = true, + overlayClassName, + hideOverlay = false, + unstyled = false, + unstyledOverlay = false, + portalContainer, ...props }: React.ComponentProps & { showCloseButton?: boolean + overlayClassName?: string + hideOverlay?: boolean + unstyled?: boolean + unstyledOverlay?: boolean + portalContainer?: Element | DocumentFragment | null }) { return ( - - + + {!hideOverlay && (unstyledOverlay + ? + : )} ) { return ( - + ) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/design/uiLayerPolicy.ts b/src/design/uiLayerPolicy.ts new file mode 100644 index 00000000..fb911978 --- /dev/null +++ b/src/design/uiLayerPolicy.ts @@ -0,0 +1,119 @@ +export type UiLayerAuditScope = "all" | "primitives" | "motion"; + +export type UiLayerFinding = { + file: string; + packageName: string; + reason: string; +}; + +export type UiLayerAuditResult = { + ok: boolean; + checkedFiles: number; + findings: UiLayerFinding[]; +}; + +const IMPORT_RE = /(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g; + +const RADIX_AI_ELEMENT_EXCEPTIONS = new Set([ + "src/components/ai-elements/chain-of-thought.tsx", + "src/components/ai-elements/reasoning.tsx", +]); + +export function auditUiLayerImports( + files: Record, + scope: UiLayerAuditScope = "all", +): UiLayerAuditResult { + const findings: UiLayerFinding[] = []; + + for (const [rawFile, source] of Object.entries(files)) { + const file = normalizePath(rawFile); + for (const packageName of importsFrom(source)) { + if (scope !== "motion") auditPrimitiveImport(file, packageName, findings); + if (scope !== "primitives") auditMotionImport(file, packageName, findings); + auditVendorBusinessBoundary(file, packageName, findings); + } + } + + return { ok: findings.length === 0, checkedFiles: Object.keys(files).length, findings }; +} + +function auditPrimitiveImport(file: string, packageName: string, findings: UiLayerFinding[]) { + if (!isRadixImport(packageName)) return; + if (file.startsWith("src/components/ui/")) return; + if (packageName === "@radix-ui/react-use-controllable-state" && RADIX_AI_ELEMENT_EXCEPTIONS.has(file)) return; + findings.push({ + file, + packageName, + reason: "Radix behavior must be wrapped by src/components/ui; AI Elements has only the two recorded controllable-state exceptions.", + }); +} + +function auditMotionImport(file: string, packageName: string, findings: UiLayerFinding[]) { + const effectBoundary = file.startsWith("src/components/effects/react-bits/"); + const aiElementsBoundary = file.startsWith("src/components/ai-elements/"); + const motionBoundary = file.startsWith("src/motion/"); + const vantaBoundary = file.startsWith("src/components/backgrounds/vanta/"); + + if (isPackage(packageName, "gsap") || isPackage(packageName, "@gsap/react")) { + if (!motionBoundary) findings.push({ file, packageName, reason: "GSAP imports belong only in src/motion." }); + return; + } + if (isPackage(packageName, "lenis") || isPackage(packageName, "@studio-freight/lenis")) { + if (!motionBoundary) findings.push({ file, packageName, reason: "Lenis imports belong only in the route-scoped src/motion provider." }); + return; + } + if (isPackage(packageName, "vanta") || isPackage(packageName, "three")) { + if (!vantaBoundary) findings.push({ file, packageName, reason: "Vanta and its renderer belong only in the lazy background wrapper." }); + return; + } + if (isPackage(packageName, "motion") || isPackage(packageName, "framer-motion")) { + if (!motionBoundary && !effectBoundary && !aiElementsBoundary) findings.push({ file, packageName, reason: "Motion imports belong in src/motion, AI Elements, or a reviewed React Bits effect." }); + return; + } + if (packageName.startsWith("@react-bits/")) { + if (!effectBoundary) findings.push({ file, packageName, reason: "React Bits recipes belong only in src/components/effects/react-bits." }); + } +} + +function auditVendorBusinessBoundary(file: string, packageName: string, findings: UiLayerFinding[]) { + const vendorBoundary = file.startsWith("src/components/ui/") + || file.startsWith("src/components/effects/react-bits/") + || file.startsWith("src/components/backgrounds/vanta/") + || file.startsWith("src/motion/"); + if (!vendorBoundary) return; + + if ( + packageName === "convex" + || packageName.startsWith("convex/") + || /(?:^|\/)convex(?:\/|$)/.test(packageName) + || /(?:^|\/)nodeagent(?:\/|$)/i.test(packageName) + || /(?:^|\/)app\/store(?:\.|$)/.test(packageName) + ) { + findings.push({ + file, + packageName, + reason: "Vendor UI and motion layers receive product state through props; they may not own Convex or NodeAgent behavior.", + }); + } +} + +function importsFrom(source: string): string[] { + const imports = new Set(); + for (const match of source.matchAll(IMPORT_RE)) { + const packageName = match[1] ?? match[2]; + if (packageName) imports.add(packageName); + } + return [...imports]; +} + +function isRadixImport(packageName: string): boolean { + return packageName === "radix-ui" || packageName.startsWith("@radix-ui/"); +} + +function isPackage(packageName: string, root: string): boolean { + return packageName === root || packageName.startsWith(`${root}/`); +} + +function normalizePath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, ""); +} diff --git a/src/ui/Chat.tsx b/src/ui/Chat.tsx index bad94e9c..f5725e0c 100644 --- a/src/ui/Chat.tsx +++ b/src/ui/Chat.tsx @@ -1,10 +1,13 @@ /** Public/private Copilot chat surfaces. Reads via useStore(). */ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type ClipboardEvent, type DragEvent, type KeyboardEvent, type ReactNode } from "react"; -import { Lock, MessageCircle, Globe, Send, Square, Sparkles, Copy, Check, ArrowUpRight, Pencil, Paperclip, Link2, X, Timer, RefreshCw, ChevronDown, ChevronUp, ChevronRight, ListChecks, GitBranch, ShieldCheck, Database, FileText, StickyNote, Table2, Brain, Target, Mic, MicOff, Search, AlertTriangle } from "lucide-react"; +import { Lock, MessageCircle, Globe, Send, Square, Sparkles, Copy, Check, ArrowUpRight, Pencil, Paperclip, Link2, X, Timer, RefreshCw, ChevronDown, ChevronUp, ChevronRight, ListChecks, GitBranch, ShieldCheck, Database, FileText, StickyNote, Table2, Brain, Target, Mic, MicOff, AlertTriangle } from "lucide-react"; import { useQuery } from "convex/react"; import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; import { MessageResponse } from "@/components/ai-elements/message"; import { Suggestion } from "@/components/ai-elements/suggestion"; +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tool, ToolContent, ToolHeader, type ToolPart } from "@/components/ai-elements/tool"; import { useStore, CONVEX_SITE_URL, type AgentJobDetailTelemetry, type AgentJobTelemetry, type AgentModelSelection, type PrivateStreamAccess, type RoomStore } from "../app/store"; import { abortable, parseUploadedFiles, UPLOAD_TIMEOUT_MS } from "../app/uploadedArtifact"; @@ -949,7 +952,6 @@ function AgentModelPicker({ }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); - const rootRef = useRef(null); const selectedChoice = choices.find((choice) => choice.model === (modelPolicy || defaultModel)); const selectedPreset = AGENT_MODEL_PRESETS.find((preset) => preset.value === mode) ?? AGENT_MODEL_PRESETS[0]; const triggerLabel = mode === "specific" ? (selectedChoice?.model ?? (modelPolicy || "Specific model")) : selectedPreset.label; @@ -963,23 +965,6 @@ function AgentModelPicker({ return choices.filter((choice) => choice.searchText.includes(q)); }, [choices, search]); - useEffect(() => { - if (!open) return; - const onMouseDown = (event: MouseEvent) => { - const target = event.target; - if (target instanceof Node && rootRef.current && !rootRef.current.contains(target)) setOpen(false); - }; - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", onMouseDown); - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("mousedown", onMouseDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [open]); - const choosePreset = (next: AgentModelSelection["mode"]) => { onModeChange(next); if (next === "specific" && !modelPolicy && defaultModel) onSpecificModelChange(defaultModel); @@ -993,7 +978,8 @@ function AgentModelPicker({ }; return ( -
+ +
{/* Compatibility hooks for existing proof scripts. The visible product UI is the trigger/popover below. */} ) => setSearch(e.target.value)} - placeholder="Search provider or model" - data-testid="chat-model-search" - autoFocus - /> -
+ +
{AGENT_MODEL_PRESETS.map((preset) => ( - + ))}
-
+ {filteredChoices.length === 0 ? ( -
No matching models
+ No matching models ) : filteredChoices.map((choice) => ( - + ))} -
-
- )} - + + + + ); } @@ -1365,8 +1359,6 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId const [refs, setRefs] = useState([]); const [dropActive, setDropActive] = useState(false); const [thinking, setThinking] = useState(false); - const [slashOpen, setSlashOpen] = useState(false); - const [slashIndex, setSlashIndex] = useState(0); // @-mention typeahead (matches Cursor/Notion): type @ to attach a room artifact as a reference. const [mention, setMention] = useState<{ q: string; start: number } | null>(null); const [mentionIndex, setMentionIndex] = useState(0); @@ -1497,10 +1489,9 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId }; })), [specificModelGroups]); const defaultSpecificModel = specificModelGroups[0]?.models[0] ?? ""; - const slashOptions = useMemo(() => [] as { label: string; insert: string; hint: string }[], [store.mode]); type MentionItem = | { kind: "agent"; key: string; label: string; hint: string } - | { kind: "artifact"; key: string; label: string; hint: string; ref: ArtifactRef }; + | { kind: "context"; key: string; label: string; hint: string; ref: ArtifactRef }; const mentionMatches = useMemo(() => { if (!mention) return []; const q = mention.q.toLowerCase(); @@ -1514,7 +1505,7 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId for (const a of store.listArtifacts(roomId)) { const ref = { id: a.id, title: a.title, kind: a.kind, contextKind: "artifact" as const }; if (already.has(artifactRefKey(ref)) || (q !== "" && !a.title.toLowerCase().includes(q))) continue; - items.push({ kind: "artifact", key: a.id, label: a.title, hint: a.kind, ref }); + items.push({ kind: "context", key: a.id, label: a.title, hint: a.kind, ref }); if (items.length >= 7) break; } return items; @@ -1865,7 +1856,7 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId const cid = crypto.randomUUID(); void store.postMessage({ roomId, channel, author: me, text: messageText, clientMsgId: cid, kind: "chat" }) .then((fb) => { if (fb && !fb.ok) setFailedSends((f) => { if (f.some((x) => x.cid === cid)) return f; const next = [...f, { cid, text: messageText }]; return next.length > MAX_FAILED_SENDS ? next.slice(-MAX_FAILED_SENDS) : next; }); }); - setText(""); setRefs([]); setSlashOpen(false); setSlashIndex(0); setMention(null); setMentionIndex(0); setContextPickerOpen(false); setContextQuery(""); + setText(""); setRefs([]); setMention(null); setMentionIndex(0); setContextPickerOpen(false); setContextQuery(""); requestAnimationFrame(grow); const publicNodeAgentRequest = !isPrivate ? parsePublicNodeAgentRequest(t) : null; @@ -1944,7 +1935,7 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId void store.retryLongFreeJob(longJob.id).then((fb) => { if (!fb.ok) setJobErr(jobReason(fb.reason)); }).finally(() => setJobBusy(null)); }; - const applySlash = (insert: string) => { setText(insert); setSlashOpen(false); setSlashIndex(0); requestAnimationFrame(() => { grow(); taRef.current?.focus(); }); }; + const applyComposerPrompt = (insert: string) => { setText(insert); requestAnimationFrame(() => { grow(); taRef.current?.focus(); }); }; const applyMention = (item: MentionItem) => { if (!mention) return; const head = (v: string) => v.slice(0, mention.start); @@ -2047,13 +2038,15 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId const onChange = (e: ChangeEvent) => { const v = e.target.value; setText(v); grow(); - const open = !isPrivate && v.trimStart() === "/" && slashOptions.length > 0; - setSlashOpen(open); - if (open) setSlashIndex(0); // @-mention: an @ at the caret (start or after whitespace) opens the artifact picker. const caret = e.target.selectionStart ?? v.length; - const m = open ? null : /(?:^|\s)@(\S*)$/.exec(v.slice(0, caret)); - if (m) { setMention({ q: m[1], start: caret - m[1].length - 1 }); setMentionIndex(0); } + const m = /(?:^|\s)@(\S*)$/.exec(v.slice(0, caret)); + if (m) { + setContextPickerOpen(false); + setContextQuery(""); + setMention({ q: m[1], start: caret - m[1].length - 1 }); + setMentionIndex(0); + } else setMention(null); }; const onPaste = (e: ClipboardEvent) => { @@ -2069,25 +2062,8 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { e.preventDefault(); applyMention(mentionMatches[mentionIndex] ?? mentionMatches[0]); return; } if (e.key === "Escape") { e.preventDefault(); setMention(null); return; } } - if (slashOpen && slashOptions.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setSlashIndex((i) => (i + 1) % slashOptions.length); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setSlashIndex((i) => (i - 1 + slashOptions.length) % slashOptions.length); - return; - } - if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { - e.preventDefault(); - applySlash(slashOptions[slashIndex]?.insert ?? slashOptions[0].insert); - return; - } - } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } - else if (e.key === "Escape") { if (slashOpen) setSlashOpen(false); else taRef.current?.blur(); } + else if (e.key === "Escape") taRef.current?.blur(); }; const canSend = !uploadingFiles && (text.trim().length > 0 || refs.length > 0); const rootClass = `${embedded ? `r-chat-embedded nr-chat-panel ${isPrivate ? "private" : "public"}` : `r-panel nr-panel nr-chat-panel ${isPrivate ? "right nr-panel--right" : "center nr-panel--center"}`}${isPrivate ? "" : " fx-chat"}`; @@ -2260,7 +2236,7 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId )} {showDecisionCard && ( - + )} {pinnedAgentResearchReceipt && (
@@ -2370,41 +2346,6 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId
)} - {slashOpen && slashOptions.length > 0 && ( -
- {slashOptions.map((c, i) => ( - - ))} -
- )} - {mention && mentionMatches.length > 0 && ( -
- {mentionMatches.map((item, i) => ( - - ))} -
- )} - {contextPickerOpen && ( -
-
- - setContextQuery(event.target.value)} placeholder="Find artifacts, slides, proposals, or traces" /> - -
-
- {contextOptions.map((option) => ( - - ))} - {contextOptions.length === 0 &&

No matching room context.

} -
-
- )} {refs.length > 0 && (
{refs.map((ref) => ( @@ -2444,10 +2385,62 @@ export function Chat({ roomId, me, channel, variant, agentName, activeArtifactId aria-label="Attach files" tabIndex={-1} /> -