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>
  );
}