summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte73
-rw-r--r--src/app/App.test.ts175
-rw-r--r--src/app/store.svelte.ts74
-rw-r--r--src/app/store.test.ts72
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();
});