From 578c280a4f295d5f20a7fecf829aefbbe03929ab Mon Sep 17 00:00:00 2001 From: Agents Date: Sun, 24 May 2026 13:43:40 +0100 Subject: [PATCH] feat: add side-by-side compare view across multiple instances Adds a /compare route that renders N columns, one per pinned Honcho instance, so the user can see how each instance's representation of a peer differs without switching the active instance. - New scoped API client (`createScopedClient`) bound to a specific instance rather than the active one. Backs the compare view's queries without disturbing the active-instance client used elsewhere. - New scoped query hooks for workspaces, peers, peer representation, and peer card; query keys scoped by instance.id to keep caches isolated across columns. - Compare route stores pinned instance IDs and target peer name in URL search params so views are shareable/deep-linkable. - Sidebar nav gets a Compare entry between Workspaces and Settings. - Per-column auto-selects the first workspace and prefers the target peer name if it exists in that instance, else first peer. Tests: - scopedClient: requests target the instance baseUrl + Bearer token - compare route: empty-state prompt when no instances selected --- packages/web/src/api/compareQueries.ts | 85 +++++++ packages/web/src/api/scopedClient.ts | 18 ++ .../src/components/compare/CompareColumn.tsx | 210 ++++++++++++++++++ .../src/components/compare/CompareView.tsx | 192 ++++++++++++++++ .../web/src/components/layout/Sidebar.tsx | 2 + packages/web/src/routeTree.gen.ts | 21 ++ packages/web/src/routes/compare.tsx | 15 ++ packages/web/src/test/compare.test.tsx | 40 ++++ packages/web/src/test/scoped-client.test.ts | 34 +++ 9 files changed, 617 insertions(+) create mode 100644 packages/web/src/api/compareQueries.ts create mode 100644 packages/web/src/api/scopedClient.ts create mode 100644 packages/web/src/components/compare/CompareColumn.tsx create mode 100644 packages/web/src/components/compare/CompareView.tsx create mode 100644 packages/web/src/routes/compare.tsx create mode 100644 packages/web/src/test/compare.test.tsx create mode 100644 packages/web/src/test/scoped-client.test.ts diff --git a/packages/web/src/api/compareQueries.ts b/packages/web/src/api/compareQueries.ts new file mode 100644 index 0000000..d18407b --- /dev/null +++ b/packages/web/src/api/compareQueries.ts @@ -0,0 +1,85 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Instance } from "@/lib/config"; +import { createScopedClient } from "./scopedClient"; + +function err(e: unknown): never { + throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e)); +} + +// Query keys are scoped by instance.id so caches never collide across columns. +const CK = { + workspaces: (instId: string, page: number, size: number) => + ["compare", instId, "workspaces", page, size] as const, + peers: (instId: string, wsId: string, page: number, size: number) => + ["compare", instId, "peers", wsId, page, size] as const, + peerRepresentation: (instId: string, wsId: string, pId: string) => + ["compare", instId, "peer-representation", wsId, pId] as const, + peerCard: (instId: string, wsId: string, pId: string) => + ["compare", instId, "peer-card", wsId, pId] as const, +}; + +export function useScopedWorkspaces(instance: Instance, page = 1, pageSize = 20) { + return useQuery({ + queryKey: CK.workspaces(instance.id, page, pageSize), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.POST("/v3/workspaces/list", { + params: { query: { page, page_size: pageSize } }, + body: {}, + }); + return data ?? err(error); + }, + }); +} + +export function useScopedPeers(instance: Instance, workspaceId: string, page = 1, pageSize = 20) { + return useQuery({ + queryKey: CK.peers(instance.id, workspaceId, page, pageSize), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.POST("/v3/workspaces/{workspace_id}/peers/list", { + params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } }, + body: {}, + }); + return data ?? err(error); + }, + enabled: Boolean(workspaceId), + }); +} + +export function useScopedPeerRepresentation( + instance: Instance, + workspaceId: string, + peerId: string, +) { + return useQuery({ + queryKey: CK.peerRepresentation(instance.id, workspaceId, peerId), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.POST( + "/v3/workspaces/{workspace_id}/peers/{peer_id}/representation", + { + params: { path: { workspace_id: workspaceId, peer_id: peerId } }, + body: { max_conclusions: 20 }, + }, + ); + return data ?? err(error); + }, + enabled: Boolean(workspaceId) && Boolean(peerId), + }); +} + +export function useScopedPeerCard(instance: Instance, workspaceId: string, peerId: string) { + return useQuery({ + queryKey: CK.peerCard(instance.id, workspaceId, peerId), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.GET( + "/v3/workspaces/{workspace_id}/peers/{peer_id}/card", + { params: { path: { workspace_id: workspaceId, peer_id: peerId } } }, + ); + return data ?? err(error); + }, + enabled: Boolean(workspaceId) && Boolean(peerId), + }); +} diff --git a/packages/web/src/api/scopedClient.ts b/packages/web/src/api/scopedClient.ts new file mode 100644 index 0000000..c37a9af --- /dev/null +++ b/packages/web/src/api/scopedClient.ts @@ -0,0 +1,18 @@ +import createClient from "openapi-fetch"; +import type { Instance } from "@/lib/config"; +import { httpFetch } from "@/lib/http"; +import type { paths } from "./schema.d.ts"; + +export type ScopedClient = ReturnType>; + +/** + * Create an openapi-fetch client bound to a specific instance. Use for views + * that need to query non-active instances (e.g. side-by-side comparison). + * For single-instance access, prefer `client.current` which tracks the active + * instance via localStorage. + */ +export function createScopedClient(instance: Instance): ScopedClient { + const headers: Record = { "Content-Type": "application/json" }; + if (instance.token) headers.Authorization = `Bearer ${instance.token}`; + return createClient({ baseUrl: instance.baseUrl, headers, fetch: httpFetch }); +} diff --git a/packages/web/src/components/compare/CompareColumn.tsx b/packages/web/src/components/compare/CompareColumn.tsx new file mode 100644 index 0000000..0351d74 --- /dev/null +++ b/packages/web/src/components/compare/CompareColumn.tsx @@ -0,0 +1,210 @@ +import { X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { + useScopedPeerCard, + useScopedPeerRepresentation, + useScopedPeers, + useScopedWorkspaces, +} from "@/api/compareQueries"; +import type { components } from "@/api/schema.d.ts"; +import { ErrorAlert } from "@/components/shared/ErrorAlert"; +import { PeerCardViewer } from "@/components/shared/PeerCardViewer"; +import { Skeleton } from "@/components/shared/Skeleton"; +import { Caption, MonoCaption, SectionHeading } from "@/components/ui/typography"; +import { useDemo } from "@/hooks/useDemo"; +import type { Instance } from "@/lib/config"; + +type Workspace = components["schemas"]["Workspace"]; +type Peer = components["schemas"]["Peer"]; +type Conclusion = components["schemas"]["Conclusion"]; + +interface Props { + instance: Instance; + targetPeerName: string | undefined; + onRemove: () => void; +} + +export function CompareColumn({ instance, targetPeerName, onRemove }: Props) { + const { mask } = useDemo(); + const [workspaceId, setWorkspaceId] = useState(""); + const [peerId, setPeerId] = useState(""); + + const workspacesQuery = useScopedWorkspaces(instance); + const peersQuery = useScopedPeers(instance, workspaceId); + const representation = useScopedPeerRepresentation(instance, workspaceId, peerId); + const card = useScopedPeerCard(instance, workspaceId, peerId); + + const workspaces: Workspace[] = useMemo( + () => (workspacesQuery.data as { items?: Workspace[] } | undefined)?.items ?? [], + [workspacesQuery.data], + ); + const peers: Peer[] = useMemo( + () => (peersQuery.data as { items?: Peer[] } | undefined)?.items ?? [], + [peersQuery.data], + ); + + // Auto-select first workspace once loaded. + useEffect(() => { + if (workspaces.length > 0 && !workspaceId) { + setWorkspaceId(workspaces[0].id); + } + }, [workspaces, workspaceId]); + + // Auto-select peer: prefer the target name if it exists in this instance, + // otherwise fall back to the first peer. + useEffect(() => { + if (peers.length === 0) return; + if (targetPeerName) { + const match = peers.find((p) => p.id === targetPeerName); + if (match && match.id !== peerId) { + setPeerId(match.id); + return; + } + if (match) return; + } + if (!peerId) setPeerId(peers[0].id); + }, [peers, targetPeerName, peerId]); + + const cardLines: string[] = useMemo(() => { + const raw = (card.data as { peer_card?: unknown } | undefined)?.peer_card; + if (Array.isArray(raw)) return raw as string[]; + if (typeof raw === "string") return [raw]; + return []; + }, [card.data]); + + const conclusions: Conclusion[] = useMemo(() => { + const raw = (representation.data as { conclusions?: Conclusion[] } | undefined)?.conclusions; + return Array.isArray(raw) ? raw : []; + }, [representation.data]); + + return ( +
+
+
+

