AgentSettingsSection.tsx10.0 KBView on GitHub
'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

import { Field, FieldRows, SelectField, TextareaField } from '@/components/ui/field';
import { AGENT_FOLDER_LABELS } from '@/modules/agents/utils/agent-groups';
import { agentNamespacePath } from '@/modules/agents/utils/agent-paths';
import {
  AGENT_FOLDERS,
  type AgentDetail,
  type CustomFrontmatterEntry,
} from '@/modules/agents/types';
import { AgentFieldsCallout } from '@/modules/aop/components/AgentFieldsCallout';
import { useTRPC } from '@/providers/query-provider';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';

/** `default_file: none` — a real choice, and distinct from "reset me to the default". */
const NO_DEFAULT_FILE = '__none__';

/**
 * The agent's own frontmatter, as a form — mounted INSIDE the Playbook section.
 *
 * Beside it, it read as two objects. An agent's settings and its instructions are one
 * thing: the same document, the same write path, the same save. Two sibling cards on one
 * page said otherwise, and rendered the CRM fields in both of them.
 *
 * What is NOT here is as deliberate as what is. The agent's id and name are the workspace
 * header one row up. Its folder namespace, whether chat is enabled, and its fill
 * instructions are decisions made once at creation with no writer behind them — a row each
 * was three rows of furniture in a column of live controls. All of it is still readable in
 * the document below; none of it is pretending to be a setting.
 */
