diff --git a/.github/workflows/dogfood-qa-gate.yml b/.github/workflows/dogfood-qa-gate.yml index c36a3ad09..d0d03a9ca 100644 --- a/.github/workflows/dogfood-qa-gate.yml +++ b/.github/workflows/dogfood-qa-gate.yml @@ -93,6 +93,9 @@ jobs: - name: Comment PR with QA results if: always() + # Reporting must not override the actual QA result when GitHub's + # comments API is temporarily unavailable. + continue-on-error: true uses: actions/github-script@v9 with: script: | diff --git a/CHANGELOG/components/ui-primitives.md b/CHANGELOG/components/ui-primitives.md new file mode 100644 index 000000000..f38fb83a4 --- /dev/null +++ b/CHANGELOG/components/ui-primitives.md @@ -0,0 +1,29 @@ +# UI primitives + +Append-only lane for the shared shadcn/Radix interaction layer and migrations +away from handwritten generic control infrastructure. Newest entries first. + +## 2026-07-16 — Standardize generic interactions on shadcn/Radix + +Dialogs, sheets, popovers, hover cards, menus, command palettes, tabs, toggle +groups, switches, checkboxes, accordions, and toast delivery now compose shared +shadcn/Radix wrappers across the primary chat, reports, documents, research, +entity, strategy, onboarding, settings, and calendar surfaces. Retired focus-trap, +bottom-sheet, keyboard-navigation, portal-positioning, and duplicate toast +infrastructure was removed; native browser form controls, route navigation, +editor decorations, and product-specific virtualized menus remain intentionally +outside the generic primitive layer. +The production-data browser pass also repaired a missing report-menu import and +made both report rails key repeated display names by their runtime artifact ID. + +**PR / canonical main commit**: PENDING #559 MAIN SHA / FINAL QA. + +**Evidence state**: +- Source: pending CI-gated merge. +- Checks: local TypeScript, 31 final migration-focused tests, 297 broader regression tests, design-system tests, design lint, and the post-repair production build passed; exact PR checks pending. +- Visual proof: local desktop reports/menu and mobile chat captures at `.qa/evidence/2026-07-16-radix-migration/` show zero horizontal overflow and collision-safe controls; exact preview proof pending. +- Preview: pending exact-head Vercel preview. +- Production live: pending merge, deployment, and exact-revision verification. + +**Author**: Homen Shum + Codex. +**Touches**: [`../pages/redesign-chat.md`](../pages/redesign-chat.md), [`fast-agent-panel.md`](fast-agent-panel.md). diff --git a/CHANGELOG/pages/redesign-chat.md b/CHANGELOG/pages/redesign-chat.md index a30bd9561..565938299 100644 --- a/CHANGELOG/pages/redesign-chat.md +++ b/CHANGELOG/pages/redesign-chat.md @@ -3,6 +3,23 @@ Append-only lane for the public redesign chat, reproducible answer receipts, and their transition into an authenticated live conversation. Newest entries first. +## 2026-07-16 - Normalize structured answer typography + +Structured answer copy now uses the compact product reading scale instead of an oversized +display treatment: 14px at a calm 450 weight with a 1.55 line height. The answer card and +mobile transcript use the existing spacing tokens for a quieter, more consistent rhythm. + +This is presentation-only. Browser comparison confirmed identical answer text, visible +content, and accessibility snapshots; runtime provenance, controls, and touch targets are +unchanged. The focused chat guards, typecheck, design-system suite, and production build +pass, and the visual-rubric type-scale score improves from 0 to 1 without regression. + +**PR / canonical main commit**: pending CI-gated candidate. + +**Evidence state**: local responsive evidence remains outside git under `.qa/evidence/`. + +**Author**: Homen Shum + Codex. + ## 2026-07-16 - Continue an immutable answer in live chat Reproducible answer receipts now separate two intents that were previously collapsed: diff --git a/package.json b/package.json index 3a30bceb9..50b8f31c8 100644 --- a/package.json +++ b/package.json @@ -260,15 +260,22 @@ "@pdfme/schemas": "^5.5.8", "@polar-sh/checkout": "^0.2.0", "@polar-sh/sdk": "^0.47.0", + "@radix-ui/react-accordion": "^1.2.16", + "@radix-ui/react-checkbox": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.16", + "@radix-ui/react-context-menu": "^2.3.3", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-dropdown-menu": "^2.1.20", "@radix-ui/react-hover-card": "^1.1.19", + "@radix-ui/react-popover": "^1.1.19", "@radix-ui/react-progress": "^1.1.12", "@radix-ui/react-scroll-area": "^1.2.14", "@radix-ui/react-select": "^2.3.3", "@radix-ui/react-separator": "^1.1.11", "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-switch": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.17", + "@radix-ui/react-toggle-group": "^1.1.15", "@radix-ui/react-tooltip": "^1.2.12", "@radix-ui/react-use-controllable-state": "^1.2.3", "@remotion/cli": "4.0.486", diff --git a/src/components/ai-ui/PrimitiveMigration.guard.test.ts b/src/components/ai-ui/PrimitiveMigration.guard.test.ts new file mode 100644 index 000000000..47945bb72 --- /dev/null +++ b/src/components/ai-ui/PrimitiveMigration.guard.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = process.cwd(); +const SRC = join(ROOT, "src"); + +const migratedSurfaces = [ + "src/shared/components/DialogOverlay.tsx", + "src/features/chat/components/ChatShareSheet.tsx", + "src/features/chat/components/ThreadActionsSheet.tsx", + "src/features/reports/components/ReportShareSheet.tsx", + "src/features/research/ProofDrawer.tsx", + "src/features/research/components/FeedReaderModal.tsx", + "src/features/research/components/newsletter/EvidenceDrawer.tsx", + "src/features/controlPlane/components/DelegationModal.tsx", + "src/features/onboarding/components/OnboardingWizard.tsx", + "src/features/redesign/components/ShortcutsOverlay.tsx", + "src/features/redesign/components/MobileShell.tsx", + "src/shared/components/MiniEditorPopover.tsx", + "src/features/calendar/components/agenda/AgendaEditorPopover.tsx", + "src/features/calendar/components/agenda/AgendaMiniRow.tsx", + "src/layouts/chrome/HashtagQuickNotePopover.tsx", + "src/features/founder/components/FeedbackWidget.tsx", + "src/features/notebook/components/NotebookBlockFinder.tsx", + "src/features/redesign/components/CommandPalette.tsx", + "src/layouts/chrome/CommandPalette.tsx", + "src/features/redesign/components/MessageActions.tsx", + "src/features/redesign/components/RightInspector.tsx", + "src/shared/ui/SurfacePrimitives.tsx", + "src/features/redesign/surfaces/ReportsSurface.tsx", + "src/layouts/settings/SettingsModal.tsx", + "src/features/research/components/DeepDiveAccordion.tsx", + "src/features/documents/components/documentsHub/planner/PlannerModeToggle.tsx", + "src/features/redesign/surfaces/ChatSurface.tsx", + "src/layouts/CockpitLayout.tsx", + "src/features/product/components/ProductFileAssetPicker.tsx", + "src/features/documents/components/DocumentsPlannerOverlays.tsx", + "src/features/entities/views/EntityPage.tsx", +].map((path) => join(ROOT, path)); + +const migratedOverlaySurfaces = [ + "src/shared/components/DialogOverlay.tsx", + "src/features/chat/components/ChatShareSheet.tsx", + "src/features/chat/components/ThreadActionsSheet.tsx", + "src/features/reports/components/ReportShareSheet.tsx", + "src/features/research/ProofDrawer.tsx", + "src/features/research/components/FeedReaderModal.tsx", + "src/features/research/components/newsletter/EvidenceDrawer.tsx", + "src/features/controlPlane/components/DelegationModal.tsx", + "src/features/onboarding/components/OnboardingWizard.tsx", + "src/features/redesign/components/MobileShell.tsx", + "src/shared/components/MiniEditorPopover.tsx", + "src/features/calendar/components/agenda/AgendaEditorPopover.tsx", + "src/features/calendar/components/agenda/AgendaMiniRow.tsx", + "src/layouts/chrome/HashtagQuickNotePopover.tsx", + "src/features/founder/components/FeedbackWidget.tsx", + "src/features/notebook/components/NotebookBlockFinder.tsx", + "src/features/product/components/ProductFileAssetPicker.tsx", + "src/features/documents/components/DocumentsPlannerOverlays.tsx", + "src/features/entities/views/EntityPage.tsx", +].map((path) => join(ROOT, path)); + +function sourceFiles(dir = SRC): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (statSync(path).isDirectory()) return sourceFiles(path); + return /\.(?:ts|tsx)$/.test(entry) ? [path] : []; + }); +} + +function rel(path: string) { + return relative(ROOT, path).replaceAll("\\", "/"); +} + +function implementation(path: string) { + return readFileSync(path, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, ""); +} + +describe("shadcn/Radix primitive adoption", () => { + it("keeps migrated dialog semantics on shared primitives", () => { + const offenders = migratedOverlaySurfaces.flatMap((path) => { + const source = implementation(path); + if (!/aria-modal=|role=["']dialog["']/.test(source)) return []; + if (/components\/ai-ui\/(?:command|dialog|popover|sheet)/.test(source)) return []; + return [rel(path)]; + }); + + expect(offenders).toEqual([]); + }); + + it("does not reintroduce handwritten tab, listbox, or menu roles on migrated surfaces", () => { + const offenders = migratedSurfaces.flatMap((path) => + /role=["'](?:listbox|menu|menuitem|option|tab|tablist)["']/.test(implementation(path)) + ? [rel(path)] + : [], + ); + + expect(offenders).toEqual([]); + }); + + it("leaves portals only in editor decoration infrastructure", () => { + const portalFiles = sourceFiles() + .filter((path) => !/\.test\.tsx?$/.test(path)) + .filter((path) => implementation(path).includes("createPortal(")) + .map(rel) + .sort(); + + expect(portalFiles).toEqual([ + "src/features/editor/components/UnifiedEditor/PendingEditHighlights.tsx", + "src/features/editor/components/UnifiedEditor/ProposalInlineDecorations.tsx", + ]); + }); + + it("delegates global keyboard dismissal to primitives on migrated surfaces", () => { + const offenders = migratedOverlaySurfaces.flatMap((path) => + /addEventListener\(["']keydown["']/.test(implementation(path)) ? [rel(path)] : [], + ); + + expect(offenders).toEqual([]); + }); + + it("removes retired handwritten focus and bottom-sheet hooks", () => { + expect(existsSync(join(SRC, "hooks/useFocusTrap.ts"))).toBe(false); + expect(existsSync(join(SRC, "hooks/useKeyboardNavigation.tsx"))).toBe(false); + expect( + existsSync( + join(SRC, "features/agents/components/FastAgentPanel/useBottomSheet.ts"), + ), + ).toBe(false); + }); + + it("keeps the report action item primitive imported at runtime", () => { + const source = readFileSync( + join(SRC, "features/redesign/surfaces/ReportsSurface.tsx"), + "utf8", + ); + expect(source).toMatch(/DropdownMenuContent,\s+DropdownMenuItem,/); + expect(source).toContain(" readFileSync(resolve(process.cwd(), path), "utf8"); + +describe("residual control primitive migration", () => { + it.each([ + "src/features/controlPlane/views/LegalPage.tsx", + "src/features/deepSim/views/DecisionMemoView.tsx", + "src/features/research/views/ReportDetailWorkspace.tsx", + "src/features/research/views/ResearchHub.tsx", + "src/features/strategy/views/ExecutionTraceView.tsx", + "src/features/strategy/views/ProductDirectionMemoView.tsx", + ])("uses the shared Radix tabs contract in %s", (path) => { + const source = read(path); + expect(source).toContain("@/components/ai-ui/tabs"); + expect(source).toContain(" { + const toolbar = read("src/features/documents/components/FiltersToolsBar.tsx"); + const dataHook = read("src/features/documents/hooks/useDocumentData.ts"); + + expect(toolbar).toContain("@/components/ai-ui/toggle-group"); + expect(toolbar.match(/ { + const source = read("src/features/strategy/views/ExecutionTraceView.tsx"); + expect(source).toContain(" { + const source = read("src/features/product/components/ProductIntakeComposer.tsx"); + expect(source).toContain("@/components/ai-ui/toggle-group"); + expect(source.match(/ { + const source = read("src/features/redesign/surfaces/HomeV2PrototypeSurface.tsx"); + expect(source).toContain("@/components/ai-ui/toggle-group"); + expect(source).toContain("id: report.id"); + expect(source.match(/item\.id \?\? item\.name/g)).toHaveLength(2); + expect(source).not.toContain('role="tablist" aria-label="Filter reports by status"'); + }); +}); diff --git a/src/components/ai-ui/accordion.tsx b/src/components/ai-ui/accordion.tsx new file mode 100644 index 000000000..deac8e857 --- /dev/null +++ b/src/components/ai-ui/accordion.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ai-ui/checkbox.tsx b/src/components/ai-ui/checkbox.tsx new file mode 100644 index 000000000..6a36167cf --- /dev/null +++ b/src/components/ai-ui/checkbox.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ai-ui/command.tsx b/src/components/ai-ui/command.tsx index 8afb943d3..7bc4a0093 100644 --- a/src/components/ai-ui/command.tsx +++ b/src/components/ai-ui/command.tsx @@ -35,9 +35,11 @@ const CommandDialog = ({ children, ...props }: DialogProps) => { const CommandInput = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
+ React.ComponentPropsWithoutRef & { + wrapperClassName?: string + } +>(({ className, wrapperClassName, ...props }, ref) => ( +
, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) + React.ComponentPropsWithoutRef & { + accessibleLabel?: string + } +>(({ className, accessibleLabel, ...props }, ref) => { + const setRef = (node: React.ElementRef | null) => { + if (typeof ref === "function") ref(node) + else if (ref) ref.current = node + if (node && accessibleLabel) { + node.setAttribute("aria-label", accessibleLabel) + queueMicrotask(() => node.setAttribute("aria-label", accessibleLabel)) + } + } + + return ( + + ) +}) CommandList.displayName = CommandPrimitive.List.displayName diff --git a/src/components/ai-ui/context-menu.tsx b/src/components/ai-ui/context-menu.tsx new file mode 100644 index 000000000..6592970f9 --- /dev/null +++ b/src/components/ai-ui/context-menu.tsx @@ -0,0 +1,64 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + portalContainer?: HTMLElement | null + } +>(({ className, portalContainer, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +export { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} diff --git a/src/components/ai-ui/dialog.tsx b/src/components/ai-ui/dialog.tsx index 9dbeaa096..c6286053a 100644 --- a/src/components/ai-ui/dialog.tsx +++ b/src/components/ai-ui/dialog.tsx @@ -29,10 +29,14 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - + React.ComponentPropsWithoutRef & { + overlayClassName?: string + portalContainer?: HTMLElement | null + showCloseButton?: boolean + } +>(({ className, children, overlayClassName, portalContainer, showCloseButton = true, ...props }, ref) => ( + + {children} - - - Close - + {showCloseButton ? ( + + + Close + + ) : null} )) diff --git a/src/components/ai-ui/hover-card.tsx b/src/components/ai-ui/hover-card.tsx index 95643d9c7..83f263e15 100644 --- a/src/components/ai-ui/hover-card.tsx +++ b/src/components/ai-ui/hover-card.tsx @@ -11,16 +11,18 @@ const HoverCardContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - + + + )) HoverCardContent.displayName = HoverCardPrimitive.Content.displayName diff --git a/src/components/ai-ui/popover.tsx b/src/components/ai-ui/popover.tsx new file mode 100644 index 000000000..8ee8f036c --- /dev/null +++ b/src/components/ai-ui/popover.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root +const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverAnchor = PopoverPrimitive.Anchor +const PopoverClose = PopoverPrimitive.Close + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverAnchor, PopoverClose, PopoverContent } diff --git a/src/components/ai-ui/sheet.tsx b/src/components/ai-ui/sheet.tsx new file mode 100644 index 000000000..7ef4834f4 --- /dev/null +++ b/src/components/ai-ui/sheet.tsx @@ -0,0 +1,64 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = DialogPrimitive.Root +const SheetTrigger = DialogPrimitive.Trigger +const SheetClose = DialogPrimitive.Close +const SheetPortal = DialogPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = DialogPrimitive.Overlay.displayName + +type SheetContentProps = React.ComponentPropsWithoutRef & { + showCloseButton?: boolean +} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ className, children, showCloseButton = true, ...props }, ref) => ( + + {!props.asChild && props["aria-label"] && !props["aria-labelledby"] ? ( + {props["aria-label"]} + ) : null} + {children} + {showCloseButton ? ( + + + Close + + ) : null} + +)) +SheetContent.displayName = DialogPrimitive.Content.displayName + +const SheetTitle = DialogPrimitive.Title +const SheetDescription = DialogPrimitive.Description + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetPortal, + SheetOverlay, + SheetContent, + SheetTitle, + SheetDescription, +} diff --git a/src/components/ai-ui/switch.tsx b/src/components/ai-ui/switch.tsx new file mode 100644 index 000000000..f40fddbde --- /dev/null +++ b/src/components/ai-ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitive.Root.displayName + +export { Switch } diff --git a/src/components/ai-ui/tabs.tsx b/src/components/ai-ui/tabs.tsx new file mode 100644 index 000000000..9aa22a0b9 --- /dev/null +++ b/src/components/ai-ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/src/components/ai-ui/toggle-group.tsx b/src/components/ai-ui/toggle-group.tsx new file mode 100644 index 000000000..732b62753 --- /dev/null +++ b/src/components/ai-ui/toggle-group.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; + +import { cn } from "@/lib/utils"; + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index 2bbd560b3..803f3e066 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -1,174 +1,2 @@ -/** - * Toast - Animated notification system - * - * Features: - * - Slide-in animation from top-right - * - Auto-dismiss with progress bar - * - Success/error/warning/info variants - * - Stacking support - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-react'; -import { cn } from '../../lib/utils'; -import { useMotionConfig } from '@/lib/motion'; - -type ToastType = 'success' | 'error' | 'warning' | 'info'; - -interface Toast { - id: string; - type: ToastType; - title: string; - message?: string; - duration?: number; -} - -interface ToastContextType { - toasts: Toast[]; - addToast: (toast: Omit) => void; - removeToast: (id: string) => void; - success: (title: string, message?: string) => void; - error: (title: string, message?: string) => void; - warning: (title: string, message?: string) => void; - info: (title: string, message?: string) => void; -} - -const ToastContext = createContext(null); - -export function useToast() { - const context = useContext(ToastContext); - if (!context) { - throw new Error('useToast must be used within a ToastProvider'); - } - return context; -} - -const toastConfig = { - success: { - icon: CheckCircle2, - bg: 'bg-green-50 border-green-200', - iconColor: 'text-green-600', - titleColor: 'text-green-900', - progressColor: 'bg-green-500', - }, - error: { - icon: XCircle, - bg: 'bg-red-50 border-red-200', - iconColor: 'text-red-600', - titleColor: 'text-red-900', - progressColor: 'bg-red-500', - }, - warning: { - icon: AlertTriangle, - bg: 'bg-amber-50 border-amber-200', - iconColor: 'text-amber-600', - titleColor: 'text-amber-900', - progressColor: 'bg-amber-500', - }, - info: { - icon: Info, - bg: 'bg-blue-50 border-blue-200', - iconColor: 'text-blue-600', - titleColor: 'text-blue-900', - progressColor: 'bg-blue-500', - }, -}; - -function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: () => void }) { - const config = toastConfig[toast.type]; - const Icon = config.icon; - const duration = toast.duration ?? 4000; - const { instant, transition } = useMotionConfig(); - - useEffect(() => { - const timer = setTimeout(onRemove, duration); - return () => clearTimeout(timer); - }, [duration, onRemove]); - - return ( - - -
-

{toast.title}

- {toast.message && ( -

{toast.message}

- )} -
- - - {/* Progress bar */} - - - ); -} - -export function ToastProvider({ children }: { children: React.ReactNode }) { - const [toasts, setToasts] = useState([]); - - const addToast = useCallback((toast: Omit) => { - const id = Math.random().toString(36).slice(2); - setToasts((prev) => [...prev, { ...toast, id }]); - }, []); - - const removeToast = useCallback((id: string) => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }, []); - - const success = useCallback((title: string, message?: string) => { - addToast({ type: 'success', title, message }); - }, [addToast]); - - const error = useCallback((title: string, message?: string) => { - addToast({ type: 'error', title, message, duration: 6000 }); - }, [addToast]); - - const warning = useCallback((title: string, message?: string) => { - addToast({ type: 'warning', title, message }); - }, [addToast]); - - const info = useCallback((title: string, message?: string) => { - addToast({ type: 'info', title, message }); - }, [addToast]); - - return ( - - {children} - - {/* Toast container */} -
- - {toasts.map((toast) => ( - removeToast(toast.id)} - /> - ))} - -
-
- ); -} - -export default ToastProvider; +export { ToastProvider, useToast } from "@/shared/ui/Toast"; +export { ToastProvider as default } from "@/shared/ui/Toast"; diff --git a/src/features/agents/components/FastAgentPanel/FastAgentPanel.UIMessageBubble.tsx b/src/features/agents/components/FastAgentPanel/FastAgentPanel.UIMessageBubble.tsx index 29912c904..6dba383aa 100644 --- a/src/features/agents/components/FastAgentPanel/FastAgentPanel.UIMessageBubble.tsx +++ b/src/features/agents/components/FastAgentPanel/FastAgentPanel.UIMessageBubble.tsx @@ -1824,7 +1824,6 @@ export function FastAgentUIMessageBubble({ const COLLAPSE_THRESHOLD = 1200; // chars (~300 words) // Smart actions menu - const [showSmartActions, setShowSmartActions] = useState(false); // Markdown raw/rendered toggle const [showRawMarkdown, setShowRawMarkdown] = useState(false); diff --git a/src/features/agents/components/FastAgentPanel/useBottomSheet.ts b/src/features/agents/components/FastAgentPanel/useBottomSheet.ts deleted file mode 100644 index 1045ebe6c..000000000 --- a/src/features/agents/components/FastAgentPanel/useBottomSheet.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * useBottomSheet — manages a 3-state mobile bottom sheet (peek / half / full). - * - * Touch-drag handling: - * - Swipe up → expand (peek→half→full) - * - Swipe down → collapse (full→half→peek) - * - Fast fling (velocity > threshold) snaps to next state in fling direction - * - Slow drag snaps to nearest state based on current translateY position - */ - -import { useState, useRef, useCallback, useEffect } from 'react'; - -export type BottomSheetState = 'closed' | 'peek' | 'half' | 'full'; - -/** Pixel offsets from the TOP of the 95vh container for each state. - * These correspond to the CSS translateY values. */ -const SHEET_HEIGHT_VH = 95; -const PEEK_HEIGHT_PX = 80; -const HALF_VH = 60; - -function getTranslateYForState(state: BottomSheetState, viewportHeight: number): number { - const sheetHeight = viewportHeight * (SHEET_HEIGHT_VH / 100); - switch (state) { - case 'closed': return sheetHeight; - case 'peek': return sheetHeight - PEEK_HEIGHT_PX; - case 'half': return sheetHeight - viewportHeight * (HALF_VH / 100); - case 'full': return 0; - } -} - -const VELOCITY_THRESHOLD = 0.5; // px/ms — above this = fling - -interface UseBottomSheetOptions { - /** Called when the sheet reaches 'closed' state (via swipe-down past peek). */ - onClose?: () => void; - /** Initial state when opened. Default: 'half' */ - initialState?: BottomSheetState; -} - -export function useBottomSheet(isOpen: boolean, options: UseBottomSheetOptions = {}) { - const { onClose, initialState = 'half' } = options; - const [sheetState, setSheetState] = useState(isOpen ? initialState : 'closed'); - const [isDragging, setIsDragging] = useState(false); - - // Track the live translateY during drag (CSS inline style override) - const [dragTranslateY, setDragTranslateY] = useState(null); - - const touchStartY = useRef(0); - const touchStartTime = useRef(0); - const touchStartTranslateY = useRef(0); - - // Sync open/close - useEffect(() => { - if (isOpen && sheetState === 'closed') { - setSheetState(initialState); - } else if (!isOpen && sheetState !== 'closed') { - setSheetState('closed'); - } - }, [isOpen, initialState]); // eslint-disable-line react-hooks/exhaustive-deps - - const snapToState = useCallback((nextState: BottomSheetState) => { - setDragTranslateY(null); - setIsDragging(false); - setSheetState(nextState); - if (nextState === 'closed') { - onClose?.(); - } - }, [onClose]); - - const onTouchStart = useCallback((e: React.TouchEvent) => { - const touch = e.touches[0]; - if (!touch) return; - const vh = window.innerHeight; - touchStartY.current = touch.clientY; - touchStartTime.current = Date.now(); - touchStartTranslateY.current = getTranslateYForState(sheetState, vh); - setIsDragging(true); - }, [sheetState]); - - const onTouchMove = useCallback((e: React.TouchEvent) => { - const touch = e.touches[0]; - if (!touch) return; - const deltaY = touch.clientY - touchStartY.current; - const newTranslateY = Math.max(0, touchStartTranslateY.current + deltaY); - setDragTranslateY(newTranslateY); - }, []); - - const onTouchEnd = useCallback((e: React.TouchEvent) => { - const touch = e.changedTouches[0]; - if (!touch) { - setIsDragging(false); - setDragTranslateY(null); - return; - } - - const deltaY = touch.clientY - touchStartY.current; - const elapsed = Date.now() - touchStartTime.current; - const velocity = elapsed > 0 ? deltaY / elapsed : 0; // positive = downward - - const vh = window.innerHeight; - const currentTranslateY = Math.max(0, touchStartTranslateY.current + deltaY); - - // Determine snap target - const states: BottomSheetState[] = ['full', 'half', 'peek', 'closed']; - const stateYs = states.map(s => getTranslateYForState(s, vh)); - - // If velocity is above threshold, snap in the fling direction - if (Math.abs(velocity) > VELOCITY_THRESHOLD) { - const currentIdx = states.indexOf(sheetState); - if (velocity > 0) { - // Fling down → next smaller state - const nextIdx = Math.min(currentIdx + 1, states.length - 1); - snapToState(states[nextIdx]); - } else { - // Fling up → next larger state - const nextIdx = Math.max(currentIdx - 1, 0); - snapToState(states[nextIdx]); - } - } else { - // Snap to nearest state by translateY distance - let closest = states[0]; - let minDist = Infinity; - for (let i = 0; i < states.length; i++) { - const dist = Math.abs(currentTranslateY - stateYs[i]); - if (dist < minDist) { - minDist = dist; - closest = states[i]; - } - } - snapToState(closest); - } - }, [sheetState, snapToState]); - - /** Dismiss to peek when backdrop is tapped */ - const onBackdropTap = useCallback(() => { - if (sheetState === 'full' || sheetState === 'half') { - snapToState('peek'); - } - }, [sheetState, snapToState]); - - /** Expand to half if in peek (tap on the handle area) */ - const onHandleTap = useCallback(() => { - if (sheetState === 'peek') { - snapToState('half'); - } else if (sheetState === 'half') { - snapToState('full'); - } - }, [sheetState, snapToState]); - - // Compute CSS class name for the state - const sheetClassName = - sheetState === 'closed' ? 'sheet-closed' : - sheetState === 'peek' ? 'sheet-peek' : - sheetState === 'half' ? 'sheet-half' : 'sheet-full'; - - // Inline style override during drag - const dragStyle: React.CSSProperties | undefined = - isDragging && dragTranslateY !== null - ? { transform: `translateY(${dragTranslateY}px)` } - : undefined; - - return { - sheetState, - setSheetState: snapToState, - isDragging, - sheetClassName, - dragStyle, - handleProps: { - onTouchStart, - onTouchMove, - onTouchEnd, - }, - onBackdropTap, - onHandleTap, - }; -} diff --git a/src/features/calendar/components/agenda/AgendaEditorPopover.tsx b/src/features/calendar/components/agenda/AgendaEditorPopover.tsx index 76a91ecad..9977b9113 100644 --- a/src/features/calendar/components/agenda/AgendaEditorPopover.tsx +++ b/src/features/calendar/components/agenda/AgendaEditorPopover.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import ReactDOM from "react-dom"; +import { useMemo } from "react"; import type { Id } from "../../../../../convex/_generated/dataModel"; import DualCreateMiniPanel from "@/features/documents/editors/DualCreateMiniPanel"; import DualEditMiniPanel from "@/features/documents/editors/DualEditMiniPanel"; import PopoverMiniEditor from "@/features/documents/editors/PopoverMiniEditor"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ai-ui/popover"; type Props = { isOpen: boolean; @@ -19,111 +19,27 @@ type Props = { documentIdForAssociation?: Id<"documents"> | null; }; -const portalRootId = "agenda-editor-portal-root"; - -function ensurePortalRoot(): HTMLElement { - let root = document.getElementById(portalRootId); - if (!root) { - root = document.createElement("div"); - root.id = portalRootId; - document.body.appendChild(root); - } - return root; -} - -function useAnchoredPosition(anchorEl: HTMLElement | null, deps: React.DependencyList = []) { - const [pos, setPos] = useState<{ top: number; left: number; placement: "bottom" | "top" } | null>(null); - - const recompute = React.useCallback(() => { - if (!anchorEl) return; - // Prefer anchoring to the inner agenda mini row when present - const inner = anchorEl.querySelector('[data-agenda-mini-row]'); - const target: HTMLElement = inner ?? anchorEl; - const rect = target.getBoundingClientRect(); - const margin = 8; - const width = Math.min(560, Math.max(360, rect.width)); - const height = 500; // approximate outer height - - // Default: open to the right of the anchor (east placement) - // Note: popover is fixed-position; use viewport coordinates directly (no scroll offsets). - let top = rect.top; - // Clamp vertically within viewport - if (top + height > window.innerHeight - margin) { - top = Math.max(margin, window.innerHeight - margin - height); - } - - // Prefer opening to the RIGHT of the anchor - let left = rect.right + margin; - const viewportRight = window.innerWidth - margin; - // If not enough space on the right, open to the LEFT of the anchor instead of overlapping - if (left + width > viewportRight) { - left = rect.left - width - margin; - } - // Clamp within viewport bounds - const viewportLeft = margin; - if (left < viewportLeft) left = viewportLeft; - if (left + width > viewportRight) { - left = Math.max(viewportLeft, viewportRight - width); - } - - setPos({ top, left, placement: "bottom" }); - }, [anchorEl]); - - useLayoutEffect(() => { - recompute(); - const onResize = () => recompute(); - const onScroll = () => recompute(); - window.addEventListener("resize", onResize); - window.addEventListener("scroll", onScroll, true); - return () => { - window.removeEventListener("resize", onResize); - window.removeEventListener("scroll", onScroll, true); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [anchorEl, ...deps]); - - return pos; -} - export default function AgendaEditorPopover({ isOpen, anchorEl, onClose, kind, eventId, taskId, dateMs, defaultKind: _defaultKind, defaultTitle, defaultAllDay, documentIdForAssociation }: Props) { - const portalRoot = useMemo(() => (typeof window !== "undefined" ? ensurePortalRoot() : null), []); - const containerRef = useRef(null); - const pos = useAnchoredPosition(anchorEl, [isOpen, eventId, taskId, dateMs]); - - useEffect(() => { - if (!isOpen) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [isOpen, onClose]); - - // Close on outside click - useEffect(() => { - if (!isOpen) return; - const onDocClick = (e: MouseEvent) => { - const el = containerRef.current; - if (!el) return; - if (e.target instanceof Node && !el.contains(e.target)) { - onClose(); - } - }; - document.addEventListener("mousedown", onDocClick); - return () => document.removeEventListener("mousedown", onDocClick); - }, [isOpen, onClose]); - - // Do not render until we have a computed position to avoid flashing at (0,0) - if (!isOpen || !anchorEl || !portalRoot || !pos) return null; + const virtualTarget = useMemo( + () => anchorEl?.querySelector('[data-agenda-mini-row]') ?? anchorEl, + [anchorEl], + ); + const virtualAnchorRef = useMemo(() => ({ current: virtualTarget }), [virtualTarget]); + if (!anchorEl) return null; - const content = ( -
{ if (!next) onClose(); }}> + + event.preventDefault()} + className="z-[9999] w-[min(560px,calc(100vw-24px))] p-0 shadow-2xl rounded-lg border border-edge bg-surface-secondary" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} @@ -154,8 +70,7 @@ export default function AgendaEditorPopover({ isOpen, anchorEl, onClose, kind, e /> )}
-
+ + ); - - return ReactDOM.createPortal(content, portalRoot); } diff --git a/src/features/calendar/components/agenda/AgendaMiniRow.test.tsx b/src/features/calendar/components/agenda/AgendaMiniRow.test.tsx new file mode 100644 index 000000000..dc479f34c --- /dev/null +++ b/src/features/calendar/components/agenda/AgendaMiniRow.test.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AgendaMiniRow } from "./AgendaMiniRow"; + +describe("AgendaMiniRow", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("preserves row keyboard selection and keeps checkbox changes isolated", () => { + const onSelect = vi.fn(); + const onToggleComplete = vi.fn(); + + const { container } = render( + , + ); + + const row = container.querySelector("[data-agenda-mini-row]"); + expect(row).not.toBeNull(); + + fireEvent.keyDown(row!, { key: "Enter" }); + fireEvent.keyDown(row!, { key: " " }); + expect(onSelect).toHaveBeenNthCalledWith(1, "task-1"); + expect(onSelect).toHaveBeenNthCalledWith(2, "task-1"); + + onSelect.mockClear(); + fireEvent.click( + screen.getByRole("checkbox", { name: "Mark task as done" }), + ); + expect(onToggleComplete).toHaveBeenCalledWith("task-1", true); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("opens the Radix hover card from pointer and focus while preserving proposed actions", () => { + vi.useFakeTimers(); + const onAcceptProposed = vi.fn(); + const onDeclineProposed = vi.fn(); + + const { container } = render( + , + ); + + const row = container.querySelector("[data-agenda-mini-row]"); + expect(row).not.toBeNull(); + + fireEvent.pointerEnter(row!, { pointerType: "mouse" }); + act(() => vi.advanceTimersByTime(100)); + + expect( + screen.getByText("Interview proposed from the candidate email."), + ).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + fireEvent.click(screen.getByRole("button", { name: "Decline" })); + expect(onAcceptProposed).toHaveBeenCalledWith("event-1"); + expect(onDeclineProposed).toHaveBeenCalledWith("event-1"); + + fireEvent.pointerLeave(row!, { pointerType: "mouse" }); + act(() => vi.advanceTimersByTime(130)); + fireEvent.focus(row!); + act(() => vi.advanceTimersByTime(100)); + expect( + screen.getByText("Interview proposed from the candidate email."), + ).toBeVisible(); + }); +}); diff --git a/src/features/calendar/components/agenda/AgendaMiniRow.tsx b/src/features/calendar/components/agenda/AgendaMiniRow.tsx index 9d017479f..1960df87f 100644 --- a/src/features/calendar/components/agenda/AgendaMiniRow.tsx +++ b/src/features/calendar/components/agenda/AgendaMiniRow.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { createPortal } from "react-dom"; import type { Id } from "../../../../../convex/_generated/dataModel"; import { Mail, CalendarDays } from "lucide-react"; +import { Checkbox } from "@/components/ai-ui/checkbox"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ai-ui/hover-card"; export type AgendaMiniKind = "task" | "event" | "holiday" | "note"; @@ -161,74 +162,10 @@ export const AgendaMiniRow: React.FC = React.memo(function A ); const time = kind === "event" ? eventTime(item) : undefined; const id = String(item?._id ?? ""); - const [hovered, setHovered] = React.useState(false); - const anchorRef = React.useRef(null); - const [pos, setPos] = React.useState<{ top: number; left: number } | null>(null); - const openHoverTimerRef = React.useRef(null); - const closeHoverTimerRef = React.useRef(null); - - const updatePosition = React.useCallback(() => { - const el = anchorRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const width = 256; // w-64 - const margin = 8; - let left = rect.right - width; // align right edges - if (left < margin) left = margin; - if (left + width > window.innerWidth - margin) left = window.innerWidth - margin - width; - const top = rect.bottom + 4; // mt-1 - setPos({ top, left }); - }, []); - - React.useEffect(() => { - if (!hovered) return; - updatePosition(); - const onScroll = () => updatePosition(); - const onResize = () => updatePosition(); - window.addEventListener("scroll", onScroll, true); - window.addEventListener("resize", onResize); - return () => { - window.removeEventListener("scroll", onScroll, true); - window.removeEventListener("resize", onResize); - }; - }, [hovered, updatePosition]); - - React.useEffect(() => { - return () => { - if (openHoverTimerRef.current) window.clearTimeout(openHoverTimerRef.current); - if (closeHoverTimerRef.current) window.clearTimeout(closeHoverTimerRef.current); - }; - }, []); - - const openHoverCard = React.useCallback(() => { - if (closeHoverTimerRef.current) { - window.clearTimeout(closeHoverTimerRef.current); - closeHoverTimerRef.current = null; - } - if (hovered) return; - if (openHoverTimerRef.current) window.clearTimeout(openHoverTimerRef.current); - openHoverTimerRef.current = window.setTimeout(() => { - updatePosition(); - setHovered(true); - openHoverTimerRef.current = null; - }, 90); - }, [hovered, updatePosition]); - - const closeHoverCard = React.useCallback(() => { - if (openHoverTimerRef.current) { - window.clearTimeout(openHoverTimerRef.current); - openHoverTimerRef.current = null; - } - if (closeHoverTimerRef.current) window.clearTimeout(closeHoverTimerRef.current); - closeHoverTimerRef.current = window.setTimeout(() => { - setHovered(false); - closeHoverTimerRef.current = null; - }, 120); - }, []); - return ( -
+ +
= React.memo(function A tabIndex={onSelect ? 0 : -1} onClick={() => onSelect?.(id)} onKeyDown={(e) => { if (onSelect && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); onSelect(id); } }} - onMouseEnter={openHoverCard} - onMouseLeave={closeHoverCard} - onFocus={openHoverCard} - onBlur={closeHoverCard} >
{kind === 'task' && showCheckbox ? ( - { - e.stopPropagation(); + onCheckedChange={(checked) => { const tid = (item?._id ?? "") as Id; - onToggleComplete?.(tid, e.target.checked); + onToggleComplete?.(tid, checked === true); }} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} aria-label={String(item?.status ?? 'todo') === 'done' ? 'Mark task as not done' : 'Mark task as done'} - className="h-3.5 w-3.5 rounded border-edge text-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]/50 bg-surface dark:bg-surface" + className="h-3.5 w-3.5 rounded border-edge bg-surface text-[var(--accent-primary)] focus-visible:ring-1 focus-visible:ring-[var(--accent-primary)]/50 dark:bg-surface" /> ) : null} = React.memo(function A {title}
- {hovered && pos && typeof document !== 'undefined' && createPortal( -
+
+ + {/* Header */}
{title}
@@ -383,10 +316,8 @@ export const AgendaMiniRow: React.FC = React.memo(function A )}
-
, - document.body - )} -
+ + ); }); diff --git a/src/features/chat/components/ChatShareSheet.tsx b/src/features/chat/components/ChatShareSheet.tsx index 62c4f2870..5cdd4f427 100644 --- a/src/features/chat/components/ChatShareSheet.tsx +++ b/src/features/chat/components/ChatShareSheet.tsx @@ -1,8 +1,6 @@ -import { memo, useEffect } from "react"; -import { createPortal } from "react-dom"; +import { memo } from "react"; import { Check, ExternalLink, Link2, Send, X } from "lucide-react"; -import { AnimatePresence, motion } from "framer-motion"; -import { useMotionConfig } from "@/lib/motion"; +import { Sheet, SheetContent, SheetOverlay, SheetPortal } from "@/components/ai-ui/sheet"; export interface ChatShareSheetProps { open: boolean; @@ -29,61 +27,18 @@ export const ChatShareSheet = memo(function ChatShareSheet({ canSystemShare = false, canOpenCanonicalReport = false, }: ChatShareSheetProps) { - const { transition } = useMotionConfig(); - const portalRoot = typeof document !== "undefined" ? document.body : null; - - useEffect(() => { - if (!open) return; - const handleKey = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - onClose(); - } - }; - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - }, [onClose, open]); - - useEffect(() => { - if (!open || typeof document === "undefined") return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = previousOverflow; - }; - }, [open]); - - if (!portalRoot) return null; - - return createPortal( - - {open ? ( - - - { if (!next) onClose(); }}> + + + +
+ + + ); }); diff --git a/src/features/controlPlane/components/DelegationModal.tsx b/src/features/controlPlane/components/DelegationModal.tsx index b249ad0dc..bb06af65d 100644 --- a/src/features/controlPlane/components/DelegationModal.tsx +++ b/src/features/controlPlane/components/DelegationModal.tsx @@ -26,6 +26,7 @@ import { Clock, Send, } from "lucide-react"; +import { DialogOverlay } from "@/shared/components/DialogOverlay"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -241,16 +242,16 @@ function DelegationModalInner({ }); }, [buildBrief, packetSummary]); - if (!isOpen) return null; - return ( -
-
+
{/* Close button */}
-
+ ); } diff --git a/src/features/controlPlane/views/LegalPage.tsx b/src/features/controlPlane/views/LegalPage.tsx index 6cc9048b4..5f0d1fdc3 100644 --- a/src/features/controlPlane/views/LegalPage.tsx +++ b/src/features/controlPlane/views/LegalPage.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useState } from "react"; import { useRevealOnMount } from "@/hooks/useRevealOnMount"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ai-ui/tabs"; type LegalTab = "terms" | "privacy"; @@ -181,34 +182,44 @@ export const LegalPage = memo(function LegalPage() {

