AgentConfigPage.tsx5.3 KBView on GitHub 'use client';
import { AGENT_CONFIG_SECTIONS, type AgentConfigSectionId } from '@/modules/agents/types';
import { useEffect } from 'react';
import { cn } from '@/lib/utils';
/**
* The one surface every Config section's content sits on.
*
* Exported rather than drawn by this page, because the sections do not all put the
* same thing inside it: Sources keeps its summary line ABOVE the surface, so a
* wrapper drawn here would box the summary in with the list it describes. What has
* to be identical across the three sections is the surface itself — one radius, one
* border, one `bg-raised` — and that is exactly what this constant is.
*/
export const AGENT_SECTION_SURFACE = 'bg-raised border-border/60 rounded-lg border';
/**
* The "Add …" control every Config section ends with — one more ROW of the list it
* adds to, not a button sitting under it.
*
* It was a filled pill (the composer's send button in beige), and inside a list of
* plain rows that is exactly what it looked like: a foreign object parked at the
* bottom. The list already has a shape — icon column, 15px label, full-width hover —
* so the way to say "this belongs here" is to take that shape. The icon lands in the
* same column as the trigger icons and the connection badges, so the labels line up
* down the whole card.
*
* A PERMANENT row, not one revealed on hover: hover-only hid the one thing an empty
* Triggers list exists to offer.
*/
export const AGENT_SECTION_ADD_BUTTON =
'text-muted-foreground hover:bg-sunken hover:text-foreground flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-[15px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50';
interface AgentConfigPageProps {
sourcesSection: React.ReactNode;
connectionsSection: React.ReactNode;
instructionsSection: React.ReactNode;
className?: string;
}
/**
* Config is ONE scrolling page with three anchored sections — not three tabs.
*
* Triggers, connections and instructions are the same act: deciding what this
* agent does. As separate tabs you tab-hop to answer one question ("it fires on
* every email — can it even reach Slack, and does its body mention Slack?"), and
* the debug rail has to argue with a tab bar about which tab it belongs to.
*
* The section ids are a URL contract: `?tab=config#connections` SCROLLS rather
* than switching anything, so a tool-denial message can link at the exact control
* that fixes it.
*
* No side nav: the page sits in the same centered column the conversation view uses,
* and a nav rail inside that column steals width from the content it indexes for
* three links you can see by scrolling.
*/
export function AgentConfigPage({
sourcesSection,
connectionsSection,
instructionsSection,
className,
}: AgentConfigPageProps) {
/**
* Honour a `#section`, on mount AND every time it changes afterwards.
*
* The scroll has to wait a frame: the sections are rendered by this component, so
* at effect time on first paint the target may not have layout yet and
* scrollIntoView would be a no-op.
*
* Listening as well as reading once is what makes the anchor half of the address
* work like the rest of it — a denial message linking `#connections` while this
* page is already open, or a back/forward between two sections, moves the page
* instead of doing nothing. Window events rather than the router's location, so
* this component still renders with no router in scope.
*/
useEffect(() => {
let raf = 0;
const scrollToHash = () => {
const hash = window.location.hash.slice(1) as AgentConfigSectionId;
if (!AGENT_CONFIG_SECTIONS.some((s) => s.id === hash)) return;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
document.getElementById(hash)?.scrollIntoView({ block: 'start' });
});
};
scrollToHash();
window.addEventListener('hashchange', scrollToHash);
window.addEventListener('popstate', scrollToHash);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('hashchange', scrollToHash);
window.removeEventListener('popstate', scrollToHash);
};
}, []);
const body: Record<AgentConfigSectionId, React.ReactNode> = {
sources: sourcesSection,
connections: connectionsSection,
instructions: instructionsSection,
};
return (
// `gap-6`, down from `gap-10`. Three sections a screen-height apart read as three
// pages you happen to be scrolling through; the whole point of one Config page is
// that triggers, connections and the playbook are visible as one answer.
<div className={cn('flex flex-col gap-6 pb-0 pt-6', className)}>
{AGENT_CONFIG_SECTIONS.map((s) => (
<section key=[redacted] id={s.id} className="scroll-mt-6">
{/* Heading only. The section's own action is the last row of its container
— a control you can see without hovering the right thing first. */}
<h2 className="mb-2 text-xl font-semibold tracking-tight">{s.label}</h2>
{body[s.id]}
</section>
))}
</div>
);
}
/** Placeholder for a Config section whose phase has not landed yet. */
export function AgentConfigSectionPending({ phase, what }: { phase: number; what: string }) {
return (
<p className="text-muted-foreground text-sm">
{what} — lands in phase {phase}.
</p>
);
}