Research tool row grays on the registry's own search signal (Teacher flag)

The teacher registry drops the research tool when no search engine is
live (chat-handler's searchAvailable, 2026-09-20) — the settings row
now grays on the SAME /api/search signal, so the row and the actual
registry availability can never disagree (Teacher-stream flag,
2026-09-22, routed by master).

- researchToolUnavailable (pure): research grays exactly when the live
  status says unavailable; a loading/failed fetch grays nothing; the
  other tools never touch the search port.
- The row: whole-row dim (graph-view graying) + disabled toggle + the
  status reason as the hint — the same line the search-engine row
  under it speaks from.

Two live-caught fixes while here:
- The reason line refreshed IN the pick handler raced the section PUT
  (the hint could contradict the trigger it sits under — caught with a
  Brave pin: trigger 'Brave', hint still 'tavily (auto)'). The refresh
  now keys on the agent prop's engine pin, so it reads post-write
  state.
- The engine menu options render from a map without React keys
  (console warning) — keyed.

Live-verified on 3001: no engine key → research dimmed with reason,
siblings untouched; Tavily key saved + section reopened → row alive
('search engine: tavily (auto)'); Brave pinned without its key → both
rows gray with the honest missing-key reason; Automatic restored.
Gates: typecheck, lint, 803/803, build.
This commit is contained in:
amosblomqvist
2026-09-22 14:04:15 +03:00
parent 372d7f9975
commit 57dd25993c
2 changed files with 66 additions and 13 deletions
+44 -12
View File
@@ -96,6 +96,19 @@ export function composeTool(agent: AgentSettings | undefined, id: string, on: bo
return { ...agent, tools: { ...agent?.tools, [id]: on } };
}
/** The research tool runs on the search port — the teacher registry DROPS
* it when no engine is live (chat-handler's searchAvailable, the same
* /api/search signal the section fetches). Pure — exported for tests.
* Teacher-stream flag 2026-09-22: the settings row grays on the SAME
* signal, so the row and the actual registry availability can never
* disagree. `search === null` (loading/failed) grays NOTHING. */
export function researchToolUnavailable(
toolId: string,
search: { available: boolean } | null,
): boolean {
return toolId === "research" && search !== null && !search.available;
}
/** Compose: one slot's model/thinking override. Empty model = absent
* override (Default — falls to the factory pin); empty thinking = the
* tier keeps its resolved thinking budget. Per-field independence, same
@@ -295,7 +308,6 @@ export function AgentSection({
.then((r) => r.json())
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
.catch(() => setPrompts(null));
refreshSearch();
fetch("/api/keys")
.then((r) => r.json())
.then((body: { keys: { id: string; present: boolean }[] }) => {
@@ -309,6 +321,14 @@ export function AgentSection({
.catch(() => {});
}, []);
// The reason line re-reads /api/search AFTER an engine write lands —
// the write updates the `agent` prop (the dialog's record mirror), so
// keying on the pin avoids the race where the fetch beat the PUT and
// the hint contradicted the trigger it sits under.
useEffect(() => {
refreshSearch();
}, [agent?.search?.engine]);
// The dropdown contract (the chat model selector's, mirrored): any
// click outside the open menu — or an Esc — closes it. Esc is CLAIMED
// with stopPropagation so the settings dialog behind does NOT also
@@ -393,6 +413,7 @@ export function AgentSection({
onPick: () => void,
) => (
<button
key={id}
type="button"
data-testid={`engine-option-${id}`}
onClick={onPick}
@@ -597,15 +618,28 @@ export function AgentSection({
</SettingsGroup>
<SettingsGroup label="Tools" testid="group-tools">
{TOGGLE_TOOLS.map((id) => (
<SettingsRow key={id} label={`Tool · ${id}`} testid={`tool-row-${id}`}>
<Toggle
checked={agent?.tools?.[id] ?? true}
label={`tool ${id}`}
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/>
</SettingsRow>
))}
{TOGGLE_TOOLS.map((id) => {
// Research needs a live search engine; the row + toggle gray
// on the same signal the registry drops the tool on (see
// researchToolUnavailable). The reason rides as the hint.
const searchDown = researchToolUnavailable(id, search);
return (
<SettingsRow
key={id}
label={`Tool · ${id}`}
hint={searchDown ? search?.reason : undefined}
testid={`tool-row-${id}`}
dimmed={searchDown}
>
<Toggle
checked={agent?.tools?.[id] ?? true}
disabled={searchDown}
label={`tool ${id}`}
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/>
</SettingsRow>
);
})}
{(() => {
const engine = agent?.search?.engine ?? null;
const anyKey = engineKeys.tavily === true || engineKeys.brave === true;
@@ -637,13 +671,11 @@ export function AgentSection({
{engineOption("auto", "Automatic", engine === null, null, () => {
onSectionChange(composeSearchEngine(agent, null));
setOpenMenu(null);
refreshSearch();
})}
{SEARCH_ENGINES.map((id) =>
engineOption(id, id === "tavily" ? "Tavily" : "Brave", engine === id, engineKeys[id] ?? false, () => {
onSectionChange(composeSearchEngine(agent, id));
setOpenMenu(null);
refreshSearch();
}),
)}
</AnchoredMenu>