Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 27 additions & 42 deletions apps/ui/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<string | null>(
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
Expand All @@ -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}` : "/");
}
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
37 changes: 37 additions & 0 deletions apps/ui/src/view-route.test.ts
Original file line number Diff line number Diff line change
@@ -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("/");
});
});
53 changes: 53 additions & 0 deletions apps/ui/src/view-route.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
}
156 changes: 156 additions & 0 deletions apps/ui/src/view-scope.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
});
Loading
Loading