Compare commits

...

6 Commits

Author SHA1 Message Date
Tomu Hirata dca826a2e7 test(web): add prefetchAvailableAgentDetails to useAvailableAgents mock 2026-07-15 18:29:47 +09:00
Tomu Hirata 853623be7d fix(web): prefetch session agent details on picker open to avoid lazy knobs chevron
Fetching harness on individual hover caused hasKnobs() to flip mid-render,
making the '>' chevron appear lazily on entries that gained knobs after enrichment.

Instead, fire prefetchAvailableAgentDetails for all session-discovered agents
in onOpenChange when the picker opens. By the time the user reads the list
the enrichment is done and hasKnobs is stable. Remove the per-item
onMouseEnter handlers.
2026-07-15 18:20:11 +09:00
Tomu Hirata 647fd61622 perf(web): fetch session agent details on hover instead of background eagerly
Replace the background enrichment approach with on-hover prefetching:

- Add sessionId to AvailableAgent (only set on session-discovered agents)
- Export prefetchAvailableAgentDetails(agent, queryClient): fetches
  GET /v1/sessions/{id}/agent on first hover and patches harness,
  description, and skills into the ['available-agents'] cache
- Add onMouseEnter to all three renderEntry variants in AgentHarnessPicker
  to call prefetchAvailableAgentDetails

Zero fetches on load. Agents the user never hovers cost nothing.
Harness-dependent UI (model picker, routing, host warnings) appears once
the user hovers, giving ~100ms head start before they click.
2026-07-15 18:16:00 +09:00
Tomu Hirata a24bb01116 style: fix prettier formatting in useAvailableAgents.ts 2026-07-15 18:01:35 +09:00
Tomu Hirata 4d6a7f48ae perf(web): enrich session-discovered agents in background after initial render
Previously the picker blocked on N GET /v1/sessions/{id}/agent calls before
rendering. The prior fix (sessionAgentFromScan) eliminated those calls but
dropped harness — which gates the model/effort picker, routing support, and
unconfigured-host warnings for custom agents.

New approach: render the picker immediately with name-only scan data, then
fire enrichment calls in the background via enrichInBackground(). When they
complete, setQueryData patches harness/description/skills into the
['available-agents'] cache, triggering a re-render with full data.

The picker is visible instantly; harness-dependent UI fills in asynchronously
once the per-session fetches land (typically <100ms on a local server).
2026-07-15 17:58:27 +09:00
Tomu Hirata f9a02e954e perf(web): skip per-session agent enrichment on initial picker load
useAvailableAgents fired N GET /v1/sessions/{id}/agent calls to fetch
description, harness, and skills for each session-discovered agent before
the picker could render. These are all cosmetic and not needed to display
the picker:

- description: subtitle shown on hover — can load lazily via useSessionAgent
- harness: used to derive display_name, but session-discovered agents are
  always custom uploads (never native coding agents), so capitalizeAgentName
  gives a correct display_name without harness
- skills: feeds the composer's slash menu, only relevant after session start

