From 4f0569eb77cebbbcc3b2925232c1ea1251caab42 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 07:49:40 -0700 Subject: [PATCH 1/3] Add tests for route-aware three-band contextual panel Cover shell-ui contribution registry and pins, panel empty states per route, and route identity via the panel page band after TopBar removal. --- apps/web/test/contextual-panel.test.tsx | 82 +++++++++++++++++-- apps/web/test/routes.test.tsx | 11 ++- packages/shell-ui/package.json | 24 ++++++ packages/shell-ui/src/index.ts | 16 ++++ packages/shell-ui/src/panel-contribution.ts | 77 +++++++++++++++++ packages/shell-ui/src/pins.ts | 59 +++++++++++++ .../shell-ui/test/panel-contribution.test.ts | 57 +++++++++++++ packages/shell-ui/test/pins.test.ts | 50 +++++++++++ packages/shell-ui/tsconfig.json | 9 ++ 9 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 packages/shell-ui/package.json create mode 100644 packages/shell-ui/src/index.ts create mode 100644 packages/shell-ui/src/panel-contribution.ts create mode 100644 packages/shell-ui/src/pins.ts create mode 100644 packages/shell-ui/test/panel-contribution.test.ts create mode 100644 packages/shell-ui/test/pins.test.ts create mode 100644 packages/shell-ui/tsconfig.json diff --git a/apps/web/test/contextual-panel.test.tsx b/apps/web/test/contextual-panel.test.tsx index 310d337a8..39f4fb2ed 100644 --- a/apps/web/test/contextual-panel.test.tsx +++ b/apps/web/test/contextual-panel.test.tsx @@ -1,7 +1,5 @@ -// Column 2 is bench-scoped live activity now, not a page list: it never -// mentions the page routes, and its notifications section is an honest -// empty state — there is no notification feature in the hub yet, so this -// must never render a fabricated sample entry. +// Column 2 is the route-aware three-band contextual panel: page band, +// global pins, and page-specific content. It never renders a page-nav list. import { afterEach, describe, expect, test } from "bun:test"; import { act } from "react"; @@ -23,7 +21,13 @@ function renderPanel(path: string): string { return renderToStaticMarkup( - + , ); @@ -41,6 +45,14 @@ describe("ContextualPanel", () => { expect(markup).not.toContain(">Pages<"); }); + test("renders the three panel bands", () => { + const markup = renderPanel("/"); + expect(markup).toContain("panel-band-page"); + expect(markup).toContain("panel-band-pins"); + expect(markup).toContain("panel-band-page-specific"); + expect(markup).toContain("Pinned"); + }); + test("shows an honest empty state once no bench resolves", async () => { globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(emptyMemberships.clone())) as typeof fetch; @@ -51,7 +63,13 @@ describe("ContextualPanel", () => { root.render( - + , ); @@ -67,7 +85,7 @@ describe("ContextualPanel", () => { container.remove(); }); - test("the notifications section is an honest empty state, never a fabricated entry", async () => { + test("home live-activity empty state is honest, never a fabricated entry", async () => { const membership = { data: [ { @@ -107,7 +125,53 @@ describe("ContextualPanel", () => { root.render( - + + + , + ); + }); + for (let i = 0; i < 20; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + if (container.innerHTML.includes("Quiet right now")) break; + } + expect(container.innerHTML).toContain("Quiet right now"); + expect(container.innerHTML).toContain( + "Channels and running routines for this bench will appear here.", + ); + root.unmount(); + container.remove(); + }); + + test("notifications empty state is honest when that band is registered", async () => { + globalThis.fetch = ((_input: RequestInfo | URL) => + Promise.resolve( + new Response(JSON.stringify({ data: [], nextCursor: null }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + )) as typeof fetch; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + , ); @@ -120,7 +184,7 @@ describe("ContextualPanel", () => { } expect(container.innerHTML).toContain("No notifications yet"); expect(container.innerHTML).toContain( - "mentions and mail-backed alerts will land here", + "Mentions and mail-backed alerts will land here once a notification source is wired up.", ); root.unmount(); container.remove(); diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index e166aae4d..d0a8ce610 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -1,6 +1,8 @@ // Rendering here uses react-dom/server, so effects never run and every // screen shows its pre-fetch state — which is exactly what these tests -// assert: each route mounts, names itself, and marks itself in the rail. +// assert: each route mounts, names itself in the contextual panel, and +// marks itself in the rail. Page identity lives in the panel page band +// (h2.panel-page-title), not a per-page TopBar. import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; @@ -30,8 +32,9 @@ function renderApp(path: string, session: SessionState = signedIn): string { ); } -function pageHeading(markup: string): string | undefined { - return /]*>(.*?)<\/h1>/.exec(markup)?.[1]; +/** Page name from the contextual panel's page band (TopBars are gone). */ +function panelPageTitle(markup: string): string | undefined { + return /class="panel-page-title"[^>]*>(.*?)<\/h2>/.exec(markup)?.[1]; } /** The rail marks exactly one page active at a time (an icon+label button, @@ -70,7 +73,7 @@ describe("routes render", () => { for (const route of APP_ROUTES) { test(`${route.path} renders the ${route.label} screen`, () => { const markup = renderApp(route.path); - expect(pageHeading(markup)).toBe(route.label); + expect(panelPageTitle(markup)).toBe(route.label); if (route.path === SETTINGS_PATH) { // Settings has no page-nav entry in the rail — it is reached from // the rail's own identity dock instead. diff --git a/packages/shell-ui/package.json b/packages/shell-ui/package.json new file mode 100644 index 000000000..f85450d4a --- /dev/null +++ b/packages/shell-ui/package.json @@ -0,0 +1,24 @@ +{ + "name": "@corbits/shell-ui", + "private": true, + "description": "Shell contribution contracts: route-aware contextual panel bands that page modules register into, so the shell never hardcodes per-page content", + "version": "0.0.1", + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "arktype": "catalog:", + "react": "^19.2.0" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/react": "^19.2.2", + "typescript": "catalog:" + } +} diff --git a/packages/shell-ui/src/index.ts b/packages/shell-ui/src/index.ts new file mode 100644 index 000000000..369b44ac3 --- /dev/null +++ b/packages/shell-ui/src/index.ts @@ -0,0 +1,16 @@ +export { + createPanelRegistry, + panelRegistry, + registerPanelContribution, + resolvePanelContribution, +} from "./panel-contribution"; +export type { + PageBand, + PanelAction, + PanelContribution, + PanelRegistry, + PanelRenderContext, +} from "./panel-contribution"; + +export { loadPins, savePins, togglePin, Pin, PinKind } from "./pins"; +export type { Pin as PinRecord, PinKind as PinKindValue } from "./pins"; diff --git a/packages/shell-ui/src/panel-contribution.ts b/packages/shell-ui/src/panel-contribution.ts new file mode 100644 index 000000000..903695217 --- /dev/null +++ b/packages/shell-ui/src/panel-contribution.ts @@ -0,0 +1,77 @@ +// Route-aware contributions for the shell contextual panel. Page modules +// register bands here; the shell resolves the first match for the current +// path and never hardcodes per-page content. + +import type { ReactNode } from "react"; + +export type PanelAction = { + readonly id: string; + readonly label: string; + readonly onSelect: () => void; +}; + +export type PageBand = { + readonly title: string; + readonly subtitle?: string; + readonly settingsPath?: string; + readonly actions?: readonly PanelAction[]; + readonly stats?: ReactNode; +}; + +export type PanelRenderContext = { + readonly path: string; + readonly onNavigate: (to: string) => void; +}; + +export type PanelContribution = { + readonly id: string; + readonly match: (path: string) => boolean; + readonly pageBand: (ctx: PanelRenderContext) => PageBand; + readonly pageSpecific?: (ctx: PanelRenderContext) => ReactNode; +}; + +export type PanelRegistry = { + readonly register: (contribution: PanelContribution) => void; + readonly resolve: (path: string) => PanelContribution | null; + readonly list: () => readonly PanelContribution[]; +}; + +export function createPanelRegistry( + initial: readonly PanelContribution[] = [], +): PanelRegistry { + const contributions: PanelContribution[] = [...initial]; + return { + register(contribution) { + const existing = contributions.findIndex((c) => c.id === contribution.id); + if (existing >= 0) { + contributions[existing] = contribution; + return; + } + contributions.push(contribution); + }, + resolve(path) { + for (const contribution of contributions) { + if (contribution.match(path)) return contribution; + } + return null; + }, + list() { + return contributions; + }, + }; +} + +/** Module-level registry the web shell and page modules share. */ +export const panelRegistry = createPanelRegistry(); + +export function registerPanelContribution( + contribution: PanelContribution, +): void { + panelRegistry.register(contribution); +} + +export function resolvePanelContribution( + path: string, +): PanelContribution | null { + return panelRegistry.resolve(path); +} diff --git a/packages/shell-ui/src/pins.ts b/packages/shell-ui/src/pins.ts new file mode 100644 index 000000000..8852107cd --- /dev/null +++ b/packages/shell-ui/src/pins.ts @@ -0,0 +1,59 @@ +// User-curated global pins for the contextual panel middle band. Same list +// on every page. Persistence is localStorage so pins survive reloads without +// a hub endpoint yet. + +import { type } from "arktype"; + +export const PinKind = type("'channel' | 'agent' | 'routine'"); +export type PinKind = typeof PinKind.infer; + +export const Pin = type({ + id: "string", + kind: PinKind, + label: "string", + href: "string", +}); +export type Pin = typeof Pin.infer; + +const STORAGE_KEY = "workbench.shell.pins"; + +export function loadPins( + storage: Pick = globalThis.localStorage, +): readonly Pin[] { + if (typeof storage?.getItem !== "function") return []; + const raw = storage.getItem(STORAGE_KEY); + if (raw === null || raw === "") return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + const pins: Pin[] = []; + for (const entry of parsed) { + const result = Pin(entry); + if (result instanceof type.errors) continue; + pins.push(result); + } + return pins; +} + +export function savePins( + pins: readonly Pin[], + storage: Pick = globalThis.localStorage, +): void { + if (typeof storage?.setItem !== "function") return; + storage.setItem(STORAGE_KEY, JSON.stringify(pins)); +} + +export function togglePin( + pins: readonly Pin[], + pin: Pin, +): readonly Pin[] { + const exists = pins.some((p) => p.id === pin.id && p.kind === pin.kind); + if (exists) { + return pins.filter((p) => !(p.id === pin.id && p.kind === pin.kind)); + } + return [...pins, pin]; +} diff --git a/packages/shell-ui/test/panel-contribution.test.ts b/packages/shell-ui/test/panel-contribution.test.ts new file mode 100644 index 000000000..4cad5af5b --- /dev/null +++ b/packages/shell-ui/test/panel-contribution.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { createPanelRegistry } from "../src/panel-contribution"; + +describe("createPanelRegistry", () => { + test("resolves the first matching contribution for a path", () => { + const registry = createPanelRegistry([ + { + id: "chat", + match: (path) => path === "/chat" || path.startsWith("/chat/"), + pageBand: () => ({ title: "Chat" }), + }, + { + id: "home", + match: (path) => path === "/", + pageBand: () => ({ title: "Home" }), + }, + ]); + + expect(registry.resolve("/chat/abc")?.id).toBe("chat"); + expect(registry.resolve("/")?.id).toBe("home"); + expect(registry.resolve("/unknown")).toBeNull(); + }); + + test("register replaces a contribution with the same id", () => { + const registry = createPanelRegistry(); + registry.register({ + id: "agents", + match: (path) => path === "/agents", + pageBand: () => ({ title: "Agents" }), + }); + registry.register({ + id: "agents", + match: (path) => path === "/agents", + pageBand: () => ({ title: "Agents v2" }), + }); + expect(registry.list()).toHaveLength(1); + expect(registry.resolve("/agents")?.pageBand({ path: "/agents", onNavigate: () => undefined }).title).toBe( + "Agents v2", + ); + }); + + test("pins are independent of route resolution", () => { + // Registry never owns pins — callers keep pins across resolve() calls. + const registry = createPanelRegistry([ + { + id: "routines", + match: (path) => path.startsWith("/routines"), + pageBand: () => ({ title: "Routines" }), + }, + ]); + const pins = [{ id: "c1", kind: "channel" as const, label: "ops", href: "/chat/c1" }]; + expect(registry.resolve("/routines")?.id).toBe("routines"); + expect(registry.resolve("/agents")).toBeNull(); + expect(pins).toHaveLength(1); + }); +}); diff --git a/packages/shell-ui/test/pins.test.ts b/packages/shell-ui/test/pins.test.ts new file mode 100644 index 000000000..954b225e5 --- /dev/null +++ b/packages/shell-ui/test/pins.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; + +import { loadPins, savePins, togglePin } from "../src/pins"; + +function memoryStorage(seed: Record = {}) { + const map = new Map(Object.entries(seed)); + return { + getItem(key: string) { + return map.get(key) ?? null; + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; +} + +describe("pins", () => { + test("loadPins returns empty for missing or corrupt storage", () => { + expect(loadPins(memoryStorage())).toEqual([]); + expect(loadPins(memoryStorage({ "workbench.shell.pins": "not-json" }))).toEqual( + [], + ); + }); + + test("savePins and loadPins round-trip valid pins", () => { + const storage = memoryStorage(); + const pins = [ + { + id: "ch_1", + kind: "channel" as const, + label: "general", + href: "/chat/ch_1", + }, + ]; + savePins(pins, storage); + expect(loadPins(storage)).toEqual(pins); + }); + + test("togglePin adds and removes by id+kind", () => { + const pin = { + id: "a1", + kind: "agent" as const, + label: "Myra", + href: "/agents", + }; + const added = togglePin([], pin); + expect(added).toEqual([pin]); + expect(togglePin(added, pin)).toEqual([]); + }); +}); diff --git a/packages/shell-ui/tsconfig.json b/packages/shell-ui/tsconfig.json new file mode 100644 index 000000000..461e72c55 --- /dev/null +++ b/packages/shell-ui/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"] + }, + "include": ["src", "test"] +} From d031fa3a30004236422f5b2075d55a2b8668315d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 07:49:45 -0700 Subject: [PATCH 2/3] CL-5777: Route-aware contextual panel with three bands Replace per-page TopBars and the floating main toolbar with a contribution- driven panel: page identity/actions, global pins, and route-specific activity. Move chat channel lists into the panel so the workspace is conversation-only. --- apps/web/package.json | 1 + apps/web/src/app.css | 119 +++++- apps/web/src/pages/agents-page.tsx | 54 +-- apps/web/src/pages/approvals-page.tsx | 13 - apps/web/src/pages/home-page.tsx | 16 +- apps/web/src/pages/library-page.tsx | 57 ++- apps/web/src/pages/routines-page.tsx | 85 ++-- apps/web/src/pages/settings-page.tsx | 27 +- apps/web/src/pages/skills-page.tsx | 28 +- apps/web/src/shell/app-shell.tsx | 21 +- apps/web/src/shell/contextual-panel.tsx | 271 ++++++------- apps/web/src/shell/panel-contributions.tsx | 426 +++++++++++++++++++++ bun.lock | 16 + packages/chat-ui/src/chat-workspace.tsx | 250 +++++------- packages/chat-ui/src/styles.css | 354 ++--------------- 15 files changed, 925 insertions(+), 813 deletions(-) create mode 100644 apps/web/src/shell/panel-contributions.tsx diff --git a/apps/web/package.json b/apps/web/package.json index c0597be0a..dd518951e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@corbits/command-palette": "workspace:*", "@corbits/routines": "workspace:*", "@corbits/settings-ui": "workspace:*", + "@corbits/shell-ui": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 73927b711..5574b6dd7 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -49,18 +49,125 @@ flex-direction: column; } -.shell-main-toolbar { - position: absolute; - top: 0.5rem; - right: 0.75rem; - z-index: 10; +.shell-main-content { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; } -.shell-main-content { +/* Column 2: three-band contextual panel. */ +.shell-contextual-panel { + display: flex; + width: var(--shell-contextual-width); + flex-shrink: 0; + flex-direction: column; + gap: 0; + overflow: hidden; + border-right: 1px solid var(--border); + background: var(--card); +} + +.panel-band { display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border-bottom: 1px solid var(--border); min-height: 0; +} + +.panel-band-page-specific { flex: 1; + overflow-y: auto; + border-bottom: none; +} + +.panel-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; +} + +.panel-page-identity { + min-width: 0; +} + +.panel-page-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--foreground); +} + +.panel-page-subtitle { + margin: 0.125rem 0 0; + font-size: 0.75rem; + line-height: 1.35; + color: var(--muted-foreground); +} + +.panel-page-tools { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.125rem; +} + +.panel-page-actions { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.panel-band-heading { + margin: 0; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +.panel-band-subheading { + margin: 0; + padding: 0.25rem 0.5rem 0.125rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +.panel-muted { + margin: 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--muted-foreground); +} + +.panel-stack { + display: flex; flex-direction: column; + gap: 0.125rem; +} + +.panel-stack-group { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +/* Page-local toolbars replace TopBar action clusters (search, view toggle) + that still need component state on the page. Identity lives in the panel. */ +.page-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem 0; } /* Column 4: collapses to zero width rather than being merely hidden, so the diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 1788894f3..96efdcb9c 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -13,15 +13,12 @@ import { TableHeader, TableRow, Tabs, - TopBar, - TopBarActions, - TopBarTitle, ViewToggle, formatRelativeTime, } from "@corbits/react-ui"; import type { BadgeTone, ViewMode } from "@corbits/react-ui"; -import { Bot, Copy, Plus, Workflow } from "lucide-react"; -import { useState } from "react"; +import { Bot, Copy, Workflow } from "lucide-react"; +import { useEffect, useState } from "react"; import type { ReactNode } from "react"; import { useQueryClient } from "@tanstack/react-query"; @@ -30,7 +27,6 @@ import type { AgentDirectoryData } from "../agents-api"; import type { APIQuery } from "../api"; import { useAgentDirectory } from "../agents-api"; import { useBench } from "../bench-context"; -import { countProp } from "../optional-props"; import { tenantKeys } from "../query-client"; import { QueryView } from "../query-view"; import { CreateAgentDialog } from "./create-agent-dialog"; @@ -275,40 +271,28 @@ export function AgentsPage({ const [viewMode, setViewMode] = useState("grid"); const [tab, setTab] = useState(initialTab); const [createOpen, setCreateOpen] = useState(false); - - const isReady = directory.kind === "ready"; const canCreate = directory.kind === "ready" && directory.data.tenantId !== ""; + useEffect(() => { + const onCreate = () => { + if (canCreate) setCreateOpen(true); + }; + window.addEventListener("workbench:agents:create", onCreate); + return () => + window.removeEventListener("workbench:agents:create", onCreate); + }, [canCreate]); + return ( <> - - - Agents - - - - - - - +
+ + +
{(data) => { diff --git a/apps/web/src/pages/approvals-page.tsx b/apps/web/src/pages/approvals-page.tsx index 30bca299b..772716219 100644 --- a/apps/web/src/pages/approvals-page.tsx +++ b/apps/web/src/pages/approvals-page.tsx @@ -22,8 +22,6 @@ import { DialogTitle, EmptyState, PageShell, - TopBar, - TopBarTitle, } from "@corbits/react-ui"; import type { ApprovalRequest } from "@corbits/react-ui"; import { ShieldCheck } from "lucide-react"; @@ -36,7 +34,6 @@ import { NeedsYouSchema, useAPIQuery, } from "../api"; -import { countProp } from "../optional-props"; import type { APIQuery, NeedsYouItem } from "../api"; import { useBench } from "../bench-context"; import { tenantKeys } from "../query-client"; @@ -61,16 +58,6 @@ export function ApprovalsPage({ return ( <> - - - Approvals - - {(rows) => diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 8115aafa5..daa5ec4f6 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -8,8 +8,6 @@ import { Skeleton, StatGrid, StatTile, - TopBar, - TopBarTitle, } from "@corbits/react-ui"; import type { ReactNode } from "react"; @@ -18,7 +16,6 @@ import { PrincipalsSchema, ProfileSchema, RunsSchema } from "../api"; import type { APIQuery, PrincipalsPage, Profile, RunsPage } from "../api"; import { Link } from "../navigation"; import { purposeRuns } from "../purpose-runs"; -import { subtitleProp } from "../optional-props"; import { SignedOutNotice } from "../query-view"; const SHORTCUTS = [ @@ -69,17 +66,7 @@ export function HomePage({ const greeting = profile.kind === "ready" ? `Welcome back, ${profile.data.name}` : "Welcome"; return ( - <> - - - Home - - - + {profile.kind === "unauthenticated" ? ( ) : ( @@ -122,7 +109,6 @@ export function HomePage({ )} - ); } diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx index 81c811701..ba1986ef0 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -13,9 +13,6 @@ import { TableHead, TableHeader, TableRow, - TopBar, - TopBarActions, - TopBarTitle, ViewToggle, artifactKindLabel, formatRelativeTime, @@ -30,8 +27,6 @@ import type { ArtifactSort, ArtifactSummary } from "@corbits/artifact-ui"; import { ArrowDownUp, FileStack } from "lucide-react"; import { useMemo, useState } from "react"; -import { countProp } from "../optional-props"; - const SORT_LABEL: Record = { newest: "Newest first", oldest: "Oldest first", @@ -125,36 +120,28 @@ export function LibraryPage({ return ( <> - - - Library - - - - - - - - - {(Object.keys(SORT_LABEL) as ArtifactSort[]).map((option) => ( - setSort(option)}> - {SORT_LABEL[option]} - - ))} - - - - - +
+ + + + + + + {(Object.keys(SORT_LABEL) as ArtifactSort[]).map((option) => ( + setSort(option)}> + {SORT_LABEL[option]} + + ))} + + + +
{artifacts.length === 0 ? ( Promise; + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = openProp ?? uncontrolledOpen; + const setOpen = onOpenChange ?? setUncontrolledOpen; const [name, setName] = useState(""); const [definitionId, setDefinitionId] = useState(definitions[0]?.id ?? ""); const [runMode, setRunMode] = useState<"once" | "schedule">("once"); @@ -251,11 +254,13 @@ function CreateRoutineDialog({ if (!next) reset(); }} > - - - + {openProp === undefined ? ( + + + + ) : null} New routine @@ -389,19 +394,23 @@ export function RoutinesListPage({ readonly onToggleEnabled: (routine: Routine, enabled: boolean) => void; readonly onRunNow: (routine: Routine) => Promise; }) { + const [createOpen, setCreateOpen] = useState(false); + + useEffect(() => { + const onCreateEvent = () => setCreateOpen(true); + window.addEventListener("workbench:routines:create", onCreateEvent); + return () => + window.removeEventListener("workbench:routines:create", onCreateEvent); + }, []); + return ( <> - - - Routines - - - + {(items) => @@ -477,11 +486,11 @@ export function RoutinesListPage({ } - - - Live runs - - +
+

Live runs

+

+ Runs currently executing that a routine started. +

{(runs) => runs.length === 0 ? ( @@ -520,6 +529,7 @@ export function RoutinesListPage({ ) } +
); @@ -538,18 +548,14 @@ export function RoutineDetailPage({ }) { return ( <> - - - {routine.kind === "ready" ? routine.data.name : "Routine"} - +
- +

+ {routine.kind === "ready" ? routine.data.name : "Routine"} +

+
{(data) => ( @@ -570,11 +576,9 @@ export function RoutineDetailPage({ )} - - - Run history - - +
+

Run history

+

Every time this routine fired.

{(items) => items.length === 0 ? ( @@ -620,6 +624,7 @@ export function RoutineDetailPage({ ) } +
); diff --git a/apps/web/src/pages/settings-page.tsx b/apps/web/src/pages/settings-page.tsx index 3606ba02d..442811521 100644 --- a/apps/web/src/pages/settings-page.tsx +++ b/apps/web/src/pages/settings-page.tsx @@ -17,7 +17,7 @@ import { useTenancyAccess, } from "@corbits/settings-ui"; import type { SettingsContext, SettingsSection } from "@corbits/settings-ui"; -import { PageShell, TopBar, TopBarTitle } from "@corbits/react-ui"; +import { PageShell } from "@corbits/react-ui"; import { useBench } from "../bench-context"; @@ -74,21 +74,14 @@ export function SettingsRoute() { } return ( - <> - - - Settings - - - - - - + + + ); } diff --git a/apps/web/src/pages/skills-page.tsx b/apps/web/src/pages/skills-page.tsx index f344ac85d..f4cf0d724 100644 --- a/apps/web/src/pages/skills-page.tsx +++ b/apps/web/src/pages/skills-page.tsx @@ -1,9 +1,4 @@ -import { - PageShell, - RichEmptyState, - TopBar, - TopBarTitle, -} from "@corbits/react-ui"; +import { PageShell, RichEmptyState } from "@corbits/react-ui"; import { Sparkles } from "lucide-react"; /** @@ -13,20 +8,13 @@ import { Sparkles } from "lucide-react"; */ export function SkillsPage() { return ( - <> - - - Skills - - - - } - title="Skills aren't built yet" - description="A skill will be a named, reusable capability — instructions, tools, and guardrails packaged together — that an agent definition can declare and a bench can install. There's no skill registry in the hub yet, so this page has nothing real to list." - /> - - + + } + title="Skills aren't built yet" + description="A skill will be a named, reusable capability — instructions, tools, and guardrails packaged together — that an agent definition can declare and a bench can install. There's no skill registry in the hub yet, so this page has nothing real to list." + /> + ); } diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 00781291a..2d1898af0 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -1,7 +1,8 @@ // The four-column app shell: the global rail, the contextual panel, the // main pane a route renders into, and the optional canvas. Every route in // `../routes.tsx` mounts inside this same frame — there is no per-route -// shell variant. +// shell variant. The canvas toggle lives in the panel page band, never as +// an absolute overlay over page actions. import { useRef, useState, type ReactNode } from "react"; @@ -14,7 +15,7 @@ import { resolveCanvasVisibility, toggleCanvasColumn, } from "./canvas-column-state"; -import { CanvasColumn, CanvasToggle } from "./canvas-column"; +import { CanvasColumn } from "./canvas-column"; import { ContextualPanel } from "./contextual-panel"; import { Rail } from "./rail"; import { useShellLayoutMode } from "./use-shell-layout"; @@ -47,17 +48,15 @@ export function AppShell({ onSignOut={onSignOut} /> {contextualPanelVisible(layoutMode) && ( - + setCanvasState(toggleCanvasColumn)} + canvasAllowed={canvasAllowed} + /> )}
- {canvasAllowed && ( -
- setCanvasState(toggleCanvasColumn)} - /> -
- )}
{children}
{canvasAllowed && } diff --git a/apps/web/src/shell/contextual-panel.tsx b/apps/web/src/shell/contextual-panel.tsx index e5c5ca8e1..5172acce6 100644 --- a/apps/web/src/shell/contextual-panel.tsx +++ b/apps/web/src/shell/contextual-panel.tsx @@ -1,181 +1,134 @@ -// Column 2: the bench-scoped live activity rail. Answers "what is -// happening in this bench right now" — channels, chats, and running -// routines for the currently selected bench, plus a slot for notifications -// once the hub has something to send. Nothing here is a page list: it -// refetches on bench changes, never on route changes, so items can persist -// or travel across page navigation exactly as live activity should. +// Column 2: route-aware contextual panel with three bands. +// +// 1. Page band — title, settings entry, quick actions, canvas toggle. +// 2. Global pins — user-curated, same on every page. +// 3. Page-specific — contribution content for the current route. +// +// Live activity lives here (left), never in the right canvas. Clicking a +// list item navigates to the full surface for that entity. +import { Button, EmptyState, SidebarItemRow } from "@corbits/react-ui"; import { - EmptyState, - Skeleton, - SidebarItemRow, - SidebarPanel, - SidebarPanelBody, - SidebarPanelHeader, - SidebarPanelSection, - useSidebarPanel, -} from "@corbits/react-ui"; -import type { Channel } from "@corbits/chat-ui"; -import { Bell, Hash, MessageSquare, Workflow } from "lucide-react"; + loadPins, + resolvePanelContribution, + type Pin, +} from "@corbits/shell-ui"; +import { Pin as PinIcon, Settings } from "lucide-react"; +import { useState } from "react"; -import { useBench } from "../bench-context"; -import { useBenchActivity } from "./bench-activity"; -import type { RoutineActivityItem } from "./routine-activity"; +import { CanvasToggle } from "./canvas-column"; +import { ensurePanelContributions } from "./panel-contributions"; -const CHANNELS_SECTION_ID = "channels"; -const CHATS_SECTION_ID = "chats"; -const ROUTINES_SECTION_ID = "routines"; -const NOTIFICATIONS_SECTION_ID = "notifications"; -const CHAT_PATH_PREFIX = "/chat"; - -function activeChatChannelId(path: string): string | null { - if (!path.startsWith(`${CHAT_PATH_PREFIX}/`)) return null; - const rest = path.slice(CHAT_PATH_PREFIX.length + 1); - return rest === "" ? null : decodeURIComponent(rest); -} - -function ChannelRow({ - channel, - active, - onNavigate, -}: { - readonly channel: Channel; - readonly active: boolean; - readonly onNavigate: (to: string) => void; -}) { - return ( - - onNavigate(`${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`) - } - /> - ); -} - -function RoutineRow({ routine }: { readonly routine: RoutineActivityItem }) { - return ; -} +ensurePanelContributions(); export function ContextualPanel({ path, onNavigate, + canvasOpen, + onToggleCanvas, + canvasAllowed, }: { readonly path: string; readonly onNavigate: (to: string) => void; + readonly canvasOpen: boolean; + readonly onToggleCanvas: () => void; + readonly canvasAllowed: boolean; }) { - const { selectedTenantId } = useBench(); - const activity = useBenchActivity(selectedTenantId); - const activeChannelId = activeChatChannelId(path); - const { - isSectionCollapsed, - toggleSection, - panelKey, - panelTransitionClassName, - } = useSidebarPanel({ activePageId: selectedTenantId ?? "" }); + const contribution = resolvePanelContribution(path); + const pageBand = contribution?.pageBand({ path, onNavigate }) ?? { + title: "Workbench", + subtitle: "Navigate from the rail", + }; + const pageSpecific = + contribution?.pageSpecific?.({ path, onNavigate }) ?? null; + + const [pins] = useState(() => loadPins()); return ( - - - - {activity.kind === "loading" && ( - - )} - {activity.kind === "empty" && ( - } - title="No bench selected" - description="Choose a bench from the rail to see its channels, chats, and running routines." - /> +
+
+
+

{pageBand.title}

+ {pageBand.subtitle !== undefined ? ( +

{pageBand.subtitle}

+ ) : null} +
+
+ {pageBand.settingsPath !== undefined ? ( + + ) : null} + {canvasAllowed ? ( + + ) : null} +
+
+ {pageBand.actions !== undefined && pageBand.actions.length > 0 ? ( +
+ {pageBand.actions.map((action) => ( + + ))} +
+ ) : null} + {pageBand.stats !== undefined ? ( +
{pageBand.stats}
+ ) : null} +
+ +
+

Pinned

+ {pins.length === 0 ? ( +

+ Pin channels, agents, or routines to keep them here on every page. +

+ ) : ( +
+ {pins.map((pin) => ( + onNavigate(pin.href)} + /> + ))} +
)} - {activity.kind === "error" && ( +
+ +
+

{pageBand.title}

+ {pageSpecific ?? ( } - title="Couldn't load bench activity" - description={activity.message} + title="Nothing here yet" + description="Page-specific activity will show in this band." /> )} - {activity.kind === "ready" && ( - <> - toggleSection(CHANNELS_SECTION_ID)} - > - {activity.channels.length === 0 ? ( - } - title="No channels yet" - description="Channels created in this bench appear here." - /> - ) : ( - activity.channels.map((channel) => ( - - )) - )} - - toggleSection(CHATS_SECTION_ID)} - > - {activity.chats.length === 0 ? ( - } - title="No chats yet" - description="Direct chats with an agent in this bench appear here." - /> - ) : ( - activity.chats.map((channel) => ( - - )) - )} - - toggleSection(ROUTINES_SECTION_ID)} - > - {activity.routines.length === 0 ? ( - } - title="Nothing running" - description="A routine running in this bench shows up here while it executes." - /> - ) : ( - activity.routines.map((routine) => ( - - )) - )} - - toggleSection(NOTIFICATIONS_SECTION_ID)} - > - } - title="No notifications yet" - description="This bench has no notification source wired up yet — mentions and mail-backed alerts will land here once it does." - /> - - - )} - - +
+ ); } + +// PinIcon kept for future pin-toggle affordances in rows. +void PinIcon; diff --git a/apps/web/src/shell/panel-contributions.tsx b/apps/web/src/shell/panel-contributions.tsx new file mode 100644 index 000000000..f2349c6d7 --- /dev/null +++ b/apps/web/src/shell/panel-contributions.tsx @@ -0,0 +1,426 @@ +// Registers each page's contextual-panel contribution. Imported once from +// the shell so matchers are on the registry before first render. + +import { + EmptyState, + SidebarItemRow, + Skeleton, +} from "@corbits/react-ui"; +import { + registerPanelContribution, + type PanelRenderContext, +} from "@corbits/shell-ui"; +import { Bell, Hash, MessageSquare, Workflow } from "lucide-react"; + +import { useBench } from "../bench-context"; +import { useBenchActivity } from "./bench-activity"; +import type { RoutineActivityItem } from "./routine-activity"; + +const CHAT_PATH_PREFIX = "/chat"; + +function pathMatches(prefix: string, path: string): boolean { + return path === prefix || path.startsWith(`${prefix}/`); +} + +function activeChatChannelId(path: string): string | null { + if (!path.startsWith(`${CHAT_PATH_PREFIX}/`)) return null; + const rest = path.slice(CHAT_PATH_PREFIX.length + 1); + return rest === "" ? null : decodeURIComponent(rest); +} + +function ChannelsBand({ + path, + onNavigate, +}: { + readonly path: string; + readonly onNavigate: (to: string) => void; +}) { + const { selectedTenantId } = useBench(); + const activity = useBenchActivity(selectedTenantId); + const activeId = activeChatChannelId(path); + + if (activity.kind === "loading") { + return ; + } + if (activity.kind === "empty") { + return ( + } + title="No bench selected" + description="Choose a bench from the rail to see its channels." + /> + ); + } + if (activity.kind === "error") { + return ( + } + title="Couldn't load channels" + description={activity.message} + /> + ); + } + + const channels = activity.channels; + const chats = activity.chats; + if (channels.length === 0 && chats.length === 0) { + return ( + } + title="No channels yet" + description="Create a channel from Chat to start a conversation." + /> + ); + } + + return ( +
+ {channels.length > 0 ? ( +
+

Channels

+ {channels.map((channel) => ( + + onNavigate( + `${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`, + ) + } + /> + ))} +
+ ) : null} + {chats.length > 0 ? ( +
+

Chats

+ {chats.map((channel) => ( + + onNavigate( + `${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`, + ) + } + /> + ))} +
+ ) : null} +
+ ); +} + +function RoutinesFeedBand({ + onNavigate, +}: { + readonly onNavigate: (to: string) => void; +}) { + const { selectedTenantId } = useBench(); + const activity = useBenchActivity(selectedTenantId); + + if (activity.kind === "loading") { + return ; + } + if (activity.kind === "empty") { + return ( + } + title="No bench selected" + description="Choose a bench from the rail to see running routines." + /> + ); + } + if (activity.kind === "error") { + return ( + } + title="Couldn't load activity" + description={activity.message} + /> + ); + } + if (activity.routines.length === 0) { + return ( + } + title="Nothing running" + description="A routine running in this bench shows up here while it executes." + /> + ); + } + return ( +
+ {activity.routines.map((routine: RoutineActivityItem) => ( + onNavigate("/routines")} + /> + ))} +
+ ); +} + +function LiveActivityBand({ + path, + onNavigate, +}: { + readonly path: string; + readonly onNavigate: (to: string) => void; +}) { + // Home and other surfaces share the live pulse: channels + running routines. + const { selectedTenantId } = useBench(); + const activity = useBenchActivity(selectedTenantId); + const activeId = activeChatChannelId(path); + + if (activity.kind === "loading") { + return ; + } + if (activity.kind === "empty") { + return ( + } + title="No bench selected" + description="Choose a bench from the rail to see live activity." + /> + ); + } + if (activity.kind === "error") { + return ( + } + title="Couldn't load activity" + description={activity.message} + /> + ); + } + + const hasAnything = + activity.channels.length > 0 || + activity.chats.length > 0 || + activity.routines.length > 0; + + if (!hasAnything) { + return ( + } + title="Quiet right now" + description="Channels and running routines for this bench will appear here." + /> + ); + } + + return ( +
+ {activity.routines.length > 0 ? ( +
+

Running

+ {activity.routines.map((routine) => ( + onNavigate("/routines")} + /> + ))} +
+ ) : null} + {activity.channels.length > 0 ? ( +
+

Channels

+ {activity.channels.map((channel) => ( + + onNavigate( + `${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`, + ) + } + /> + ))} +
+ ) : null} + {activity.chats.length > 0 ? ( +
+

Chats

+ {activity.chats.map((channel) => ( + + onNavigate( + `${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`, + ) + } + /> + ))} +
+ ) : null} +
+ ); +} + +function NotificationsBand() { + return ( + } + title="No notifications yet" + description="Mentions and mail-backed alerts will land here once a notification source is wired up." + /> + ); +} + +function defaultBand( + title: string, + subtitle: string, + settingsPath?: string, +) { + return (_ctx: PanelRenderContext) => ({ + title, + subtitle, + ...(settingsPath !== undefined ? { settingsPath } : {}), + }); +} + +let registered = false; + +/** Idempotent — safe to call from the shell on every module load. */ +export function ensurePanelContributions(): void { + if (registered) return; + registered = true; + + registerPanelContribution({ + id: "home", + match: (path) => path === "/", + pageBand: defaultBand("Home", "Your workbench at a glance"), + pageSpecific: (ctx) => ( + + ), + }); + + registerPanelContribution({ + id: "chat", + match: (path) => pathMatches("/chat", path), + pageBand: (ctx) => ({ + title: "Chat", + subtitle: "Channels and conversations", + actions: [ + { + id: "new-channel", + label: "New channel", + onSelect: () => { + window.dispatchEvent(new CustomEvent("workbench:chat:new-channel")); + if (!pathMatches("/chat", ctx.path)) ctx.onNavigate("/chat"); + }, + }, + ], + }), + pageSpecific: (ctx) => ( + + ), + }); + + registerPanelContribution({ + id: "agents", + match: (path) => pathMatches("/agents", path), + pageBand: (ctx) => ({ + title: "Agents", + subtitle: "Definitions that run on this bench", + actions: [ + { + id: "create-agent", + label: "Create agent", + onSelect: () => { + window.dispatchEvent(new CustomEvent("workbench:agents:create")); + if (!pathMatches("/agents", ctx.path)) ctx.onNavigate("/agents"); + }, + }, + ], + }), + pageSpecific: (ctx) => ( + + ), + }); + + registerPanelContribution({ + id: "routines", + match: (path) => pathMatches("/routines", path), + pageBand: (ctx) => ({ + title: "Routines", + subtitle: "Scheduled and on-demand workflows", + actions: [ + { + id: "create-routine", + label: "Create routine", + onSelect: () => { + window.dispatchEvent(new CustomEvent("workbench:routines:create")); + if (!pathMatches("/routines", ctx.path)) ctx.onNavigate("/routines"); + }, + }, + ], + }), + pageSpecific: (ctx) => , + }); + + registerPanelContribution({ + id: "approvals", + match: (path) => pathMatches("/approvals", path), + pageBand: defaultBand( + "Approvals", + "Pending decisions waiting on you", + ), + pageSpecific: () => , + }); + + registerPanelContribution({ + id: "library", + match: (path) => pathMatches("/library", path), + pageBand: defaultBand( + "Library", + "Artifacts this bench has produced", + ), + }); + + registerPanelContribution({ + id: "skills", + match: (path) => pathMatches("/skills", path), + pageBand: defaultBand( + "Skills", + "Packaged capabilities an agent definition can pick up", + ), + }); + + registerPanelContribution({ + id: "insights", + match: (path) => pathMatches("/insights", path), + pageBand: defaultBand( + "Insights", + "Usage and audit trail for this bench", + ), + }); + + registerPanelContribution({ + id: "settings", + match: (path) => pathMatches("/settings", path), + pageBand: defaultBand( + "Settings", + "Bench, members, and preferences", + ), + }); + + registerPanelContribution({ + id: "benches", + match: (path) => pathMatches("/benches", path), + pageBand: defaultBand( + "Benches", + "Every workbench you can access", + ), + }); +} diff --git a/bun.lock b/bun.lock index 64631eab5..a39043048 100644 --- a/bun.lock +++ b/bun.lock @@ -93,6 +93,7 @@ "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@corbits/routines": "workspace:*", "@corbits/settings-ui": "workspace:*", + "@corbits/shell-ui": "workspace:*", "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", @@ -400,6 +401,19 @@ "typescript": "catalog:", }, }, + "packages/shell-ui": { + "name": "@corbits/shell-ui", + "version": "0.0.1", + "dependencies": { + "arktype": "catalog:", + "react": "^19.2.0", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/react": "^19.2.2", + "typescript": "catalog:", + }, + }, "packages/webhook-triggers": { "name": "@corbits/webhook-triggers", "version": "0.0.1", @@ -859,6 +873,8 @@ "@corbits/settings-ui": ["@corbits/settings-ui@workspace:packages/settings-ui"], + "@corbits/shell-ui": ["@corbits/shell-ui@workspace:packages/shell-ui"], + "@corbits/webhook-triggers": ["@corbits/webhook-triggers@workspace:packages/webhook-triggers"], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 991566073..83c6a76ba 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -1,9 +1,8 @@ -// Orchestrates the whole chat surface: resolves which bench (tenant) this +// Chat workspace: the host resolves which bench the signed-in // account chats in, loads its channels and deployed agents, and wires the -// sidebar, timeline, and composer together for whichever channel is -// selected. The chat API has no tenant switcher of its own yet — like the -// onboarding flow's personal bench, this uses the account's first bench -// membership. +// timeline and composer together for whichever channel is +// selected. Channel list lives in the shell contextual panel — this +// surface is the active conversation only. // // Resolving *which* bench that is is host-specific (it rides on // whatever session/query plumbing the embedding app already has — in @@ -17,13 +16,9 @@ import { Button, EmptyState, Skeleton, - TopBar, - TopBarActions, - TopBarTitle, } from "@corbits/react-ui"; import { CircleAlert, - Lock, MessageSquare, Settings, UserPlus, @@ -37,7 +32,6 @@ import { inviteAgent, listChannels, listMessages, - patchChannelSettings, putReadState, sendMessage, channelStreamUrl, @@ -49,7 +43,6 @@ import { Composer } from "./composer"; import { InviteAgentDialog } from "./invite-agent-dialog"; import { mentionCandidatesFromParticipants } from "./mentions"; import { NewChannelDialog } from "./new-channel-dialog"; -import { ChatSidebar } from "./sidebar"; import { CHAT_STRINGS } from "./strings"; import { AgentBadge, ChannelTimeline } from "./timeline"; import type { CurrentUser } from "./timeline"; @@ -68,16 +61,6 @@ export type TenantResolution = | { readonly kind: "empty" } | { readonly kind: "ready"; readonly tenantId: string }; -/** - * This workspace compiles under `exactOptionalPropertyTypes`, and the - * component library's optional props are declared without - * `| undefined` — so an absent prop has to be omitted, not passed as - * `undefined`. - */ -function subtitleProp(subtitle: string | undefined): { subtitle?: string } { - return subtitle === undefined ? {} : { subtitle }; -} - type ChannelsState = | { readonly kind: "loading" } | { readonly kind: "error"; readonly message: string } @@ -190,6 +173,7 @@ function ChatWorkspaceInner({ ); const unauthorizedRef = useRef(false); + // `background: true` is a refresh from SSE/polling: the previous ready // items stay on screen (and the composer stays mounted) until fresh data // lands, and a failed background refresh is swallowed rather than @@ -248,6 +232,17 @@ function ChatWorkspaceInner({ if (activeChannelId !== null) void loadMessages(activeChannelId); }, [activeChannelId, loadMessages]); + // Host shell opens the new-channel dialog from the contextual panel action. + useEffect(() => { + const onNewChannel = () => { + setCreateChannelError(null); + setDialogOpen(true); + }; + window.addEventListener("workbench:chat:new-channel", onNewChannel); + return () => + window.removeEventListener("workbench:chat:new-channel", onNewChannel); + }, []); + const refreshUnlessUnauthorized = () => { if (unauthorizedRef.current) return; if (activeChannelId !== null) { @@ -289,18 +284,6 @@ function ChatWorkspaceInner({ await loadMessages(activeChannelId); } - async function handleRename(channelId: string, name: string) { - await patchChannelSettings(tenantId, channelId, { "chat/name": name }); - setChannelsRefresh((value) => value + 1); - } - - async function handleTogglePin(channel: Channel) { - await patchChannelSettings(tenantId, channel.id, { - "chat/pinned": !channel.pinned, - }); - setChannelsRefresh((value) => value + 1); - } - async function handleSend(text: string): Promise { if (activeChannelId === null) return false; try { @@ -330,113 +313,93 @@ function ChatWorkspaceInner({ return ( <> - - - Chat - - {activeChatAgent !== undefined ? : null} - {activeChannelId !== null ? ( - - {canInviteAgent(activeChannel?.kind) ? ( - - ) : null} - - - ) : null} -
- {channelsState.kind === "loading" ? ( - - ) : channelsState.kind === "error" ? ( - } - title={`Couldn't load ${CHAT_STRINGS.couldNotLoadChannels}`} - description={channelsState.message} - action={ - - } - /> - ) : ( - setActiveChannelId(channel.id)} - onNewChannel={() => { - setCreateChannelError(null); - setDialogOpen(true); - }} - onRename={(channelId, name) => void handleRename(channelId, name)} - onTogglePin={(channel) => void handleTogglePin(channel)} - onOpenSettings={(channel) => { - setActiveChannelId(channel.id); - setSettingsChannelId(channel.id); - }} - /> - )}
- {activeChannelId === null ? ( - } - title={CHAT_STRINGS.noChatSelectedTitle} - description={CHAT_STRINGS.noChatSelectedDescription} - /> - ) : messagesState.kind === "loading" ? ( + {channelsState.kind === "loading" ? ( - ) : messagesState.kind === "error" ? ( + ) : channelsState.kind === "error" ? ( } - title={`Couldn't load ${CHAT_STRINGS.couldNotLoadMessages}`} - description={messagesState.message} + title={`Couldn't load ${CHAT_STRINGS.couldNotLoadChannels}`} + description={channelsState.message} action={ - } /> + ) : activeChannelId === null ? ( + } + title={CHAT_STRINGS.noChatSelectedTitle} + description={CHAT_STRINGS.noChatSelectedDescription} + /> ) : ( <> - {streamState !== "live" ? ( -
- {CHAT_STRINGS.reconnectingMessage} +
+
+

+ {activeChannel?.title || CHAT_STRINGS.unnamedChannel} +

+ {activeChatAgent !== undefined ? : null}
- ) : null} - - +
+ {canInviteAgent(activeChannel?.kind) ? ( + + ) : null} + +
+
+ {messagesState.kind === "loading" ? ( + + ) : messagesState.kind === "error" ? ( + } + title={`Couldn't load ${CHAT_STRINGS.couldNotLoadMessages}`} + description={messagesState.message} + action={ + + } + /> + ) : ( + <> + {streamState !== "live" ? ( +
+ {CHAT_STRINGS.reconnectingMessage} +
+ ) : null} + + + + )} )}
@@ -478,14 +441,7 @@ function ChatWorkspaceInner({ } function ChatWorkspaceFrame({ children }: { readonly children: ReactNode }) { - return ( - <> - - Chat - - {children} - - ); + return
{children}
; } export function ChatWorkspace({ @@ -522,24 +478,18 @@ export function ChatWorkspace({ } - title={CHAT_STRINGS.noChannelsTitle} - description="This account is not a member of any bench yet, so there is nowhere to chat." + title="No bench yet" + description="Create or join a bench before chatting." /> ); - case "loading": - return ( - - - - ); case "unauthenticated": return ( } - title="Sign in required" - description="Your session has ended. Reload the page to sign in again." + icon={} + title="Sign in to chat" + description="Your conversations live on a bench — sign in to open them." /> ); @@ -548,10 +498,16 @@ export function ChatWorkspace({ } - title="Couldn't load your benches" + title="Couldn't open chat" description={tenant.message} /> ); + case "loading": + return ( + + + + ); } } diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index a3ce92444..03ef0ba41 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -1,8 +1,6 @@ -/* Chat surface — a two-pane layout nested inside the host's own content - area: a narrow page-local sidebar (channels/chats) beside the active - channel's timeline and composer. Layout glue only, same as the host - app's own stylesheet: visual styling comes from `@corbits/react-ui`'s - prebuilt stylesheet and the theme's tokens. */ +/* Chat surface — active conversation only. Channel list lives in the host + shell's contextual panel. Layout glue only: visual styling comes from + `@corbits/react-ui`'s prebuilt stylesheet and the theme's tokens. */ .chat-workspace { /* Summit Blue accent for the agent badge — the one literal allowed by the @@ -16,103 +14,45 @@ overflow: hidden; } -.chat-sidebar { +.chat-workspace-frame { display: flex; - width: 16rem; - flex-shrink: 0; - flex-direction: column; - gap: 0.75rem; - overflow-y: auto; - border-right: 1px solid var(--border); - padding: 1rem 0.75rem; -} - -.chat-sidebar-header { - display: flex; -} - -.chat-sidebar-section { - display: flex; - flex-direction: column; - gap: 0.125rem; -} - -.chat-sidebar-section-label { - padding: 0.25rem 0.5rem; - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--muted-foreground); -} - -.chat-sidebar-item { - display: block; - width: 100%; - border: none; - border-radius: calc(var(--radius) - 2px); - background: none; - padding: 0.5rem 0.5rem; - text-align: left; - font: inherit; - color: var(--foreground); - cursor: pointer; - transition: background-color 150ms ease; -} - -.chat-sidebar-item:hover { - background: var(--accent); -} - -.chat-sidebar-item:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} - -.chat-sidebar-item[data-active="true"] { - background: var(--accent); - font-weight: 600; + flex: 1; + min-height: 0; + align-items: center; + justify-content: center; } -.chat-sidebar-row { - position: relative; +.chat-channel-header { display: flex; + flex-shrink: 0; align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-bottom: 1px solid var(--border); + padding: 0.75rem 1rem; } -.chat-sidebar-row .chat-sidebar-item { - flex: 1; +.chat-channel-identity { + display: flex; min-width: 0; + align-items: center; + gap: 0.5rem; } -.chat-sidebar-item-rename { - width: 100%; - border: 1px solid var(--ring); - border-radius: calc(var(--radius) - 2px); - background: var(--background); - padding: 0.5rem 0.5rem; - font: inherit; - color: var(--foreground); +.chat-channel-title { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9375rem; + font-weight: 600; } -.chat-sidebar-row-menu-trigger { +.chat-channel-actions { display: flex; + flex-shrink: 0; align-items: center; - justify-content: center; - border: none; - background: none; - border-radius: calc(var(--radius) - 2px); - padding: 0.25rem; - color: var(--muted-foreground); - cursor: pointer; - opacity: 0; - transition: opacity 100ms ease; -} - -.chat-sidebar-row:hover .chat-sidebar-row-menu-trigger, -.chat-sidebar-row-menu-trigger:focus-visible, -.chat-sidebar-row-menu-trigger[data-state="open"] { - opacity: 1; + gap: 0.375rem; } .chat-settings-panel { @@ -184,240 +124,24 @@ justify-content: center; } -.chat-bubble-row { - display: flex; - align-items: flex-end; - justify-content: flex-start; - gap: 0.5rem; -} - -.chat-bubble-row[data-own="true"] { - justify-content: flex-end; -} - -.chat-day-divider { - display: flex; - justify-content: center; - padding: 0.25rem 0; +.chat-stream-indicator { + flex-shrink: 0; + border-bottom: 1px solid var(--border); + padding: 0.375rem 1rem; font-size: 0.75rem; color: var(--muted-foreground); } -.chat-sender-avatar { - display: flex; - height: 1.75rem; - width: 1.75rem; - flex-shrink: 0; +.chat-agent-badge { + display: inline-flex; align-items: center; - justify-content: center; + gap: 0.25rem; border-radius: 999px; - background: var(--muted); - color: var(--muted-foreground); + background: color-mix(in srgb, var(--chat-agent-accent) 18%, transparent); + padding: 0.125rem 0.5rem; font-size: 0.6875rem; font-weight: 600; -} - -.chat-agent-badge { - border-radius: 999px; - border: 1px solid var(--chat-agent-accent); - color: var(--chat-agent-accent); - padding: 0 0.375rem; - margin-left: 0.375rem; - font-size: 0.625rem; - font-weight: 600; - text-transform: uppercase; letter-spacing: 0.02em; -} - -/* Own messages get the Bedrock Charcoal foreground-on-dark surface, never - the brand's Breakthrough Orange — the brand reserves that color for - primary CTAs, not a recurring message surface. Everyone else's messages - render on the theme's muted/card surface with dark text. */ -.chat-bubble { - display: flex; - max-width: 32rem; - flex-direction: column; - gap: 0.25rem; - border-radius: calc(var(--radius) + 2px); - background: var(--muted); - padding: 0.625rem 0.875rem; - color: var(--foreground); -} - -.chat-bubble[data-own="true"] { - background: var(--foreground); - color: var(--background); -} - -.chat-bubble-sender { - font-size: 0.6875rem; - font-weight: 600; - opacity: 0.85; -} - -.chat-bubble-text { - margin: 0; - overflow-wrap: break-word; - white-space: pre-wrap; -} - -.chat-bubble-time { - align-self: flex-end; - font-family: "Space Mono", monospace; - font-size: 0.8125rem; - opacity: 0.75; -} - -.chat-event-line { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.75rem; - color: var(--muted-foreground); -} - -.chat-event-time { - font-family: "Space Mono", monospace; - font-size: 0.8125rem; - opacity: 0.75; -} - -.chat-fallback-block { - display: flex; - flex-direction: column; - gap: 0.25rem; - border: 1px solid var(--border); - border-radius: calc(var(--radius) - 2px); - padding: 0.5rem 0.75rem; - font-size: 0.75rem; -} - -.chat-fallback-label { - font-weight: 600; - color: var(--muted-foreground); -} - -.chat-fallback-body { - margin: 0; - overflow-x: auto; - white-space: pre-wrap; -} - -.chat-composer { - position: relative; - border-top: 1px solid var(--border); - padding: 0.75rem 1rem; -} - -.chat-composer-row { - display: flex; - align-items: flex-end; - gap: 0.5rem; -} - -.chat-composer-input { - flex: 1; - resize: none; - border: 1px solid var(--border); - border-radius: calc(var(--radius) - 2px); - background: var(--background); - padding: 0.5rem 0.75rem; - font: inherit; - color: var(--foreground); - max-height: 10rem; -} - -.chat-mention-popover { - position: absolute; - bottom: 100%; - left: 1rem; - z-index: 10; - display: flex; - max-height: 12rem; - width: 16rem; - flex-direction: column; - overflow-y: auto; - border-radius: calc(var(--radius) - 2px); - margin-bottom: 0.5rem; - background: var(--popover, var(--background)); - box-shadow: - 0 0 0 1px rgb(0 0 0 / 0.06), - 0 4px 8px rgb(0 0 0 / 0.08), - 0 12px 24px rgb(0 0 0 / 0.12); -} - -.chat-mention-option { - display: flex; - align-items: baseline; - gap: 0.5rem; - border: none; - background: none; - padding: 0.5rem 0.75rem; - text-align: left; - font: inherit; - color: var(--foreground); - cursor: pointer; - transition: background-color 150ms ease; -} - -.chat-mention-option:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} - -.chat-mention-label { - font-size: 0.75rem; - color: var(--muted-foreground); -} - -.chat-mention-option[data-highlighted="true"], -.chat-mention-option:hover { - background: var(--accent); -} - -.chat-mention-empty { - padding: 0.5rem 0.75rem; - font-size: 0.75rem; - color: var(--muted-foreground); -} - -.chat-new-channel-form { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.chat-form-field { - display: flex; - flex-direction: column; - gap: 0.375rem; - border: none; - padding: 0; - margin: 0; -} - -.chat-radio-option { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - font-weight: 400; -} - -.chat-composer-error { - padding: 0.25rem 0.75rem 0; - font-size: 0.75rem; - color: var(--destructive); -} - -.chat-dialog-error { - font-size: 0.8125rem; - color: var(--destructive); - margin: 0; -} - -.chat-stream-indicator { - padding: 0.25rem 0.75rem; - font-size: 0.75rem; - color: var(--muted-foreground); + text-transform: uppercase; + color: var(--chat-agent-accent); } From 9df30c3c3cdc55be82546275bd3265026dd22143 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 08:30:15 -0700 Subject: [PATCH 3/3] Drop @corbits/shell-ui; keep panel registry in the web shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contribution registry and pins are workbench-specific shell logic — they belong next to the panel, not a new package. UI primitives stay on @corbits/react-ui. Also fix Prettier and the non-null assertion lint. --- apps/web/package.json | 1 - apps/web/src/pages/home-page.tsx | 78 ++++---- apps/web/src/pages/routines-page.tsx | 168 +++++++++--------- apps/web/src/shell/contextual-panel.tsx | 12 +- .../web/src/shell}/panel-contribution.ts | 3 +- apps/web/src/shell/panel-contributions.tsx | 48 ++--- .../src => apps/web/src/shell}/pins.ts | 5 +- .../web}/test/panel-contribution.test.ts | 19 +- .../shell-ui => apps/web}/test/pins.test.ts | 8 +- bun.lock | 16 -- packages/chat-ui/src/chat-workspace.tsx | 13 +- packages/shell-ui/package.json | 24 --- packages/shell-ui/src/index.ts | 16 -- packages/shell-ui/tsconfig.json | 9 - 14 files changed, 164 insertions(+), 256 deletions(-) rename {packages/shell-ui/src => apps/web/src/shell}/panel-contribution.ts (93%) rename {packages/shell-ui/src => apps/web/src/shell}/pins.ts (94%) rename {packages/shell-ui => apps/web}/test/panel-contribution.test.ts (81%) rename {packages/shell-ui => apps/web}/test/pins.test.ts (86%) delete mode 100644 packages/shell-ui/package.json delete mode 100644 packages/shell-ui/src/index.ts delete mode 100644 packages/shell-ui/tsconfig.json diff --git a/apps/web/package.json b/apps/web/package.json index dd518951e..c0597be0a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,6 @@ "@corbits/command-palette": "workspace:*", "@corbits/routines": "workspace:*", "@corbits/settings-ui": "workspace:*", - "@corbits/shell-ui": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index daa5ec4f6..290ed29dc 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -67,48 +67,42 @@ export function HomePage({ profile.kind === "ready" ? `Welcome back, ${profile.data.name}` : "Welcome"; return ( - {profile.kind === "unauthenticated" ? ( - - ) : ( - <> -
- - - - -
-
-
- {SHORTCUTS.map((shortcut) => ( - - - - {shortcut.title} - - {shortcut.description} - - - - - ))} -
-
- - )} -
+ {profile.kind === "unauthenticated" ? ( + + ) : ( + <> +
+ + + + +
+
+
+ {SHORTCUTS.map((shortcut) => ( + + + + {shortcut.title} + {shortcut.description} + + + + ))} +
+
+ + )} + ); } diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 8188f08ad..2799a5782 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -491,44 +491,46 @@ export function RoutinesListPage({

Runs currently executing that a routine started.

- - {(runs) => - runs.length === 0 ? ( - } - title="No routine runs in flight" - description="When a routine fires, its run appears here while it executes." - /> - ) : ( - - - - Definition - Bench - Status - Started - - - - {runs.map((run) => ( - - {run.definitionName} - {run.tenantName} - - - {run.status} - - - - {formatRelativeTime(run.createdAt, now)} - + + {(runs) => + runs.length === 0 ? ( + } + title="No routine runs in flight" + description="When a routine fires, its run appears here while it executes." + /> + ) : ( +
+ + + Definition + Bench + Status + Started - ))} - -
- ) - } -
+ + + {runs.map((run) => ( + + {run.definitionName} + {run.tenantName} + + + {run.status} + + + + {formatRelativeTime(run.createdAt, now)} + + + ))} + + + ) + } + @@ -579,51 +581,53 @@ export function RoutineDetailPage({

Run history

Every time this routine fired.

- - {(items) => - items.length === 0 ? ( - } - title="No runs yet" - description="This routine has not fired yet — manually or on a schedule." - /> - ) : ( - - - - Triggered by - Status - When - - - - {items.map((run) => { - const status = run.run?.status; - return ( - - - {run.triggeredBy} - - - {typeof status === "string" ? ( - - {status} - - ) : ( - "—" - )} - - - {formatRelativeTime(run.createdAt, now)} - - - ); - })} - -
- ) - } -
+ + {(items) => + items.length === 0 ? ( + } + title="No runs yet" + description="This routine has not fired yet — manually or on a schedule." + /> + ) : ( + + + + Triggered by + Status + When + + + + {items.map((run) => { + const status = run.run?.status; + return ( + + + {run.triggeredBy} + + + {typeof status === "string" ? ( + + {status} + + ) : ( + "—" + )} + + + {formatRelativeTime(run.createdAt, now)} + + + ); + })} + +
+ ) + } +
diff --git a/apps/web/src/shell/contextual-panel.tsx b/apps/web/src/shell/contextual-panel.tsx index 5172acce6..782bbf467 100644 --- a/apps/web/src/shell/contextual-panel.tsx +++ b/apps/web/src/shell/contextual-panel.tsx @@ -8,16 +8,13 @@ // list item navigates to the full surface for that entity. import { Button, EmptyState, SidebarItemRow } from "@corbits/react-ui"; -import { - loadPins, - resolvePanelContribution, - type Pin, -} from "@corbits/shell-ui"; import { Pin as PinIcon, Settings } from "lucide-react"; import { useState } from "react"; import { CanvasToggle } from "./canvas-column"; +import { resolvePanelContribution } from "./panel-contribution"; import { ensurePanelContributions } from "./panel-contributions"; +import { loadPins, type Pin } from "./pins"; ensurePanelContributions(); @@ -65,7 +62,10 @@ export function ContextualPanel({ size="sm" aria-label="Page settings" title="Page settings" - onClick={() => onNavigate(pageBand.settingsPath!)} + onClick={() => { + const settingsPath = pageBand.settingsPath; + if (settingsPath !== undefined) onNavigate(settingsPath); + }} > diff --git a/packages/shell-ui/src/panel-contribution.ts b/apps/web/src/shell/panel-contribution.ts similarity index 93% rename from packages/shell-ui/src/panel-contribution.ts rename to apps/web/src/shell/panel-contribution.ts index 903695217..ce2ee3a93 100644 --- a/packages/shell-ui/src/panel-contribution.ts +++ b/apps/web/src/shell/panel-contribution.ts @@ -1,6 +1,7 @@ // Route-aware contributions for the shell contextual panel. Page modules // register bands here; the shell resolves the first match for the current -// path and never hardcodes per-page content. +// path and never hardcodes per-page content. Workbench-specific — lives next +// to the panel, not in a separate package. UI primitives stay in react-ui. import type { ReactNode } from "react"; diff --git a/apps/web/src/shell/panel-contributions.tsx b/apps/web/src/shell/panel-contributions.tsx index f2349c6d7..71ce8b49f 100644 --- a/apps/web/src/shell/panel-contributions.tsx +++ b/apps/web/src/shell/panel-contributions.tsx @@ -1,19 +1,15 @@ // Registers each page's contextual-panel contribution. Imported once from // the shell so matchers are on the registry before first render. -import { - EmptyState, - SidebarItemRow, - Skeleton, -} from "@corbits/react-ui"; -import { - registerPanelContribution, - type PanelRenderContext, -} from "@corbits/shell-ui"; +import { EmptyState, SidebarItemRow, Skeleton } from "@corbits/react-ui"; import { Bell, Hash, MessageSquare, Workflow } from "lucide-react"; import { useBench } from "../bench-context"; import { useBenchActivity } from "./bench-activity"; +import { + registerPanelContribution, + type PanelRenderContext, +} from "./panel-contribution"; import type { RoutineActivityItem } from "./routine-activity"; const CHAT_PATH_PREFIX = "/chat"; @@ -277,11 +273,7 @@ function NotificationsBand() { ); } -function defaultBand( - title: string, - subtitle: string, - settingsPath?: string, -) { +function defaultBand(title: string, subtitle: string, settingsPath?: string) { return (_ctx: PanelRenderContext) => ({ title, subtitle, @@ -361,7 +353,8 @@ export function ensurePanelContributions(): void { label: "Create routine", onSelect: () => { window.dispatchEvent(new CustomEvent("workbench:routines:create")); - if (!pathMatches("/routines", ctx.path)) ctx.onNavigate("/routines"); + if (!pathMatches("/routines", ctx.path)) + ctx.onNavigate("/routines"); }, }, ], @@ -372,20 +365,14 @@ export function ensurePanelContributions(): void { registerPanelContribution({ id: "approvals", match: (path) => pathMatches("/approvals", path), - pageBand: defaultBand( - "Approvals", - "Pending decisions waiting on you", - ), + pageBand: defaultBand("Approvals", "Pending decisions waiting on you"), pageSpecific: () => , }); registerPanelContribution({ id: "library", match: (path) => pathMatches("/library", path), - pageBand: defaultBand( - "Library", - "Artifacts this bench has produced", - ), + pageBand: defaultBand("Library", "Artifacts this bench has produced"), }); registerPanelContribution({ @@ -400,27 +387,18 @@ export function ensurePanelContributions(): void { registerPanelContribution({ id: "insights", match: (path) => pathMatches("/insights", path), - pageBand: defaultBand( - "Insights", - "Usage and audit trail for this bench", - ), + pageBand: defaultBand("Insights", "Usage and audit trail for this bench"), }); registerPanelContribution({ id: "settings", match: (path) => pathMatches("/settings", path), - pageBand: defaultBand( - "Settings", - "Bench, members, and preferences", - ), + pageBand: defaultBand("Settings", "Bench, members, and preferences"), }); registerPanelContribution({ id: "benches", match: (path) => pathMatches("/benches", path), - pageBand: defaultBand( - "Benches", - "Every workbench you can access", - ), + pageBand: defaultBand("Benches", "Every workbench you can access"), }); } diff --git a/packages/shell-ui/src/pins.ts b/apps/web/src/shell/pins.ts similarity index 94% rename from packages/shell-ui/src/pins.ts rename to apps/web/src/shell/pins.ts index 8852107cd..c820c34d6 100644 --- a/packages/shell-ui/src/pins.ts +++ b/apps/web/src/shell/pins.ts @@ -47,10 +47,7 @@ export function savePins( storage.setItem(STORAGE_KEY, JSON.stringify(pins)); } -export function togglePin( - pins: readonly Pin[], - pin: Pin, -): readonly Pin[] { +export function togglePin(pins: readonly Pin[], pin: Pin): readonly Pin[] { const exists = pins.some((p) => p.id === pin.id && p.kind === pin.kind); if (exists) { return pins.filter((p) => !(p.id === pin.id && p.kind === pin.kind)); diff --git a/packages/shell-ui/test/panel-contribution.test.ts b/apps/web/test/panel-contribution.test.ts similarity index 81% rename from packages/shell-ui/test/panel-contribution.test.ts rename to apps/web/test/panel-contribution.test.ts index 4cad5af5b..700352e9a 100644 --- a/packages/shell-ui/test/panel-contribution.test.ts +++ b/apps/web/test/panel-contribution.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { createPanelRegistry } from "../src/panel-contribution"; +import { createPanelRegistry } from "../src/shell/panel-contribution"; describe("createPanelRegistry", () => { test("resolves the first matching contribution for a path", () => { @@ -35,9 +35,11 @@ describe("createPanelRegistry", () => { pageBand: () => ({ title: "Agents v2" }), }); expect(registry.list()).toHaveLength(1); - expect(registry.resolve("/agents")?.pageBand({ path: "/agents", onNavigate: () => undefined }).title).toBe( - "Agents v2", - ); + expect( + registry + .resolve("/agents") + ?.pageBand({ path: "/agents", onNavigate: () => undefined }).title, + ).toBe("Agents v2"); }); test("pins are independent of route resolution", () => { @@ -49,7 +51,14 @@ describe("createPanelRegistry", () => { pageBand: () => ({ title: "Routines" }), }, ]); - const pins = [{ id: "c1", kind: "channel" as const, label: "ops", href: "/chat/c1" }]; + const pins = [ + { + id: "c1", + kind: "channel" as const, + label: "ops", + href: "/chat/c1", + }, + ]; expect(registry.resolve("/routines")?.id).toBe("routines"); expect(registry.resolve("/agents")).toBeNull(); expect(pins).toHaveLength(1); diff --git a/packages/shell-ui/test/pins.test.ts b/apps/web/test/pins.test.ts similarity index 86% rename from packages/shell-ui/test/pins.test.ts rename to apps/web/test/pins.test.ts index 954b225e5..ffe09d38b 100644 --- a/packages/shell-ui/test/pins.test.ts +++ b/apps/web/test/pins.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { loadPins, savePins, togglePin } from "../src/pins"; +import { loadPins, savePins, togglePin } from "../src/shell/pins"; function memoryStorage(seed: Record = {}) { const map = new Map(Object.entries(seed)); @@ -17,9 +17,9 @@ function memoryStorage(seed: Record = {}) { describe("pins", () => { test("loadPins returns empty for missing or corrupt storage", () => { expect(loadPins(memoryStorage())).toEqual([]); - expect(loadPins(memoryStorage({ "workbench.shell.pins": "not-json" }))).toEqual( - [], - ); + expect( + loadPins(memoryStorage({ "workbench.shell.pins": "not-json" })), + ).toEqual([]); }); test("savePins and loadPins round-trip valid pins", () => { diff --git a/bun.lock b/bun.lock index a39043048..64631eab5 100644 --- a/bun.lock +++ b/bun.lock @@ -93,7 +93,6 @@ "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@corbits/routines": "workspace:*", "@corbits/settings-ui": "workspace:*", - "@corbits/shell-ui": "workspace:*", "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", @@ -401,19 +400,6 @@ "typescript": "catalog:", }, }, - "packages/shell-ui": { - "name": "@corbits/shell-ui", - "version": "0.0.1", - "dependencies": { - "arktype": "catalog:", - "react": "^19.2.0", - }, - "devDependencies": { - "@types/bun": "catalog:", - "@types/react": "^19.2.2", - "typescript": "catalog:", - }, - }, "packages/webhook-triggers": { "name": "@corbits/webhook-triggers", "version": "0.0.1", @@ -873,8 +859,6 @@ "@corbits/settings-ui": ["@corbits/settings-ui@workspace:packages/settings-ui"], - "@corbits/shell-ui": ["@corbits/shell-ui@workspace:packages/shell-ui"], - "@corbits/webhook-triggers": ["@corbits/webhook-triggers@workspace:packages/webhook-triggers"], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 83c6a76ba..26be253db 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -12,17 +12,8 @@ // narrow-port shape `@corbits/chat`'s `routes.ts` uses for `ChatPlatform`. import { isAgentAddress } from "@corbits/chat/mentions"; -import { - Button, - EmptyState, - Skeleton, -} from "@corbits/react-ui"; -import { - CircleAlert, - MessageSquare, - Settings, - UserPlus, -} from "lucide-react"; +import { Button, EmptyState, Skeleton } from "@corbits/react-ui"; +import { CircleAlert, MessageSquare, Settings, UserPlus } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; diff --git a/packages/shell-ui/package.json b/packages/shell-ui/package.json deleted file mode 100644 index f85450d4a..000000000 --- a/packages/shell-ui/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@corbits/shell-ui", - "private": true, - "description": "Shell contribution contracts: route-aware contextual panel bands that page modules register into, so the shell never hardcodes per-page content", - "version": "0.0.1", - "license": "SEE LICENSE IN LICENSE.md", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:", - "react": "^19.2.0" - }, - "devDependencies": { - "@types/bun": "catalog:", - "@types/react": "^19.2.2", - "typescript": "catalog:" - } -} diff --git a/packages/shell-ui/src/index.ts b/packages/shell-ui/src/index.ts deleted file mode 100644 index 369b44ac3..000000000 --- a/packages/shell-ui/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { - createPanelRegistry, - panelRegistry, - registerPanelContribution, - resolvePanelContribution, -} from "./panel-contribution"; -export type { - PageBand, - PanelAction, - PanelContribution, - PanelRegistry, - PanelRenderContext, -} from "./panel-contribution"; - -export { loadPins, savePins, togglePin, Pin, PinKind } from "./pins"; -export type { Pin as PinRecord, PinKind as PinKindValue } from "./pins"; diff --git a/packages/shell-ui/tsconfig.json b/packages/shell-ui/tsconfig.json deleted file mode 100644 index 461e72c55..000000000 --- a/packages/shell-ui/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "jsx": "react-jsx", - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": ["bun"] - }, - "include": ["src", "test"] -}