/** * Scope panels — the merged Service list for one wire scope (`app` / * `session` / `agent`), shared by the chat inspector (session + agent tabs) * and the standalone App Services rail view. The baseline is the dynamic * channel list served by `GET /api/v1/debug/channels` (every wire-exposed * Service with its methods), rendered by `DynamicServiceCard`; the * handwritten descriptors in `panels.ts` override individual Services with * curated cards (`ServiceCard`). * * Two layouts consume the same merged list (`useScopePanels`): * - `ScopePanels`: a plain stacked card list (the chat inspector). * - `ScopePanelsScrollspy`: a Postman-style three-pane layout — Service * list on the left with a fuzzy filter box, every Service's card * expanded (no collapsing) in one continuously scrolling column in the * middle (scroll position drives the left-side highlight, clicks scroll * the middle), and a request/response call history on the right fed by * recording proxies around every card. Card headers copy the Service * name on click (`CopyableName`). * * Everything here is fetch-on-demand (Load / Refresh buttons): the v2 event * socket (`/api/v2/ws`) that used to push core/session/agent event streams * was removed server-side, so there is no live push to render. */ import { useQuery } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { fetchChannelDescriptors, type ChannelDescriptor, type ChannelScope } from '../channel'; import { useConnection } from '../connection'; import { AGENT_PANELS, CORE_PANELS, SESSION_PANELS, call, type AnyService, type ServicePanelDef, } from '../panels'; import { ActionButton, Badge, ErrorLine, JsonView, errorMessage, relTime } from '../ui'; import { buildArgs, fieldKey, parseParamFields, type ParamField } from './methodArgs'; const PANEL_OVERRIDES: ReadonlyMap = new Map( [...CORE_PANELS, ...SESSION_PANELS, ...AGENT_PANELS].map((def) => [def.id, def]), ); /** One merged Service entry: the wire channel plus its curated override, if any. */ interface ScopeEntry { readonly name: string; readonly channel?: ChannelDescriptor; readonly def?: ServicePanelDef; } /** Load the full protocol list once per connection (every channel, 1:1). */ function useChannels() { const { klient } = useConnection(); return useQuery({ queryKey: ['channels', klient.baseUrl], queryFn: () => fetchChannelDescriptors(klient), staleTime: Number.POSITIVE_INFINITY, }); } /** * Merge the dynamic channel list with the handwritten overrides for one * scope. When the channels endpoint is unavailable, fall back to the * handwritten panels only. */ function useScopePanels(scope: ChannelScope) { const channels = useChannels(); const entries = useMemo(() => { const byName = new Map(); if (channels.data !== undefined) { for (const c of channels.data) { if (c.scope === scope) byName.set(c.name, c); } // Keep overrides the introspection missed (e.g. server drift). for (const def of PANEL_OVERRIDES.values()) { if (def.scope === scope && !byName.has(def.id)) byName.set(def.id, undefined); } } else { for (const def of PANEL_OVERRIDES.values()) { if (def.scope === scope) byName.set(def.id, undefined); } } return [...byName.entries()].map(([name, channel]) => ({ name, channel, def: PANEL_OVERRIDES.get(name), })); }, [channels.data, scope]); return { entries, channels }; } /** * Render every Service of one scope as a plain stacked card list. * `proxyFor` materializes the Service proxy for a channel name (null when * the scope is not callable, e.g. no session selected); `onError` observes * call failures (the agent switcher uses it to mark unloaded agents). */ export function ScopePanels({ scope, proxyFor, onError, }: { readonly scope: ChannelScope; readonly proxyFor: (name: string) => AnyService | null; readonly onError?: (error: unknown) => void; }) { const { entries, channels } = useScopePanels(scope); return ( <> {channels.isError ? (
dynamic channel list unavailable — showing handwritten panels only
) : null} {entries.map(({ name, channel, def }) => { if (def !== undefined) { return ; } if (channel === undefined) return null; return ( ); })} ); } /** * Subsequence fuzzy match (case-insensitive): `ssm` matches * `sessionScopeMetadata`. An empty query matches everything. */ function fuzzyMatch(name: string, query: string): boolean { const q = query.trim().toLowerCase(); if (q === '') return true; const n = name.toLowerCase(); let i = 0; for (const ch of n) { if (ch === q[i]) i++; if (i === q.length) return true; } return false; } /** * Three-pane scope browser: the Service list on the left (with a fuzzy * filter box), every Service's card expanded in one continuously scrolling * column in the middle, and a Postman-style call history on the right * (`HistoryPane`) recording every wire request/response the cards make. * The scroll position highlights the matching list item (and keeps it * visible); clicking a list item smooth-scrolls the middle column to that * Service. The list hides below the `md` breakpoint and the history below * `lg`. */ export function ScopePanelsScrollspy({ scope, title, proxyFor, onError, }: { readonly scope: ChannelScope; readonly title: string; readonly proxyFor: (name: string) => AnyService | null; readonly onError?: (error: unknown) => void; }) { const { entries, channels } = useScopePanels(scope); const [active, setActive] = useState(null); const [query, setQuery] = useState(''); const scrollRef = useRef(null); const sectionRefs = useRef(new Map()); const navRefs = useRef(new Map()); // The filter narrows the left list only; the middle column keeps every // card mounted so in-flight inputs and results survive a search. const filtered = useMemo( () => entries.filter(({ name }) => fuzzyMatch(name, query)), [entries, query], ); // Call history: every card proxy is wrapped (`makeRecordingProxy`), so // curated and dynamic cards record uniformly without per-card plumbing. const [records, setRecords] = useState([]); const [expandedId, setExpandedId] = useState(null); const recordId = useRef(0); const record = useCallback((entry: Omit) => { const id = ++recordId.current; setRecords((prev) => [{ ...entry, id }, ...prev].slice(0, 100)); // Like Postman's response pane: the newest call is always open. setExpandedId(id); }, []); const recordingProxyFor = useCallback( (name: string): AnyService | null => { const svc = proxyFor(name); return svc === null ? null : makeRecordingProxy(name, svc, record); }, [proxyFor, record], ); // Highlight the section nearest the top of the scroll port. Driven by the // scroll event (not IntersectionObserver) so tall sections and the tail of // the list behave predictably. Section refs sit in DOM order in the map. const syncActive = useCallback(() => { const root = scrollRef.current; if (root === null) return; const rootTop = root.getBoundingClientRect().top; let current: string | null = null; for (const [name, el] of sectionRefs.current) { if (el.getBoundingClientRect().top - rootTop <= 96) current = name; else break; } current ??= entries[0]?.name ?? null; setActive((prev) => (prev === current ? prev : current)); }, [entries]); // Sections mount as the channel list resolves, so re-sync on list changes. useEffect(() => { syncActive(); }, [syncActive]); // Keep the highlighted item inside the left list's viewport. useEffect(() => { if (active === null) return; navRefs.current.get(active)?.scrollIntoView({ block: 'nearest' }); }, [active]); const scrollTo = (name: string) => { setActive(name); sectionRefs.current.get(name)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; return (
{channels.isError ? (
dynamic channel list unavailable — showing handwritten panels only
) : null} {entries.map(({ name, channel, def }) => (
{ if (el === null) sectionRefs.current.delete(name); else sectionRefs.current.set(name, el); }} className="scroll-mt-3" > {def !== undefined ? ( ) : channel !== undefined ? ( ) : null}
))}
setExpandedId((prev) => (prev === id ? null : id))} onClear={() => { setRecords([]); setExpandedId(null); }} />
); } // --------------------------------------------------------------------------- // Call history — one record per wire call, newest first, capped at 100. // --------------------------------------------------------------------------- interface CallRecord { readonly id: number; readonly service: string; readonly method: string; readonly args: readonly unknown[]; readonly at: number; readonly durationMs: number; readonly ok: boolean; readonly result?: unknown; readonly error?: string; } /** * Wrap a Service proxy so every method call is recorded (args, result or * error, duration) before its promise settles. Errors rethrow unchanged, so * the calling card's own error handling still runs. */ function makeRecordingProxy( service: string, svc: AnyService, record: (entry: Omit) => void, ): AnyService { return new Proxy(svc, { get(target, prop) { const member = target[prop as string]; if (typeof member !== 'function') return member; return (...args: unknown[]) => { const at = Date.now(); return Promise.resolve(member(...args)).then( (result) => { record({ service, method: String(prop), args, at, durationMs: Date.now() - at, ok: true, result, }); return result; }, (error: unknown) => { record({ service, method: String(prop), args, at, durationMs: Date.now() - at, ok: false, error: errorMessage(error), }); throw error; }, ); }; }, }); } function HistoryPane({ records, expandedId, onToggle, onClear, }: { readonly records: readonly CallRecord[]; readonly expandedId: number | null; readonly onToggle: (id: number) => void; readonly onClear: () => void; }) { return (
History {records.length > 0 ? `(${records.length})` : ''} {records.length > 0 ? Clear : null}
{records.length === 0 ? (
call any method to see the request/response here
) : null} {records.map((r) => { const open = r.id === expandedId; return (
onToggle(r.id)} > ● {r.service}.{r.method} {r.durationMs}ms {relTime(r.at)} {open ? '▾' : '▸'}
{open ? (
Request
{r.ok ? 'Response' : 'Error'}
{r.ok ? ( ) : (
{r.error}
)}
) : null}
); })}
); } // --------------------------------------------------------------------------- // Generic service card // --------------------------------------------------------------------------- /** Service name that copies itself to the clipboard on click. */ function CopyableName({ name, className }: { name: string; className?: string }) { const [copied, setCopied] = useState(false); return ( ); } function ServiceCard({ def, svc, onError, }: { def: ServicePanelDef; svc: AnyService | null; onError?: (error: unknown) => void; }) { const [data, setData] = useState(undefined); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); const [loaded, setLoaded] = useState(false); const refresh = async () => { if (svc === null || def.fetch === undefined) return; try { setError(null); const result = await def.fetch(svc); setData(result); setLoaded(true); } catch (error) { setError(error); onError?.(error); } }; return (
{def.label}
{def.fetch !== undefined ? ( void refresh()} disabled={svc === null}> {loaded ? 'Refresh' : 'Load'} ) : null}
{error !== null ? (
) : null} {def.fetch !== undefined ? ( loaded ? ( ) : (
click Load to read this Service
) ) : null} {def.actions !== undefined && def.actions.length > 0 ? (
{def.actions.map((action) => ( { if (svc === null) return; let input: string | undefined; if (action.input !== undefined) { const raw = window.prompt(action.input); if (raw === null) return; input = raw; } setBusy(action.label); setError(null); try { const result = await action.run(svc, input); if (result !== undefined && def.fetch === undefined) setData(result); if (def.fetch !== undefined) await refresh(); } catch (error) { setError(error); onError?.(error); } finally { setBusy(null); } }} > {busy === action.label ? '…' : action.label} ))}
) : null} {def.fetch === undefined && data !== undefined ? (
) : null}
); } // --------------------------------------------------------------------------- // Dynamic service card — generic renderer for channels without a handwritten // override. Every method gets a call button labeled with its declared // signature; parameters become structured inputs (`MethodArgInputs`: one // field per parameter, one per key for destructured objects, smart-parsed — // see `methodArgs.ts`). Getters become read buttons. Results render inline. // --------------------------------------------------------------------------- function DynamicServiceCard({ channel, svc, onError, collapsible = true, inlineResults = true, }: { channel: ChannelDescriptor; svc: AnyService | null; onError?: (error: unknown) => void; /** * Allow collapsing the method list behind the header. Off in the * scrollspy layout, where every card stays expanded and the header name * is a copy target instead of a toggle. */ collapsible?: boolean; /** * Render call results inline under each method. Off in the scrollspy * layout, where results land in the right-side history pane instead. */ inlineResults?: boolean; }) { const [open, setOpen] = useState(false); const [inputs, setInputs] = useState>>({}); const [results, setResults] = useState>({}); const [errors, setErrors] = useState>({}); const [busy, setBusy] = useState(null); const invoke = async (method: ChannelDescriptor['methods'][number]) => { if (svc === null) return; let argv: unknown[]; try { argv = buildArgs(parseParamFields(method.params), inputs[method.name] ?? {}); } catch { setErrors((prev) => ({ ...prev, [method.name]: new Error('arg is not valid JSON') })); return; } setBusy(method.name); setErrors((prev) => ({ ...prev, [method.name]: null })); try { const result = await call(svc, method.name, ...argv); setResults((prev) => ({ ...prev, [method.name]: result ?? '(no result)' })); } catch (error) { setErrors((prev) => ({ ...prev, [method.name]: error })); onError?.(error); } finally { setBusy(null); } }; return (
setOpen((v) => !v) : undefined} >
{channel.methods.length} methods · {channel.domain}
{collapsible ? ( {open ? '▾' : '▸'} ) : null}
{!collapsible || open ? (
{channel.methods.length === 0 ? (
no callable members
) : null} {channel.methods.map((m) => { const fields = m.kind === 'method' ? parseParamFields(m.params) : []; return (
void invoke(m)} > {busy === m.name ? '…' : `${m.name}(${m.params})`} {m.kind === 'property' ? get : null}
{fields.length > 0 ? ( setInputs((prev) => ({ ...prev, [m.name]: { ...prev[m.name], [key]: value }, })) } /> ) : null} {errors[m.name] ? (
) : null} {inlineResults && results[m.name] !== undefined ? (
) : null}
); })}
) : null}
); } // --------------------------------------------------------------------------- // Structured argument inputs — one labeled field per declared parameter. // Values are smart-parsed on invoke (JSON when it parses, plain string // otherwise), so simple strings need no quotes. // --------------------------------------------------------------------------- function MethodArgInputs({ fields, values, onChange, }: { fields: readonly ParamField[]; values: Readonly>; onChange: (key: string, value: string) => void; }) { return (
{fields.map((f, i) => { if (f.kind === 'object') { return (
{f.name}
{f.keys.map((key) => (
onChange(fieldKey(i, key), v)} />
))}
); } if (f.kind === 'value') { return (
onChange(fieldKey(i), v)} />
); } return (
onChange(fieldKey(i), v)} />
); })}
); } function ArgLabel({ label }: { label: string }) { return ( {label} ); } function ArgInput({ placeholder, value, onChange, }: { placeholder: string; value: string; onChange: (value: string) => void; }) { return ( onChange(e.target.value)} /> ); }