diff --git a/apps/ui/src/store.ts b/apps/ui/src/store.ts index 65dd0d263..b3f1e0568 100644 --- a/apps/ui/src/store.ts +++ b/apps/ui/src/store.ts @@ -69,7 +69,17 @@ import { DEFAULT_TRACKER_CONFIG, type TrackerSeam, } from "./tracker"; - +import { parseRoute } from "./view-route"; + +// Open on the first subscribed channel so the shell boots into a live +// conversation; null before the first snapshot (components render empty state). +export function firstChannelId(channels: readonly Channel[]): string | null { + return ( + channels.find((c) => c.membership === "subscribed")?.id ?? + channels[0]?.id ?? + null + ); +} /** The caller — whose visibility scopes every listing and whose membership the * rail reflects. The daemon derives this from the authenticated connection * (comms.proto: "the caller is the account authenticated on the connection"); @@ -961,15 +971,8 @@ export function createAppStore(options: AppStoreOptions): AppStore { const setActiveRightTab = (tab: RightSidebarTab) => { setActiveRightTabRaw(tab); }; - // Open on the first subscribed channel so the shell boots into a live - // conversation, not the empty state — no hardcoded id, and null before the - // first snapshot arrives (the components render their empty state). - const firstChannelId = (state: CommsState): string | null => - state.channels.find((c) => c.membership === "subscribed")?.id ?? - state.channels[0]?.id ?? - null; const [selectedChannelId, setSelectedChannelId] = createSignal( - firstChannelId(comms()), + firstChannelId(comms().channels), ); // True once the first comms snapshot has arrived from the stream. The // pending-aware route fallback (applyChannelRoute) reads it: before the first @@ -988,7 +991,7 @@ export function createAppStore(options: AppStoreOptions): AppStore { // The selection is absent from the pushed snapshot. Under routes-as-truth the // channel surface is a route: if the current route names a channel, re-point it // through navigate; then re-seed the signal as the "last visited" fallback. - const fallback = firstChannelId(next); + const fallback = firstChannelId(next.channels); if (currentPath().startsWith("/channel/")) { navigateTo(fallback ? `/channel/${fallback}` : "/"); } @@ -1173,40 +1176,22 @@ export function createAppStore(options: AppStoreOptions): AppStore { // seam (offline) or the bound route-sync effect. It parses currentPath itself — the // store is outside the router tree and cannot call useParams. ── function applyRoute(path: string): void { - const segs = path.split("/").filter((s) => s.length > 0); - const [head, param, sub, subParam] = segs; - switch (head) { - case undefined: - setView("bridge"); - return; - case "channel": - // `/channel/:channelId/topic/:topicId` drills into a topic; the plain - // `/channel/:channelId` shows the topic index. - if (param && sub === "topic" && subParam) { - applyTopicRoute(param, subParam); - } else if (param) { - applyChannelRoute(param); - } else { - setView("bridge"); - } - return; - case "agent": - if (param) applyAgentRoute(param); - else setView("bridge"); - return; + const match = parseRoute(path); + switch (match.view) { + case "bridge": case "backlog": - setView("backlog"); - return; case "done": - setView("done"); - return; case "settings": - setView("settings"); + setView(match.view); return; - default: - // Unknown path — the router's `*` route redirects to "/"; leave the - // routed state untouched until that navigation lands (no blank - // surface, no wrong-view write). + case "channel": + applyChannelRoute(match.channelId); + return; + case "topic": + applyTopicRoute(match.channelId, match.topicId); + return; + case "agent": + applyAgentRoute(match.agentId); return; } } @@ -1221,7 +1206,7 @@ export function createAppStore(options: AppStoreOptions): AppStore { return; } if (firstSnapshotArrived()) { - const fallback = firstChannelId(comms()); + const fallback = firstChannelId(comms().channels); navigateTo(fallback ? `/channel/${fallback}` : "/"); return; } @@ -1235,7 +1220,7 @@ export function createAppStore(options: AppStoreOptions): AppStore { setView("topic"); const channelKnown = channels().some((c) => c.id === channelId); if (!channelKnown && firstSnapshotArrived()) { - const fallback = firstChannelId(comms()); + const fallback = firstChannelId(comms().channels); navigateTo(fallback ? `/channel/${fallback}` : "/"); return; } diff --git a/apps/ui/src/view-route.test.ts b/apps/ui/src/view-route.test.ts new file mode 100644 index 000000000..e61619731 --- /dev/null +++ b/apps/ui/src/view-route.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import type { RouteMatch } from "./view-route"; +import { parseRoute, routePath } from "./view-route"; + +const routes: { name: string; path: string; match: RouteMatch }[] = [ + { name: "bridge", path: "/", match: { view: "bridge" } }, + { + name: "channel", + path: "/channel/ch-1", + match: { view: "channel", channelId: "ch-1" }, + }, + { + name: "topic", + path: "/channel/ch-1/topic/t-9", + match: { view: "topic", channelId: "ch-1", topicId: "t-9" }, + }, + { + name: "agent", + path: "/agent/acc-x", + match: { view: "agent", agentId: "acc-x" }, + }, + { name: "backlog", path: "/backlog", match: { view: "backlog" } }, + { name: "done", path: "/done", match: { view: "done" } }, + { name: "settings", path: "/settings", match: { view: "settings" } }, +]; + +describe("view routes", () => { + test.each(routes)("round-trips the $name route", ({ path, match }) => { + expect(parseRoute(path)).toEqual(match); + expect(routePath(match)).toBe(path); + }); + + test("maps an unknown path to the bridge route", () => { + expect(parseRoute("/not-a-view")).toEqual({ view: "bridge" }); + expect(routePath(parseRoute("/not-a-view"))).toBe("/"); + }); +}); diff --git a/apps/ui/src/view-route.ts b/apps/ui/src/view-route.ts new file mode 100644 index 000000000..8a9e85977 --- /dev/null +++ b/apps/ui/src/view-route.ts @@ -0,0 +1,53 @@ +export type RouteMatch = + | { view: "bridge" } + | { view: "backlog" } + | { view: "done" } + | { view: "settings" } + | { view: "channel"; channelId: string } + | { view: "topic"; channelId: string; topicId: string } + | { view: "agent"; agentId: string }; + +export function parseRoute(path: string): RouteMatch { + const [head, param, sub, subParam] = path + .split("/") + .filter((segment) => segment.length > 0); + + switch (head) { + case undefined: + return { view: "bridge" }; + case "channel": + if (param && sub === "topic" && subParam) { + return { view: "topic", channelId: param, topicId: subParam }; + } + return param ? { view: "channel", channelId: param } : { view: "bridge" }; + case "agent": + return param ? { view: "agent", agentId: param } : { view: "bridge" }; + case "backlog": + return { view: "backlog" }; + case "done": + return { view: "done" }; + case "settings": + return { view: "settings" }; + default: + return { view: "bridge" }; + } +} + +export function routePath(match: RouteMatch): string { + switch (match.view) { + case "bridge": + return "/"; + case "backlog": + return "/backlog"; + case "done": + return "/done"; + case "settings": + return "/settings"; + case "channel": + return `/channel/${match.channelId}`; + case "topic": + return `/channel/${match.channelId}/topic/${match.topicId}`; + case "agent": + return `/agent/${match.agentId}`; + } +} diff --git a/apps/ui/src/view-scope.test.ts b/apps/ui/src/view-scope.test.ts new file mode 100644 index 000000000..db821affc --- /dev/null +++ b/apps/ui/src/view-scope.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import { createRoot } from "solid-js"; +import type { FakeComms } from "./live/comms-fake"; +import { + createFakeComms, + wireAccount, + wireChannel, + wireTopic, +} from "./live/comms-fake"; +import { type AppStore, createAppStore } from "./store"; +import { testQueryClient } from "./test-support"; +import type { ViewScope } from "./view-scope"; +import { createViewScope, useView } from "./view-scope"; + +const CALLER = "acc-me"; +const CHANNEL = "chan-deep"; +const TOPIC = "top-deep"; + +async function drainSnapshot(): Promise { + for (let i = 0; i < 30; i++) await Promise.resolve(); +} + +function mountScope( + initialPath: string, + channelIds: readonly string[], + topicIds: readonly string[] = [], +): { + store: AppStore; + scope: ViewScope; + otherScope: ViewScope; + fake: FakeComms; + dispose: () => void; +} { + const fake = createFakeComms({ + accounts: [wireAccount(CALLER)], + channels: channelIds.map((id) => wireChannel(id, CALLER)), + topicsByChannel: { + [CHANNEL]: topicIds.map((id) => + wireTopic({ id, channelId: CHANNEL, name: id }), + ), + }, + messagesByChannel: {}, + }); + let dispose = () => {}; + const mounted = createRoot((stop) => { + dispose = stop; + const store = createAppStore({ + comms: fake.client, + callerId: CALLER, + queryClient: testQueryClient(), + }); + return { + store, + scope: createViewScope(store, "test-view", initialPath), + otherScope: createViewScope(store, "other-view", "/done"), + }; + }); + return { ...mounted, fake, dispose }; +} + +describe("ViewScope pending route resolution", () => { + test("holds a channel deep link until the snapshot resolves it", async () => { + const { scope, store, fake, dispose } = mountScope(`/channel/${CHANNEL}`, [ + CHANNEL, + ]); + try { + expect(store.firstSnapshotArrived()).toBe(false); + expect(scope.path()).toBe(`/channel/${CHANNEL}`); + expect(scope.route()).toEqual({ view: "channel", channelId: CHANNEL }); + expect(scope.channel()).toBeUndefined(); + + await drainSnapshot(); + + expect(store.firstSnapshotArrived()).toBe(true); + expect(scope.path()).toBe(`/channel/${CHANNEL}`); + expect(scope.channel()?.id).toBe(CHANNEL); + } finally { + fake.close(); + dispose(); + } + }); + + test("falls back from an unknown channel after the snapshot", async () => { + const { scope, otherScope, store, fake, dispose } = mountScope( + "/channel/chan-gone", + [CHANNEL], + ); + try { + expect(scope.path()).toBe("/channel/chan-gone"); + expect(store.firstSnapshotArrived()).toBe(false); + + await drainSnapshot(); + + expect(scope.path()).toBe(`/channel/${CHANNEL}`); + expect(scope.channel()?.id).toBe(CHANNEL); + expect(otherScope.path()).toBe("/done"); + } finally { + fake.close(); + dispose(); + } + }); + + test("holds a topic deep link until the snapshot resolves it", async () => { + const { scope, store, fake, dispose } = mountScope( + `/channel/${CHANNEL}/topic/${TOPIC}`, + [CHANNEL], + [TOPIC], + ); + try { + expect(store.firstSnapshotArrived()).toBe(false); + expect(scope.path()).toBe(`/channel/${CHANNEL}/topic/${TOPIC}`); + expect(scope.route()).toEqual({ + view: "topic", + channelId: CHANNEL, + topicId: TOPIC, + }); + expect(scope.topic()).toBeUndefined(); + + await drainSnapshot(); + + expect(scope.path()).toBe(`/channel/${CHANNEL}/topic/${TOPIC}`); + expect(scope.channel()?.id).toBe(CHANNEL); + expect(scope.topic()?.id).toBe(TOPIC); + } finally { + fake.close(); + dispose(); + } + }); + + test("falls back from an unknown topic to its channel after the snapshot", async () => { + const { scope, otherScope, store, fake, dispose } = mountScope( + `/channel/${CHANNEL}/topic/top-gone`, + [CHANNEL], + [TOPIC], + ); + try { + expect(scope.path()).toBe(`/channel/${CHANNEL}/topic/top-gone`); + expect(store.firstSnapshotArrived()).toBe(false); + + await drainSnapshot(); + + expect(scope.path()).toBe(`/channel/${CHANNEL}`); + expect(scope.route()).toEqual({ view: "channel", channelId: CHANNEL }); + expect(scope.channel()?.id).toBe(CHANNEL); + expect(scope.topic()).toBeUndefined(); + expect(otherScope.path()).toBe("/done"); + } finally { + fake.close(); + dispose(); + } + }); +}); + +test("useView throws outside a ViewContext provider", () => { + expect(() => createRoot(() => useView())).toThrow(); +}); diff --git a/apps/ui/src/view-scope.ts b/apps/ui/src/view-scope.ts new file mode 100644 index 000000000..fc0c7148b --- /dev/null +++ b/apps/ui/src/view-scope.ts @@ -0,0 +1,96 @@ +import type { Accessor, Context } from "solid-js"; +import { + createContext, + createEffect, + createMemo, + createSignal, + useContext, +} from "solid-js"; +import type { Channel, Topic } from "./comms-stub"; +import { type AppStore, firstChannelId } from "./store"; +import type { Agent } from "./stub-data"; +import type { RouteMatch } from "./view-route"; +import { parseRoute } from "./view-route"; + +export interface ViewScope { + id: string; + path: Accessor; + route: Accessor; + channel: Accessor; + topic: Accessor; + agent: Accessor; + navigate: (path: string) => void; +} + +export const ViewContext: Context = createContext< + ViewScope | undefined +>(undefined); + +export function useView(): ViewScope { + const view = useContext(ViewContext); + if (!view) + throw new Error("useView must be used within a ViewContext provider"); + return view; +} + +export function createViewScope( + store: AppStore, + id: string, + initialPath: string, +): ViewScope { + const [path, setPath] = createSignal(initialPath); + const route = createMemo(() => parseRoute(path())); + const channel = createMemo(() => { + const match = route(); + if (match.view !== "channel" && match.view !== "topic") return undefined; + return store.channels().find((item) => item.id === match.channelId); + }); + const topic = createMemo(() => { + const match = route(); + if (match.view !== "topic") return undefined; + return store.topics().find((item) => item.id === match.topicId); + }); + const agent = createMemo(() => { + const match = route(); + if (match.view !== "agent") return undefined; + return store.agentById(match.agentId); + }); + const navigate = (nextPath: string): void => { + setPath(nextPath); + }; + + // Pending-aware: an unknown id is held until the first snapshot, then this + // view (only) falls back, mirroring the store's applyChannelRoute. + createEffect( + () => { + const match = route(); + if (match.view !== "channel" && match.view !== "topic") return null; + const channels = store.channels(); + return { + match, + channelKnown: channels.some((item) => item.id === match.channelId), + topicKnown: + match.view === "topic" && + store.topics().some((item) => item.id === match.topicId), + firstSnapshotArrived: store.firstSnapshotArrived(), + fallbackChannelId: firstChannelId(channels), + }; + }, + (resolution) => { + if (!resolution?.firstSnapshotArrived) return; + if (!resolution.channelKnown) { + navigate( + resolution.fallbackChannelId + ? `/channel/${resolution.fallbackChannelId}` + : "/", + ); + return; + } + if (resolution.match.view === "topic" && !resolution.topicKnown) { + navigate(`/channel/${resolution.match.channelId}`); + } + }, + ); + + return { id, path, route, channel, topic, agent, navigate }; +}