export function AgentSettingsSection({
  agent,
  targetUserId,
  isLoading,
  className,
}: {
  agent?: AgentDetail;
  /** An org admin/owner viewing a teammate's agent — see `AgentView`'s prop of the same name. */
  targetUserId?: string;
  isLoading?: boolean;
  className?: string;
}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const documentId = agent?.documentId;
  const agentId = agent?.agentId;
  const header = agent?.header;

  // Local draft so typing stays smooth; committed on blur.
  const [description, setDescription] = useState('');

  // Seeded on the agent's IDENTITY, not on `header`'s content. Every commit invalidates
  // `agent.get`, so a content-keyed effect would re-run on each successful write and reset
  // the draft from the server mid-sentence.
  useEffect(() => {
    if (!header) return;
    setDescription(header.description ?? '');
    // eslint-disable-next-line react-hooks/exhaustive-deps -- identity, not content; see above
  }, [agentId]);

  const invalidate = useCallback(() => {
    if (agentId) {
      void queryClient.invalidateQueries({
        queryKey=[redacted] agentId, targetUserId }),
      });
    }
    void queryClient.invalidateQueries({ queryKey=[redacted] });
    if (documentId) {
      void queryClient.invalidateQueries({
        queryKey=[redacted] documentId }),
      });
    }
  }, [agentId, documentId, targetUserId, queryClient, trpc]);

  /**
   * One writer for the frontmatter: `aop.updateSubagentHeader` patches it surgically, so
   * unknown keys, ordering and the read-only identity keys all survive the save.
   */
  const { mutate: updateHeader } = useMutation(
    trpc.aop.updateSubagentHeader.mutationOptions({
      onSuccess: invalidate,
      onError: (error) => toast.error(`Could not update the agent: ${error.message}`),
    }),
  );

  const { mutate: setFolder } = useMutation(
    trpc.agent.setFolder.mutationOptions({
      onSuccess: invalidate,
      onError: (error) => toast.error(`Could not move the agent: ${error.message}`),
    }),
  );

  const { mutate: setDefaultFile } = useMutation(
    trpc.agent.setDefaultFile.mutationOptions({
      onSuccess: invalidate,
      onError: (error) => toast.error(`Could not set the default file: ${error.message}`),
    }),
  );

  const commit = useCallback(
    (patch: Record<string, string>) => {
      if (!documentId) return;
      updateHeader({ documentId, patch });
    },
    [documentId, updateHeader],
  );

  /**
   * The files this agent actually has, for the default-file picker.
   *
   * A free-text box would happily accept a path nothing is ever written to; a list of what
   * exists cannot. Same query key as the Files tab, so this shares that result rather than
   * issuing a second read.
   */
  const { data: outputs } = useQuery({
    ...trpc.agent.getOutputs.queryOptions({ agentId: agentId ?? '', targetUserId }),
    enabled: !!agentId,
  });

  const fileOptions = useMemo(() => {
    if (!agentId || !agent) return [];
    const namespace = agentNamespacePath(
      agentId,
      agent.namespace === 'org' ? { type: 'org' } : { type: 'user' },
    );
    const files = [
      ...(outputs?.byNamespace?.outputs ?? []),
      ...(outputs?.byNamespace?.config ?? []),
    ];
    const seen = new Set<string>();
    const options: Array<{ value: string; label: string }> = [];
    for (const file of files) {
      if (!file.path.startsWith(`${namespace}/`)) continue;
      const relative = file.path.slice(namespace.length + 1);
      if (!relative || seen.has(relative)) continue;
      seen.add(relative);
      options.push({ value: relative, label: relative });
    }
    options.sort((a, b) => a.label.localeCompare(b.label));
    return options;
  }, [agent, agentId, outputs]);

  if (isLoading || !agent || !header) {
    return (
      <div className={cn('flex flex-col gap-3 py-3', className)}>
        <Skeleton className="h-6 w-48" />
        <Skeleton className="h-6 w-64" />
      </div>
    );
  }

  /**
   * `overview` is offered even when no overview document exists yet: it is the fallback
   * the runtime uses for an agent that has declared nothing, so leaving it out would make
   * the CURRENT value unselectable on a brand-new agent.
   */
  const others = fileOptions.filter((o) => o.value !== 'overview');
  const defaultFileOptions = [
    { value: 'overview', label: 'Overview' },
    ...others,
    { value: NO_DEFAULT_FILE, label: 'Nothing — show the file list' },
  ];

  return (
    <div className={cn(className)}>
      <FieldRows bare>
        <TextareaField
          label="Description"
          hint="What this agent is for, and when the orchestrator should reach for it."
          rows={3}
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          onBlur={() => description !== (header.description ?? '') && commit({ description })}
        />
        <SelectField
          label="Folder"
          hint="How this agent is grouped in lists. Changes nothing about how it runs."
          value={header.folder}
          onValueChange={(v) => {
            // Narrowed against the real list rather than asserted: `SelectField` infers its
            // value as `string` from `header.folder`, so the assertion was the only thing
            // making this compile — and it would have compiled just as happily on a typo.
            const folder = AGENT_FOLDERS.find((f) => f === v);
            if (agentId && folder) setFolder({ agentId, targetUserId, folder });
          }}
          options={AGENT_FOLDERS.map((f) => ({ value: f, label: AGENT_FOLDER_LABELS[f] }))}
        />
        <SelectField
          label="Default file"
          hint={
            others.length > 0
              ? 'Opens first when you open this agent.'
              : 'Opens first. Every file this agent writes becomes available here.'
          }
          value={header.defaultFile ?? NO_DEFAULT_FILE}
          onValueChange={(v) =>
            agentId &&
            setDefaultFile({ agentId, targetUserId, defaultFile: v === NO_DEFAULT_FILE ? 'none' : v })
          }
          options={defaultFileOptions}
        />
        {/* Capture is always on; injection is what makes what was captured act on the next
            run. An agent with memory switched off entirely says so instead — offering the
            choice would imply a switch this form does not have. */}
        {header.memoryEnabled ? (
          <SelectField
            label="Memory"
            hint="Corrections are always recorded. Injected also reads them back on every run."
            value={header.memoryInject ? 'injected' : 'captured'}
            onValueChange={(v) => commit({ memory_inject: v === 'injected' ? 'true' : 'false' })}
            options={[
              { value: 'captured', label: 'Captured' },
              { value: 'injected', label: 'Injected' },
            ]}
          />
        ) : (
          <Field label="Memory" hint="This agent is deterministic by configuration.">
            <span className="text-muted-foreground truncate text-sm">Off</span>
          </Field>
        )}
        {/* A row like any other. It was a bordered callout with its own uppercase heading —
            a second frame drawn around one decision in a column of decisions. */}
        {agent.aopId && agentId && (
          <Field label="CRM fields" hint="Fields this agent is responsible for writing." stacked>
            <AgentFieldsCallout aopId={agent.aopId} agentId={agentId} bare />
          </Field>
        )}
      </FieldRows>

      {header.custom.length > 0 && (
        <div className="border-border/60 mt-1 border-t px-3 py-2.5">
          <p className="text-muted-foreground text-xs font-medium">Custom metadata</p>
          <p className="text-muted-foreground/70 mt-0.5 text-xs">
            Kept on every save. Nothing in Cedar reads these.
          </p>
          <dl className="mt-2 flex flex-col gap-1">
            {header.custom.map((entry: CustomFrontmatterEntry) => (
              <div key=[redacted] className="flex items-baseline gap-2 text-sm">
                <dt className="text-muted-foreground shrink-0 font-mono text-xs">{entry.key}</dt>
                <dd className="truncate">
                  {entry.value || <span className="text-muted-foreground/70">—</span>}
                </dd>
              </div>
            ))}
          </dl>
        </div>
      )}
    </div>
  );
}