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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user