+ setActiveTab(value as LegalTab)} + className="contents" + > {/* Tabs */} -
+ {TABS.map((tab) => ( - + ))} -
+ {/* Content */} -
- {activeTab === "terms" ? : } -
+ + + + + +
); diff --git a/src/features/deepSim/views/DecisionMemoView.tsx b/src/features/deepSim/views/DecisionMemoView.tsx index d3f182cce..3d9283aa8 100644 --- a/src/features/deepSim/views/DecisionMemoView.tsx +++ b/src/features/deepSim/views/DecisionMemoView.tsx @@ -14,6 +14,7 @@ import { Variable, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ai-ui/tabs"; import type { DeepSimClaim, DeepSimCounterModel, @@ -388,6 +389,15 @@ function DecisionMemoViewInner({ memo: overrideMemo }: { memo?: DeepSimMemo }) { setSearchParams(next, { replace: true }); }; + const handleWorkflowChange = (nextWorkflow: string) => { + const matchingFixture = DEEP_SIM_FIXTURE_OPTIONS.find((option) => + nextWorkflow === "founder_strategy" + ? option.key.includes("founder") || option.key.includes("strategy") + : !option.key.includes("founder") && !option.key.includes("strategy"), + ); + if (matchingFixture) handleFixtureChange(matchingFixture.key); + }; + // Render immediately visible — no stagger gating on IntersectionObserver const stagger = useCallback( (_delay: string): React.CSSProperties => ({}), @@ -465,35 +475,30 @@ function DecisionMemoViewInner({ memo: overrideMemo }: { memo?: DeepSimMemo }) { {/* Workflow tabs */} -
+ + {WORKFLOW_TABS.map((tab) => ( - + ))} -
+ {/* Tab panel content */} -
+ {/* Stats row */}
@@ -620,7 +625,8 @@ function DecisionMemoViewInner({ memo: overrideMemo }: { memo?: DeepSimMemo }) {
- {/* end tabpanel */} + + ); diff --git a/src/features/documents/components/DocumentsHomeHub.tsx b/src/features/documents/components/DocumentsHomeHub.tsx index 397f2d860..78646b720 100644 --- a/src/features/documents/components/DocumentsHomeHub.tsx +++ b/src/features/documents/components/DocumentsHomeHub.tsx @@ -248,9 +248,7 @@ export function DocumentsHomeHub({ orderDocsBy: docWs.orderDocsBy, saveOrderForFilter: docWs.saveOrderForFilter, saveOrderForSegmented: docWs.saveOrderForSegmented, - filterButtonRefs: docWs.filterButtonRefs, - onFilterKeyDown: docWs.onFilterKeyDown, - }), [docWs.docOrderByFilter, docWs.setDocOrderByFilter, docWs.segmentedOrderByGroup, docWs.setSegmentedOrderByGroup, docWs.orderDocsBy, docWs.saveOrderForFilter, docWs.saveOrderForSegmented, docWs.filterButtonRefs, docWs.onFilterKeyDown]); + }), [docWs.docOrderByFilter, docWs.setDocOrderByFilter, docWs.segmentedOrderByGroup, docWs.setSegmentedOrderByGroup, docWs.orderDocsBy, docWs.saveOrderForFilter, docWs.saveOrderForSegmented]); const documentUploadSlice = useMemo(() => ({ isUploading: docWs.isUploading, diff --git a/src/features/documents/components/DocumentsPlannerOverlays.tsx b/src/features/documents/components/DocumentsPlannerOverlays.tsx index 38ffa9651..b8e7b97e7 100644 --- a/src/features/documents/components/DocumentsPlannerOverlays.tsx +++ b/src/features/documents/components/DocumentsPlannerOverlays.tsx @@ -10,9 +10,21 @@ * local state, refs) remain as props. */ -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useMemo } from "react"; import type { Id } from "../../../../convex/_generated/dataModel"; import { X } from "lucide-react"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@/components/ai-ui/dialog"; +import { + Popover, + PopoverAnchor, + PopoverClose, + PopoverContent, +} from "@/components/ai-ui/popover"; import { usePlannerEditorCtx, usePlannerViewCtx, @@ -58,6 +70,20 @@ export default function DocumentsPlannerOverlays({ miniEditorDocId, closeMiniEditor, }: DocumentsPlannerOverlaysProps) { + const inlineCreateAnchorRef = useMemo( + () => ({ + current: { + getBoundingClientRect: () => + new DOMRect( + typeof window === "undefined" ? 0 : window.innerWidth - 24, + typeof window === "undefined" ? 0 : window.innerHeight - 24, + 0, + 0, + ), + }, + }), + [], + ); // ── Context slices ────────────────────────────────────────────────────────── const { agendaPopover, @@ -86,39 +112,38 @@ export default function DocumentsPlannerOverlays({ return ( <> {/* New Task Modal */} - {showNewTaskModal && ( -
{ - if (!isSubmittingTask) setShowNewTaskModal(false); - }} - > -
e.stopPropagation()} + { + if (!open && !isSubmittingTask) setShowNewTaskModal(false); + }} + > + { if (isSubmittingTask) event.preventDefault(); }} + onPointerDownOutside={(event) => { if (isSubmittingTask) event.preventDefault(); }} aria-busy={isSubmittingTask} >
-