Replace enrichSessionAgent (async, 1 fetch per agent) with sessionAgentFromScan
(sync, no fetch) that builds the AvailableAgent directly from scan data.
The resolved array is now built synchronously after the initial 2-request
parallel fetch (GET /v1/agents + GET /v1/sessions?kind=any).
2026-07-15 17:47:47 +09:00
3 changed files with 61 additions and 33 deletions
+46 -31
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import { authenticatedFetch } from "@/lib/identity";
import { agentRootName } from "@/lib/forkHarness";
import { capitalizeAgentName } from "@/lib/agentLabels";
@@ -39,6 +39,10 @@ export interface AvailableAgent {
// immutable, so it is the stable signal. Omitted on older servers and on
// session-derived agents (whose recency comes from the scanned session).
created_at?: number | null;
// Session id used to fetch the full agent spec on hover. Only set on
// session-discovered agents (custom uploads); absent on catalog agents
// whose full data is already present from GET /v1/agents.
sessionId?: string;
}
const DISPLAY_NAMES: Record<string, string> = {
@@ -194,42 +198,57 @@ interface AgentObjectWire {
}
/**
* Enrich one scanned session agent into the picker's AvailableAgent
* shape via `GET /v1/sessions/{id}/agent` (description, harness,
* bundled skills). On failure the agent is still listed with the
* name-only fields from the scan — mirroring the server's own
* `_to_agent_object` degradation: one unloadable bundle must not
* break discovery.
* Build an AvailableAgent from session scan data alone — no extra fetch.
* description, harness, and skills are null/empty and filled in on hover
* via prefetchAvailableAgentDetails.
*/
async function enrichSessionAgent(scanned: ScannedSessionAgent): Promise<AvailableAgent> {
const fallback: AvailableAgent = {
function sessionAgentFromScan(scanned: ScannedSessionAgent): AvailableAgent {
return {
id: scanned.agentId,
name: scanned.agentName,
display_name: displayNameForAgent(scanned.agentName),
description: null,
harness: null,
skills: [],
sessionId: scanned.sessionId,
// builtin/created_at intentionally omitted: session-derived agents never
// seed the catalog, and their recency comes from the scanned session's
// createdAt (used directly in the dedup), not from this object.
};
}
/**
* Fetch harness, description, and skills for a session-discovered agent and
* patch them into the ["available-agents"] cache. Call on hover so the data
* is ready before the user clicks — zero cost for agents they never hover.
*/
export async function prefetchAvailableAgentDetails(
agent: AvailableAgent,
queryClient: QueryClient,
): Promise<void> {
if (!agent.sessionId || agent.harness !== null || agent.description !== null) return;
try {
const res = await authenticatedFetch(
`/v1/sessions/${encodeURIComponent(scanned.sessionId)}/agent`,
`/v1/sessions/${encodeURIComponent(agent.sessionId)}/agent`,
);
if (!res.ok) return fallback;
if (!res.ok) return;
const json = (await res.json()) as AgentObjectWire;
return {
...fallback,
display_name: displayNameForAgent(json.name, json.harness),
description: json.description ?? null,
harness: json.harness ?? null,
skills: json.skills ?? [],
};
queryClient.setQueryData<AvailableAgent[]>(["available-agents"], (prev) => {
if (!prev) return prev;
return prev.map((a) =>
a.id !== agent.id
? a
: {
...a,
display_name: displayNameForAgent(json.name, json.harness),
description: json.description ?? null,
harness: json.harness ?? null,
skills: json.skills ?? [],
},
);
});
} catch {
// Network-level failure — same best-effort degradation as the
// non-ok branch above: list the agent from scan fields.
return fallback;
// Best-effort — agent stays name-only on failure.
}
}
@@ -326,16 +345,12 @@ async function fetchAvailableAgents(): Promise<AvailableAgent[]> {
}
}
const resolved = (
await Promise.all(
Array.from(byName.values()).map((c) =>
c.template !== null ? Promise.resolve(c.template) : enrichSessionAgent(c.scanned!),
),
)
).filter((agent) => {
const nativeKey = nativeCodingAgentForAvailableAgent(agent)?.key;
return nativeKey !== "kiro" || !hasKiroBuiltin;
});
const resolved = Array.from(byName.values())
.map((c) => (c.template !== null ? c.template : sessionAgentFromScan(c.scanned!)))
.filter((agent) => {
const nativeKey = nativeCodingAgentForAvailableAgent(agent)?.key;
return nativeKey !== "kiro" || !hasKiroBuiltin;
});
// Seeded built-ins first; user templates / custom uploads follow, newest
// first. NewChatDialog's display-order sort is stable, so unranked names
// keep this relative order.
+4 -1
View File
@@ -42,7 +42,10 @@ vi.mock("@/lib/identity", async (importOriginal) => ({
authenticatedFetch: vi.fn(),
}));
vi.mock("@/hooks/useHosts", () => ({ useHosts: vi.fn() }));
vi.mock("@/hooks/useAvailableAgents", () => ({ useAvailableAgents: vi.fn() }));
vi.mock("@/hooks/useAvailableAgents", () => ({
useAvailableAgents: vi.fn(),
prefetchAvailableAgentDetails: vi.fn(),
}));
vi.mock("@/hooks/useHostFilesystem", () => ({
useHostFilesystem: vi.fn(),
// WorkspacePicker (rendered by the file browser) reads this on mount;
+11 -1
View File
@@ -88,7 +88,11 @@ import {
onHostStatusChanged,
type HostIdentity,
} from "@/lib/nativeBridge";
import { useAvailableAgents, type AvailableAgent } from "@/hooks/useAvailableAgents";
import {
useAvailableAgents,
prefetchAvailableAgentDetails,
type AvailableAgent,
} from "@/hooks/useAvailableAgents";
import { useAutoGrowTextarea } from "@/hooks/useAutoGrowTextarea";
import { useRecentWorkspaces } from "@/hooks/useRecentWorkspaces";
import { useDirectorySessions } from "@/hooks/useDirectorySessions";
@@ -1300,6 +1304,7 @@ function AgentHarnessPicker({
// Controlled so clicking a knobbed row can commit the pick and close the
// menu (see the sub-trigger onClick below) without diving into the submenu.
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
// Touch devices can't hover, so the desktop knob flyout (a Radix sub-menu
// that opens on hover) is unreachable there. On mobile we instead swap the
@@ -1575,6 +1580,11 @@ function AgentHarnessPicker({
// tall list, so the later drill-in (shorter page) can't flip it.
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) setMobileSide(window.innerHeight - rect.bottom >= rect.top ? "bottom" : "top");
// Prefetch harness/description/skills for all session-discovered
// agents so hasKnobs is stable before the user reads the list.
for (const agent of [...harnessEntries, ...agentEntries]) {
void prefetchAvailableAgentDetails(agent, queryClient);
}
} else {
// Closing resets the in-place page so the menu always reopens on the
// agent list, never a stale knobs page.