+ {instance.name} +

+ + {mask(instance.baseUrl.replace(/^https?:\/\//, ""))} + +
+ +
+ +
+ + + +
+ +
+ {workspacesQuery.error && } + + {peerId && ( + <> +
+ Conclusions + {representation.isLoading && } + {!representation.isLoading && conclusions.length === 0 && ( + No conclusions yet for this peer. + )} + {conclusions.length > 0 && ( +
    + {conclusions.map((c, i) => ( +
  • + {c.content} +
  • + ))} +
+ )} + {conclusions.length > 0 && ( + + {conclusions.length} {conclusions.length === 1 ? "conclusion" : "conclusions"} + + )} +
+ +
+ Peer card + {card.isLoading && } + {!card.isLoading && } +
+ + )} +
+
+ ); +} diff --git a/packages/web/src/components/compare/CompareView.tsx b/packages/web/src/components/compare/CompareView.tsx new file mode 100644 index 0000000..e0d6c6a --- /dev/null +++ b/packages/web/src/components/compare/CompareView.tsx @@ -0,0 +1,192 @@ +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { Columns2, Plus } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { EmptyState } from "@/components/shared/EmptyState"; +import { Body, Caption, PageTitle } from "@/components/ui/typography"; +import { useInstances } from "@/hooks/useInstances"; +import type { Instance } from "@/lib/config"; +import { CompareColumn } from "./CompareColumn"; + +interface CompareSearch { + instances?: string; + peer?: string; +} + +export function CompareView() { + const navigate = useNavigate(); + const search = useSearch({ strict: false }) as CompareSearch; + const { instances } = useInstances(); + + const selectedIds = useMemo(() => { + if (!search.instances) return []; + return search.instances.split(",").filter(Boolean); + }, [search.instances]); + + const selected: Instance[] = useMemo(() => { + const lookup = new Map(instances.map((i) => [i.id, i] as const)); + return selectedIds.map((id) => lookup.get(id)).filter((i): i is Instance => i !== undefined); + }, [selectedIds, instances]); + + const available = useMemo( + () => instances.filter((i) => !selectedIds.includes(i.id)), + [instances, selectedIds], + ); + + const [pickerOpen, setPickerOpen] = useState(false); + const pickerRef = useRef(null); + + useEffect(() => { + if (!pickerOpen) return; + function onClick(e: MouseEvent) { + if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false); + } + window.addEventListener("mousedown", onClick); + return () => window.removeEventListener("mousedown", onClick); + }, [pickerOpen]); + + function setInstancesSearch(ids: string[]) { + navigate({ + to: "/compare" as never, + search: { ...search, instances: ids.length > 0 ? ids.join(",") : undefined } as never, + }); + } + + function addInstance(id: string) { + setInstancesSearch([...selectedIds, id]); + setPickerOpen(false); + } + + function removeInstance(id: string) { + setInstancesSearch(selectedIds.filter((sid) => sid !== id)); + } + + function setTargetPeer(peer: string) { + navigate({ + to: "/compare" as never, + search: { ...search, peer: peer || undefined } as never, + }); + } + + if (instances.length === 0) { + return ( +
+ Compare + + Configure at least two Honcho instances in Settings before using compare. + +
+ ); + } + + return ( +
+
+
+ Compare + + Side-by-side view of peer representations across instances + +
+
+ + +
+ + {pickerOpen && available.length > 0 && ( +
+ {available.map((inst) => ( + + ))} +
+ )} +
+
+
+ + {selected.length === 0 ? ( +
+ +
+ ) : ( +
+ {selected.map((inst) => ( + removeInstance(inst.id)} + /> + ))} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx index 4615861..94a6a04 100644 --- a/packages/web/src/components/layout/Sidebar.tsx +++ b/packages/web/src/components/layout/Sidebar.tsx @@ -6,6 +6,7 @@ import { Check, ChevronRight, ChevronsUpDown, + Columns2, Eye, EyeOff, LayoutDashboard, @@ -29,6 +30,7 @@ import { COLOR } from "@/lib/constants"; const TOP_NAV = [ { to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true }, { to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false }, + { to: "/compare" as const, label: "Compare", icon: Columns2, exact: false }, { to: "/settings" as const, label: "Settings", icon: Settings, exact: false }, ]; diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 934ef2b..767fd5c 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WorkspacesRouteImport } from './routes/workspaces' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ExploreRouteImport } from './routes/explore' +import { Route as CompareRouteImport } from './routes/compare' import { Route as IndexRouteImport } from './routes/index' import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId' import { Route as WorkspacesWorkspaceIdWebhooksRouteImport } from './routes/workspaces_.$workspaceId_.webhooks' @@ -37,6 +38,11 @@ const ExploreRoute = ExploreRouteImport.update({ path: '/explore', getParentRoute: () => rootRouteImport, } as any) +const CompareRoute = CompareRouteImport.update({ + id: '/compare', + path: '/compare', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -92,6 +98,7 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/compare': typeof CompareRoute '/explore': typeof ExploreRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute @@ -106,6 +113,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/compare': typeof CompareRoute '/explore': typeof ExploreRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute @@ -121,6 +129,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/compare': typeof CompareRoute '/explore': typeof ExploreRoute '/settings': typeof SettingsRoute '/workspaces': typeof WorkspacesRoute @@ -137,6 +146,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/compare' | '/explore' | '/settings' | '/workspaces' @@ -151,6 +161,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/compare' | '/explore' | '/settings' | '/workspaces' @@ -165,6 +176,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/compare' | '/explore' | '/settings' | '/workspaces' @@ -180,6 +192,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CompareRoute: typeof CompareRoute ExploreRoute: typeof ExploreRoute SettingsRoute: typeof SettingsRoute WorkspacesRoute: typeof WorkspacesRoute @@ -216,6 +229,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ExploreRouteImport parentRoute: typeof rootRouteImport } + '/compare': { + id: '/compare' + path: '/compare' + fullPath: '/compare' + preLoaderRoute: typeof CompareRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -284,6 +304,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + CompareRoute: CompareRoute, ExploreRoute: ExploreRoute, SettingsRoute: SettingsRoute, WorkspacesRoute: WorkspacesRoute, diff --git a/packages/web/src/routes/compare.tsx b/packages/web/src/routes/compare.tsx new file mode 100644 index 0000000..630633a --- /dev/null +++ b/packages/web/src/routes/compare.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { CompareView } from "@/components/compare/CompareView"; + +interface CompareSearch { + instances?: string; + peer?: string; +} + +export const Route = createFileRoute("/compare")({ + validateSearch: (search: Record): CompareSearch => ({ + instances: typeof search.instances === "string" ? search.instances : undefined, + peer: typeof search.peer === "string" ? search.peer : undefined, + }), + component: CompareView, +}); diff --git a/packages/web/src/test/compare.test.tsx b/packages/web/src/test/compare.test.tsx new file mode 100644 index 0000000..a0f7d76 --- /dev/null +++ b/packages/web/src/test/compare.test.tsx @@ -0,0 +1,40 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DemoProvider } from "@/context/DemoContext"; +import { MetadataProvider } from "@/context/MetadataContext"; +import { saveStore } from "@/lib/config"; +import { routeTree } from "@/routeTree.gen"; + +function renderAt(initialPath: string) { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [initialPath] }), + }); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + {/* biome-ignore lint/suspicious/noExplicitAny: test router type */} + + + + , + ); +} + +describe("Compare route", () => { + it("prompts the user to add instances when none are selected", async () => { + saveStore({ + instances: [ + { id: "neo", name: "Neo", baseUrl: "http://localhost:8001", token: "" }, + { id: "iris", name: "Iris", baseUrl: "http://localhost:8002", token: "" }, + ], + activeId: "neo", + }); + renderAt("/compare"); + expect(await screen.findByText(/Pick instances to compare/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/test/scoped-client.test.ts b/packages/web/src/test/scoped-client.test.ts new file mode 100644 index 0000000..42a3442 --- /dev/null +++ b/packages/web/src/test/scoped-client.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { createScopedClient } from "@/api/scopedClient"; +import type { Instance } from "@/lib/config"; + +vi.mock("@/lib/http", () => ({ + httpFetch: vi.fn(async () => new Response("{}", { status: 200 })), +})); + +const instance: Instance = { + id: "inst-1", + name: "Neo", + baseUrl: "http://localhost:8001", + token: "secret-token", +}; + +describe("createScopedClient", () => { + it("issues requests to the instance baseUrl", async () => { + const { httpFetch } = await import("@/lib/http"); + const client = createScopedClient(instance); + await client.GET("/v3/keys" as never); + + const request = (httpFetch as ReturnType).mock.calls[0][0] as Request; + expect(request.url).toBe("http://localhost:8001/v3/keys"); + }); + + it("attaches the instance token as a Bearer Authorization header", async () => { + const { httpFetch } = await import("@/lib/http"); + const client = createScopedClient(instance); + await client.GET("/v3/keys" as never); + + const request = (httpFetch as ReturnType).mock.calls[0][0] as Request; + expect(request.headers.get("Authorization")).toBe("Bearer secret-token"); + }); +});