New Task -

+ - + + +
-
- - )} + + {/* Unified Task Editor Panel (overlay only outside list mode) */} {selectedTaskId && mode !== "list" && ( @@ -231,14 +255,22 @@ export default function DocumentsPlannerOverlays({ )} {/* Centralized Agenda Create (floating popover) */} - {inlineCreate && ( -
-
+ { if (!open) setInlineCreate(null); }} + > + + {inlineCreate ? ( + +
Create on{" "} @@ -249,13 +281,15 @@ export default function DocumentsPlannerOverlays({ })}
- + + +
@@ -277,8 +311,9 @@ export default function DocumentsPlannerOverlays({
-
- )} + + ) : null} + {/* Mini Editor Popover */} {!hideCalendarCard && ( diff --git a/src/features/documents/components/DocumentsTabContent.tsx b/src/features/documents/components/DocumentsTabContent.tsx index 97cbb38e5..735f62372 100644 --- a/src/features/documents/components/DocumentsTabContent.tsx +++ b/src/features/documents/components/DocumentsTabContent.tsx @@ -49,8 +49,6 @@ export interface DocumentsTabContentProps { setDocViewMode: (mode: ViewMode) => void; documentTypes: DocumentType[]; countsByFilter: Record; - filterButtonRefs: React.MutableRefObject>; - onFilterKeyDown: (e: React.KeyboardEvent) => void; // Documents data filteredDocuments: DocumentCardData[]; @@ -143,8 +141,6 @@ export const DocumentsTabContent = memo(function DocumentsTabContent( setDocViewMode, documentTypes, countsByFilter, - filterButtonRefs, - onFilterKeyDown, filteredDocuments, orderedDocuments, groupedDocuments, @@ -399,8 +395,6 @@ export const DocumentsTabContent = memo(function DocumentsTabContent( filter={filter} setFilter={setFilter} countsByFilter={countsByFilter} - filterButtonRefs={filterButtonRefs} - onFilterKeyDown={onFilterKeyDown} viewMode={docViewMode} setViewMode={setDocViewMode} loggedInUser={loggedInUser} diff --git a/src/features/documents/components/DocumentsWorkspaceSurface.tsx b/src/features/documents/components/DocumentsWorkspaceSurface.tsx index 4f350f314..e374fbed9 100644 --- a/src/features/documents/components/DocumentsWorkspaceSurface.tsx +++ b/src/features/documents/components/DocumentsWorkspaceSurface.tsx @@ -73,8 +73,6 @@ export default function DocumentsWorkspaceSurface({ orderDocsBy, saveOrderForFilter, saveOrderForSegmented, - filterButtonRefs, - onFilterKeyDown, } = order; const { handleChatWithFile, @@ -167,8 +165,6 @@ export default function DocumentsWorkspaceSurface({ setDocViewMode: overlay.setDocViewMode, documentTypes, countsByFilter: data.countsByFilter as Record, - filterButtonRefs, - onFilterKeyDown, filteredDocuments: data.filteredDocuments, orderedDocuments: data.orderedDocuments, groupedDocuments: data.groupedDocuments, diff --git a/src/features/documents/components/FiltersToolsBar.tsx b/src/features/documents/components/FiltersToolsBar.tsx index 481003d27..3c2ed6a04 100644 --- a/src/features/documents/components/FiltersToolsBar.tsx +++ b/src/features/documents/components/FiltersToolsBar.tsx @@ -4,6 +4,7 @@ import React, { memo } from "react"; import { Star, Trash2, X, Plus, Loader2, Sparkles } from "lucide-react"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ai-ui/toggle-group"; export type DocumentType = { id: string; @@ -18,8 +19,6 @@ type Props = { filter: string; setFilter: (id: string) => void; countsByFilter: Record; - filterButtonRefs: React.MutableRefObject>; - onFilterKeyDown: (e: React.KeyboardEvent) => void; viewMode: ViewMode; setViewMode: (mode: ViewMode) => void; onUploadClick: () => void; @@ -40,8 +39,6 @@ const FiltersToolsBar = memo(function FiltersToolsBar(props: Props) { filter, setFilter, countsByFilter, - filterButtonRefs, - onFilterKeyDown, viewMode, setViewMode, loggedInUser, @@ -58,30 +55,26 @@ const FiltersToolsBar = memo(function FiltersToolsBar(props: Props) { return (
-
value && setFilter(value)} className="flex flex-wrap items-center gap-2" - role="tablist" aria-label="Document filters" - onKeyDown={onFilterKeyDown} > - {documentTypes.map((t, idx) => { + {documentTypes.map((t) => { const isActive = filter === t.id; const count = countsByFilter[t.id] ?? 0; return ( - + ); })} -
+
-
- - - -
+ + - - - - -
+ + + + + + + + + + ); } diff --git a/src/features/documents/context/types.ts b/src/features/documents/context/types.ts index 00c2e50cb..a49b4f6ac 100644 --- a/src/features/documents/context/types.ts +++ b/src/features/documents/context/types.ts @@ -94,10 +94,6 @@ export interface DocumentOrderSlice { saveOrderForFilter: (value: string) => void; /** Persist order preference for segmented context */ saveOrderForSegmented: (value: string) => void; - /** Refs for filter pill buttons (keyboard navigation) */ - filterButtonRefs: React.MutableRefObject>; - /** Keyboard handler for filter pill navigation */ - onFilterKeyDown: (e: React.KeyboardEvent, filter: string) => void; } // ─── Document Upload Slice ─────────────────────────────────────────────────── diff --git a/src/features/documents/hooks/useDocumentData.ts b/src/features/documents/hooks/useDocumentData.ts index 583bdb6cf..4f09ffae4 100644 --- a/src/features/documents/hooks/useDocumentData.ts +++ b/src/features/documents/hooks/useDocumentData.ts @@ -4,7 +4,7 @@ * Document data normalization, filtering, and computed document sets. */ -import { useState, useEffect, useMemo, useRef, useCallback } from "react"; +import { useState, useEffect, useMemo } from "react"; import type { DocumentCardData } from "@features/documents/components/documentsHub/utils/documentHelpers"; import { normalizeDocument } from "@features/documents/components/documentsHub/utils/documentHelpers"; @@ -46,11 +46,7 @@ export interface DocumentDataSlice { docsById: Record; emptyFileCount: number; - // --- Filter toolbar keyboard nav --- - filterButtonRefs: React.MutableRefObject>; - onFilterKeyDown: (e: React.KeyboardEvent) => void; documentTypes: Array<{ id: string; label: string; icon: React.ReactNode }>; - filterIds: string[]; } // --------------------------------------------------------------------------- @@ -157,7 +153,7 @@ export function useDocumentData({ }, [allDocuments]); // ------------------------------------------------------------------------- - // Filter toolbar keyboard navigation + // Filter toolbar options // ------------------------------------------------------------------------- const documentTypes = useMemo( @@ -170,45 +166,6 @@ export function useDocumentData({ [], ); - const filterIds: Array = useMemo( - () => documentTypes.map((t) => t.id), - [documentTypes], - ); - - const filterButtonRefs = useRef>([]); - - const focusFilterByIndex = useCallback((i: number) => { - filterButtonRefs.current[i]?.focus(); - }, []); - - const onFilterKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const idx = filterIds.indexOf(filter); - let nextIdx = idx; - - if (e.key === "ArrowRight" || e.key === "ArrowDown") { - e.preventDefault(); - nextIdx = (idx + 1) % filterIds.length; - } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { - e.preventDefault(); - nextIdx = (idx - 1 + filterIds.length) % filterIds.length; - } else if (e.key === "Home") { - e.preventDefault(); - nextIdx = 0; - } else if (e.key === "End") { - e.preventDefault(); - nextIdx = filterIds.length - 1; - } - - if (nextIdx !== idx) { - const nextId = filterIds[nextIdx]; - setFilter(nextId); - focusFilterByIndex(nextIdx); - } - }, - [filter, filterIds, focusFilterByIndex, setFilter], - ); - // ------------------------------------------------------------------------- // Return // ------------------------------------------------------------------------- @@ -227,9 +184,6 @@ export function useDocumentData({ filteredDocuments, docsById, emptyFileCount, - filterButtonRefs, - onFilterKeyDown, documentTypes, - filterIds, }; } diff --git a/src/features/documents/hooks/useDocumentWorkspaceState.ts b/src/features/documents/hooks/useDocumentWorkspaceState.ts index 6097ae36b..8b04880bc 100644 --- a/src/features/documents/hooks/useDocumentWorkspaceState.ts +++ b/src/features/documents/hooks/useDocumentWorkspaceState.ts @@ -125,11 +125,7 @@ export interface UseDocumentWorkspaceStateReturn { docsById: Record; emptyFileCount: number; - // --- Filter toolbar keyboard nav --- - filterButtonRefs: React.MutableRefObject>; - onFilterKeyDown: (e: React.KeyboardEvent) => void; documentTypes: Array<{ id: string; label: string; icon: React.ReactNode }>; - filterIds: string[]; // --- CRUD handlers --- handleDeleteDocument: (documentId: Id<"documents">) => void; @@ -221,10 +217,7 @@ export function useDocumentWorkspaceState({ countsByFilter: data.countsByFilter, filteredDocuments: data.filteredDocuments, emptyFileCount: data.emptyFileCount, - filterButtonRefs: data.filterButtonRefs, - onFilterKeyDown: data.onFilterKeyDown, documentTypes: data.documentTypes, - filterIds: data.filterIds, // --- Selection slice --- selectedDocIds: selection.selectedDocIds, diff --git a/src/features/entities/views/EntityPage.tsx b/src/features/entities/views/EntityPage.tsx index 7efa5be31..fd168d0b1 100644 --- a/src/features/entities/views/EntityPage.tsx +++ b/src/features/entities/views/EntityPage.tsx @@ -77,6 +77,7 @@ import { EntityNotebookSurface } from "@/features/entities/components/EntityNote import { ViewModeToggle } from "@/features/entities/components/ViewModeToggle"; import { useViewMode } from "@/features/entities/lib/useViewMode"; import { EntityShareSheet } from "@/features/entities/components/EntityShareSheet"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ai-ui/toggle-group"; import { DiligenceSection } from "@/features/entities/components/DiligenceSection"; import { SignInForm } from "@/SignInForm"; import { SignOutButton } from "@/SignOutButton"; @@ -87,6 +88,13 @@ import { type EntityNoteDocument, } from "@/features/entities/lib/entityNoteDocument"; import { getStarterEntityWorkspace } from "@/features/entities/lib/starterEntityWorkspaces"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ai-ui/dialog"; const EntityNoteEditor = lazy( () => import("@/features/entities/components/EntityNoteEditor"), @@ -1916,15 +1924,6 @@ function EntityWorkspaceView({ [workspace?.timeline], ); - // Close sources modal on Escape - useEffect(() => { - if (!showSources) return; - const handleKey = (event: KeyboardEvent) => { - if (event.key === "Escape") setShowSources(false); - }; - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - }, [showSources]); const latestBriefReport = useMemo( () => workspace?.latest ?? workspace?.timeline?.[0] ?? null, [workspace?.latest, workspace?.timeline], @@ -2680,46 +2679,41 @@ function EntityWorkspaceView({ ) : null} {/* ── Sources modal ── */} - {showSources ? ( -
setShowSources(false)} - role="dialog" - aria-modal="true" - aria-label={`Sources for ${entity.name}`} - > -
e.stopPropagation()} + + {/* Modal header */}
-

+ {sourceCount} source{sourceCount === 1 ? "" : "s"} -

-

+ + Across all saved revisions of {entity.name} -

+
- + + + + +
{/* Source list */} @@ -2780,9 +2774,8 @@ function EntityWorkspaceView({ )}
-
-
- ) : null} + + {/* ── Inline context strip — earned-complexity: only the "Since last visit" chip is ever rendered when there are @@ -3588,16 +3581,17 @@ function EntityWorkspaceView({ and "Related" — plain product language. The storage key stays "evidence"/"context" so existing persisted preferences don't get migrated. */} -
{ + if (value === "evidence" || value === "context") setWorkspaceRailView(value); + }} aria-label="Companion panel" className="inline-flex rounded-full border border-black/8 bg-black/[0.03] p-1 dark:border-white/10 dark:bg-white/[0.03]" > - - -
+ +
diff --git a/src/features/founder/components/FeedbackWidget.tsx b/src/features/founder/components/FeedbackWidget.tsx index 5b624f66e..ac0169412 100644 --- a/src/features/founder/components/FeedbackWidget.tsx +++ b/src/features/founder/components/FeedbackWidget.tsx @@ -5,12 +5,14 @@ * Stores to localStorage via useFeedbackStore. No backend required. */ -import { memo, useState, useCallback, useEffect, useRef } from "react"; +import { memo, useState, useCallback, useRef } from "react"; import { useFeedbackStore, FEEDBACK_CATEGORIES, type FeedbackCategory, } from "../hooks/useFeedbackStore"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ai-ui/popover"; +import { toast } from "sonner"; // ── Star rating component ──────────────────────────────────────────────────── @@ -65,50 +67,8 @@ export const FeedbackWidget = memo(function FeedbackWidget() { const [rating, setRating] = useState(0); const [comment, setComment] = useState(""); const [category, setCategory] = useState("Other"); - const [showThanks, setShowThanks] = useState(false); - const formRef = useRef(null); - const buttonRef = useRef(null); const inputRef = useRef(null); - // Focus input when form opens - useEffect(() => { - if (isOpen) { - // Small delay for the animation to start - const t = setTimeout(() => inputRef.current?.focus(), 80); - return () => clearTimeout(t); - } - }, [isOpen]); - - // Escape to close - useEffect(() => { - if (!isOpen) return; - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") { - setIsOpen(false); - buttonRef.current?.focus(); - } - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [isOpen]); - - // Click outside to close - useEffect(() => { - if (!isOpen) return; - const handler = (e: MouseEvent) => { - if ( - formRef.current && - !formRef.current.contains(e.target as Node) && - buttonRef.current && - !buttonRef.current.contains(e.target as Node) - ) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [isOpen]); - const resetForm = useCallback(() => { setRating(0); setComment(""); @@ -120,21 +80,15 @@ export const FeedbackWidget = memo(function FeedbackWidget() { submitFeedback({ rating, comment, category }); resetForm(); setIsOpen(false); - setShowThanks(true); - const t = setTimeout(() => setShowThanks(false), 2000); - return () => clearTimeout(t); + toast.success("Thanks for your feedback!"); }, [rating, comment, category, submitFeedback, resetForm]); return ( - <> + {/* Floating button */} + - - {/* Thanks toast */} - {showThanks && ( -
- Thanks for your feedback! -
- )} +
{/* Feedback form */} - {isOpen && ( -
{ + event.preventDefault(); + inputRef.current?.focus(); + }} + className="z-40 w-72 rounded-xl border border-white/[0.06] bg-[rgba(20,22,28,0.95)] p-0 backdrop-blur-2xl shadow-2xl" role="dialog" aria-label="Feedback form" aria-modal="false" @@ -265,8 +209,7 @@ export const FeedbackWidget = memo(function FeedbackWidget() { Submit
-
- )} - + + ); }); diff --git a/src/features/notebook/components/NotebookBlockFinder.tsx b/src/features/notebook/components/NotebookBlockFinder.tsx index 36a28f5eb..0b569ce20 100644 --- a/src/features/notebook/components/NotebookBlockFinder.tsx +++ b/src/features/notebook/components/NotebookBlockFinder.tsx @@ -29,7 +29,13 @@ * - DETERMINISTIC — match scan walks the DOM in document order; same input * + same content → same match list */ -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { useId, useMemo, useRef, useState } from "react"; +import { + Popover, + PopoverAnchor, + PopoverClose, + PopoverContent, +} from "@/components/ai-ui/popover"; const MAX_QUERY = 200; const MAX_MATCHES = 200; @@ -121,21 +127,19 @@ export function NotebookBlockFinder({ setQueryRaw(next.length > MAX_QUERY ? next.slice(0, MAX_QUERY) : next); }; - useEffect(() => { - inputRef.current?.focus(); - }, []); - - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - onClose(); - } - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); + const virtualAnchorRef = useMemo( + () => ({ + current: { + getBoundingClientRect: () => { + const rect = containerRef.current?.getBoundingClientRect(); + const right = rect ? rect.right - 12 : 332; + const top = rect ? rect.top + 12 : 12; + return new DOMRect(right, top, 0, 0); + }, + }, + }), + [containerRef], + ); const matches = useMemo( () => findMatches(containerRef.current ?? null, query.trim()), @@ -154,14 +158,28 @@ export function NotebookBlockFinder({ }; return ( -
+ { if (!open) onClose(); }}> + + { + event.preventDefault(); + inputRef.current?.focus(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + containerRef.current?.querySelector('[contenteditable="true"]')?.focus(); + }} + className="z-30 w-[320px] rounded-lg border border-black/[0.08] bg-white p-3 shadow-lg dark:border-white/[0.08] dark:bg-[#1b1815]" + >
+ + ); } diff --git a/src/features/onboarding/components/OnboardingWizard.tsx b/src/features/onboarding/components/OnboardingWizard.tsx index 46ca7ca5f..9116a8e04 100644 --- a/src/features/onboarding/components/OnboardingWizard.tsx +++ b/src/features/onboarding/components/OnboardingWizard.tsx @@ -7,8 +7,9 @@ * Design: glass card overlay, step dots, skip link, reduced-motion safe. */ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback } from "react"; import { MessageSquare, BarChart3, Activity } from "lucide-react"; +import { DialogOverlay } from "@/shared/components/DialogOverlay"; const STORAGE_KEY = "nodebench-onboarded"; @@ -62,24 +63,17 @@ export function OnboardingWizard({ onClose }: { onClose: () => void }) { else finish(); }, [step, finish]); - // Close on Escape - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") finish(); - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [finish]); - return ( -
-
+
{/* Skip link */}
-
+ ); } diff --git a/src/features/product/components/ProductFileAssetPicker.tsx b/src/features/product/components/ProductFileAssetPicker.tsx index e4548d360..2bfb3ffaa 100644 --- a/src/features/product/components/ProductFileAssetPicker.tsx +++ b/src/features/product/components/ProductFileAssetPicker.tsx @@ -3,6 +3,13 @@ import { useQuery } from "convex/react"; import { Search, X } from "lucide-react"; import { useConvexApi } from "@/lib/convexApi"; import { getAnonymousProductSessionId } from "@/features/product/lib/productIdentity"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ai-ui/dialog"; export type ProductFileAsset = { _id: string; @@ -58,24 +65,27 @@ export function ProductFileAssetPicker({ }); }, [files, query]); - if (!open) return null; - return ( -
-
+ { if (!nextOpen) onClose(); }}> +
-

