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