SplitSettingsCreator.tsx21.9 KBView on GitHub import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCallback, useMemo, useState } from 'react';
import { Check, ChevronDown, Plus, Sparkles, Tag, Trash2 } from 'lucide-react';
import { LABEL_COLORS, type LabelColor } from '@/modules/labels/utils/label-colors';
import { cn } from '@/lib/utils';
import {
InboxLinkedDealsSection,
type LinkedDealsState,
} from '@/modules/threads/components/InboxLinkedDealsSection';
import { computeFiltersFromConfig } from '@/modules/crm/utils/compute-canvas-filters';
import { toConversationFilterParams } from '@/modules/crm/utils/conversation-filter-params';
import { resolveAops } from '@/modules/crm/hooks/use-aop-filter-columns';
import { mergeCustomFieldDefinitions } from '@/modules/crm/utils/aop-columns';
import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { InboxConversationFilter } from '@/modules/threads/hooks/use-inboxes';
import { useCedarStore } from '@/modules/store';
/** A user label the split definition can reference. */
export type SplitLabelOption = {
id: string;
name: string;
color?: { backgroundColor: string; textColor: string };
};
export type SplitSettingsDraft = {
name: string;
query?: string;
alsoShowInImportant: boolean;
hideWhenEmpty: boolean;
position: number;
/**
* The CRM rule from the "Linked deals" section. `null` on save means the
* user cleared it — `undefined` would read as "leave whatever is stored".
*/
conversationFilter?: InboxConversationFilter | null;
};
type SplitSettingsCreatorProps = {
draft?: Partial<SplitSettingsDraft>;
/** Label for the "also show in" target — e.g. "inbox" or "Important / Other". */
alsoShowTargetLabel?: string;
/**
* Optional callback fired immediately when the user toggles the "Also show in"
* switch. Lets the parent persist the change to the server without waiting
* for the user to click Save (matches the template-card UX where each
* checkbox click is its own save). When omitted, the switch only updates
* local form state.
*/
onAlsoShowInImportantChange?: (value: boolean) => void;
/** Existing user labels offered in the "Add label" picker. */
labels?: SplitLabelOption[];
/** Create a plain Gmail label. Omit to hide the "create label" affordance. */
onCreateLabel?: (input: { name: string; color: LabelColor }) => Promise<void>;
/**
* Create a Cedar AI label from a name + agent instructions. Returns the
* resolved label name (e.g. `Cedar/AI/recruiting`) so it can be added to the
* definition. Omit to hide the "create AI label" affordance.
*/
onCreateAiLabel?: (input: {
displayName: string;
description: string;
}) => Promise<{ labelName: string }>;
/** Permanently delete a label. Omit to hide the per-label delete affordance. */
onDeleteLabel?: (label: SplitLabelOption) => Promise<void>;
onBack: () => void;
onDelete?: () => void;
onSave: (draft: SplitSettingsDraft) => void;
};
const DEFAULT_QUERY = 'label:INBOX';
/**
* Build the Gmail-style search token for a label. Names with whitespace must be
* quoted so Gmail treats them as a single label rather than two AND'd terms.
*/
function labelToken(name: string): string {
return /\s/.test(name) ? `label:"${name}"` : `label:${name}`;
}
/** Remove a label's `label:` clause from the definition (used when it's deleted). */
function removeLabelFromQuery(query: string | undefined, labelName: string): string {
const token=[redacted];
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return (query ?? '')
.replace(new RegExp(`(^|\\s)${escaped}(?=\\s|$)`, 'g'), '$1')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Reopen a stored rule as editable state. `uiConfig` is the round-trip copy of
* the per-column config; the AOP scope comes back off the resolved filters,
* which is the only place it is stored.
*/
function linkedDealsFromFilter(
filter: InboxConversationFilter | null | undefined,
): LinkedDealsState {
const aopIds = filter?.filters?.aopIds;
return {
aopIds: Array.isArray(aopIds) ? (aopIds as (string | null)[]) : [],
config: (filter?.uiConfig as CanvasFilterSortConfiguration | undefined) ?? {},
};
}
/** Append a `label:` clause to the definition, ignoring the throwaway default. */
function appendLabelToQuery(query: string | undefined, labelName: string): string {
const token=[redacted];
const trimmed = (query ?? '').trim();
if (!trimmed || trimmed === DEFAULT_QUERY) return token;
if (trimmed.includes(token)) return trimmed;
return `${trimmed} ${token}`;
}
export function SplitSettingsCreator({
draft,
alsoShowTargetLabel = 'inbox',
labels = [],
onCreateLabel,
onCreateAiLabel,
onDeleteLabel,
onAlsoShowInImportantChange,
onBack,
onDelete,
onSave,
}: SplitSettingsCreatorProps) {
const [form, setForm] = useState<SplitSettingsDraft>({
name: draft?.name ?? '',
query: draft?.query ?? undefined,
alsoShowInImportant: draft?.alsoShowInImportant ?? false,
hideWhenEmpty: draft?.hideWhenEmpty ?? false,
position: draft?.position ?? 0,
});
// The CRM rule is edited as its two live halves — the AOP scope and the
// per-column config — and only assembled into the stored shape on save.
const [linkedDeals, setLinkedDeals] = useState<LinkedDealsState>(() =>
linkedDealsFromFilter(draft?.conversationFilter),
);
const aopsById = useCedarStore((state) => state.aopsById);
const canSave = useMemo(() => form.name.trim().length > 0, [form.name]);
const hasConversationFilter = linkedDeals.aopIds.length > 0;
/**
* Attaching a CRM rule flips "also show in inbox" on. A filtered inbox's
* query is a bare `label:INBOX` scope anchor, so treating it as a partition
* would carve the whole Inbox out of the default tab. The switch stays
* user-editable — the exclusion builder skips filtered inboxes regardless.
*/
const handleLinkedDealsChange = useCallback(
(next: LinkedDealsState) => {
if (linkedDeals.aopIds.length === 0 && next.aopIds.length > 0) {
setForm((current) => ({ ...current, alsoShowInImportant: true }));
}
setLinkedDeals(next);
},
[linkedDeals.aopIds.length],
);
/**
* Assemble what the server stores: the resolved rule under `filters`, plus
* the raw per-column state under `uiConfig` so reopening this form
* round-trips exactly what the user picked (the resolved rule is lossy —
* relative dates have already been flattened to absolute ISO strings).
*/
const buildConversationFilter = useCallback((): InboxConversationFilter | null => {
if (!hasConversationFilter) return null;
const customFieldDefinitions = mergeCustomFieldDefinitions(
resolveAops(linkedDeals.aopIds, aopsById),
);
const { filters, customFieldFilters } = computeFiltersFromConfig(
linkedDeals.config,
undefined,
customFieldDefinitions,
);
return {
filters: toConversationFilterParams(filters, {
aopIds: linkedDeals.aopIds,
customFieldFilters,
}),
uiConfig: linkedDeals.config,
};
}, [hasConversationFilter, linkedDeals, aopsById]);
const [panel, setPanel] = useState<'none' | 'add' | 'create'>('none');
const [labelSearch, setLabelSearch] = useState('');
const [newLabelName, setNewLabelName] = useState('');
const [newLabelColor, setNewLabelColor] = useState<LabelColor>(LABEL_COLORS[0]);
const [createMode, setCreateMode] = useState<'label' | 'ai'>('label');
const [aiInstructions, setAiInstructions] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const canCreate = !!onCreateLabel || !!onCreateAiLabel;
const filteredLabels = useMemo(() => {
const term = labelSearch.trim().toLowerCase();
if (!term) return labels;
return labels.filter((label) => label.name.toLowerCase().includes(term));
}, [labels, labelSearch]);
const resetPanels = () => {
setPanel('none');
setLabelSearch('');
setNewLabelName('');
setAiInstructions('');
setCreateMode(onCreateLabel ? 'label' : 'ai');
};
const togglePanel = (next: 'add' | 'create') => {
setPanel((current) => (current === next ? 'none' : next));
if (next === 'create') setCreateMode(onCreateLabel ? 'label' : 'ai');
};
const addLabelToken=[redacted] string) => {
setForm((current) => ({ ...current, query: appendLabelToQuery(current.query, labelName) }));
resetPanels();
};
const handleCreateLabel = async () => {
const name = newLabelName.trim();
if (!name || !onCreateLabel || isCreating) return;
setIsCreating(true);
try {
await onCreateLabel({ name, color: newLabelColor });
addLabelToken(name);
} finally {
setIsCreating(false);
}
};
const handleCreateAiLabel = async () => {
const displayName = newLabelName.trim();
const description = aiInstructions.trim();
if (!displayName || !description || !onCreateAiLabel || isCreating) return;
setIsCreating(true);
try {
const { labelName } = await onCreateAiLabel({ displayName, description });
addLabelToken(labelName);
} finally {
setIsCreating(false);
}
};
const handleDeleteLabel = async (label: SplitLabelOption) => {
if (!onDeleteLabel || isDeleting) return;
setIsDeleting(true);
try {
await onDeleteLabel(label);
// Drop any dangling reference to the now-deleted label from the definition.
setForm((current) => ({ ...current, query: removeLabelFromQuery(current.query, label.name) }));
setConfirmDeleteId(null);
} catch {
// The parent surfaces the failure toast; keep the confirm row for retry.
} finally {
setIsDeleting(false);
}
};
return (
// The dialog is sized for the template gallery (6xl); a form is not. Constrain
// to a readable column and centre it, so fields aren't stretched to ~1150px and
// the filter rows below keep their popovers anchored well inside the viewport.
<div className="min-h-0 flex-1 overflow-y-auto py-2 pr-1">
<div className="mx-auto w-full max-w-2xl space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
placeholder="Important"
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Definition</Label>
<Textarea
value={form.query ?? ''}
placeholder={DEFAULT_QUERY}
rows={2}
className="font-mono text-xs"
onChange={(event) => setForm((current) => ({ ...current, query: event.target.value }))}
/>
<p className="text-muted-foreground text-xs">
A Gmail-style search query that decides which threads land in this split — e.g.{' '}
<code className="rounded bg-muted px-1 py-0.5">from:<email></code> or{' '}
<code className="rounded bg-muted px-1 py-0.5">label:Receipts is:unread</code>.
</p>
{hasConversationFilter && (
<p className="text-muted-foreground text-xs">
This inbox also has a Linked deals rule. Keep the definition as{' '}
<code className="rounded bg-muted px-1 py-0.5">{DEFAULT_QUERY}</code> — the CRM rule
below does the narrowing, and the two are AND-ed. Extra Gmail clauses here narrow it
further, they never widen it.
</p>
)}
</div>
<InboxLinkedDealsSection value={linkedDeals} onChange={handleLinkedDealsChange} />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Labels</Label>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => togglePanel('add')}
>
{panel === 'add' ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
Add label
</Button>
{canCreate && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => togglePanel('create')}
>
{panel === 'create' ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<Tag className="h-3.5 w-3.5" />
)}
Create label
</Button>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
Add an existing label, or create a new one — including an AI label with instructions for
the agent to apply. The chosen label is added to the definition above.
</p>
{panel === 'add' && (
<div className="bg-muted/20 space-y-2 rounded-md border p-2">
<Input
autoFocus
value={labelSearch}
onChange={(event) => setLabelSearch(event.target.value)}
placeholder="Search labels…"
className="h-8 text-xs"
/>
<div className="max-h-44 space-y-0.5 overflow-y-auto">
{filteredLabels.map((label) =>
confirmDeleteId === label.id ? (
<div
key=[redacted]
className="flex w-full items-center gap-2 rounded-md bg-destructive/10 px-2 py-1.5 text-xs"
>
<span className="text-destructive truncate">Delete “{label.name}”?</span>
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
disabled={isDeleting}
onClick={() => setConfirmDeleteId(null)}
>
Cancel
</Button>
<Button
type="button"
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
disabled={isDeleting}
onClick={() => handleDeleteLabel(label)}
>
Delete
</Button>
</div>
</div>
) : (
<div
key=[redacted]
className="group hover:bg-muted flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs"
>
<button
type="button"
onClick={() => addLabelToken(label.name)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: label.color?.backgroundColor || '#888' }}
/>
<span className="truncate">{label.name}</span>
</button>
{onDeleteLabel && (
<button
type="button"
aria-label={`Delete ${label.name}`}
onClick={() => setConfirmDeleteId(label.id)}
className="text-muted-foreground hover:text-destructive shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
),
)}
{filteredLabels.length === 0 && (
<p className="text-muted-foreground px-2 py-1.5 text-xs">No matching labels.</p>
)}
</div>
</div>
)}
{panel === 'create' && canCreate && (
<div className="bg-muted/20 space-y-2 rounded-md border p-2">
{onCreateLabel && onCreateAiLabel && (
<div className="bg-muted flex items-center gap-1 rounded-md p-0.5">
<button
type="button"
onClick={() => setCreateMode('label')}
className={cn(
'flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-xs',
createMode === 'label' && 'bg-background shadow-sm',
)}
>
<Tag className="h-3.5 w-3.5" />
Label
</button>
<button
type="button"
onClick={() => setCreateMode('ai')}
className={cn(
'flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-xs',
createMode === 'ai' && 'bg-background shadow-sm',
)}
>
<Sparkles className="h-3.5 w-3.5" />
AI label
</button>
</div>
)}
<Input
autoFocus
value={newLabelName}
onChange={(event) => setNewLabelName(event.target.value)}
placeholder="Label name"
className="h-8 text-xs"
/>
{onCreateLabel && (!onCreateAiLabel || createMode === 'label') ? (
<>
<div className="flex items-center gap-1.5">
{LABEL_COLORS.map((color) => (
<button
key=[redacted]
type="button"
onClick={() => setNewLabelColor(color)}
className="flex h-5 w-5 items-center justify-center rounded-full"
style={{ backgroundColor: color.backgroundColor }}
aria-label={`Color ${color.backgroundColor}`}
>
{newLabelColor.backgroundColor === color.backgroundColor && (
<Check className="h-3 w-3" style={{ color: color.textColor }} />
)}
</button>
))}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-full text-xs"
disabled={isCreating || newLabelName.trim().length === 0}
onClick={handleCreateLabel}
>
<Tag className="h-3.5 w-3.5" />
Create label
</Button>
</>
) : (
<>
<Textarea
value={aiInstructions}
onChange={(event) => setAiInstructions(event.target.value)}
rows={3}
placeholder="Instructions for the agent — what should this label catch? e.g. “Emails about job applications or recruiting outreach.”"
className="text-xs"
/>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-full text-xs"
disabled={
isCreating ||
newLabelName.trim().length === 0 ||
aiInstructions.trim().length === 0
}
onClick={handleCreateAiLabel}
>
<Sparkles className="h-3.5 w-3.5" />
Create AI label
</Button>
</>
)}
</div>
)}
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">Hide when empty</p>
<p className="text-muted-foreground text-xs">Do not show this tab when there are no matching threads.</p>
</div>
<Switch
checked={form.hideWhenEmpty}
onCheckedChange={(value) => setForm((current) => ({ ...current, hideWhenEmpty: value }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">{`Also show in ${alsoShowTargetLabel}`}</p>
<p className="text-muted-foreground text-xs">
Threads in this split will also appear in the default {alsoShowTargetLabel} view.
</p>
</div>
<Switch
checked={form.alsoShowInImportant}
onCheckedChange={(value) => {
setForm((current) => ({ ...current, alsoShowInImportant: value }));
// Fire immediately so the toggle persists without waiting for Save.
onAlsoShowInImportantChange?.(value);
}}
/>
</div>
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={onBack}>
Back
</Button>
<div className="flex items-center gap-2">
{onDelete && (
<Button variant="destructive" size="sm" onClick={onDelete}>
Delete
</Button>
)}
<Button
size="sm"
disabled={!canSave}
onClick={() =>
onSave({
...form,
name: form.name.trim(),
query: form.query?.trim() || DEFAULT_QUERY,
conversationFilter: buildConversationFilter(),
})
}
>
Save split
</Button>
</div>
</div>
</div>
</div>
);
}