diff --git a/.cursor/rules/no-hand-roll-components.mdc b/.cursor/rules/no-hand-roll-components.mdc new file mode 100644 index 00000000..90f8c7fc --- /dev/null +++ b/.cursor/rules/no-hand-roll-components.mdc @@ -0,0 +1,36 @@ +--- +description: Never hand-roll UI when a catalog/library component exists — install and mount it. +alwaysApply: true +--- + +# No hand-rolled components + +If a visual or behavioral need already exists as a component (21st.dev, shadcn registry, jalco, blocks.so, ledger source, npm library), **install that component and mount it**. Do not approximate it with a local rewrite. + +## Required sequence + +1. **Search first** — `21st search …`, shadcn/registry, and the console library ledger in `apps/console/CLAUDE.md`. +2. **Install** — `npx @21st-dev/cli add author/slug` or `npx shadcn@latest add …` into `apps/console`. Auth via `~/.config/21st/auth.json` / `API_KEY_21ST` when the registry requires it. +3. **Mount the installed file** — product code imports from `components/ui/.tsx` (or the package). Keep the published structure and visual DNA. +4. **Adapt in place** — add props/slots/retokens inside the installed file or a thin wrapper that still renders it. Do not recreate the layout in a parallel hand-rolled file. + +## Forbidden + +```tsx +// ❌ BAD — "inspired by" / pattern copy while the real component exists +// components/ui/icon-rail-sidebar.tsx (hand-rolled stand-in for jshguo) + +// ✅ GOOD — installed source + product shell export +// components/ui/sidebar-component.tsx (21st/@jshguo/sidebar-component) +import { TwoLevelSidebarShell } from '@/components/ui/sidebar-component'; +``` + +- No second implementation that “looks similar” to a named upstream. +- No deleting an installed component and replacing it with divs + Tailwind that mimic it. +- If nothing in the catalog fits, say so, add a ledger row with the named gap, then build — never skip the search. + +## Evidence of compliance + +- `// SOURCING: 21st/@author/slug` (or registry/package) on the file. +- The installed path exists under `components/ui/` or `node_modules`. +- Call sites import that path, not a local lookalike. diff --git a/apps/console/.theorem/runs/fe40c00e-6cc7-4515-93b7-cdde99e6c51e.seq b/apps/console/.theorem/runs/fe40c00e-6cc7-4515-93b7-cdde99e6c51e.seq index 4e9e2884..4f09af71 100644 --- a/apps/console/.theorem/runs/fe40c00e-6cc7-4515-93b7-cdde99e6c51e.seq +++ b/apps/console/.theorem/runs/fe40c00e-6cc7-4515-93b7-cdde99e6c51e.seq @@ -1 +1 @@ -63 \ No newline at end of file +492 \ No newline at end of file diff --git a/apps/console/AGENTS.md b/apps/console/AGENTS.md index f305bd5c..ec1fa082 100644 --- a/apps/console/AGENTS.md +++ b/apps/console/AGENTS.md @@ -99,6 +99,7 @@ asks for inspiration, reference, mood, or direction. | Need | Source | Owns | |---|---|---| | Split layout, tool window panels | `react-resizable-panels` | split geometry, persisted sizes, 1px `--ij-divider` handles | +| Inspector rail dock | `react-resizable-panels` collapsible `Panel` in a second group (`autoSaveId="console.inspector-dock"`) | the right rail's width, drag, collapsed state, keyboard resize, and persistence. A second group, not another Panel in the surface group: the surface group persists sizes by region id through the host, and the rail is chrome with no region of its own. Collapse is `collapsedSize={0}`, so the rail's own edge control (collapse) renders only while open and the well renders `InspectorRailReopen`; a Panel clips its overflow, so a control left inside a zero-width rail is unclickable and still in the accessibility tree | | Search everywhere, palettes | `cmdk` | command list, filtering, keyboard nav | | Approval dialog | `cmdk` Dialog | focus-managed, keyboard-selectable one-shot Plan approval and rejection | | Tabular lenses | tablecn structure on `@tanstack/react-table` plus blocks.so `table-01`..`table-05` anatomy | record.table sorting, filtering, column model | @@ -124,7 +125,7 @@ asks for inspiration, reference, mood, or direction. | Object contract | `@commonplace/block-view` | BlockHost, ObjectQuery, descriptors, surface tree | | Ground / Material Layer | hand-roll (MaterialLayer / GroundCanvas alias) | full-viewport WebGL SDF islands, terracotta ground, edge falloff, grain; DOM shell fills stay transparent | | Files tree | 21st.dev `builduilabs/filesystem-item` behavior extraction plus `@tanstack/react-virtual` | recursive disclosure behavior and large memory projection virtualization | -| Chat sidebar dock | fork of ibelick/motion-primitives `dock` under `components/ui/dock.tsx` (`motion/react`, magnification 44) | sole surface switcher in the chat sidebar | +| Chat / console left chrome | 21st.dev `@jshguo/sidebar-component` installed at `components/ui/sidebar-component.tsx` (`TwoLevelSidebarShell`) | icon rail + detail panel; never hand-roll a lookalike. Retokened onto the register: the published file's Tailwind neutrals, `rounded-lg`, Lexend literals, and `duration-500` resolve to nothing here, because the `@theme` block resets `--color-*`, `--radius-*`, and `--font-*` to initial. Elevation is lifted rail / frame panel / sunken well. The vendored `@carbon/icons-react` glyphs drive the Frame760 demo only; every rail icon a user sees arrives through `items` as a Noun mark, and `brand` / `panelBrand` carry the product's own mark | | Chat context tree | fork of Build UI filesystem-item under `components/ui/filesystem-item.tsx` | graph-object folders with include/exclude and unavailable honesty | | Composer material | `ComposerMaterial` (`@paper-design/shaders-react` GrainGradient) | scoped ShaderMount behind the chat composer; idle speed 0; no backdrop-filter | | Form selection controls | Radix UI Select and Checkbox through `components/ui/select.tsx` and `components/ui/checkbox.tsx` | keyboard and pointer selection, checked state, focus, and disabled behavior | @@ -134,6 +135,7 @@ asks for inspiration, reference, mood, or direction. | Agent plan | `@assistant-ui/react` plus 21st.dev `isaiahbjork/agent-plan` structure extraction | in-thread plan rows, tool labels, and run status | | Graph canvas | `@xyflow/react` (React Flow) over `@dagrejs/dagre` (layered ranking) | the proactivity graph, per SPEC-PROACTIVITY-GRAPH-WIRING: dagre computes the layered join topology (roots rank first, sinks last), React Flow owns pan, zoom, selection, and edge routing; nodes and edges are register-styled (base CSS only, `--xy-*` mapped to the register in app.css), dynamic-imported so the sentence and card altitudes load no graph bundle | | Data canvas | `@xyflow/react` wrap plus `@commonplace/json-canvas` (JSON Canvas 1.0 interchange) plus Paper DotGrid via `ShaderSurface` | graph-native spatial arrangement of ObjectRefs; Obsidian `.canvas` import/export at the edge only; Island Shells DotGrid extract (size 0.7, gap 32, triangle, speed 0, register colors) behind the pane; mounts as surface and companion | +| Inspector Obsidian canvas | 21st `@arunjdass/dashboard-sidebar` Z-layer + `JsonCanvasLayer` + `CanvasStore` id `canvas.inspector.rail` | chrome-owned rail canvas (not block companion); double-click adds text; persists via `canvas.apply_json`. The nav's Places and Objects rows paint Noun marks (`NavIcon` widens the published `LucideIcon` slot); the vendored lucide glyphs drive the demo tree only | | Graph node and sentence card building blocks | jalco-ui `commit-graph` and `repo-card` (ui.justinlevine.me), structure extraction | the commit-entry node (a response is a stack of agent-action steps a person builds) and the RepoCard sentence card; the jalco layouts are reproduced and every shadcn token re-skinned to the register | | Object addressing and copy | `@commonplace/block-view/addressing` (the shared `theorem://` grammar, per DESIGN-THEOREM-URI) plus the platform Clipboard API | every canonical address this app emits, parses, or offers: the inspector footer, the card copy affordance, mention chips, the Composer paste offer, and the Search field's address lane. `src/lib/object-address.ts` is the only place a tenant plus an object becomes an address, and `src/lib/use-copy.ts` is the only clipboard call (the apps/web hook's shape, re-implemented because the import fence is structural) | | Goal Stack canvas | `@xyflow/react` plus `@dagrejs/dagre`, `cmdk`, and `@dnd-kit/core` | DAG canvas and progress edges, named left-to-right layout, searchable capability palette and approval dialog, deferred-affordance drag and drop | diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index 43f4bb37..000eb5f4 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -97,9 +97,14 @@ gap: add the row, with a named source, before writing code. | Need | Source | Owns | |---|---|---| | Split layout, tool window panels | `react-resizable-panels` | split geometry, persisted sizes, 1px `--ij-divider` handles | +| Inspector rail dock | `react-resizable-panels` collapsible `Panel` in a second group (`autoSaveId="console.inspector-dock"`) | the right rail's width, drag, collapsed state, keyboard resize, and persistence. A second group, not another Panel in the surface group: the surface group persists sizes by region id through the host, and the rail is chrome with no region of its own. Collapse is `collapsedSize={0}`, so the rail's own edge control (collapse) renders only while open and the well renders `InspectorRailReopen`; a Panel clips its overflow, so a control left inside a zero-width rail is unclickable and still in the accessibility tree | | Search everywhere, palettes | `cmdk` | command list, filtering, keyboard nav | | Approval dialog | `cmdk` Dialog | focus-managed, keyboard-selectable one-shot Plan approval and rejection | -| Tabular lenses | tablecn structure on `@tanstack/react-table` | record.table sorting, filtering, column model | +| Tabular lenses | tablecn structure on `@tanstack/react-table` plus blocks.so `table-01`..`table-05` anatomy | record.table sorting, filtering, column model | +| Schema-driven record cells and chips | FieldType map in `views/records/cells.tsx` plus blocks.so badge / jalco chip anatomy | polymorphic cells, RecordChip, select hues | +| Model canvas object cards | `@xyflow/react` plus jalco `repo-card` header anatomy (structure extract) | ObjectTypeCard / GhostCard on the diagram lens | +| Model relation edges | `@xyflow/react` BaseEdge / smooth-step | field-anchored RelationEdge | +| Record view bar and calculate footer | `cmdk` plus blocks.so command-menu / table footer patterns | ViewBar grammar and server aggregate footer | | Row virtualization | `@tanstack/react-virtual` | large record sets | | Thread and messages | `@assistant-ui/react` 0.12 | message list, streaming, message state | | Composer mechanics | `@assistant-ui/react` 0.12 | auto-grow input, attachments, object mentions, destination slot, send behavior | @@ -118,14 +123,21 @@ gap: add the row, with a named source, before writing code. | Object contract | `@commonplace/block-view` | BlockHost, ObjectQuery, descriptors, surface tree | | Ground / Material Layer | hand-roll (MaterialLayer / GroundCanvas alias) | full-viewport WebGL SDF islands, terracotta ground, edge falloff, grain; DOM shell fills stay transparent | | Files tree | 21st.dev `builduilabs/filesystem-item` behavior extraction plus `@tanstack/react-virtual` | recursive disclosure behavior and large memory projection virtualization | -| Chat sidebar dock | fork of ibelick/motion-primitives `dock` under `components/ui/dock.tsx` (`motion/react`, magnification 44) | sole surface switcher in the chat sidebar | +| Chat / console left chrome | 21st.dev `@jshguo/sidebar-component` installed at `components/ui/sidebar-component.tsx` (`TwoLevelSidebarShell`) | icon rail + detail panel; never hand-roll a lookalike. Retokened onto the register: the published file's Tailwind neutrals, `rounded-lg`, Lexend literals, and `duration-500` resolve to nothing here, because the `@theme` block resets `--color-*`, `--radius-*`, and `--font-*` to initial. Elevation is lifted rail / frame panel / sunken well. The vendored `@carbon/icons-react` glyphs drive the Frame760 demo only; every rail icon a user sees arrives through `items` as a Noun mark, and `brand` / `panelBrand` carry the product's own mark | | Chat context tree | fork of Build UI filesystem-item under `components/ui/filesystem-item.tsx` | graph-object folders with include/exclude and unavailable honesty | | Composer material | `ComposerMaterial` (`@paper-design/shaders-react` GrainGradient) | scoped ShaderMount behind the chat composer; idle speed 0; no backdrop-filter | | Context graph | D3 | deterministic ego graph layout and relation geometry | | Proactivity graph | `@xyflow/react`, `@dagrejs/dagre`, and the existing `@commonplace/theorem-acp` state/session path | controlled denormalized dependency graph, deterministic topological layout, same-origin firing overlay, and pending compile-review gate | | Agent plan | `@assistant-ui/react` plus 21st.dev `isaiahbjork/agent-plan` structure extraction | in-thread plan rows, tool labels, and run status | | Graph canvas | `@xyflow/react` (React Flow) over `@dagrejs/dagre` (layered ranking) | the proactivity graph, per SPEC-PROACTIVITY-GRAPH-WIRING: dagre computes the layered join topology (roots rank first, sinks last), React Flow owns pan, zoom, selection, and edge routing; nodes and edges are register-styled (base CSS only, `--xy-*` mapped to the register in app.css), dynamic-imported so the sentence and card altitudes load no graph bundle | -| Data canvas | `@xyflow/react` wrap plus `@commonplace/json-canvas` (JSON Canvas 1.0 interchange) plus Paper DotGrid via `ShaderSurface` | graph-native spatial arrangement of ObjectRefs; Obsidian `.canvas` import/export at the edge only; Island Shells DotGrid extract (size 0.7, gap 32, triangle, speed 0, register colors) behind the pane; mounts as surface and companion | +| Data canvas (pane `CanvasView`) | `@xyflow/react` wrap plus `@commonplace/json-canvas` (JSON Canvas 1.0 interchange) plus Paper DotGrid via `ShaderSurface` | graph-native spatial arrangement of ObjectRefs; Obsidian `.canvas` import/export at the edge only; mounts as surface/full pane | +| Inspector Obsidian canvas | 21st `@arunjdass/dashboard-sidebar` Z-layer + `JsonCanvasLayer` + `CanvasStore` id `canvas.inspector.rail` | chrome-owned rail canvas (not block companion); double-click adds text; persists via `canvas.apply_json` | +| Inspector rail frame | `twenty-ui/surfaces` `Card` / `CardContent` / `CardFooter` | the rail is a floating inset panel, not an aside flush to the window edge with a `border-l`. Radius, surface elevation, and the seam against the ground come from the fork's card, so the rail reads as a card lying on the MaterialLayer the way the OpenHuman reference does | +| Inspector rail activity rows | `twenty-ui/navigation` `MenuItemNavigate` | one row per agent step in the rail's task-insights list: label plus the trailing navigate affordance, which is the row anatomy the reference uses. Never a hand-rolled `div` with a chevron | +| Inspector rail step state | `twenty-ui/data-display` `Tag` and `Status` | the three weights the reference distinguishes: a step that is the current heading, a step running now (the warm accent), and a step already passed (muted). Colour arrives through the fork's token generator, so `--ij-*` still drives it | +| Inspector rail action cluster | `twenty-ui/input` `RoundedIconButton` (primary) plus `LightIconButton` (secondary) | the rail's bottom action group: one round primary affordance and the smaller secondary controls beside it. Sizing and press behaviour are the fork's, not restated here | +| Inspector rail meta footer | `twenty-ui/data-display` `Pill` plus register type utilities | the context/cost readout and the goal affordance on the rail's last line | +| Inspector rail data canvas | `@commonplace/model-canvas` (OWOX hard fork, Apache-2.0) `ModelCanvasShell` | the rail's canvas body. Supersedes `JsonCanvasLayer` as the rail's canvas: `SPEC-COMMONPLACE-MODEL-CANVAS-FORK-1.0` makes the OWOX fork the one canvas language, and the rail carrying a second one was the reason the rail and the Data model surface disagreed about what a node looks like. Same package the Data model surface mounts through `ForkDiagramCanvas`, so `MartNode` / `RelEdge` / the substrate edge language are shared rather than reimplemented | | Goal Stack canvas | `@xyflow/react` plus `@dagrejs/dagre`, `cmdk`, and `@dnd-kit/core` | DAG canvas and progress edges, named left-to-right layout, searchable capability palette and approval dialog, deferred-affordance drag and drop | | Workspace substrate | `@tanstack/react-virtual`, CodeMirror 6 merge, `cmdk`, and Radix Popover | semantic workspace entity tree, virtual rows, local-history revision diff, project Find, readiness detail | | Graph node and sentence card building blocks | jalco-ui `commit-graph` and `repo-card` (ui.justinlevine.me), structure extraction | the commit-entry node (a response is a stack of agent-action steps a person builds) and the RepoCard sentence card; the jalco layouts are reproduced and every shadcn token re-skinned to the register | @@ -175,6 +187,7 @@ markdown. Use colons, periods, commas, semicolons, or parentheses instead. | Date | Decision | Why | |---|---|---| +| 2026-07-29 | Inspector Obsidian canvas is chrome-owned (`canvas.inspector.rail` in `DashboardSidebar`), not the `CanvasView` companion mount. Live durability: `CONSOLE_LIVE_CANVAS_SMOKE=1` plus `CONSOLE_LIVE_CANVAS_BASE` (and auth cookie/header as required) runs `src/lib/canvas/store.live.test.ts`; v2 manual smoke is logout/login hard-refresh after placing a note. | Spec-review F3 choice A; unit seam alone cannot close F2. | | 2026-07-18 | The user-facing topic result surface is Indexer. Existing `survey.*` identifiers remain stable. | A topic click returns the full indexed evidence corpus. Keeping internal identifiers avoids breaking persisted surface objects while the product language settles. | | 2026-07-18 | Indexer keeps the installed 21st.dev golden-ratio sphere. Relationship data may contain cycles and never controls card placement. | The spatial shape is the primary reading surface. Edges stay faint at rest, strengthen on hover, and pin on click. | | 2026-07-18 | Indexer pegboard is retired in favor of MaterialLayer grammar. | Source cards and their evidence connections remain the canvas while the transparent scene shell preserves the shared ambient ground. | diff --git a/apps/console/components.json b/apps/console/components.json index 89cb9a1d..863962f5 100644 --- a/apps/console/components.json +++ b/apps/console/components.json @@ -21,5 +21,8 @@ }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "registries": { + "@blocks-so": "https://blocks.so/r/{name}.json", + "@jalco": "https://ui.justinlevine.me/r/{name}.json" + } } diff --git a/apps/console/e2e/appearance.spec.ts b/apps/console/e2e/appearance.spec.ts index d10e9299..6bd0cf8a 100644 --- a/apps/console/e2e/appearance.spec.ts +++ b/apps/console/e2e/appearance.spec.ts @@ -154,7 +154,8 @@ test.describe('appearance surface', () => { }); expect(running).toBe('rgb(31, 117, 54)'); await expect(page.locator('[data-run-widget]')).toHaveCount(0); - await expect(page.locator('[data-account-trigger]')).toHaveCSS('height', '28px'); + // Twenty proportions: --ij-control-h is --t-spacing-8. + await expect(page.locator('[data-account-trigger]')).toHaveCSS('height', '32px'); await expect(page.locator('html')).toHaveCSS('font-size', '13px'); await page.keyboard.press('Alt+Shift+1'); await expect(page.locator('[data-tool-window="files"]')).toBeVisible(); diff --git a/apps/console/e2e/console-ia.spec.ts b/apps/console/e2e/console-ia.spec.ts index 018b5ca2..1809e4df 100644 --- a/apps/console/e2e/console-ia.spec.ts +++ b/apps/console/e2e/console-ia.spec.ts @@ -150,7 +150,7 @@ test.describe('Console information architecture', () => { ); await expect(page.locator('[data-companion-nav]')).toHaveCount(0); await expect(page.locator('[data-rail-connection]')).toHaveCount(0); - await expect(page.locator('[data-connection-owner="status-bar"]')).toHaveCount(1); + await expect(page.locator('[data-connection-owner="status-bar"]')).toHaveCount(0); const blocks = page.locator('[data-rail-tier="blocks"] button'); await expect(blocks).toHaveCount(11); @@ -204,7 +204,8 @@ test.describe('Console information architecture', () => { const input = composer.locator('[data-composer-input]'); await expect(page.locator('[data-chat-sidebar]')).toBeVisible(); await expect(page.locator('[data-chat-transcript]')).toBeVisible(); - await expect(page.locator('[data-chat-rail]')).toBeVisible(); + await expect(page.locator('[data-inspector-rail]')).toBeVisible(); + await expect(page.locator('[data-inspector-rail-edge]')).toBeVisible(); await expect(page.locator('[data-thread-composer-input]')).toHaveCount(0); await expect(composer.getByRole('button', { name: 'Attach file' })).toBeVisible(); await expect(composer.getByLabel('Model')).toHaveValue('theorem'); diff --git a/apps/console/e2e/signatures.spec.ts b/apps/console/e2e/signatures.spec.ts index 8be3be74..8a1807af 100644 --- a/apps/console/e2e/signatures.spec.ts +++ b/apps/console/e2e/signatures.spec.ts @@ -161,18 +161,21 @@ for (const { theme, preset } of THEMES) { await expect(selected).not.toHaveCSS('background-color', accent); await expect(selected).toHaveCSS('color', ink); - // The sidebar is frame chrome (flush activity bar), not an island. + // Left chrome: published 21st/@jshguo TwoLevelSidebar (w-16 rail + w-80 panel). + const iconRail = page.locator('[data-jshguo-icon-rail]'); + const railShell = page.locator('[data-jshguo-sidebar]'); + await expect(iconRail).toHaveCSS('width', '64px'); + await expect(railShell).toHaveAttribute('data-panel-open', 'true'); const stripe = page.locator('[data-paint-region="stripe"]'); - await expect(stripe).toHaveCSS('width', '180px'); await expect(stripe).toHaveAttribute('data-frame-resident', 'stripe'); - await expect(stripe).toHaveAttribute('data-sidebar-collapsed', 'false'); await expect(stripe).not.toHaveAttribute('data-island'); const glyph = selected.locator('svg'); await expect(glyph).toHaveAttribute('width', '16'); await page.keyboard.press('Meta+b'); - await expect(stripe).toHaveAttribute('data-sidebar-collapsed', 'true'); - await expect(stripe).toHaveCSS('width', '48px'); + await expect(railShell).toHaveAttribute('data-panel-open', 'false'); + await expect(iconRail).toHaveCSS('width', '64px'); await page.keyboard.press('Meta+b'); + await expect(railShell).toHaveAttribute('data-panel-open', 'true'); // Companions stay dock panels (Alt+Shift), not rail destinations. await page.keyboard.press('Alt+Shift+1'); @@ -200,18 +203,15 @@ for (const { theme, preset } of THEMES) { }); // Signature 4. Account chrome stays in the toolbar; the run widget is gone. - // The consolidated shell restores the Int UI status bar as the single - // transport claim instead of deleting that frame region. - test('account chrome and the consolidated status bar hold without a run widget', async ({ page }) => { + // Bottom status / presence metadata is removed from the page frame. + test('account chrome holds without a run widget or bottom status metadata', async ({ page }) => { await expect(page.locator('[data-run-widget]')).toHaveCount(0); await expect(page.locator('[data-account-trigger]')).toBeVisible(); - await expect(page.locator('[data-account-trigger]')).toHaveCSS('height', '28px'); - const status = page.locator('[data-paint-region="status-bar"]'); - await expect(status).toBeVisible(); - await expect(status).toHaveAttribute('data-frame-resident', 'status-bar'); - await expect(status).toHaveAttribute('data-connection-owner', 'status-bar'); - await expect(status).toHaveCSS('height', '28px'); - await expect(status.locator('[data-connection]')).toHaveCount(1); + // 32, not the Int UI 28: --ij-control-h resolves to Twenty's + // --t-spacing-8 since the register inverted onto Twenty's proportions. + await expect(page.locator('[data-account-trigger]')).toHaveCSS('height', '32px'); + await expect(page.locator('[data-paint-region="status-bar"]')).toHaveCount(0); + await expect(page.locator('[data-connection-owner="status-bar"]')).toHaveCount(0); await expect(page.locator('[data-shell-sidebar-seam]')).toBeVisible(); }); @@ -399,7 +399,7 @@ for (const { theme, preset } of THEMES.filter((entry) => entry.theme === 'dark') scrollWidth: document.documentElement.scrollWidth, }, shell: rect('[data-shell]'), - sidebar: rect('[data-shell-sidebar]'), + sidebar: rect('[data-jshguo-sidebar]'), ground: rect('[data-shell-region="ground"]'), status: rect('[data-paint-region="status-bar"]'), }; @@ -417,11 +417,11 @@ for (const { theme, preset } of THEMES.filter((entry) => entry.theme === 'dark') height: viewport.height, bottom: viewport.height, }); - expect(geometry.sidebar?.width).toBe(180); + // Published jshguo: icon rail (w-16=64) + expanded detail (w-80=320). + expect(geometry.sidebar?.width).toBe(384); expect(geometry.ground?.width ?? 0).toBeGreaterThan(viewport.width / 3); expect(geometry.ground?.height ?? 0).toBeGreaterThan(viewport.height / 2); - expect(geometry.status?.height).toBe(28); - expect(geometry.status?.bottom).toBe(viewport.height); + expect(geometry.status).toBeNull(); }); } } diff --git a/apps/console/package.json b/apps/console/package.json index 416bca61..dba3a78f 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -38,6 +38,7 @@ "@assistant-ui/react-markdown": "0.12.11", "@assistant-ui/store": "0.2.13", "@assistant-ui/tap": "0.5.16", + "@carbon/icons-react": "^11.84.0", "@codemirror/commands": "^6.10.3", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", diff --git a/apps/console/scripts/check-contrast.mjs b/apps/console/scripts/check-contrast.mjs index b9d0881a..0e1487cd 100644 --- a/apps/console/scripts/check-contrast.mjs +++ b/apps/console/scripts/check-contrast.mjs @@ -10,62 +10,142 @@ import { wcagContrast } from '@travis-gilbert/markdown-theory/tokens'; import { NAVY_KNOBS, generateTheme } from '../src/styles/theme-engine.ts'; const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/* The gate measures the paint that actually ships, so this list mirrors the + @import order in src/styles/app.css exactly. Reading a subset is how a gate + goes falsely green: before the register inversion this file read only the + Int UI registers, and kept passing after those files stopped deciding the + paint. If app.css gains or reorders a register stylesheet, this list moves + with it. */ const registerSources = [ 'src/styles/int-ui-register.css', 'src/styles/int-ui-register-light.css', + '../../packages/twenty-ui/src/theme-constants/theme-dark.css', + '../../packages/twenty-ui/src/theme-constants/theme-light.css', + 'src/styles/twenty-register.css', 'src/styles/register-bridge.css', 'src/styles/primer-register.css', ].map((relative) => readFileSync(path.join(appRoot, relative), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')); -function hexToOklch(hex) { - const value = hex.replace('#', ''); - const [r, g, b] = [0, 2, 4] - .map((index) => Number.parseInt(value.slice(index, index + 2), 16) / 255) - .map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); - const linearL = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; - const linearM = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; - const linearS = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; - const l3 = Math.cbrt(linearL); - const m3 = Math.cbrt(linearM); - const s3 = Math.cbrt(linearS); +/* OKLab via XYZ, so sRGB hex and Twenty's color(display-p3 ...) reach the same + space through their own primaries rather than one pretending to be the other. + Feeding P3 coordinates through the sRGB matrix would shift every hue and + inflate chroma, which on a chroma-clamped register reads as a passing value. */ +const SRGB_TO_XYZ = [ + [0.4123907993, 0.3575843394, 0.1804807884], + [0.2126390059, 0.7151686788, 0.0721923154], + [0.0193308187, 0.1191947798, 0.9505321522], +]; +const P3_TO_XYZ = [ + [0.4865709486, 0.2656676932, 0.1982172852], + [0.2289745641, 0.6917385218, 0.0792869141], + [0.0, 0.0451133819, 1.0439443689], +]; + +const toLinear = (channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + +function xyzToOklch([x, y, z]) { + const l3 = Math.cbrt(0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z); + const m3 = Math.cbrt(0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z); + const s3 = Math.cbrt(0.0482003018 * x + 0.2643662691 * y + 0.633851707 * z); const l = 0.2104542553 * l3 + 0.793617785 * m3 - 0.0040720468 * s3; const a = 1.9779984951 * l3 - 2.428592205 * m3 + 0.4505937099 * s3; const bAxis = 0.0259040371 * l3 + 0.7827717662 * m3 - 0.808675766 * s3; return { l, c: Math.hypot(a, bAxis), h: (Math.atan2(bAxis, a) * 180 / Math.PI + 360) % 360 }; } -function selectorApplies(selector, preset) { - if (!selector.includes('[data-register="intui"]')) return false; - const lightOnly = selector.includes('[data-theme="light"]'); - const darkOnly = selector.includes('[data-theme="dark"]'); - if (lightOnly && preset.mode !== 'light') return false; - if (darkOnly && preset.mode !== 'dark') return false; - const presetMatch = selector.match(/\[data-theme-preset="([^"]+)"\]/); +const project = (matrix, rgb) => matrix.map((row) => + row[0] * rgb[0] + row[1] * rgb[1] + row[2] * rgb[2]); + +function toOklch(color) { + return xyzToOklch(project(color.space === 'display-p3' ? P3_TO_XYZ : SRGB_TO_XYZ, color.rgb)); +} + +/* One arm of a selector list, scored the way the cascade scores it. Order alone + is not enough once the fork's theme layer is in the list: it scopes light mode + with two attributes, so a one-attribute block later in the file still loses. */ +function armApplies(arm, preset) { + if (!arm.includes('[data-register="intui"]')) return false; + if (arm.includes('[data-theme="light"]') && preset.mode !== 'light') return false; + if (arm.includes('[data-theme="dark"]') && preset.mode !== 'dark') return false; + const presetMatch = arm.match(/\[data-theme-preset="([^"]+)"\]/); return !presetMatch || presetMatch[1] === preset.id; } +/** Specificity of the winning arm, counted in attribute selectors. */ +function selectorWeight(selector, preset) { + let weight = -1; + for (const arm of selector.split(',')) { + if (!armApplies(arm, preset)) continue; + weight = Math.max(weight, (arm.match(/\[/g) ?? []).length); + } + return weight; +} + function declarationsFor(preset) { const declarations = new Map(); + let order = 0; for (const source of registerSources) { for (const block of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { - if (!selectorApplies(block[1], preset)) continue; - for (const declaration of block[2].matchAll(/(--(?:ij|cp)-[a-z0-9-]+)\s*:\s*([^;]+);/gi)) { - declarations.set(declaration[1], declaration[2].trim()); + const weight = selectorWeight(block[1], preset); + if (weight < 0) continue; + order += 1; + for (const declaration of block[2].matchAll(/(--(?:ij|cp|t)-[a-z0-9-]+)\s*:\s*([^;]+);/gi)) { + const previous = declarations.get(declaration[1]); + if (previous && previous.weight > weight) continue; + declarations.set(declaration[1], { value: declaration[2].trim(), weight, order }); } } } return declarations; } +/** Parses the colour syntaxes the register actually emits into linear RGB. */ +function parseColor(value) { + const hex = value.match(/^#([0-9a-f]{6})$/i); + if (hex) { + return { + space: 'srgb', + rgb: [0, 2, 4].map((index) => + toLinear(Number.parseInt(hex[1].slice(index, index + 2), 16) / 255)), + }; + } + const p3 = value.match(/^color\(\s*display-p3\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)$/i); + if (p3) { + /* Twenty writes display-p3 coordinates already in the transfer-encoded + form, same as an sRGB hex, so they linearise the same way. */ + return { space: 'display-p3', rgb: p3.slice(1, 4).map((n) => toLinear(Number(n))) }; + } + const rgb = value.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*[\d.%]+\s*)?\)$/i); + if (rgb) { + return { space: 'srgb', rgb: rgb.slice(1, 4).map((n) => toLinear(Number(n) / 255)) }; + } + return null; +} + function resolveToken(name, declarations, seen = new Set()) { if (seen.has(name)) throw new Error(`token cycle at ${name}`); seen.add(name); - const value = declarations.get(name); - if (!value) throw new Error(`token ${name} not found`); - const reference = value.match(/^var\((--[a-z0-9-]+)\)$/i); + const entry = declarations.get(name); + if (!entry) throw new Error(`token ${name} not found`); + const reference = entry.value.match(/^var\((--[a-z0-9-]+)\)$/i); if (reference) return resolveToken(reference[1], declarations, seen); - if (/^#[0-9a-f]{6}$/i.test(value)) return value; - throw new Error(`token ${name} resolves to unsupported gate value: ${value}`); + const color = parseColor(entry.value); + /* A translucent or computed value cannot be measured against a floor without + knowing what is behind it, so the gate names it rather than guessing. */ + if (!color) throw new Error(`token ${name} resolves to unsupported gate value: ${entry.value}`); + return color; +} + +/** The terminal declared value, for anchors that pin an exact literal. */ +function resolveRaw(name, declarations, seen = new Set()) { + if (seen.has(name)) throw new Error(`token cycle at ${name}`); + seen.add(name); + const entry = declarations.get(name); + if (!entry) throw new Error(`token ${name} not found`); + const reference = entry.value.match(/^var\((--[a-z0-9-]+)\)$/i); + return reference ? resolveRaw(reference[1], declarations, seen) : entry.value; } const PAIRS = [ @@ -76,10 +156,20 @@ const PAIRS = [ { name: 'ink on editor', foreground: '--ij-ink', background: '--ij-editor', target: 4.5 }, { name: 'bright ink on accent', foreground: '--ij-ink-bright', background: '--ij-accent', target: 3 }, { name: 'keyline on chrome', foreground: '--ij-keyline', background: '--ij-chrome', target: 1.2 }, - /* Both island classes sit darker than frame (dark frame-inversion). Floor is - the sunken-vs-frame step for tool and editor alike; gutters carry the rest. */ + /* One symmetric elevation floor, and the arithmetic that forced it. + The frame now sits between the two islands: the sidebar lifts above it and + the editor sinks below it. The editor step used to ask 1.08 against the + chrome step's 1.05, tuned to the Int UI ladder. Twenty's dark background + ladder spans 1.126 end to end (bg-primary to bg-quaternary), and asking for + both steps around one frame needs 1.08 * 1.05 = 1.134. That does not fit, + and there is no step between bg-quaternary (0.133) and gray6 (0.282) to + borrow, so the only way to hold 1.08 would be a sidebar on gray6: 1.74 + against the frame, heavier separation than the Int UI register ever had and + the opposite of the flatter reading this round is for. + 1.05 * 1.05 = 1.1025 fits inside 1.126 with room. Both islands separate + from the frame by a real, measured step; neither floor is waived. */ { name: 'chrome island on frame', foreground: '--ij-chrome', background: '--ij-frame', target: 1.05 }, - { name: 'editor sunken on frame', foreground: '--ij-editor', background: '--ij-frame', target: 1.08 }, + { name: 'editor sunken on frame', foreground: '--ij-editor', background: '--ij-frame', target: 1.05 }, /* HANDOFF-CONSOLE-ISLAND-SHELL: header band over island base (elevation step). */ { name: 'island header tool over chrome', foreground: '--ij-island-header-tool', background: '--ij-chrome', target: 1.05 }, { name: 'island header editor over editor', foreground: '--ij-island-header-editor', background: '--ij-editor', target: 1.05 }, @@ -147,7 +237,7 @@ for (const preset of [ for (const pair of PAIRS) { const foreground = resolveToken(pair.foreground, declarations); const background = resolveToken(pair.background, declarations); - const ratio = wcagContrast(hexToOklch(foreground), hexToOklch(background)); + const ratio = wcagContrast(toOklch(foreground), toOklch(background)); const pass = ratio >= pair.target; failed ||= !pass; console.log(`${pass ? 'PASS' : 'FAIL'} ${preset.id} · ${pair.name}: ${ratio.toFixed(2)} (target ${pair.target})`); @@ -155,7 +245,7 @@ for (const preset of [ const anchors = PRIMER_ANCHORS[preset.id]; if (anchors) { for (const [token, expected] of Object.entries(anchors)) { - const actual = resolveToken(token, declarations).toUpperCase(); + const actual = resolveRaw(token, declarations).toUpperCase(); const pass = actual === expected; failed ||= !pass; console.log(`${pass ? 'PASS' : 'FAIL'} ${preset.id} · Primer anchor ${token}: ${actual}`); @@ -173,7 +263,7 @@ for (const preset of [ for (const pair of SPEAKER_PAIRS) { const foreground = resolveToken(pair.foreground, declarations); const background = resolveToken(pair.background, declarations); - const ratio = wcagContrast(hexToOklch(foreground), hexToOklch(background)); + const ratio = wcagContrast(toOklch(foreground), toOklch(background)); const pass = ratio >= pair.target; failed ||= !pass; console.log(`${pass ? 'PASS' : 'FAIL'} ${preset.id} · ${pair.name}: ${ratio.toFixed(2)} (target ${pair.target})`); @@ -181,7 +271,7 @@ for (const preset of [ for (const pair of RAIL_PAIRS) { const foreground = resolveToken(pair.foreground, declarations); const background = resolveToken(pair.background, declarations); - const ratio = wcagContrast(hexToOklch(foreground), hexToOklch(background)); + const ratio = wcagContrast(toOklch(foreground), toOklch(background)); const pass = ratio >= pair.target; failed ||= !pass; console.log(`${pass ? 'PASS' : 'FAIL'} ${preset.id} · ${pair.name}: ${ratio.toFixed(2)} (target ${pair.target})`); diff --git a/apps/console/scripts/check-twenty-fence.mjs b/apps/console/scripts/check-twenty-fence.mjs index 91ce8c5a..53672cae 100644 --- a/apps/console/scripts/check-twenty-fence.mjs +++ b/apps/console/scripts/check-twenty-fence.mjs @@ -81,6 +81,15 @@ for (const root of importRoots) { }); continue; } + /* A relative specifier is a file on disk, not a package, so judge it by + where it lands rather than by what it is called. './twenty-register.css' + is a console stylesheet named after the source it aliases and never + leaves the app; '../../packages/twenty-front/x' really does cross the + line and is still caught, because the test is on the resolved path. */ + if (spec.startsWith('.')) { + const resolved = path.resolve(path.dirname(file), spec); + if (!/[\\/]packages[\\/]twenty-/.test(resolved)) continue; + } // Anything else naming twenty must resolve to the vendored fork. if ( /(^|\/)twenty[-/]/.test(spec) && diff --git a/apps/console/src/.theorem/runs/fe40c00e-6cc7-4515-93b7-cdde99e6c51e.seq b/apps/console/src/.theorem/runs/fe40c00e-6cc7-4515-93b7-cdde99e6c51e.seq index 4800c7da..0fa6a7b0 100644 --- a/apps/console/src/.theorem/runs/fe40c00e-6cc7-4515-93b7-cdde99e6c51e.seq +++ b/apps/console/src/.theorem/runs/fe40c00e-6cc7-4515-93b7-cdde99e6c51e.seq @@ -1 +1 @@ -58 \ No newline at end of file +90 \ No newline at end of file diff --git a/apps/console/src/app/api/proactivity/compile/route.ts b/apps/console/src/app/api/proactivity/compile/route.ts index a0a1685e..d8eb2009 100644 --- a/apps/console/src/app/api/proactivity/compile/route.ts +++ b/apps/console/src/app/api/proactivity/compile/route.ts @@ -10,11 +10,12 @@ import { } from '@commonplace/theorem-acp/bridge'; import { parseProactivityCompilation } from '@/lib/proactivity/compilation'; import { proactivityCompilationStream } from '@/lib/proactivity/compilation-stream'; -import { - configuredServiceTenantMatches, - resolveHarnessPrincipal, -} from '@/lib/server/harness-principal'; +import { resolveHarnessPrincipal } from '@/lib/server/harness-principal'; import { stageProactivityCompilation } from '@/lib/server/proactivity-harness'; +import { + credentialRefusalResponse, + resolveUpstreamCredential, +} from '@/lib/server/upstream-credential'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; @@ -28,14 +29,9 @@ export async function POST(request: Request): Promise { } const resolution = await resolveHarnessPrincipal(); if (!resolution.ok) return resolution.response; - if (!configuredServiceTenantMatches(resolution.principal)) { - return Response.json( - { - error: 'tenant_connector_unavailable', - message: 'This signed-in tenant does not yet have a matching hosted ACP credential.', - }, - { status: 403 }, - ); + const resolvedCredential = await resolveUpstreamCredential(resolution.principal); + if (!resolvedCredential.ok) { + return credentialRefusalResponse(resolvedCredential.refusal); } try { const command: BridgeCommand = { @@ -58,7 +54,13 @@ export async function POST(request: Request): Promise { sourceId: null, displayText: parsed.data.intent, }; - const session = await resolveBridgeSession({}); + const session = await resolveBridgeSession({ + tenant: resolution.principal.tenant, + authToken: + resolvedCredential.credential.kind === 'service_key' + ? resolvedCredential.credential.key + : resolvedCredential.credential.token, + }); await dispatchBridgeCommands(session, [command]); return new Response( proactivityCompilationStream( diff --git a/apps/console/src/app/chat/page.test.tsx b/apps/console/src/app/chat/page.test.tsx index a3691fa1..0061fc93 100644 --- a/apps/console/src/app/chat/page.test.tsx +++ b/apps/console/src/app/chat/page.test.tsx @@ -51,10 +51,28 @@ describe('ordinary chat route', () => { it('sends unresolved principals to login', async () => { mocks.resolveHarnessPrincipal.mockResolvedValue({ ok: false, - response: new Response(null, { status: 401 }), + response: Response.json( + { error: 'principal_resolution=unauthenticated' }, + { status: 401 }, + ), }); - await expect(ChatIndexPage()).rejects.toThrow('NEXT_REDIRECT:/login?callbackUrl=/chat'); - expect(mocks.redirect).toHaveBeenCalledWith('/login?callbackUrl=/chat'); + await expect(ChatIndexPage()).rejects.toThrow( + 'NEXT_REDIRECT:/login?callbackUrl=%2Fchat', + ); + expect(mocks.redirect).toHaveBeenCalledWith('/login?callbackUrl=%2Fchat'); + }); + + it('sends signed-in users missing an active workspace to onboarding', async () => { + mocks.resolveHarnessPrincipal.mockResolvedValue({ + ok: false, + response: Response.json( + { error: 'active_workspace_claim_required' }, + { status: 403 }, + ), + }); + + await expect(ChatIndexPage()).rejects.toThrow('NEXT_REDIRECT:/onboarding'); + expect(mocks.redirect).toHaveBeenCalledWith('/onboarding'); }); }); diff --git a/apps/console/src/app/chat/page.tsx b/apps/console/src/app/chat/page.tsx index 07fd55d6..d4aefc2e 100644 --- a/apps/console/src/app/chat/page.tsx +++ b/apps/console/src/app/chat/page.tsx @@ -1,16 +1,16 @@ -// SOURCING: none. Chat index stays on /chat so OW4 middleware can reverse-proxy -// the workspace openwork door. Do not redirect into /workspace/*/chat — that -// path bypasses the proxy matcher and remounts the retired assistant-ui page. +// SOURCING: none. Chat page route (CH1). Unscoped /chat redirects into the +// active workspace chat once membership and scope are verified. -import { redirect } from 'next/navigation'; +import { ChatUnavailable } from './chat-unavailable'; import { OpenworkChatRegister } from '@/views/OpenworkChatRegister'; +import { redirect } from 'next/navigation'; +import { redirectForFailedPrincipal } from '@/lib/server/principal-redirect'; import { resolveHarnessPrincipal } from '@/lib/server/harness-principal'; -import { ChatUnavailable } from './chat-unavailable'; export default async function ChatIndexPage() { const resolution = await resolveHarnessPrincipal(); if (!resolution.ok) { - redirect('/login?callbackUrl=/chat'); + return redirectForFailedPrincipal(resolution, '/chat'); } if (!resolution.principal.workspaceId || !resolution.principal.scopeRef) { return ; diff --git a/apps/console/src/app/dev/chrome-preview/page.tsx b/apps/console/src/app/dev/chrome-preview/page.tsx new file mode 100644 index 00000000..360f05bb --- /dev/null +++ b/apps/console/src/app/dev/chrome-preview/page.tsx @@ -0,0 +1,96 @@ +'use client'; + +// SOURCING: 21st/@jshguo/sidebar-component (via ChatSidebar) + InspectorRail. + +import { useState } from 'react'; +import { SessionProvider } from 'next-auth/react'; +import { MaterialLayer } from '@/components/ground/MaterialLayer'; +import { ChatSidebar } from '@/components/chat/ChatSidebar'; +import { + CHAT_INSPECTOR_SECTIONS, + InspectorRail, + InspectorRailReopen, +} from '@/components/shell/InspectorRail'; +import { ConsoleBlockHost } from '@/lib/console-host'; +import { CONSOLE_VIEW_REGISTRY } from '@/views/registry'; +import type { ChatCatalog } from '@/lib/chat/project-types'; + +const catalog: ChatCatalog = { + activeProjectId: 'proj-default', + projects: [ + { + id: 'proj-default', + name: 'Default project', + description: 'Preview', + documentIds: [], + objectTypes: [], + updatedAt: 0, + }, + ], + threads: [ + { + id: 'thread-1', + projectId: 'proj-default', + title: 'Preview thread', + sessionId: null, + sessionResumable: false, + capability: null, + railCollapsed: true, + updatedAt: 0, + scrollTop: 0, + messages: [], + }, + ], +}; + +export default function ChromePreviewPage() { + const [inspectorOpen, setInspectorOpen] = useState(true); + const host = new ConsoleBlockHost(CONSOLE_VIEW_REGISTRY, {}); + + return ( + +
+ +
+ {}} + onOpenThread={() => {}} + surface="chat" + /> +
+

