TableFillInstructions.tsx6.9 KBView on GitHub
'use client';

/**
 * The fill-instruction panel — every column's standing instruction to its agent, in one place.
 *
 * A `fillInstruction` is not a column setting like wrapping or type. It is the sentence an
 * agent is handed when it fills that cell ("Score {{company}} against our ICP from {{deal}}"),
 * so the column set reads as a small team: one instruction per worker, and they only make sense
 * against each other. Editing them one at a time, in a textarea that REPLACED the column menu,
 * meant you could never see the other three while writing the fourth — and closing the menu was
 * the only way to go read one.
 *
 * So this opens on hover as a flyout, exactly as Type and Wrapping do, and it is wide: the
 * column you opened it on is editable at the top, every column is listed underneath with its
 * instruction, and clicking any of them moves the editor to it. One hover, the whole set.
 */

import { useState } from 'react';
import { Lock, Wand2 } from 'lucide-react';

import { PICKER_SURFACE_CLASS } from '@/components/ui/option-picker';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { TableColumn } from '@zero/server/table';

import { COLUMN_TYPE_ICONS } from './constants';

/** The flyout's surface: the app's popover, wide enough to read a paragraph in. */
export const FILL_PANEL_CLASS = cn(PICKER_SURFACE_CLASS, 'w-[26rem]');

/** A bound column is computed, so its cells are never an agent's to write. */
function isFillable(column: TableColumn): boolean {
  return !column.binding;
}

export function TableFillInstructions({
  columns,
  activeKey,
  onPatchColumn,
  onClose,
}: {
  /** Every declared column, in schema order — the whole team, not just this one. */
  columns: TableColumn[];
  /** The column whose header opened the menu. Where the editor starts. */
  activeKey=[redacted];
  onPatchColumn: (key=[redacted], patch: Partial<Omit<TableColumn, 'key'>>) => void;
  /** Escape / Done — back to the column menu. */
  onClose: () => void;
}) {
  const [editingKey, setEditingKey] = useState(activeKey);
  const [draft, setDraft] = useState(
    () => columns.find((column) => column.key === activeKey)?.fillInstruction ?? '',
  );

  const editing = columns.find((column) => column.key === editingKey);

  /** Write the draft back to the column it belongs to. Nothing to do when unchanged. */
  const commit = (key=[redacted], value: string) => {
    const column = columns.find((c) => c.key === key);
    if (!column) return;
    const trimmed = value.trim();
    if (trimmed === (column.fillInstruction ?? '')) return;
    onPatchColumn(key, { fillInstruction: trimmed || undefined });
  };

  // Switching columns saves first: the panel's whole point is moving between instructions, so
  // losing one on the way to the next would make it the most dangerous control in the menu.
  const switchTo = (key=[redacted] => {
    commit(editingKey, draft);
    setEditingKey(key);
    setDraft(columns.find((column) => column.key === key)?.fillInstruction ?? '');
  };

  const withInstruction = columns.filter((column) => column.fillInstruction?.trim()).length;

  return (
    <div className="flex flex-col">
      <div className="flex items-center gap-2 px-3 pt-2.5 pb-2">
        <Wand2 className="text-muted-foreground size-3.5 shrink-0" />
        <span className="flex-1 text-sm font-medium">Fill instructions</span>
        <span className="text-muted-foreground text-xs tabular-nums">
          {withInstruction} of {columns.length}
        </span>
      </div>

      {editing && (
        <div className="px-3 pb-2">
          <p className="text-muted-foreground pb-1 text-xs">
            What an agent should write into <span className="text-foreground">{editing.label}</span>
          </p>
          {isFillable(editing) ? (
            <Textarea
              autoFocus
              key=[redacted]
              aria-label={`Fill instruction for ${editing.label}`}
              rows={4}
              value={draft}
              placeholder="How should an agent derive this cell? Reference other columns as {{key}}."
              onChange={(event) => setDraft(event.target.value)}
              onBlur={() => commit(editingKey, draft)}
              // A Radix menu claims every character key for its own typeahead, so a field
              // inside one has to stop each keystroke before it gets there — the same reason
              // `OptionPicker` does. Escape still closes, through the flyout's own handler.
              onKeyDown={(event) => {
                if (event.key !== 'Escape') event.stopPropagation();
              }}
              className="text-sm"
            />
          ) : (
            <p className="text-muted-foreground bg-sunken/60 rounded-md px-2 py-1.5 text-xs">
              Computed from <span className="font-mono">{editing.binding}</span> — Cedar keeps
              these cells up to date, so no agent writes them.
            </p>
          )}
        </div>
      )}

      <div className="border-seam border-t" />

      {/* The rest of the team. Every column, so an instruction is written against the ones it
          sits beside rather than in isolation. */}
      <div className="max-h-64 overflow-y-auto p-1.5">
        {columns.map((column) => {
          const Icon = COLUMN_TYPE_ICONS[column.type];
          const isEditing = column.key === editingKey;
          const instruction = column.fillInstruction?.trim();
          return (
            <button
              key=[redacted]
              type="button"
              onClick={() => switchTo(column.key)}
              className={cn(
                'flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors',
                isEditing ? 'bg-selected' : 'hover:bg-hover',
              )}
            >
              <Icon aria-hidden className="text-muted-foreground mt-0.5 size-3.5 shrink-0" />
              <span className="min-w-0 flex-1">
                <span className="flex items-center gap-1.5">
                  <span className="truncate text-sm">{column.label}</span>
                  {!isFillable(column) && (
                    <Lock aria-hidden className="text-muted-foreground size-3 shrink-0" />
                  )}
                </span>
                <span
                  className={cn(
                    'mt-0.5 line-clamp-2 text-xs',
                    instruction ? 'text-muted-foreground' : 'text-muted-foreground/60',
                  )}
                >
                  {instruction || 'No instruction — cells here are filled by hand'}
                </span>
              </span>
            </button>
          );
        })}
      </div>

      <div className="border-seam border-t p-1.5">
        <button
          type="button"
          onClick={() => {
            commit(editingKey, draft);
            onClose();
          }}
          className="hover:bg-hover w-full cursor-pointer rounded-lg px-2 py-1.5 text-sm"
        >
          Done
        </button>
      </div>
    </div>
  );
}