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
23 changes: 23 additions & 0 deletions .changeset/agent-sends-in-history.md
Original file line number Diff line number Diff line change
@@ -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.
122 changes: 106 additions & 16 deletions src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { ContextMenu, Dialog, Tooltip } from 'bits-ui';
import {
applyPathParams,
endpointKey,
LOOSE_SECTION_ID,
methodColor,
normalizeBaseUrl,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, Map<string, string>>();
for (const section of collections.sections) {
const mine = new Map<string, string>();
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);
});
});

Expand Down Expand Up @@ -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
</button>
Expand Down Expand Up @@ -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}
<span class="min-w-0 flex-1 truncate text-xs text-text" title={name}>{name}</span>
{:else}
<!-- Unnamed entries still push the right-hand group over, so
every row lines up down the list. -->
<span class="flex-1"></span>
<span class="min-w-0 flex-1 truncate text-xs text-text" title={name}>{name}</span>
{#if entry.viaMcp}
<!-- An agent sent this, not you. It sits with the name rather
than the right-hand group so the fixed-width columns stay
aligned with the rows that don't wear it. -->
<span
class="shrink-0 rounded border border-border px-1 font-mono text-2.5 text-muted"
title="Sent by an agent through the MCP server">MCP</span
>
{/if}
<!-- Method, status and time are one group on a single `gap-1`, so
the space before the timestamp matches the one between the
Expand Down
49 changes: 47 additions & 2 deletions src/lib/history.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ import {
/** Requests that aren't saved to a section all share this bucket. */
export const SCRATCH_ID = 'scratch';

/**
* What the MCP server prefixes the request id of everything it sends.
*
* It records into the same database as the window, under one synthetic request
* per collection — `mcp:<section id>` — which is also how `mcp_body` on the
* Rust side tells its own rows apart. Mirrored here so the History tab can say
* which entries an agent sent.
*/
export const MCP_REQUEST_PREFIX = 'mcp:';

export interface HistoryEntry {
id: string;
/** The saved request this belongs to, or `SCRATCH_ID`. */
Expand All @@ -28,6 +38,13 @@ export interface HistoryEntry {
* returned; those match any section rather than disappearing.
*/
sectionId: string | null;
/**
* Sent by the MCP server on an agent's behalf rather than from this window.
*
* Read off the request id — see `MCP_REQUEST_PREFIX` — so it is known from
* the list alone, without a lookup per row.
*/
viaMcp: boolean;
at: number;
method: string;
url: string;
Expand All @@ -46,6 +63,7 @@ function fromRecord(record: HistoryRecord): HistoryEntry {
id: record.id,
requestId: record.requestId,
sectionId: record.sectionId ?? null,
viaMcp: record.requestId.startsWith(MCP_REQUEST_PREFIX),
at: record.at,
method: record.method,
url: record.url,
Expand Down Expand Up @@ -89,6 +107,33 @@ class History {
}
}

/**
* Picks up what another process has written since the last look.
*
* The MCP server runs outside this window and records into the same
* database, so a list loaded at startup went stale the moment an agent sent
* something — its requests only showed up after a relaunch. This merges
* rather than replaces: an entry already here keeps its object, and with it
* a body that is loaded or a send still streaming onto it. Nothing is
* dropped either — an entry the page limit has since pushed out is still a
* real row, and losing it from the list would look like history had been
* lost.
*/
async refresh(): Promise<void> {
let records: HistoryRecord[];
try {
records = await historyList();
this.error = null;
} catch (error) {
this.error = String(error);
return;
}
const known = new Set(this.entries.map((entry) => entry.id));
const added = records.filter((record) => !known.has(record.id)).map(fromRecord);
if (added.length === 0) return;
this.entries = [...added, ...this.entries].sort((a, b) => b.at - a.at);
}

/**
* The bucket a request's entries live in.
*
Expand Down Expand Up @@ -155,10 +200,10 @@ class History {
}
}

start(entry: Omit<HistoryEntry, 'pending' | 'bodyLoaded'>): void {
start(entry: Omit<HistoryEntry, 'pending' | 'bodyLoaded' | 'viaMcp'>): void {
// Sending shows the new response, not whatever history was open.
this.viewingId = null;
this.entries.unshift({ ...entry, pending: true, bodyLoaded: false });
this.entries.unshift({ ...entry, viaMcp: false, pending: true, bodyLoaded: false });
this.select(entry.requestId, entry.id, entry.sectionId);
}

Expand Down
9 changes: 8 additions & 1 deletion src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -725,14 +725,21 @@
</script>

<!--
Focus is the moment you are about to look, so it is when history catches up
with what the MCP server wrote from its own process while you were in the
agent's window — a send made there only used to appear after a relaunch.

`pagehide` is the last moment the webview is guaranteed to still be there.
The quit paths above already flush, and so does the update restart; this
covers the ways out that route through none of them — a reload from the
devtools, or the webview being torn down and brought back after a crash.
-->
<svelte:window
onkeydown={onKeydown}
onfocus={() => collections.refreshStale()}
onfocus={() => {
collections.refreshStale();
history.refresh();
}}
onpagehide={() => session.flush()}
/>

Expand Down
Loading