+ Lifted main island +

+

+ Left: the 21st/@jshguo sidebar-component (TwoLevelSidebarShell), retokened onto the + register. Right: the integrated DashboardSidebar (arunjdass 21st nav) with Obsidian + JSON Canvas as an in-component feature layer (double-click to add a note). The three + planes read lifted rail, frame panel, sunken editor, straight off Twenty's + background ladder. +

+
+ {/* The rail fills its container now: in the shell that container is + a collapsible Panel, and here it is this fixed column. Reopen is + the container's job in both places. */} +
+ + {inspectorOpen ? null : setInspectorOpen(true)} />} +
+
+
+
+ ); +} diff --git a/apps/console/src/app/page.tsx b/apps/console/src/app/page.tsx index ef581da5..599c8c42 100644 --- a/apps/console/src/app/page.tsx +++ b/apps/console/src/app/page.tsx @@ -3,11 +3,12 @@ import { redirect } from 'next/navigation'; import { resolveHarnessPrincipal } from '@/lib/server/harness-principal'; +import { redirectForFailedPrincipal } from '@/lib/server/principal-redirect'; export default async function RootPage() { const resolution = await resolveHarnessPrincipal(); if (!resolution.ok) { - redirect('/login'); + return redirectForFailedPrincipal(resolution, '/'); } if (resolution.principal.workspaceId && resolution.principal.scopeRef) { redirect( diff --git a/apps/console/src/components/blocks/BlockShell.tsx b/apps/console/src/components/blocks/BlockShell.tsx index 6e1a8fbb..0e4181de 100644 --- a/apps/console/src/components/blocks/BlockShell.tsx +++ b/apps/console/src/components/blocks/BlockShell.tsx @@ -186,7 +186,7 @@ export function BlockShell({ data-register-impl={registerImpl} data-paint-region="island-shell" aria-label={headerTitle} - className="group relative flex h-full min-h-0 flex-col overflow-hidden rounded-ij-island bg-transparent" + className="group relative flex h-full min-h-0 flex-col overflow-hidden bg-transparent" > {showHeader ? (
}, + { id: 'chat' as const, label: 'Chat', icon: }, + { id: 'runs' as const, label: 'Runs', icon: }, + { id: 'graph' as const, label: 'Graph', icon: }, + { id: 'models' as const, label: 'Models', icon: }, +]; export function ChatSidebar({ catalog, activeThreadId, - capabilities: _capabilities, + capabilities, unreachable = false, onCatalogChange, onOpenThread, @@ -89,36 +97,14 @@ export function ChatSidebar({ surface: surfaceProp, onSurfaceChange, }: ChatSidebarProps) { - const router = useRouter(); + const [panelOpen, setPanelOpen] = useState(true); const [surfaceState, setSurfaceState] = useState('chat'); const surface = surfaceProp ?? surfaceState; const setSurface = (next: ChatDockSurface) => { onSurfaceChange?.(next); if (surfaceProp === undefined) setSurfaceState(next); }; - const [pinnedIds, setPinnedIds] = useState([]); - const [query, setQuery] = useState(''); - const activeProject = catalog.projects.find((project) => project.id === catalog.activeProjectId) ?? null; - const activeThread = catalog.threads.find((thread) => thread.id === activeThreadId) ?? null; - - useEffect(() => { - const frame = window.requestAnimationFrame(() => setPinnedIds(readPinned())); - return () => window.cancelAnimationFrame(frame); - }, []); - - const pinnedThreads = useMemo(() => { - const byId = new Map(catalog.threads.map((thread) => [thread.id, thread])); - return pinnedIds - .map((id) => byId.get(id)) - .filter((thread): thread is ChatThreadRecord => Boolean(thread)) - .filter((thread) => thread.id !== activeThreadId); - }, [catalog.threads, pinnedIds, activeThreadId]); - - const filteredPinned = useMemo(() => { - const needle = query.trim().toLowerCase(); - if (!needle) return pinnedThreads; - return pinnedThreads.filter((thread) => thread.title.toLowerCase().includes(needle)); - }, [pinnedThreads, query]); + const router = useRouter(); const goSurface = (next: ChatDockSurface) => { setSurface(next); @@ -144,6 +130,86 @@ export function ChatSidebar({ } }; + return ( + } + panelBrand={ +
+ + + CommonPlace + +
+ } + activeSection={surface} + onSectionChange={(id) => goSurface(id as ChatDockSurface)} + panelOpen={panelOpen} + onPanelOpenChange={setPanelOpen} + title={surface === 'chat' ? 'Chat' : surface.charAt(0).toUpperCase() + surface.slice(1)} + panel={ + + } + /> + ); +} + +function ChatSidebarPanel({ + catalog, + activeThreadId, + capabilities, + unreachable, + onCatalogChange, + onOpenThread, + contextFolders, + onToggleContextInclude, + surface, +}: { + catalog: ChatCatalog; + activeThreadId: string | null; + capabilities: readonly CapabilityItem[]; + unreachable: boolean; + onCatalogChange: (catalog: ChatCatalog) => void; + onOpenThread: (threadId: string) => void; + contextFolders: readonly ContextFolder[]; + onToggleContextInclude?: (entryId: string) => void; + surface: ChatDockSurface; +}) { + const [pinnedIds, setPinnedIds] = useState([]); + const [query, setQuery] = useState(''); + const activeProject = catalog.projects.find((project) => project.id === catalog.activeProjectId) ?? null; + const activeThread = catalog.threads.find((thread) => thread.id === activeThreadId) ?? null; + + useEffect(() => { + const frame = window.requestAnimationFrame(() => setPinnedIds(readPinned())); + return () => window.cancelAnimationFrame(frame); + }, []); + + const pinnedThreads = useMemo(() => { + const byId = new Map(catalog.threads.map((thread) => [thread.id, thread])); + return pinnedIds + .map((id) => byId.get(id)) + .filter((thread): thread is ChatThreadRecord => Boolean(thread)) + .filter((thread) => thread.id !== activeThreadId); + }, [catalog.threads, pinnedIds, activeThreadId]); + + const filteredPinned = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return pinnedThreads; + return pinnedThreads.filter((thread) => thread.title.toLowerCase().includes(needle)); + }, [pinnedThreads, query]); + const newThread = async () => { if (unreachable) return; const thread = await createChatThread({ @@ -172,14 +238,8 @@ export function ChatSidebar({ }; return ( - + ); } - function ProjectEditorInline({ project, projects, @@ -415,7 +428,7 @@ function ProjectEditorInline({ const [description, setDescription] = useState(project.description); return ( -
+