diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 73 | ||||
| -rw-r--r-- | src/app/App.test.ts | 175 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 74 | ||||
| -rw-r--r-- | src/app/store.test.ts | 72 |
4 files changed, 203 insertions, 191 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 4ee071d..ff6b1ca 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,22 +1,39 @@ <script lang="ts"> import type { InvokeMessage } from "@dispatch/ui-contract"; - import { ChatView, Composer, ModelSelector } from "../features/chat"; - import { TabBar } from "../features/tabs"; - import { SurfaceView } from "../features/surface-host"; + import Table from "../components/Table.svelte"; + import { ChatView, Composer, manifest as chatManifest, ModelSelector } from "../features/chat"; + import { manifest as conversationCacheManifest } from "../features/conversation-cache"; + import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host"; + import { manifest as tabsManifest, TabBar } from "../features/tabs"; + import { manifest as viewsManifest, ViewSidebar } from "../features/views"; import type { AppStore } from "./store.svelte"; let { store }: { store: AppStore } = $props(); + // The view kinds offered in the sidebar's dropdown. Generic data — the + // `viewContent` snippet below maps each kind id to its renderer. + const viewKinds = [{ id: "extensions", label: "Extensions" }] as const; + + // Frontend module list for the "Loaded Modules" view, AGGREGATED from each + // feature's public `manifest` export so it can't drift from what's actually + // composed. (The backend's "Loaded Extensions" surface is a SEPARATE, + // backend-owned list.) FE features are internal units of this single repo, so + // there is no per-module version — they all share dispatch-web's version. + const MODULE_COLUMNS = ["Module", "Description"] as const; + const loadedModules: readonly (readonly [string, string])[] = [ + chatManifest, + tabsManifest, + surfaceHostManifest, + viewsManifest, + conversationCacheManifest, + ].map((m) => [m.name, m.description] as const); + // Right sidebar: open by default on wide screens (pushes the chat aside), // closed by default on narrow screens (overlays the chat). Initial state is // derived from the viewport width once; the hamburger toggles it thereafter. const WIDE_BREAKPOINT = 1024; // Tailwind `lg` let sidebarOpen = $state(typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true); - function handleSelect(surfaceId: string) { - store.select(surfaceId); - } - function handleInvoke(msg: InvokeMessage) { store.invoke(msg.surfaceId, msg.actionId, msg.payload); } @@ -106,31 +123,6 @@ </div> <Composer onSend={handleSend} /> - - {#if store.catalog.length > 0} - <section class="border-t border-base-300 p-4"> - <h2 class="mb-2 text-sm font-semibold">Surfaces</h2> - <div class="flex flex-wrap gap-2"> - {#each store.catalog as entry (entry.id)} - <button - class="btn btn-sm" - class:btn-active={entry.id === store.selectedId} - aria-current={entry.id === store.selectedId ? "true" : undefined} - onclick={() => handleSelect(entry.id)} - > - {entry.title} - <span class="text-xs opacity-60">({entry.region})</span> - </button> - {/each} - </div> - </section> - {/if} - - {#if store.selectedSpec} - <section class="border-t border-base-300 p-4"> - <SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} /> - </section> - {/if} </div> <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so @@ -145,7 +137,7 @@ class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out" style="transform: translateX({sidebarOpen ? '0' : '100%'})" > - <h2 class="text-sm font-semibold opacity-60">Sidebar</h2> + <ViewSidebar kinds={viewKinds} content={viewContent} /> </div> </aside> @@ -164,3 +156,18 @@ ></div> {/if} </main> + +{#snippet viewContent(kind: string)} + {#if kind === "extensions"} + <section> + <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> + <Table columns={MODULE_COLUMNS} rows={loadedModules} /> + </section> + <section class="mt-4 flex flex-col gap-3"> + <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3> + {#each store.surfaces as spec (spec.id)} + <SurfaceView {spec} onInvoke={handleInvoke} /> + {/each} + </section> + {/if} +{/snippet} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 8110d41..121bd20 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -1,6 +1,6 @@ import type { WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; -import { render, screen, within } from "@testing-library/svelte"; +import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; @@ -119,7 +119,7 @@ describe("App component interaction tests", () => { store.dispose(); }); - it("renders catalog buttons when surfaces are available", () => { + it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, @@ -128,6 +128,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); + ws.sent.length = 0; ws.feedSurfaceMessage({ type: "catalog", catalog: [ @@ -138,17 +139,16 @@ describe("App component interaction tests", () => { render(App, { props: { store } }); - const surfacesSection = screen.getByRole("heading", { name: "Surfaces" }).closest("section"); - if (surfacesSection === null) throw new Error("Surfaces section not found"); - const buttons = within(surfacesSection).getAllByRole("button"); - expect(buttons).toHaveLength(2); - expect(buttons[0]).toHaveTextContent("Surface One"); - expect(buttons[1]).toHaveTextContent("Surface Two"); + const subscribed = sentMessages(ws) + .filter((m: { type: string }) => m.type === "subscribe") + .map((m: { surfaceId: string }) => m.surfaceId); + expect(subscribed).toContain("s1"); + expect(subscribed).toContain("s2"); store.dispose(); }); - it("clicking a catalog entry subscribes and renders its surface", async () => { + it("renders every surface expanded once their specs arrive", async () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, @@ -159,22 +159,15 @@ describe("App component interaction tests", () => { ws.feedSurfaceMessage({ type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], }); render(App, { props: { store } }); - const user = userEvent.setup(); - const button = screen.getByRole("button", { name: /Surface One/ }); - ws.sent.length = 0; - await user.click(button); - - const msgs = sentMessages(ws); - const subscribe = msgs.find( - (m: { type: string; surfaceId: string }) => m.type === "subscribe" && m.surfaceId === "s1", - ); - expect(subscribe).toBeTruthy(); - + // No interaction: specs arrive and both surfaces render expanded. ws.feedSurfaceMessage({ type: "surface", spec: { @@ -184,83 +177,19 @@ describe("App component interaction tests", () => { fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], }, }); + ws.feedSurfaceMessage({ + type: "surface", + spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, + }); expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Surface Two" })).toBeInTheDocument(); expect(await screen.findByText("Tokens")).toBeInTheDocument(); expect(await screen.findByText("1,234")).toBeInTheDocument(); store.dispose(); }); - it("clicking a different entry unsubscribes the previous then subscribes the new", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - render(App, { props: { store } }); - - const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: /Surface One/ })); - ws.sent.length = 0; - - await user.click(screen.getByRole("button", { name: /Surface Two/ })); - - const msgs = sentMessages(ws) as Array<{ type: string; surfaceId: string }>; - const unsubIdx = msgs.findIndex((m) => m.type === "unsubscribe" && m.surfaceId === "s1"); - const subIdx = msgs.findIndex((m) => m.type === "subscribe" && m.surfaceId === "s2"); - expect(unsubIdx).toBeGreaterThanOrEqual(0); - expect(subIdx).toBeGreaterThanOrEqual(0); - expect(unsubIdx).toBeLessThan(subIdx); - - store.dispose(); - }); - - it("selected catalog button reflects aria-current", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - render(App, { props: { store } }); - - const user = userEvent.setup(); - const btn1 = screen.getByRole("button", { name: /Surface One/ }); - const btn2 = screen.getByRole("button", { name: /Surface Two/ }); - - await user.click(btn1); - expect(btn1).toHaveAttribute("aria-current", "true"); - expect(btn2).not.toHaveAttribute("aria-current"); - - await user.click(btn2); - expect(btn2).toHaveAttribute("aria-current", "true"); - expect(btn1).not.toHaveAttribute("aria-current"); - - store.dispose(); - }); - it("an error message renders the alert banner", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -300,8 +229,7 @@ describe("App component interaction tests", () => { render(App, { props: { store } }); const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: /Surface One/ })); - + // Surface is auto-subscribed; its spec arrives and renders expanded. ws.feedSurfaceMessage({ type: "surface", spec: { @@ -403,4 +331,67 @@ describe("App component interaction tests", () => { store.dispose(); }); + + it("renders a custom 'table' field of a surface as a table", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store } }); + + // Auto-subscribed; the custom-table spec arrives and renders expanded. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [ + { + kind: "custom", + rendererId: "table", + payload: { + columns: ["Name", "Scope"], + rows: [["cache-warm", "backend"]], + }, + }, + ], + }, + }); + + expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument(); + expect(await screen.findByText("cache-warm")).toBeInTheDocument(); + expect(await screen.findByText("backend")).toBeInTheDocument(); + + store.dispose(); + }); + + it("the Extensions view lists frontend modules aggregated from feature manifests", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + // Extensions is the default view, so the modules table renders immediately. + expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); + for (const name of ["chat", "tabs", "surface-host", "views", "conversation-cache"]) { + expect(screen.getByRole("cell", { name })).toBeInTheDocument(); + } + + store.dispose(); + }); }); diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index fe3c55c..efbe065 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -5,7 +5,7 @@ import type { ConversationMetricsResponse, ModelsResponse, } from "@dispatch/transport-contract"; -import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; +import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; import { createIdbChunkStore } from "../adapters/idb"; import { createLocalStore } from "../adapters/local-storage"; import type { WebSocketLike } from "../adapters/ws"; @@ -37,15 +37,14 @@ export interface AppStore { readonly models: readonly string[]; readonly activeModel: string; readonly catalog: ProtocolState["catalog"]; - readonly selectedId: string | null; - readonly selectedSpec: SurfaceSpec | null; + /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ + readonly surfaces: readonly SurfaceSpec[]; readonly lastError: ProtocolState["lastError"]; send(text: string): void; selectModel(model: string): void; newDraft(): void; selectTab(conversationId: string): void; closeTab(conversationId: string): void; - select(surfaceId: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; dispose(): void; } @@ -85,7 +84,6 @@ function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSy export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let protocol = $state<ProtocolState>(protocolInitialState()); - let selectedId = $state<string | null>(null); let models = $state<readonly string[]>([]); let activeModel = $state(DEFAULT_MODEL); @@ -183,6 +181,32 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { function handleServerMessage(msg: SurfaceServerMessage): void { protocol = applyServerMessage(protocol, msg); + // Surfaces are auto-expanded: whenever the catalog changes, subscribe to + // every entry (and drop subscriptions for entries that vanished). + if (msg.type === "catalog") { + syncSubscriptions(); + } + } + + /** Subscribe to every catalog entry not yet subscribed; unsubscribe stragglers. */ + function syncSubscriptions(): void { + for (const entry of protocol.catalog) { + const result = protocolSubscribe(protocol, entry.id); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + const catalogIds = new Set(protocol.catalog.map((e) => e.id)); + for (const id of [...protocol.subscriptions.keys()]) { + if (!catalogIds.has(id)) { + const result = protocolUnsubscribe(protocol, id); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + } } let socket: ReturnType<typeof createSurfaceSocket> | null = null; @@ -192,12 +216,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { onMessage: handleServerMessage, onChat: handleChatMessage, onReopen() { - if (selectedId !== null) { - const result = protocolSubscribe(protocol, selectedId); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } + // The server forgot our subscriptions on reconnect; re-send for all + // catalog entries (protocolSubscribe would no-op since they're still in + // our local map, so emit the wire messages directly). + for (const entry of protocol.catalog) { + const msg: SubscribeMessage = { type: "subscribe", surfaceId: entry.id }; + socket?.send(msg); } }, }; @@ -265,12 +289,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get catalog() { return protocol.catalog; }, - get selectedId() { - return selectedId; - }, - get selectedSpec() { - if (selectedId === null) return null; - return protocol.subscriptions.get(selectedId) ?? null; + get surfaces(): readonly SurfaceSpec[] { + const out: SurfaceSpec[] = []; + for (const entry of protocol.catalog) { + const spec = protocol.subscriptions.get(entry.id); + if (spec) out.push(spec); + } + return out; }, get lastError() { return protocol.lastError; @@ -341,21 +366,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); }, - select(surfaceId: string): void { - if (selectedId !== null && selectedId !== surfaceId) { - const unsub = protocolUnsubscribe(protocol, selectedId); - protocol = unsub.state; - for (const msg of unsub.outgoing) { - socket?.send(msg); - } - } - selectedId = surfaceId; - const sub = protocolSubscribe(protocol, surfaceId); - protocol = sub.state; - for (const msg of sub.outgoing) { - socket?.send(msg); - } - }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { const result = protocolInvoke(protocol, surfaceId, actionId, payload); protocol = result.state; diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 86a21d6..19530e2 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -105,7 +105,7 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string } describe("createAppStore", () => { - it("starts with empty catalog and no selection", () => { + it("starts with empty catalog and no surfaces", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, @@ -116,8 +116,7 @@ describe("createAppStore", () => { ws.resolveOpen(); expect(store.catalog).toEqual([]); - expect(store.selectedId).toBeNull(); - expect(store.selectedSpec).toBeNull(); + expect(store.surfaces).toEqual([]); expect(store.lastError).toBeNull(); store.dispose(); @@ -148,7 +147,7 @@ describe("createAppStore", () => { store.dispose(); }); - it("select sends subscribe and sets selectedId", () => { + it("auto-subscribes to every catalog entry when the catalog arrives", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, @@ -158,25 +157,26 @@ describe("createAppStore", () => { }); ws.resolveOpen(); + ws.sent.length = 0; ws.feedSurfaceMessage({ type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], }); - ws.sent.length = 0; - store.select("s1"); - - expect(store.selectedId).toBe("s1"); - const subscribeMsg = ws.sent.find((s) => { - const parsed = JSON.parse(s); - return parsed.type === "subscribe" && parsed.surfaceId === "s1"; - }); - expect(subscribeMsg).toBeTruthy(); + const subscribed = ws.sent + .map((s) => JSON.parse(s)) + .filter((p) => p.type === "subscribe") + .map((p) => p.surfaceId); + expect(subscribed).toContain("s1"); + expect(subscribed).toContain("s2"); store.dispose(); }); - it("selecting a different surface unsubscribes from previous", () => { + it("unsubscribes from entries that vanish from a new catalog", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, @@ -195,25 +195,22 @@ describe("createAppStore", () => { }); ws.sent.length = 0; - store.select("s1"); - store.select("s2"); - - const unsubscribeMsg = ws.sent.find((s) => { - const parsed = JSON.parse(s); - return parsed.type === "unsubscribe" && parsed.surfaceId === "s1"; + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); - expect(unsubscribeMsg).toBeTruthy(); - const subscribeMsg = ws.sent.find((s) => { - const parsed = JSON.parse(s); - return parsed.type === "subscribe" && parsed.surfaceId === "s2"; - }); - expect(subscribeMsg).toBeTruthy(); + const unsubscribed = ws.sent + .map((s) => JSON.parse(s)) + .filter((p) => p.type === "unsubscribe") + .map((p) => p.surfaceId); + expect(unsubscribed).toContain("s2"); + expect(unsubscribed).not.toContain("s1"); store.dispose(); }); - it("surface message updates selectedSpec", () => { + it("exposes received surface specs via `surfaces`, in catalog order", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, @@ -225,11 +222,13 @@ describe("createAppStore", () => { ws.feedSurfaceMessage({ type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], }); - store.select("s1"); - + // Only s1's spec has arrived: surfaces reflects what's actually received. ws.feedSurfaceMessage({ type: "surface", spec: { @@ -239,10 +238,15 @@ describe("createAppStore", () => { fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], }, }); + expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]); - expect(store.selectedSpec).not.toBeNull(); - expect(store.selectedSpec?.id).toBe("s1"); - expect(store.selectedSpec?.fields).toHaveLength(1); + ws.feedSurfaceMessage({ + type: "surface", + spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, + }); + // Catalog order preserved (s1 before s2). + expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]); + expect(store.surfaces[0]?.fields).toHaveLength(1); store.dispose(); }); |