{title}

-

{description}

+ {title} + {description}
- + + +
@@ -126,8 +136,8 @@ export function ProductFileAssetPicker({
)}
-
- + + ); } diff --git a/src/features/product/components/ProductIntakeComposer.tsx b/src/features/product/components/ProductIntakeComposer.tsx index bce219c16..cf95c4140 100644 --- a/src/features/product/components/ProductIntakeComposer.tsx +++ b/src/features/product/components/ProductIntakeComposer.tsx @@ -11,6 +11,7 @@ import { useRollbackInterceptor, type RollbackOutcome, } from "@/features/chat/hooks/useRollbackInterceptor"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ai-ui/toggle-group"; export type ProductComposerMode = "ask" | "note" | "task"; @@ -455,8 +456,10 @@ export function ProductIntakeComposer({ > {showIntegratedCaptureModes ? (
-
value && onModeChange?.(value as ProductComposerMode)} aria-label="Composer mode" className={`inline-flex items-center gap-1 rounded-full p-1 ${ isChatVariant @@ -465,13 +468,10 @@ export function ProductIntakeComposer({ }`} > {captureModes.map(({ id, label, icon: Icon }) => ( - + ))} -
+
) : null} @@ -581,22 +581,21 @@ export function ProductIntakeComposer({ : "flex text-gray-500 dark:text-gray-300" }`}> {showResearchLaneControls ? ( -
value && onResearchLaneChange?.(value)} aria-label="Research lane" className="flex min-w-0 flex-wrap items-center gap-2" > {researchLanes!.map((lane) => { const active = selectedResearchLane === lane.id; return ( - + ); })} -
+ ) : ( <> @@ -835,8 +834,10 @@ export function ProductIntakeComposer({ ) : null} {showLensSelector && !isCaptureMode ? ( -
value && onLensChange(value as LensId)} aria-label="Lens" className={`no-scrollbar -mx-1 mt-3 flex items-center justify-start gap-0.5 overflow-x-auto rounded-full p-1 px-1 sm:justify-center sm:overflow-visible ${ isChatVariant @@ -845,12 +846,9 @@ export function ProductIntakeComposer({ }`} > {LENSES.map((option) => ( - + ))} -
+ ) : null} {files.length > 0 ? ( diff --git a/src/features/redesign/RedesignShell.tsx b/src/features/redesign/RedesignShell.tsx index d7294d32b..8820a5f12 100644 --- a/src/features/redesign/RedesignShell.tsx +++ b/src/features/redesign/RedesignShell.tsx @@ -29,7 +29,6 @@ import { TopNav } from "./components/TopNav"; import { ReportNotebookView } from "./components/ReportNotebookView"; import { CommandPalette, useCommandPalette } from "./components/CommandPalette"; import { ShortcutsOverlay } from "./components/ShortcutsOverlay"; -import { ToastViewport } from "./components/Toast"; import { HomeV2BriefingRail, HomeV2EditionRail, @@ -243,7 +242,6 @@ export default function RedesignShell() { cmdk.setOpen(false)} /> - ); } @@ -255,7 +253,6 @@ export default function RedesignShell() { cmdk.setOpen(false)} /> - ); } @@ -269,7 +266,6 @@ export default function RedesignShell() { {showQaChrome && } cmdk.setOpen(false)} /> - ); } @@ -428,7 +424,6 @@ export default function RedesignShell() { {/* Cross-surface primitives — Cmd+K palette, ? shortcuts, toasts */} cmdk.setOpen(false)} /> - ); } diff --git a/src/features/redesign/agent-workspace.css b/src/features/redesign/agent-workspace.css index 1fe2290d4..9c9728054 100644 --- a/src/features/redesign/agent-workspace.css +++ b/src/features/redesign/agent-workspace.css @@ -98,6 +98,17 @@ gap: 18px; } +[data-redesign] .rd-answer-copy { + margin: 0; + color: var(--rd-ink-strong); + font-family: var(--rd-font-display); + font-size: var(--rd-type-answer); + font-weight: var(--rd-weight-answer); + letter-spacing: 0; + line-height: var(--rd-leading-answer); + white-space: pre-wrap; +} + [data-redesign] .rd-chat-scroll-btn { bottom: 16px; z-index: 4; @@ -242,7 +253,7 @@ } [data-redesign] .rd-chat-transcript__content { - padding: 16px 14px 32px; + padding: var(--rd-s-3) var(--rd-s-3) var(--rd-s-6); } [data-redesign] .rd-composer-dock { diff --git a/src/features/redesign/components/CommandPalette.test.tsx b/src/features/redesign/components/CommandPalette.test.tsx index 22364f7bf..89485dd04 100644 --- a/src/features/redesign/components/CommandPalette.test.tsx +++ b/src/features/redesign/components/CommandPalette.test.tsx @@ -26,6 +26,24 @@ import { CommandPalette } from "./CommandPalette"; import type { FederatedSearchResponse } from "../../../layouts/chrome/commandPalette/types"; import { clearRecentCmdkSearches, getRecentCmdkSearches } from "../../../layouts/chrome/commandPalette/recentSearches"; +class ResizeObserverMock implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + writable: true, + value: ResizeObserverMock, +}); + +Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + writable: true, + value: vi.fn(), +}); + /* -------------------------------------------------------------------------- */ /* Mocks */ /* -------------------------------------------------------------------------- */ diff --git a/src/features/redesign/components/CommandPalette.tsx b/src/features/redesign/components/CommandPalette.tsx index 422bfdf86..10939af75 100644 --- a/src/features/redesign/components/CommandPalette.tsx +++ b/src/features/redesign/components/CommandPalette.tsx @@ -21,8 +21,8 @@ * - role="dialog" aria-modal aria-label * - role="listbox" with role="option" + aria-selected on each result * - aria-live="polite" announces N results across M collections - * - Focus trap (modal); Escape closes; focus returns to trigger element - * - Tab cycles input <-> result list; Cmd+1..7 jumps groups; Up/Down moves selection + * - Radix Dialog owns the modal focus trap; Escape closes; focus returns to trigger element + * - cmdk owns option traversal; Cmd+1..7 jumps groups * - Enter opens; Cmd+Enter "ask about this" pre-fills the chat composer * * Design reduction (.claude/rules/reexamine_design_reduction.md): @@ -33,6 +33,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ai-ui/dialog"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ai-ui/command"; import { useFederatedSearch } from "../../../layouts/chrome/commandPalette/useFederatedSearch"; import { addRecentCmdkSearch, @@ -96,6 +104,12 @@ type FlatItem = flatIndex: number; }; +function flatItemValue(item: FlatItem): string { + if (item.kind === "command") return `command:${item.command.id}`; + if (item.kind === "recent") return `recent:${item.query}`; + return `result:${item.collection}:${item.handle.uri}:${item.flatIndex}`; +} + /* -------------------------------------------------------------------------- */ /* Component */ /* -------------------------------------------------------------------------- */ @@ -105,7 +119,6 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); - const dialogRef = useRef(null); const listRef = useRef(null); const triggerRef = useRef(null); const [recents, setRecents] = useState([]); @@ -334,17 +347,7 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp jumpToGroup(Number(e.key)); return; } - // Arrow up/down — move selection. - if (e.key === "ArrowDown") { - e.preventDefault(); - setActive((a) => Math.min(flatItems.length - 1, a + 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setActive((a) => Math.max(0, a - 1)); - return; - } + // Arrow navigation is delegated to cmdk. if (e.key === "Enter") { e.preventDefault(); const item = flatItems[active]; @@ -361,26 +364,6 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp return () => window.removeEventListener("keydown", onKey); }, [open, flatItems, active, executeItem, jumpToGroup, handleClose]); - /* -------- focus trap inside dialog (Tab/Shift+Tab cycles within) -------- */ - const handleDialogKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key !== "Tab") return; - const root = dialogRef.current; - if (!root) return; - const focusable = root.querySelectorAll( - 'input, button, [tabindex]:not([tabindex="-1"])', - ); - if (focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }, []); - /* -------- scroll active row into view -------- */ useEffect(() => { if (!listRef.current || !open) return; @@ -421,34 +404,42 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp const totalResults = flatItems.filter((i) => i.kind === "result").length; return ( -
-
{ if (!nextOpen) handleClose(); }}> + e.stopPropagation()} - onKeyDown={handleDialogKeyDown} + aria-modal="true" + aria-describedby={undefined} + onOpenAutoFocus={(event) => { + event.preventDefault(); + inputRef.current?.focus(); + }} > +
+ Command palette + { + const next = flatItems.findIndex((item) => flatItemValue(item) === value); + if (next >= 0) setActive(next); + }} + className="rounded-none bg-transparent text-inherit" + >
- setQuery(e.target.value)} + onValueChange={setQuery} placeholder="Search anything..." aria-label="Search anything" - aria-controls="rd-cmdk-listbox" - aria-activedescendant={ - flatItems[active] ? `rd-cmdk-row-${flatItems[active].flatIndex}` : undefined - } + className="h-auto border-0 p-0" + wrapperClassName="contents [&>svg]:hidden" spellCheck={false} /> Esc @@ -484,33 +475,33 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp {liveRegionMessage} -
{/* Loading skeleton (only when no prior data is visible) */} {isLoading && !data ? : null} {/* Empty mode — show recents (if any), then commands */} {!showSearchMode && recentsBucket.length > 0 ? ( -
-
Recent searches
+ {recentsBucket.map((it) => { if (it.kind !== "recent") return null; const isActive = it.flatIndex === active; return ( - + ); })} -
+ ) : null} {/* Search mode — federated result groups */} @@ -533,7 +524,7 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp // Render group ONLY when it has results OR an error to surface. if (items.length === 0 && result.ok) return null; return ( -
setActive(it.flatIndex)} @@ -579,7 +568,7 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp {it.handle.snippet ? ( {it.handle.snippet} ) : null} - + ); })} {result.count > items.length ? ( @@ -587,28 +576,27 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp + {result.count - items.length} more in {COLLECTION_LABELS[collection].toLowerCase()}
) : null} -
+ ); }) : null} {/* Always render Commands group (acts as nav fallback) */} {commandsBucket.length > 0 ? ( -
+
Commands
{commandsBucket.map((it) => { if (it.kind !== "command") return null; const isActive = it.flatIndex === active; return ( - + ); })} -
+ ) : null} {/* Empty + no recents */} @@ -647,9 +635,11 @@ export function CommandPalette({ open, onClose, extra = [] }: CommandPaletteProp 0 results for "{query}"
) : null} + +
-
-
+ + ); } diff --git a/src/features/redesign/components/MessageActions.tsx b/src/features/redesign/components/MessageActions.tsx index aee526dea..0939a686e 100644 --- a/src/features/redesign/components/MessageActions.tsx +++ b/src/features/redesign/components/MessageActions.tsx @@ -12,6 +12,12 @@ */ import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ai-ui/dropdown-menu"; interface MessageActionsProps { /** The text/markdown to copy to clipboard. */ @@ -35,7 +41,6 @@ interface MessageActionsProps { export function MessageActions({ copyText, onRegenerate, onPin, onBranch, onWhy, onReact, onCompare, onShare }: MessageActionsProps) { const [copied, setCopied] = useState(false); const [reaction, setReaction] = useState<"up" | "down" | null>(null); - const [regenOpen, setRegenOpen] = useState(false); const copy = async () => { try { @@ -65,26 +70,26 @@ export function MessageActions({ copyText, onRegenerate, onPin, onBranch, onWhy, {onRegenerate && ( -
- - {regenOpen && ( -
setRegenOpen(false)}> - - - - -
- )} -
+ +
+ + + + + onRegenerate()}>Regenerate + onRegenerate("free")}>Try free tier + onRegenerate("fast")}>Try fast tier + onRegenerate("deep")}>Try deep tier + +
+
)} {onPin && ( diff --git a/src/features/redesign/components/MobileShell.tsx b/src/features/redesign/components/MobileShell.tsx index df89c0704..3ce6697d1 100644 --- a/src/features/redesign/components/MobileShell.tsx +++ b/src/features/redesign/components/MobileShell.tsx @@ -18,6 +18,7 @@ import { Pill } from "./Pill"; import { UniversalComposer, type RouterTier } from "./UniversalComposer"; import { useLiveArtifacts, type LiveArtifactDetail } from "../hooks/useLiveArtifacts"; import { useReportsLive } from "../hooks/useReportsLive"; +import { Sheet, SheetContent, SheetOverlay } from "@/components/ai-ui/sheet"; import { useInboxLive } from "../hooks/useInboxLive"; import { sanitizeDecisionText } from "../surfaces/ProductDecisionQueue"; @@ -590,23 +591,13 @@ function MobileMe() { function BottomSheet({ kind, detail, onClose }: { kind: "sources" | "graph" | "entity"; detail?: LiveArtifactDetail; onClose: () => void }) { return ( -
-
e.stopPropagation()} + { if (!next) onClose(); }}> + +
+ } {kind === "graph" && } {kind === "entity" && } +
-
+ ); } diff --git a/src/features/redesign/components/RightInspector.tsx b/src/features/redesign/components/RightInspector.tsx index 7fd1aa13a..1cb07a6ab 100644 --- a/src/features/redesign/components/RightInspector.tsx +++ b/src/features/redesign/components/RightInspector.tsx @@ -11,6 +11,7 @@ import { useLiveArtifacts, type LiveArtifactDetail } from "../hooks/useLiveArtif import { VoiceCostBadge } from "@/features/voice"; import { useCurrentUserId } from "@/hooks/useCurrentUser"; import { buildGraphContextBridgePacket } from "../lib/graphContextBridge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ai-ui/tabs"; export type AgentRailStatus = "done" | "running" | "queued" | "blocked" | "idle"; @@ -86,27 +87,26 @@ export function RightInspector({ activeLiveArtifactDetail, agentSnapshot }: Righ data-agent-context-ref={graphPacket?.contextRef ?? ""} data-agent-context-rank={graphPacket?.agentRank ?? ""} > -
+ setActiveTab(value as ChatContextTab)}> + {chatContextTabs.map((tab) => ( - + ))} -
+ - )} -
+ - + + ); } diff --git a/src/features/redesign/components/ShortcutsOverlay.tsx b/src/features/redesign/components/ShortcutsOverlay.tsx index 7151af126..9f50a92f9 100644 --- a/src/features/redesign/components/ShortcutsOverlay.tsx +++ b/src/features/redesign/components/ShortcutsOverlay.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useMemo, useState } from "react"; +import { DialogOverlay } from "@/shared/components/DialogOverlay"; interface ShortcutGroup { label: string; @@ -79,10 +80,6 @@ export function ShortcutsOverlay() { e.preventDefault(); setOpen(true); } - if (e.key === "Escape" && open) { - e.preventDefault(); - setOpen(false); - } }; const onCustom = () => setOpen(true); window.addEventListener("keydown", onKey); @@ -91,12 +88,18 @@ export function ShortcutsOverlay() { window.removeEventListener("keydown", onKey); window.removeEventListener("rd:shortcuts:open", onCustom); }; - }, [open]); + }, []); - if (!open) return null; return ( -
setOpen(false)}> -
e.stopPropagation()}> + setOpen(false)} + ariaLabel="Keyboard shortcuts" + positionClassName="rd-shortcuts__backdrop" + backdropClassName="bg-transparent" + contentClassName="rd-shortcuts" + > +

Keyboard shortcuts

@@ -121,6 +124,6 @@ export function ShortcutsOverlay() { ))}
-
+ ); } diff --git a/src/features/redesign/components/Toast.tsx b/src/features/redesign/components/Toast.tsx index 1bdad5e6d..c9951f962 100644 --- a/src/features/redesign/components/Toast.tsx +++ b/src/features/redesign/components/Toast.tsx @@ -1,14 +1,4 @@ -/** - * Toast — minimalist transient notification system. - * - * Use: showToast({ tone: "success", message: "Saved to report" }); - * Or: showToast({ tone: "info", message: "Brief refresh started", action: { label: "Open", onClick: ... } }); - * - * Mounts once at the redesign layout level. Listens to a global event so - * any component can trigger toasts without prop drilling. - */ - -import { useEffect, useState } from "react"; +import { toast } from "sonner"; export type ToastTone = "info" | "success" | "warning" | "error"; @@ -20,58 +10,10 @@ export interface ToastEvent { durationMs?: number; } -const TOAST_EVENT = "rd:toast:show"; - +/** Preserve the redesign call API while using the app's canonical Sonner queue. */ export function showToast(input: Omit): void { - const detail: ToastEvent = { - id: `t_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - durationMs: 3500, - ...input, - }; - window.dispatchEvent(new CustomEvent(TOAST_EVENT, { detail })); -} - -export function ToastViewport() { - const [toasts, setToasts] = useState([]); - - useEffect(() => { - const onToast = (e: Event) => { - const ce = e as CustomEvent; - setToasts((prev) => [...prev, ce.detail].slice(-4)); - const id = ce.detail.id; - const dur = ce.detail.durationMs ?? 3500; - window.setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }, dur); - }; - window.addEventListener(TOAST_EVENT, onToast); - return () => window.removeEventListener(TOAST_EVENT, onToast); - }, []); - - if (toasts.length === 0) return null; - return ( -
- {toasts.map((t) => ( -
- - {t.message} - {t.action && ( - - )} - -
- ))} -
- ); + toast[input.tone](input.message, { + duration: input.durationMs ?? 3500, + action: input.action, + }); } diff --git a/src/features/redesign/primitives.css b/src/features/redesign/primitives.css index 05a2af1b5..62bc51617 100644 --- a/src/features/redesign/primitives.css +++ b/src/features/redesign/primitives.css @@ -6527,69 +6527,7 @@ body[data-redesign-focus-mode="on"] [data-redesign] [data-focus-mode="on"] main } /* ───────── Sprint 1 P0.1 — citation hover popover ───────── */ -[data-redesign] .rd-cite-wrap { - position: relative; - display: inline; -} -[data-redesign] .rd-cite-wrap .rd-cite-popover { - position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%) translateY(2px); - z-index: 80; - width: max-content; - max-width: 320px; - padding: 10px 12px; - background: var(--rd-paper); - color: var(--rd-ink); - border: 1px solid var(--rd-line-strong); - border-radius: var(--rd-r-sm); - box-shadow: 0 6px 22px rgba(0, 0, 0, 0.18); - font-family: var(--rd-font-display); - font-size: 12px; - line-height: 1.45; - letter-spacing: 0; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: opacity 120ms ease-out, transform 120ms ease-out, visibility 120ms; -} -[data-redesign] .rd-cite-wrap .rd-cite:hover + .rd-cite-popover, -[data-redesign] .rd-cite-wrap .rd-cite:focus-visible + .rd-cite-popover { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} -[data-redesign] .rd-cite-popover__quote { - display: block; - color: var(--rd-ink-strong); - font-style: italic; - margin-bottom: 6px; -} -[data-redesign] .rd-cite-popover__source { - display: block; - font-family: var(--rd-font-mono); - font-size: 10.5px; - color: var(--rd-ink-soft); - letter-spacing: 0.02em; -} /* Sprint 3 P2.9 — source freshness pill inside popover */ -[data-redesign] .rd-cite-popover__freshness { - display: inline-block; - margin-top: 6px; - padding: 2px 6px; - font-family: var(--rd-font-mono); - font-size: 9.5px; - color: var(--rd-ink-mute); - background: var(--rd-line-faint); - border-radius: 4px; - letter-spacing: 0.04em; -} -@media (prefers-reduced-motion: reduce) { - [data-redesign] .rd-cite-wrap .rd-cite-popover { - transition: none; - } -} /* ───────── Sprint 3 P2.11 — Open-questions tray ───────── */ [data-redesign] .rd-open-q { diff --git a/src/features/redesign/surfaces/ChatResponseShape.guard.test.ts b/src/features/redesign/surfaces/ChatResponseShape.guard.test.ts index 2629cb5df..0dc6081f4 100644 --- a/src/features/redesign/surfaces/ChatResponseShape.guard.test.ts +++ b/src/features/redesign/surfaces/ChatResponseShape.guard.test.ts @@ -7,6 +7,7 @@ describe("compact chat response rendering", () => { it("does not re-inflate exact compact answers with empty memo sections", () => { const chat = read("src/features/redesign/surfaces/ChatSurface.tsx"); const replay = read("src/features/redesign/pages/ReproducibleChatPage.tsx"); + const workspaceCss = read("src/features/redesign/agent-workspace.css"); expect(chat).toContain("packet.whyItMatters.trim() &&"); expect(chat).toContain("!isCompactResponse && packet.evidence.length > 0 &&"); @@ -14,7 +15,8 @@ describe("compact chat response rendering", () => { expect(chat).toContain("packet.nextAction.trim() &&"); expect(replay).toContain("packet.whyItMatters?.trim() &&"); expect(replay).toContain("!isCompactResponse && evidenceWithCites.length > 0 &&"); - expect(chat).toContain('whiteSpace: "pre-wrap"'); + expect(chat).toContain('className="rd-answer-copy"'); + expect(workspaceCss).toContain("white-space: pre-wrap;"); expect(replay).toContain('whiteSpace: "pre-wrap"'); }); }); diff --git a/src/features/redesign/surfaces/ChatSurface.tsx b/src/features/redesign/surfaces/ChatSurface.tsx index 0e6a3b1ff..17f434ab6 100644 --- a/src/features/redesign/surfaces/ChatSurface.tsx +++ b/src/features/redesign/surfaces/ChatSurface.tsx @@ -7,7 +7,7 @@ * Bottom: UniversalComposer. */ -import React, { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { UniversalComposer, DEFAULT_TIERS, type RouterTier } from "../components/UniversalComposer"; import { Pill } from "../components/Pill"; @@ -39,6 +39,23 @@ import { ConversationContent, ConversationScrollButton, } from "@/components/ai-elements/conversation"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ai-ui/dialog"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ai-ui/context-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ai-ui/tooltip"; /** * Sprint 4 P2.13 — deterministic reproducibility hash for an answer. @@ -1706,11 +1723,18 @@ function InlineCorrection({ onSave }: InlineCorrectionProps = {}) { Correct this )} - {editing && ( -
-
+ { if (!open) setEditing(null); }}> + {editing && ( + ("[data-redesign]") : null} + showCloseButton={false} + >
- Correct this claim + + Correct this claim +
-
-
- )} + + )} + ); } @@ -1766,14 +1790,6 @@ function ABCompareModal({ onClose: () => void; onPick: (variant: "A" | "B", variantBRunId?: string) => void; }) { - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - // Phase 7 — real Variant B parallel run. // When the modal opens and the original prompt was captured, kick off a // second startChat call and subscribe to its run row + events so the modal @@ -1848,10 +1864,17 @@ function ABCompareModal({ : variantBPacket?.whyItMatters || ""; return ( -
-
+ { if (!open) onClose(); }}> + ("[data-redesign]") : null} + showCloseButton={false} + >
- A/B compare · {tierMeta.label} tier + + A/B compare · {tierMeta.label} tier + {variantBStatus === "blocked" ? "Original prompt unavailable — no fixture Variant B generated." @@ -1909,8 +1932,8 @@ function ABCompareModal({ >Pick B
-
- + + ); } @@ -2073,7 +2096,6 @@ function AnswerPacket({ const [hoverCite, setHoverCite] = useState(null); // Sprint 2 P0.3 — counterfactual probe state const [maskedIdx, setMaskedIdx] = useState(null); - const [probeMenu, setProbeMenu] = useState<{ idx: number; x: number; y: number } | null>(null); const [traceOpen, setTraceOpen] = useState(false); const traceRef = useRef(null); const researchStages = buildResearchStages(toolCalls ?? packet.trace); @@ -2085,14 +2107,7 @@ function AnswerPacket({ // Wire citation interactivity: hover [N] in body → highlight matching source row in evidence list const handleCiteEnter = (idx: number) => setHoverCite(idx); const handleCiteLeave = () => setHoverCite(null); - // Right-click on a cite chip → counterfactual probe menu - const handleCiteContext = (idx: number, e: ReactMouseEvent) => { - e.preventDefault(); - if (!onProbeRunWithoutSource) return; - setProbeMenu({ idx, x: e.clientX, y: e.clientY }); - }; const probeWithoutSource = (idx: number) => { - setProbeMenu(null); // Phase 5 — if a real probe handler is wired (authenticated user with // an active runId on this turn), call the real action. if (onProbeRunWithoutSource) { @@ -2119,26 +2134,12 @@ function AnswerPacket({ traceRef.current?.scrollIntoView({ block: "nearest", behavior: "auto" }); }, 0); }; - // Dismiss probe menu on outside click / Escape - useEffect(() => { - if (!probeMenu) return; - const onDoc = () => setProbeMenu(null); - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setProbeMenu(null); - }; - window.addEventListener("click", onDoc); - window.addEventListener("keydown", onKey); - return () => { - window.removeEventListener("click", onDoc); - window.removeEventListener("keydown", onKey); - }; - }, [probeMenu]); return (
{/* Avatar gutter — parity-studio bot icon pattern */} -
+
{packet.shortAnswer.length > 60 ? "Research complete" : "NodeBench"}
{tierMeta.label} @@ -2189,18 +2190,11 @@ function AnswerPacket({ {/* Short answer — citations clickable + hover-linked to evidence list */}
Short answer
-

- {renderInlineWithCites(packet.shortAnswer, packet.evidence, handleCiteEnter, handleCiteLeave, handleCiteContext, maskedIdx)} -

+ +

+ {renderInlineWithCites(packet.shortAnswer, packet.evidence, handleCiteEnter, handleCiteLeave, onProbeRunWithoutSource ? probeWithoutSource : undefined, maskedIdx)} +

+
{/* Compact response shapes intentionally omit memo-only sections. */} @@ -2326,39 +2320,6 @@ function AnswerPacket({
- {/* Sprint 2 P0.3 — counterfactual probe context menu (right-click on cite chip) */} - {probeMenu && ( -
e.stopPropagation()} - > - - -
- )} ); } @@ -2571,8 +2532,8 @@ function formatPct(value: number | undefined): string { /** * Render text with [N] citation patterns turned into interactive chips. * - * Sprint 1 P0.1 — hover the chip to see the source quote + provenance in a popover. - * The popover is pure-CSS positioned (`.rd-cite-wrap`) so no JS positioning math. + * Hover uses the shared Radix tooltip and right-click uses the shared Radix + * context menu, keeping positioning, dismissal, and keyboard focus primitive-owned. * Hovering a [N] also fires the linkage handler so the evidence row highlights. */ function renderInlineWithCites( @@ -2580,7 +2541,7 @@ function renderInlineWithCites( evidence: ChatAnswer["evidence"], onEnter: (idx: number) => void, onLeave: () => void, - onContextMenu?: (idx: number, e: ReactMouseEvent) => void, + onProbe?: (idx: number) => void, maskedIdx?: number | null, ): ReactNode[] { const re = /\[(\d+)\]/g; @@ -2594,39 +2555,60 @@ function renderInlineWithCites( const sourceUrl = sourceUrlFromText(cite.source); if (m.index > last) out.push({text.slice(last, m.index)}); out.push( - - onEnter(idx)} - onMouseLeave={onLeave} - onFocus={() => onEnter(idx)} - onBlur={onLeave} - onContextMenu={onContextMenu ? (e) => onContextMenu(idx, e) : undefined} - onClick={(e) => { - e.preventDefault(); - if (sourceUrl) { - window.open(sourceUrl, "_blank", "noopener,noreferrer"); - return; - } - const target = document.querySelector(`.rd-evidence-row[data-cite="${idx}"]`); - target?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }} - >{idx} - + + + + onEnter(idx)} + onMouseLeave={onLeave} + onFocus={() => onEnter(idx)} + onBlur={onLeave} + onClick={(e) => { + e.preventDefault(); + if (sourceUrl) { + window.open(sourceUrl, "_blank", "noopener,noreferrer"); + return; + } + const target = document.querySelector(`.rd-evidence-row[data-cite="${idx}"]`); + target?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }} + >{idx} + + + + “{cite.quote}” + {sourceLabel(cite.source)} + + + ("[data-redesign]") : null} > - “{cite.quote}” - {sourceLabel(cite.source)} - {/* Sprint 3 P2.9 — source freshness */} - - , + {onProbe ? ( + onProbe(idx)}> + + Probe without source [{idx}] + re-eval the answer if this source were absent + + ) : null} + { + const target = document.querySelector(`.rd-evidence-row[data-cite="${idx}"]`); + target?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }} + > + + Jump to evidence row + + + , ); last = m.index + m[0].length; } diff --git a/src/features/redesign/surfaces/ChatTypography.guard.test.ts b/src/features/redesign/surfaces/ChatTypography.guard.test.ts new file mode 100644 index 000000000..ed263fe2d --- /dev/null +++ b/src/features/redesign/surfaces/ChatTypography.guard.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const chatSurface = readFileSync(resolve(process.cwd(), "src/features/redesign/surfaces/ChatSurface.tsx"), "utf8"); +const workspaceCss = readFileSync(resolve(process.cwd(), "src/features/redesign/agent-workspace.css"), "utf8"); +const tokens = readFileSync(resolve(process.cwd(), "src/features/redesign/tokens.css"), "utf8"); + +describe("chat typography contract", () => { + it("keeps structured answers in the compact product type scale", () => { + expect(chatSurface).toContain('

'); + expect(tokens).toContain("--rd-type-answer: 14px;"); + expect(tokens).toContain("--rd-leading-answer: 1.55;"); + expect(tokens).toContain("--rd-weight-answer: 450;"); + + const answerRuleStart = workspaceCss.indexOf("[data-redesign] .rd-answer-copy {"); + const answerRuleEnd = workspaceCss.indexOf("}", answerRuleStart); + const answerRule = workspaceCss.slice(answerRuleStart, answerRuleEnd); + + expect(answerRuleStart).toBeGreaterThan(-1); + expect(answerRule).toContain("font-size: var(--rd-type-answer);"); + expect(answerRule).toContain("line-height: var(--rd-leading-answer);"); + expect(answerRule).not.toContain("display: none"); + expect(chatSurface).not.toContain("fontSize: 18"); + }); +}); diff --git a/src/features/redesign/surfaces/HomeV2PrototypeSurface.tsx b/src/features/redesign/surfaces/HomeV2PrototypeSurface.tsx index 3b05ebb62..4afd06f0c 100644 --- a/src/features/redesign/surfaces/HomeV2PrototypeSurface.tsx +++ b/src/features/redesign/surfaces/HomeV2PrototypeSurface.tsx @@ -5,6 +5,7 @@ import { UniversalComposer, type RouterTier } from "../components/UniversalCompo import type { LiveArtifactsResult, LiveArtifactSourceRow } from "../hooks/useLiveArtifacts"; import type { ReportCardData } from "../fixtures"; import { buildGraphContextBridgePacket } from "../lib/graphContextBridge"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ai-ui/toggle-group"; interface HomeAskContext { reportId?: string; @@ -159,6 +160,7 @@ const reportEntities = [ type PrototypeReportEntity = (typeof reportEntities)[number]; type ReportsRailStatus = "verified" | "review" | "stale" | "drafting" | "monitoring"; type ReportsRailItem = { + id?: string; icon: string; name: string; status?: ReportsRailStatus; @@ -235,6 +237,7 @@ function buildLiveRailGroups(reports: ReportCardData[]): typeof reportsRailGroup else if (/monitor|watch/.test(kind)) groupLabel = "Monitoring"; if (!groups[groupLabel]) groups[groupLabel] = []; groups[groupLabel].push({ + id: report.id, icon: report.entity.slice(0, 1).toUpperCase(), name: report.entity, status: statusMap[report.status] ?? "review", @@ -1031,21 +1034,26 @@ export function PrototypeV2LeftRail({ surface, onAsk, selectedEntity = "Anthropi /> -

+ { + if (value) setReportsRailFilter(value as (typeof reportRailFilters)[number]["id"]); + }} + className="lr-filters" + aria-label="Filter reports by status" + > {reportRailFilters.map((filter) => ( - + ))} -
+
{filteredGroups.map((group) => ( = [ @@ -225,24 +235,12 @@ export function ReportsSurface({ onOpen, onRunBatch, onSelectReport, inspectedRe const [inlineFilterOpen, setInlineFilterOpen] = useState(false); const [selected, setSelected] = useState>(new Set()); const [sortKey, setSortKey] = useState("updated"); - const [sortOpen, setSortOpen] = useState(false); const [stageOverrides, setStageOverrides] = useState>({}); const [graphSelectedReportId, setGraphSelectedReportId] = useState(null); const [graphScaleMode, setGraphScaleMode] = useState("clustered"); const [notebookReportId, setNotebookReportId] = useState(null); - const sortRef = useRef(null); const inlineFilterRef = useRef(null); - // Click-outside dismiss for sort dropdown - useEffect(() => { - if (!sortOpen) return; - const onClick = (e: MouseEvent) => { - if (!sortRef.current?.contains(e.target as Node)) setSortOpen(false); - }; - document.addEventListener("mousedown", onClick); - return () => document.removeEventListener("mousedown", onClick); - }, [sortOpen]); - useEffect(() => { if (!inlineFilterOpen) return; inlineFilterRef.current?.focus(); @@ -455,57 +453,63 @@ export function ReportsSurface({ onOpen, onRunBatch, onSelectReport, inspectedRe ) : ( <>
-
- {REPORT_VIEW_MODES.map((item) => ( - - ))} -
-
+ setReportViewMode(value as ReportViewMode)}> + + {REPORT_VIEW_MODES.map((item) => ( + + {item.label} + + ))} + + +
+ { if (value) setFilter(value as (typeof STATUS_FILTERS)[number]["id"]); }} + aria-label="Filter reports by status" + className="contents" + > {STATUS_FILTERS.map((item) => ( - + ))} +
-
- - {sortOpen && ( -
+ +
+ + + + + setSortKey(value as SortKey)}> {SORT_OPTIONS.map((s) => ( - + ))} -
- )} -
+ + +
+
{!inlineFilterOpen && !query ? ( + ))} -
+
Type -
+ { if (value) setKindFilter(value as (typeof KIND_FILTERS)[number]["id"]); }} + className="rd-facet__chips" + aria-label="Filter by type" + > {KIND_FILTERS.map((f) => ( - + >{f.label} ))} -
+
-
- - {sortOpen && ( -
+ +
+ + + + + setSortKey(value as SortKey)}> {SORT_OPTIONS.map((s) => ( - + ))} -
- )} -
+ + +
+ View -
+ { if (value) setDensity(value as Density); }} + className="rd-facet__chips" + aria-label="Density" + > {(["compact", "grid", "list"] as Density[]).map((d) => ( - + >{d} ))} -
+
@@ -2031,9 +2041,7 @@ function ReportCardV3({ const signals = reportSignals(report); const backlinks = reportBacklinks(report); const artifactPreview = reportArtifactPreview(detail, report, stage); - const [menuOpen, setMenuOpen] = useState(false); const menuAction = (label: string) => { - setMenuOpen(false); if (label === "Open notebook") { onOpen(report.id, "brief"); return; @@ -2062,35 +2070,30 @@ function ReportCardV3({ {report.entity.slice(0, 1).toUpperCase()} {report.entity} {stageLabel(stage)} -
- - {menuOpen && ( -
event.stopPropagation()}> + +
+ + + + {["Open notebook", "Export PDF", "Export Notion", "Copy link", "Refresh sources", "Archive"].map((label) => ( - + ))} -
- )} -
+ +
+

{report.kind}

diff --git a/src/features/redesign/tokens.css b/src/features/redesign/tokens.css index 4731e94cc..0ba50303c 100644 --- a/src/features/redesign/tokens.css +++ b/src/features/redesign/tokens.css @@ -96,6 +96,10 @@ --rd-font-display: 'Inter', 'Manrope', -apple-system, BlinkMacSystemFont, sans-serif; --rd-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; --rd-font-serif: 'Source Serif 4', 'Iowan Old Style', Georgia, serif; + /* Agent answer copy stays within the compact workspace reading scale. */ + --rd-type-answer: 14px; + --rd-leading-answer: 1.55; + --rd-weight-answer: 450; /* ───── Motion ───── */ --rd-ease: cubic-bezier(0.16, 1, 0.3, 1); diff --git a/src/features/reports/components/ReportShareSheet.tsx b/src/features/reports/components/ReportShareSheet.tsx index 350b5e6d7..03ee31907 100644 --- a/src/features/reports/components/ReportShareSheet.tsx +++ b/src/features/reports/components/ReportShareSheet.tsx @@ -1,6 +1,6 @@ import { memo, useEffect } from "react"; import { Globe, Link2, Lock, FileDown, FileText, Printer, ExternalLink, X, Check } from "lucide-react"; -import { AnimatePresence, motion } from "framer-motion"; +import { Sheet, SheetContent, SheetOverlay, SheetPortal } from "@/components/ai-ui/sheet"; export type ReportVisibility = "private" | "public"; @@ -42,50 +42,29 @@ export const ReportShareSheet = memo(function ReportShareSheet({ useEffect(() => { if (!open) return; haptic(12); - const handleKey = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - onClose(); - } - }; - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - }, [open, onClose]); + }, [open]); return ( - - {open ? ( - - { if (!next) { haptic(6); onClose(); } }}> + + + - - {isOpen && ( - -
- {content} -
-
- )} -
-
+ + + + + + {content} + + + ); }; diff --git a/src/features/research/components/FeedReaderModal.tsx b/src/features/research/components/FeedReaderModal.tsx index be0945d90..912232b8a 100644 --- a/src/features/research/components/FeedReaderModal.tsx +++ b/src/features/research/components/FeedReaderModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useMemo, useState } from "react"; import { X, ExternalLink, Calendar, Tag, Sparkles, MessageSquare, Copy } from "lucide-react"; import { useFastAgent } from "@/features/agents/context/FastAgentContext"; import { useReaderContent } from "@/features/research/hooks/useReaderContent"; @@ -10,6 +10,7 @@ import CostCrossoverCalculator from "./CostCrossoverCalculator"; import SignalTimeseriesPanel from "./SignalTimeseriesPanel"; import RepoSignalPanel from "./RepoSignalPanel"; import StrategyMetricsPanel from "./StrategyMetricsPanel"; +import { DialogOverlay } from "@/shared/components/DialogOverlay"; export interface ReaderItem { id?: string; @@ -33,34 +34,8 @@ interface FeedReaderModalProps { export const FeedReaderModal: React.FC = ({ item, onClose, techStack = [] }) => { const { openWithContext } = useFastAgent(); const readerState = useReaderContent(item?.url, item?.title); - const modalRef = useRef(null); const [deepDiveTab, setDeepDiveTab] = useState<"summary" | "data" | "timeline">("summary"); - useEffect(() => { - if (!item) return; - - const handlePointerDown = (event: PointerEvent) => { - const target = event.target as Node | null; - if (!modalRef.current || !target) return; - if (!modalRef.current.contains(target)) { - onClose(); - } - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - } - }; - - window.addEventListener("pointerdown", handlePointerDown, true); - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("pointerdown", handlePointerDown, true); - window.removeEventListener("keydown", handleKeyDown); - }; - }, [item, onClose]); - const readerData = readerState.status === "ready" ? readerState.data : null; const safeTitle = item?.title ?? ""; const safeSubtitle = item?.subtitle ?? ""; @@ -162,13 +137,16 @@ export const FeedReaderModal: React.FC = ({ item, onClose, }; return ( -
- +
@@ -470,7 +448,7 @@ export const FeedReaderModal: React.FC = ({ item, onClose,
- + ); }; diff --git a/src/features/research/components/newsletter/EvidenceDrawer.tsx b/src/features/research/components/newsletter/EvidenceDrawer.tsx index 7aa4c6978..8598ab0af 100644 --- a/src/features/research/components/newsletter/EvidenceDrawer.tsx +++ b/src/features/research/components/newsletter/EvidenceDrawer.tsx @@ -8,6 +8,7 @@ import { FileText, Youtube, Globe, Filter, ChevronDown, Link2, ShieldCheck, Clock, Eye } from 'lucide-react'; +import { Sheet, SheetContent, SheetOverlay, SheetPortal } from '@/components/ai-ui/sheet'; // ═══════════════════════════════════════════════════════════════════════════ // TYPES @@ -136,13 +137,13 @@ export function EvidenceDrawer({ const verifiedCount = sources.filter(s => s.verified).length; const unverifiedCount = sources.length - verifiedCount; - if (!isOpen) return null; - return ( -
{ if (!next) onClose(); }}> + + + {/* Header */} @@ -288,7 +289,9 @@ export function EvidenceDrawer({ Click any source to open in new tab

- + + + ); } diff --git a/src/features/research/views/ReportDetailWorkspace.tsx b/src/features/research/views/ReportDetailWorkspace.tsx index 28597499d..7e633746d 100644 --- a/src/features/research/views/ReportDetailWorkspace.tsx +++ b/src/features/research/views/ReportDetailWorkspace.tsx @@ -17,6 +17,7 @@ import { useCallback, useMemo, useState } from "react"; import { FileText, LayoutGrid, Map as MapIcon, FileStack } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ai-ui/tabs"; import MobileReportSurface from "@/features/research/views/MobileReportSurface"; import { CardInspector } from "@/features/research/components/CardInspector"; import type { @@ -177,7 +178,9 @@ export function ReportDetailWorkspace({ /> {/* Desktop cards workspace — hidden on mobile */} -
setActiveTab(value as WorkspaceTab)} data-testid="report-detail-workspace" className="hidden md:flex h-full min-h-0 flex-col" > @@ -192,16 +195,15 @@ export function ReportDetailWorkspace({
- {activeTab === "brief" && ( + - )} - {activeTab === "cards" && ( + +
- )} - {activeTab === "map" && ( +
+ c.cards)} rootUri={columns[0]?.uri ?? rootUri} /> - )} - {activeTab === "sources" && ( + + c.cards)} /> - )} +
{compareTray.length > 0 && ( @@ -251,7 +253,7 @@ export function ReportDetailWorkspace({ onClear={() => setCompareTray([])} /> )} -
+ ); } @@ -340,12 +342,10 @@ type TabDef = { function TabBar({ activeTab, - onChange, cardCount, sourceCount, }: { activeTab: WorkspaceTab; - onChange: (t: WorkspaceTab) => void; cardCount: number; sourceCount: number; }) { @@ -359,23 +359,19 @@ function TabBar({ { id: "sources", label: "Sources", icon: FileStack, count: sourceCount }, ]; return ( -
{tabs.map((t) => { const isActive = activeTab === t.id; const IconCmp = t.icon; return ( - + ); })} -
+ ); } diff --git a/src/features/research/views/ResearchHub.tsx b/src/features/research/views/ResearchHub.tsx index b91d48027..3e33b8a96 100644 --- a/src/features/research/views/ResearchHub.tsx +++ b/src/features/research/views/ResearchHub.tsx @@ -28,6 +28,7 @@ import { TimelineStrip, type TimelineEvent, type TemporalPhase } from "@/feature import type { ReaderItem } from "@/features/research/components/FeedReaderModal"; import { cn } from "@/lib/utils"; import { SurfacePageHeader } from "@/shared/ui"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ai-ui/tabs"; function SectionFallback({ title, @@ -697,21 +698,27 @@ function ResearchHubContent(props: ResearchHubProps) { {/* LEFT: MAIN CONTENT WITH TABS */}
+ { + const tab = value as ContentTab; + setActiveTab(tab); + onTabChange?.(tab); + }} + className="contents" + > {/* TAB NAVIGATION */} - + {/* TAB CONTENT */}
{/* OVERVIEW TAB: Digest + Personal Pulse */} - {activeTab === 'overview' && ( +
{/* Executive Synthesis */}
@@ -778,10 +785,10 @@ function ResearchHubContent(props: ResearchHubProps) {
- )} +
{/* SIGNALS TAB: Live Signal Stream */} - {activeTab === 'signals' && ( +
@@ -807,10 +814,10 @@ function ResearchHubContent(props: ResearchHubProps) {
- )} +
{/* BRIEFING TAB: Institutional Briefing */} - {activeTab === 'briefing' && ( +
@@ -838,10 +845,10 @@ function ResearchHubContent(props: ResearchHubProps) { />
- )} +
{/* FORECASTS TAB: Prediction Cockpit */} - {activeTab === 'forecasts' && ( +
- )} +
{/* Deals, Changes, Changelog tabs removed — accessible via Cmd+K */}
+
{/* RIGHT: COMPACT HUD SIDEBAR */} diff --git a/src/features/strategy/views/ExecutionTraceView.tsx b/src/features/strategy/views/ExecutionTraceView.tsx index 057b27f9d..736ce2147 100644 --- a/src/features/strategy/views/ExecutionTraceView.tsx +++ b/src/features/strategy/views/ExecutionTraceView.tsx @@ -14,6 +14,8 @@ import { import { useConvexAuth, useQuery } from "convex/react"; import { LazyCodeBlock } from "@/shared/components/LazyCodeBlock"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ai-ui/tabs"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ai-ui/toggle-group"; import { cn } from "@/lib/utils"; import { SourceChip } from "@/shared/ui"; import { api } from "../../../../convex/_generated/api"; @@ -450,14 +452,17 @@ export function ExecutionTraceView() { Start with the level of detail you actually need, then drill down only when the workflow outcome needs explanation or a full audit trail.

-
+ value && setDisclosureLevel(value as DisclosureLevel)} + className="flex flex-wrap gap-2" + aria-label="Execution trace disclosure levels" + > {DISCLOSURE_LEVELS.map((level) => ( - + ))} -
+
{DISCLOSURE_LEVELS.find((level) => level.id === disclosureLevel)?.description}
-
- {visibleTabs.map((tab) => ( - - ))} -
+ setActiveTab(value as TraceTab)} + className="contents" + > + + {visibleTabs.map((tab) => ( + + {tab.label} + + ))} + - {activeTab === "overview" ? ( + <> {disclosureLevel === "outcome" ? (
@@ -649,9 +659,9 @@ export function ExecutionTraceView() {
) : null} - ) : null} +
- {activeTab === "timeline" ? ( +
}>
@@ -697,9 +707,9 @@ export function ExecutionTraceView() { emptyLabel="No span-level trajectory overlays are available for this workflow yet." />
- ) : null} + - {activeTab === "evidence" ? ( +
}>
@@ -737,9 +747,9 @@ export function ExecutionTraceView() {
- ) : null} + - {activeTab === "diffs" ? ( +
{trace.diffs.map((diff: any) => ( }> @@ -779,9 +789,9 @@ export function ExecutionTraceView() { ))}
- ) : null} +
- {activeTab === "verification" ? ( + }>
{trace.verification_checks.map((check: any) => ( @@ -807,9 +817,9 @@ export function ExecutionTraceView() { ))}
- ) : null} +
- {activeTab === "json" ? ( +
}> @@ -818,7 +828,8 @@ export function ExecutionTraceView() {
- ) : null} +
+
); } diff --git a/src/features/strategy/views/ProductDirectionMemoView.tsx b/src/features/strategy/views/ProductDirectionMemoView.tsx index a96651c1f..44994f33d 100644 --- a/src/features/strategy/views/ProductDirectionMemoView.tsx +++ b/src/features/strategy/views/ProductDirectionMemoView.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { LazyCodeBlock } from "@/shared/components/LazyCodeBlock"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ai-ui/tabs"; import { cn } from "@/lib/utils"; import { SourceChip } from "@/shared/ui"; @@ -132,27 +133,32 @@ export function ProductDirectionMemoView() {
-
- {TABS.map((tab) => ( - - ))} -
+ setActiveTab(value as MemoTab)} + className="contents" + > + + {TABS.map((tab) => ( + + {tab.label} + + ))} + - {activeTab === "executive" ? ( +
}>
@@ -212,9 +218,9 @@ export function ProductDirectionMemoView() {
- ) : null} +
- {activeTab === "evidence" ? ( +
}>
@@ -249,9 +255,9 @@ export function ProductDirectionMemoView() {
- ) : null} +
- {activeTab === "options" ? ( +
}>
@@ -315,9 +321,9 @@ export function ProductDirectionMemoView() { ))}
- ) : null} +
- {activeTab === "plan" ? ( +
}>
@@ -361,9 +367,9 @@ export function ProductDirectionMemoView() {
- ) : null} +
- {activeTab === "narrative" ? ( +
}> @@ -390,9 +396,9 @@ export function ProductDirectionMemoView() {
- ) : null} +
- {activeTab === "json" ? ( +
}> @@ -401,7 +407,8 @@ export function ProductDirectionMemoView() {
- ) : null} +
+
); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1a0310b7f..6e7e286ec 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -13,8 +13,6 @@ export { useCockpitRouting, useCockpitSurfaceFromUrl } from "./useCockpitRouting // Navigation & interaction export { useCommandPalette } from "./useCommandPalette"; -export { useKeyboardNavigation } from "./useKeyboardNavigation"; -export { useFocusTrap } from "./useFocusTrap"; export { useGlobalEventListeners } from "./useGlobalEventListeners"; // Voice pipeline diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts deleted file mode 100644 index 3c5d1ef39..000000000 --- a/src/hooks/useFocusTrap.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect, useRef, useCallback } from 'react'; -import { getFocusableElements, getFirstFocusable } from '../utils/a11y'; - -interface UseFocusTrapOptions { - /** Whether the focus trap is active */ - enabled?: boolean; - /** Element to return focus to when trap is deactivated */ - returnFocusTo?: HTMLElement | null; - /** Whether to auto-focus the first focusable element */ - autoFocus?: boolean; - /** Callback when escape is pressed */ - onEscape?: () => void; -} - -/** - * Hook to trap focus within a container element - * Useful for modals, dialogs, and other overlay components - */ -export function useFocusTrap( - options: UseFocusTrapOptions = {} -) { - const { - enabled = true, - returnFocusTo, - autoFocus = true, - onEscape, - } = options; - - const containerRef = useRef(null); - const previousActiveElement = useRef(null); - - // Store the previously focused element when trap activates - useEffect(() => { - if (enabled) { - previousActiveElement.current = document.activeElement as HTMLElement; - } - }, [enabled]); - - // Auto-focus first element when trap activates - useEffect(() => { - if (!enabled || !autoFocus || !containerRef.current) return; - - // Small delay to ensure the container is rendered - const timeoutId = setTimeout(() => { - const firstFocusable = getFirstFocusable(containerRef.current!); - if (firstFocusable) { - firstFocusable.focus(); - } else { - // If no focusable elements, focus the container itself - containerRef.current?.focus(); - } - }, 10); - - return () => clearTimeout(timeoutId); - }, [enabled, autoFocus]); - - // Return focus when trap deactivates - useEffect(() => { - return () => { - if (!enabled) return; - - const elementToFocus = returnFocusTo || previousActiveElement.current; - if (elementToFocus && typeof elementToFocus.focus === 'function') { - // Small delay to ensure the trap container is unmounted - setTimeout(() => { - elementToFocus.focus(); - }, 10); - } - }; - }, [enabled, returnFocusTo]); - - // Handle keyboard events for focus trapping - const handleKeyDown = useCallback((event: KeyboardEvent) => { - if (!enabled || !containerRef.current) return; - - // Handle Escape key - if (event.key === 'Escape' && onEscape) { - event.preventDefault(); - onEscape(); - return; - } - - // Handle Tab key for focus trapping - if (event.key !== 'Tab') return; - - const focusableElements = getFocusableElements(containerRef.current); - if (focusableElements.length === 0) { - event.preventDefault(); - return; - } - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - - if (event.shiftKey) { - // Shift + Tab: if on first element, go to last - if (document.activeElement === firstElement) { - event.preventDefault(); - lastElement.focus(); - } - } else { - // Tab: if on last element, go to first - if (document.activeElement === lastElement) { - event.preventDefault(); - firstElement.focus(); - } - } - }, [enabled, onEscape]); - - // Attach keyboard listener - useEffect(() => { - if (!enabled) return; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [enabled, handleKeyDown]); - - return { - containerRef, - /** Props to spread on the container element */ - containerProps: { - ref: containerRef, - tabIndex: -1, // Make container focusable but not in tab order - role: 'dialog' as const, - 'aria-modal': true as const, - }, - }; -} - -export default useFocusTrap; - diff --git a/src/hooks/useKeyboardNavigation.tsx b/src/hooks/useKeyboardNavigation.tsx deleted file mode 100644 index cc69cbb38..000000000 --- a/src/hooks/useKeyboardNavigation.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useEffect, useCallback, useState } from 'react'; - -interface KeyboardNavigationOptions { - items: { id: string }[]; - onSelect?: (id: string) => void; - onOpen?: (id: string) => void; - onAnalyze?: (id: string) => void; - onBookmark?: (id: string) => void; - enabled?: boolean; -} - -interface KeyboardNavigationResult { - selectedIndex: number; - selectedId: string | null; - setSelectedIndex: (index: number) => void; - isKeyboardMode: boolean; -} - -export function useKeyboardNavigation({ - items, - onSelect, - onOpen, - onAnalyze, - onBookmark, - enabled = true, -}: KeyboardNavigationOptions): KeyboardNavigationResult { - const [selectedIndex, setSelectedIndex] = useState(-1); - const [isKeyboardMode, setIsKeyboardMode] = useState(false); - - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (!enabled || items.length === 0) return; - - // Don't capture if user is typing in an input - const activeElement = document.activeElement; - if ( - activeElement?.tagName === 'INPUT' || - activeElement?.tagName === 'TEXTAREA' || - (activeElement as HTMLElement)?.isContentEditable - ) { - return; - } - - switch (e.key) { - case 'j': - case 'ArrowDown': - e.preventDefault(); - setIsKeyboardMode(true); - setSelectedIndex(prev => { - const next = prev < items.length - 1 ? prev + 1 : prev; - onSelect?.(items[next]?.id); - return next; - }); - break; - - case 'k': - case 'ArrowUp': - e.preventDefault(); - setIsKeyboardMode(true); - setSelectedIndex(prev => { - const next = prev > 0 ? prev - 1 : 0; - onSelect?.(items[next]?.id); - return next; - }); - break; - - case 'Enter': - case 'o': - e.preventDefault(); - if (selectedIndex >= 0 && items[selectedIndex]) { - onOpen?.(items[selectedIndex].id); - } - break; - - case 'a': - e.preventDefault(); - if (selectedIndex >= 0 && items[selectedIndex]) { - onAnalyze?.(items[selectedIndex].id); - } - break; - - case 's': - case 'b': - e.preventDefault(); - if (selectedIndex >= 0 && items[selectedIndex]) { - onBookmark?.(items[selectedIndex].id); - } - break; - - case 'Escape': - setSelectedIndex(-1); - setIsKeyboardMode(false); - break; - - case 'g': - // Go to top - if (e.shiftKey) { - e.preventDefault(); - setSelectedIndex(items.length - 1); - onSelect?.(items[items.length - 1]?.id); - } else { - e.preventDefault(); - setSelectedIndex(0); - onSelect?.(items[0]?.id); - } - break; - } - }, [enabled, items, selectedIndex, onSelect, onOpen, onAnalyze, onBookmark]); - - // Reset keyboard mode on mouse move - useEffect(() => { - const handleMouseMove = () => { - if (isKeyboardMode) { - setIsKeyboardMode(false); - } - }; - - window.addEventListener('mousemove', handleMouseMove); - return () => window.removeEventListener('mousemove', handleMouseMove); - }, [isKeyboardMode]); - - useEffect(() => { - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); - - // Reset selection when items change - useEffect(() => { - setSelectedIndex(-1); - }, [items.length]); - - return { - selectedIndex, - selectedId: selectedIndex >= 0 ? items[selectedIndex]?.id ?? null : null, - setSelectedIndex, - isKeyboardMode, - }; -} - -// Keyboard shortcut hint component -export function KeyboardShortcutsHint({ visible }: { visible: boolean }) { - if (!visible) return null; - - return ( -
-
- Navigate: -
- j - k -
- | - Open: - o - | - Analyze: - a - | - Save: - s -
-
- ); -} diff --git a/src/layouts/CockpitLayout.tsx b/src/layouts/CockpitLayout.tsx index c1be84de5..b1b374094 100644 --- a/src/layouts/CockpitLayout.tsx +++ b/src/layouts/CockpitLayout.tsx @@ -45,6 +45,12 @@ import { type MainView, } from "@/lib/registry/viewRegistry"; import { cn } from "@/lib/utils"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@/components/ai-ui/dialog"; import { trackEvent } from "@/lib/analytics"; import { usePathTracking } from "../hooks/usePathTracking"; @@ -61,7 +67,6 @@ import { IOSChrome } from "./IOSChrome"; import { ProductTopNav } from "./ProductTopNav"; // AgentPresenceRail removed — replaced by floating FAB + slide-over panel // FeedbackWidget removed — overlapped FastAgent FAB, localStorage-only -// useBottomSheet removed — unified panel uses fixed position overlay import { useSwipeNavigation } from "@/lib/hooks/useSwipeNavigation"; import { loadLastChatPath } from "@/features/product/lib/productSession"; import { ObjectFirstGlobalToggle } from "./ObjectFirstToggle"; @@ -982,31 +987,25 @@ export function CockpitLayout({ {/* Jarvis HUD — disabled, agent panel handles chat */} {/* Keyboard shortcuts overlay */} - {showShortcuts && ( -
setShowShortcuts(false)} - role="presentation" - > -
e.stopPropagation()} + +
-

Keyboard Shortcuts

- + Keyboard Shortcuts + + +
{([ @@ -1023,9 +1022,8 @@ export function CockpitLayout({
))}
-
- - )} + + ); } diff --git a/src/layouts/chrome/CommandPalette.tsx b/src/layouts/chrome/CommandPalette.tsx index 1fa67dcfd..86db0e5b1 100644 --- a/src/layouts/chrome/CommandPalette.tsx +++ b/src/layouts/chrome/CommandPalette.tsx @@ -1,6 +1,15 @@ -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useDeferredValue, useMemo, useRef, useState, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; -import { DialogOverlay } from "@/shared/components/DialogOverlay"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ai-ui/dialog"; +import { + Command as CommandPrimitive, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandShortcut, +} from "@/components/ai-ui/command"; import { useQuery} from "convex/react"; import { useConvexApi } from "@/lib/convexApi"; import { @@ -57,9 +66,7 @@ export function CommandPalette({ const navigate = useNavigate(); const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); - const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); - const listRef = useRef(null); const navigateToSurface = useCallback((surfaceId: CockpitSurfaceId) => { navigate(buildCockpitPath({ surfaceId })); onClose(); @@ -288,63 +295,21 @@ export function CommandPalette({ settings: 'Settings' }; - // Keyboard navigation - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex(prev => Math.min(prev + 1, filteredCommands.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex(prev => Math.max(prev - 1, 0)); - } else if (e.key === 'Enter') { - e.preventDefault(); - const selectedCommand = filteredCommands[selectedIndex]; - if (selectedCommand) { - executeCommand(selectedCommand); - } - } else if (e.key === 'Escape') { - e.preventDefault(); - onClose(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, filteredCommands, selectedIndex, onClose, executeCommand]); - - // Auto-focus input when opened - useEffect(() => { - if (isOpen) { - setQuery(''); - setSelectedIndex(0); - requestAnimationFrame(() => inputRef.current?.focus()); - } - }, [isOpen]); - - // Scroll selected item into view - useEffect(() => { - if (listRef.current && isOpen) { - const selectedElement = listRef.current.querySelector(`[data-index="${selectedIndex}"]`); - selectedElement?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - }, [selectedIndex, isOpen]); - if (!isOpen) return null; - let commandIndex = 0; - return ( - + { if (!nextOpen) onClose(); }}> + { + event.preventDefault(); + setQuery(''); + requestAnimationFrame(() => inputRef.current?.focus()); + }} + > + Command palette + {/* Palette Header */}
@@ -358,17 +323,15 @@ export function CommandPalette({
{/* Search Input */} -
- - - + setQuery(e.target.value)} + onValueChange={setQuery} placeholder="Search commands or navigate…" - className="flex-1 bg-transparent border-none outline-none text-content placeholder:text-content-muted dark:placeholder-gray-500 text-sm" + className="text-content placeholder:text-content-muted dark:placeholder-gray-500" + aria-label="Search commands" data-agent-id="cmd:search" data-agent-action="search" data-agent-label="Search commands" @@ -376,47 +339,36 @@ export function CommandPalette({
{/* Commands List */} -
{filteredCommands.length === 0 ? ( -
+

No commands found

Try a different search term

-
+ ) : ( Object.entries(groupedCommands).map(([section, commands]) => ( -
-
- {sectionLabels[section] || section} -
+ {commands.map((cmd) => { - const currentIndex = commandIndex++; - const isSelected = currentIndex === selectedIndex; - return ( - + + ) : } + ); })} -
+ )) )} -
+ {/* Footer Hints */}
@@ -457,6 +407,8 @@ export function CommandPalette({ Command Palette
- + + +
); } diff --git a/src/layouts/chrome/HashtagQuickNotePopover.tsx b/src/layouts/chrome/HashtagQuickNotePopover.tsx index 8e353f556..f0b58b676 100644 --- a/src/layouts/chrome/HashtagQuickNotePopover.tsx +++ b/src/layouts/chrome/HashtagQuickNotePopover.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useMemo, useRef } from "react"; -import ReactDOM from "react-dom"; +import React, { useMemo } from "react"; import { Id } from "../../../convex/_generated/dataModel"; import { X, ExternalLink } from "lucide-react"; import { useQuery, useMutation, useAction} from "convex/react"; import { useConvexApi } from "@/lib/convexApi"; import { toast } from "sonner"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ai-ui/popover"; interface HashtagQuickNotePopoverProps { isOpen: boolean; @@ -14,57 +14,6 @@ interface HashtagQuickNotePopoverProps { onClose: () => void; } -function ensurePortalRoot(): HTMLElement { - let root = document.getElementById("hashtag-popover-root"); - if (!root) { - root = document.createElement("div"); - root.id = "hashtag-popover-root"; - document.body.appendChild(root); - } - return root; -} - -function useAnchoredPosition(anchorEl: HTMLElement | null, deps: any[]) { - const [pos, setPos] = React.useState({ top: 0, left: 0 }); - - const recompute = React.useCallback(() => { - if (!anchorEl) return; - const rect = anchorEl.getBoundingClientRect(); - const margin = 8; - const width = Math.min(640, Math.max(360, rect.width)); - const height = 420; - - // Default place below - let top = rect.bottom + margin + window.scrollY; - let placement: "bottom" | "top" = "bottom"; - // If overflow bottom, place above - if (top + height > window.scrollY + window.innerHeight) { - top = rect.top - margin - height + window.scrollY; - placement = "top"; - } - - let left = rect.left + window.scrollX; - // If overflow right, shift left - if (left + width > window.scrollX + window.innerWidth - margin) { - left = Math.max(margin, window.scrollX + window.innerWidth - margin - width); - } - - setPos({ top, left }); - }, [anchorEl]); - - React.useLayoutEffect(() => { - recompute(); - window.addEventListener("resize", recompute); - window.addEventListener("scroll", recompute, true); - return () => { - window.removeEventListener("resize", recompute); - window.removeEventListener("scroll", recompute, true); - }; - }, [recompute, ...deps]); - - return pos; -} - export default function HashtagQuickNotePopover({ isOpen, dossierId, @@ -72,51 +21,23 @@ export default function HashtagQuickNotePopover({ anchorEl, onClose, }: HashtagQuickNotePopoverProps) { - const portalRoot = useMemo(() => (typeof window !== "undefined" ? ensurePortalRoot() : null), []); - const containerRef = useRef(null); - - const { top, left } = useAnchoredPosition(anchorEl, [isOpen, dossierId]); + const virtualAnchorRef = useMemo(() => ({ current: anchorEl }), [anchorEl]); + if (!dossierId || !anchorEl) return null; - // Close on Escape - useEffect(() => { - if (!isOpen) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [isOpen, onClose]); - - // Close on click outside - useEffect(() => { - if (!isOpen) return; - const onClick = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - onClose(); - } - }; - window.addEventListener("mousedown", onClick); - return () => window.removeEventListener("mousedown", onClick); - }, [isOpen, onClose]); - - if (!isOpen || !dossierId || !portalRoot) return null; - - return ReactDOM.createPortal( -
+ return ( + { if (!next) onClose(); }}> + + event.preventDefault()} + className="z-[9999] w-[min(640px,calc(100vw-16px))] max-h-[420px] bg-surface border border-edge rounded-lg shadow-2xl overflow-hidden p-0" + > -
, - portalRoot + + ); } diff --git a/src/layouts/settings/SettingsModal.tsx b/src/layouts/settings/SettingsModal.tsx index f1a036cd9..41058668b 100644 --- a/src/layouts/settings/SettingsModal.tsx +++ b/src/layouts/settings/SettingsModal.tsx @@ -3,6 +3,8 @@ import { useThemeSafe } from "../../contexts/ThemeContext"; import { useQuery, useMutation, useAction} from "convex/react"; import { useConvexApi } from "@/lib/convexApi"; import { toast } from "sonner"; +import { Checkbox } from "@/components/ai-ui/checkbox"; +import { Switch } from "@/components/ai-ui/switch"; import { WebMcpSettingsPanel } from "./WebMcpSettingsPanel"; import { useAuthActions } from "@convex-dev/auth/react"; import { @@ -819,26 +821,20 @@ export function SettingsModal({ isOpen, onClose, initialTab }: Props) {
Expand Ungrouped Section by Default
Whether the ungrouped documents section should be expanded when you first open the app.
-