diff --git a/.changeset/agent-sends-in-history.md b/.changeset/agent-sends-in-history.md new file mode 100644 index 0000000..5eb3d6e --- /dev/null +++ b/.changeset/agent-sends-in-history.md @@ -0,0 +1,23 @@ +--- +"fiber": patch +--- + +Requests an agent sends through the MCP server now show up in History as they +happen, wearing an `MCP` badge, and every entry in the list has a name. + +The server has always recorded what it sends — it writes to the same database +as the window, so `query_response` can read a body back — but the window only +read that database at launch. A send made from an agent's window appeared in +History after the next relaunch, and then as a bare URL: the server files its +requests under one synthetic request per collection rather than an endpoint's +id, so the name lookup came up empty. The same was true of anything sent from +scratch or whose request has since been deleted, which made a row without a +name look like a rendering fault rather than what it was. + +History now catches up when the window regains focus, when the tab is opened, +and every few seconds while it stays open; the list is merged rather than +replaced, so a body you have loaded or a send still streaming is untouched. An +entry with no request to be named after takes the name of the endpoint its URL +hits — the loader's or a saved request's — and failing that its path, the way +endpoint rows read in Collections. `mcp` matches in the search box, so the badge +is something you can filter by. diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 9cc16b6..fd2d2e7 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -2,6 +2,7 @@ import { ContextMenu, Dialog, Tooltip } from 'bits-ui'; import { applyPathParams, + endpointKey, LOOSE_SECTION_ID, methodColor, normalizeBaseUrl, @@ -307,6 +308,30 @@ history.stopViewing(); } + /** + * Opening History is a look at the list, so it is the moment to make sure the + * list is current: the MCP server writes to the same database from another + * process, and nothing else tells this window when it has. + */ + function showHistory() { + session.sidebarTab = 'history'; + history.refresh(); + } + + /** + * And while History stays open, keep looking. An agent typically sends in + * bursts while you watch the tab, so a refresh only on opening it would show + * the first request of a run and none that followed. A list of a few hundred + * rows from SQLite is cheap, and a refresh that finds nothing new changes + * nothing, so polling costs no re-render. + */ + const HISTORY_POLL_MS = 5_000; + $effect(() => { + if (session.sidebarTab !== 'history') return; + const timer = setInterval(() => history.refresh(), HISTORY_POLL_MS); + return () => clearInterval(timer); + }); + /** * Same reason as above: the MCP tab isn't History either, so the entry you * were looking at stops overriding the response pane. @@ -456,27 +481,89 @@ }); /** - * The name of the request an entry came from, when there still is one. + * Every endpoint's name by what it hits — `"METHOD /path"` — per section. * - * Plenty of entries outlive their request: sent from scratch, sent by a - * loader or the MCP server, or the request has since been deleted. Those get - * no name rather than an invented one — the URL underneath is their identity. + * For the entries that have no request to be named after. The MCP server + * sends under one synthetic request per collection rather than an endpoint's + * id, so its entries never matched `requestNames`, and a run of agent calls + * showed up as a column of bare URLs. The endpoint they hit is still known: + * the loader's manifest lists it, or a saved request does. */ - function requestName(entry: HistoryEntry): string | null { - return requestNames.get(entry.requestId) ?? null; + const endpointNames = $derived.by(() => { + const names = new Map>(); + for (const section of collections.sections) { + const mine = new Map(); + for (const endpoint of collections.loaderCaches[section.id]?.endpoints ?? []) { + mine.set(endpointKey(endpoint.method, endpoint.path), endpoint.name); + } + // User-named after loader-named, so a rename of an endpoint wins. + for (const request of section.requests) { + mine.set(endpointKey(request.method, request.path), request.name); + } + for (const entry of section.overlay) mine.set(entry.id, entry.name); + names.set(section.id, mine); + } + return names; + }); + + const sectionsById = $derived( + new Map(collections.sections.map((section) => [section.id, section])) + ); + + /** + * The path an entry's URL has under its section's base, or under no base at + * all — `/users/42` either way, so that it reads like the endpoint rows in + * Collections. Never empty: a request to the base URL itself is `/`. + */ + function pathOf(entry: HistoryEntry): string { + const base = entry.sectionId ? sectionsById.get(entry.sectionId)?.baseUrl : undefined; + let path: string; + if (base && entry.url.startsWith(normalizeBaseUrl(base))) { + path = entry.url.slice(normalizeBaseUrl(base).length); + } else { + try { + path = new URL(entry.url).pathname; + } catch { + path = entry.url; + } + } + path = path.split(/[?#]/, 1)[0]; + return path.startsWith('/') ? path : `/${path}`; + } + + /** + * What to call an entry. + * + * The request it came from, when that still exists; else the endpoint its + * URL hits, when the collection knows one; else the path. Every entry gets a + * name — a row without one looked like a rendering fault rather than like a + * request sent from scratch, by a loader, or by an agent. + */ + function requestName(entry: HistoryEntry): string { + const own = requestNames.get(entry.requestId); + if (own) return own; + const path = pathOf(entry); + if (entry.sectionId) { + const named = endpointNames.get(entry.sectionId)?.get(endpointKey(entry.method, path)); + if (named) return named; + } + return path; } /** * Matched against the request's name, method, URL and status — so `404`, - * `POST` and the name now on the row all work. + * `POST` and the name now on the row all work — and against `mcp`, so the + * badge on a row is something you can search for too. */ const visibleHistory = $derived.by(() => { const needle = historyQuery.trim().toLowerCase(); if (!needle) return history.entries; return history.entries.filter((entry) => { const status = entry.response ? String(entry.response.status) : entry.error ? 'error' : ''; - const name = requestName(entry) ?? ''; - return `${name} ${entry.method} ${entry.url} ${status}`.toLowerCase().includes(needle); + const via = entry.viaMcp ? 'mcp' : ''; + return `${requestName(entry)} ${entry.method} ${entry.url} ${status} ${via}` + .toLowerCase() + .includes(needle); }); }); @@ -818,7 +905,7 @@ class="px-2 py-1 rounded text-xs transition-colors {session.sidebarTab === 'history' ? 'bg-raised text-text' : 'text-muted hover:text-text'}" - onclick={() => (session.sidebarTab = 'history')} + onclick={() => showHistory()} > History @@ -1323,12 +1410,15 @@ spinner, error icon and code all occupy the same space, so a row doesn't jump about as a request settles. --> - {#if name} - {name} - {:else} - - + {name} + {#if entry.viaMcp} + + MCP {/if} collections.refreshStale()} + onfocus={() => { + collections.refreshStale(); + history.refresh(); + }} onpagehide={() => session.flush()} />