Files
copilotkit--copilotkit/examples/canvas/langgraph-python/src/app/page.tsx
T
2026-07-13 12:58:18 +08:00

1841 lines
59 KiB
TypeScript

"use client";
import {
useCoAgent,
useCopilotAction,
useCoAgentStateRender,
useCopilotAdditionalInstructions,
useLangGraphInterrupt,
} from "@copilotkit/react-core";
import {
CopilotKitCSSProperties,
CopilotChat,
CopilotPopup,
} from "@copilotkit/react-ui";
import { useCallback, useEffect, useRef, useState } from "react";
import type React from "react";
import { Button } from "@/components/ui/button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import AppChatHeader, { PopupHeader } from "@/components/canvas/AppChatHeader";
import { X, Check, Loader2 } from "lucide-react";
import CardRenderer from "@/components/canvas/CardRenderer";
import ShikiHighlighter from "react-shiki/web";
import {
motion,
useScroll,
useTransform,
useMotionValueEvent,
} from "motion/react";
import { EmptyState } from "@/components/empty-state";
import { cn } from "@/lib/utils";
import type {
AgentState,
PlanStep,
Item,
ItemData,
ProjectData,
EntityData,
NoteData,
ChartData,
CardType,
} from "@/lib/canvas/types";
import { initialState, isNonEmptyAgentState } from "@/lib/canvas/state";
import {
projectAddField4Item,
projectSetField4ItemText,
projectSetField4ItemDone,
projectRemoveField4Item,
chartAddField1Metric,
chartSetField1Label,
chartSetField1Value,
chartRemoveField1Metric,
} from "@/lib/canvas/updates";
import useMediaQuery from "@/hooks/use-media-query";
import ItemHeader from "@/components/canvas/ItemHeader";
import NewItemMenu from "@/components/canvas/NewItemMenu";
export default function CopilotKitPage() {
const { state, setState } = useCoAgent<AgentState>({
name: "sample_agent",
initialState,
});
// Global cache for the last non-empty agent state
const cachedStateRef = useRef<AgentState>(state ?? initialState);
useEffect(() => {
if (isNonEmptyAgentState(state)) {
cachedStateRef.current = state as AgentState;
}
}, [state]);
// we use viewState to avoid transient flicker; TODO: troubleshoot and remove this workaround
const viewState: AgentState = isNonEmptyAgentState(state)
? (state as AgentState)
: cachedStateRef.current;
const isDesktop = useMediaQuery("(min-width: 768px)");
const [showJsonView, setShowJsonView] = useState<boolean>(false);
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const { scrollY } = useScroll({ container: scrollAreaRef });
const headerScrollThreshold = 64;
const headerOpacity = useTransform(
scrollY,
[0, headerScrollThreshold],
[1, 0],
);
const [headerDisabled, setHeaderDisabled] = useState<boolean>(false);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const descTextareaRef = useRef<HTMLInputElement | null>(null);
const lastCreationRef = useRef<{
type: CardType;
name: string;
id: string;
ts: number;
} | null>(null);
const lastChecklistCreationRef = useRef<
Record<string, { text: string; id: string; ts: number }>
>({});
const lastMetricCreationRef = useRef<
Record<
string,
{ label: string; value: number | ""; id: string; ts: number }
>
>({});
// Strong idempotency during plan execution: allow only one creation per type while plan runs
const createdByTypeRef = useRef<Partial<Record<CardType, string>>>({});
const prevPlanStatusRef = useRef<string | null>(null);
// Reset per-plan idempotency map on plan start/end or when plan definition changes
useEffect(() => {
const status = String(viewState?.planStatus ?? "");
const prevStatus = prevPlanStatusRef.current;
const started = status === "in_progress" && prevStatus !== "in_progress";
const ended =
prevStatus === "in_progress" &&
(status === "completed" || status === "failed" || status === "");
if (started || ended) {
createdByTypeRef.current = {};
}
prevPlanStatusRef.current = status;
}, [viewState?.planStatus]);
useMotionValueEvent(scrollY, "change", (y) => {
const disable = y >= headerScrollThreshold;
setHeaderDisabled(disable);
if (disable) {
titleInputRef.current?.blur();
descTextareaRef.current?.blur();
}
});
useEffect(() => {
console.log("[CoAgent state updated]", state);
}, [state]);
// Reset JSON view when there are no items
useEffect(() => {
const itemsCount = (viewState?.items ?? []).length;
if (itemsCount === 0 && showJsonView) {
setShowJsonView(false);
}
}, [viewState?.items, showJsonView]);
// Use cached viewState to derive plan-related fields
const planStepsMemo = (viewState?.planSteps ??
initialState.planSteps) as PlanStep[];
const planStatusMemo = viewState?.planStatus ?? initialState.planStatus;
const currentStepIndexMemo =
typeof viewState?.currentStepIndex === "number"
? viewState.currentStepIndex
: initialState.currentStepIndex;
// One-time final summary renderer in chat when plan completes or fails
useCoAgentStateRender<AgentState>({
name: "sample_agent",
nodeName: "plan-final-summary",
render: ({ state }) => {
const status = String(state?.planStatus ?? "");
const steps = (state?.planSteps ?? []) as PlanStep[];
if (!Array.isArray(steps) || steps.length === 0) return null;
if (status !== "completed" && status !== "failed") return null;
const count = steps.length;
return (
<div className="my-2 w-full">
<Accordion type="single" collapsible defaultValue="done">
<AccordionItem value="done">
<AccordionTrigger className="text-xs">
<span className="inline-flex items-center gap-2">
<Check
className={cn(
"h-4 w-4",
status === "completed"
? "text-green-600"
: "text-red-600",
)}
/>
<span className="font-medium">
{count} steps {status}
</span>
</span>
</AccordionTrigger>
<AccordionContent>
<div className="rounded-2xl border shadow-sm bg-card p-4">
<div className="mb-2 text-xs font-semibold">
Plan{" "}
<span
className={cn(
"ml-2 rounded-full px-2 py-0.5 text-[10px] font-medium border",
status === "completed"
? "text-green-700 border-green-300 bg-green-50"
: "text-red-700 border-red-300 bg-red-50",
)}
>
{status}
</span>
</div>
<ol className="space-y-1">
{steps.map((s, i) => (
<li
key={`${s.title ?? "step"}-${i}`}
className="flex items-start gap-2"
>
<span className="mt-0.5 inline-flex h-4 w-4 items-center justify-center">
{String(s.status).toLowerCase() === "completed" ? (
<Check className="h-4 w-4 text-green-600" />
) : String(s.status).toLowerCase() === "failed" ? (
<X className="h-4 w-4 text-red-600" />
) : (
<span className="block h-2 w-2 rounded-full bg-gray-300" />
)}
</span>
<div className="flex-1 text-xs">
<div
className={cn(
"leading-5",
String(s.status).toLowerCase() === "completed" &&
"text-green-700",
String(s.status).toLowerCase() === "failed" &&
"text-red-700",
)}
>
{s.title ?? `Step ${i + 1}`}
</div>
</div>
</li>
))}
</ol>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
},
});
const getStatePreviewJSON = (
s: AgentState | undefined,
): Record<string, unknown> => {
const snapshot = (s ?? initialState) as AgentState;
const { globalTitle, globalDescription, items } = snapshot;
return {
globalTitle: globalTitle ?? initialState.globalTitle,
globalDescription: globalDescription ?? initialState.globalDescription,
items: items ?? initialState.items,
};
};
// Strengthen grounding: always prefer shared state over chat history
useCopilotAdditionalInstructions({
instructions: (() => {
const items = viewState.items ?? initialState.items;
const gTitle = viewState.globalTitle ?? "";
const gDesc = viewState.globalDescription ?? "";
const summary = items
.slice(0, 5)
.map((p: Item) => `id=${p.id} • name=${p.name} • type=${p.type}`)
.join("\n");
const fieldSchema = [
"FIELD SCHEMA (authoritative):",
"- project.data:",
" - field1: string (text)",
" - field2: string (select: 'Option A' | 'Option B' | 'Option C'; empty string means unset)",
" - field3: string (date 'YYYY-MM-DD')",
" - field4: ChecklistItem[] where ChecklistItem={id: string, text: string, done: boolean, proposed: boolean}",
"- entity.data:",
" - field1: string",
" - field2: string (select: 'Option A' | 'Option B' | 'Option C'; empty string means unset)",
" - field3: string[] (selected tags; subset of field3_options)",
" - field3_options: string[] (available tags)",
"- note.data:",
" - field1: string (textarea)",
"- chart.data:",
" - field1: Array<{id: string, label: string, value: number | ''}> with value in [0..100] or ''",
].join("\n");
const toolUsageHints = [
"TOOL USAGE HINTS:",
"- Prefer calling specific actions: setProjectField1, setProjectField2, setProjectField3, addProjectChecklistItem, setProjectChecklistItem, removeProjectChecklistItem.",
"- field2 values: 'Option A' | 'Option B' | 'Option C' | '' (empty clears).",
"- field3 accepts natural dates (e.g., 'tomorrow', '2025-01-30'); it will be normalized to YYYY-MM-DD.",
"- Checklist edits accept either the generated id (e.g., '001') or a numeric index (e.g., '1', 1-based).",
"- For charts, values are clamped to [0..100]; use clearChartField1Value to clear an existing metric value.",
"- Card subtitle/description keywords (description, overview, summary, caption, blurb) map to setItemSubtitleOrDescription. Never write these to data.field1 for non-note items.",
"LOOP CONTROL: When asked to 'add a couple' items, add at most 2 and stop. Avoid repeated calls to the same mutating tool in one turn.",
"RANDOMIZATION: If the user specifically asks for random/mock values, you MAY generate and set them right away using the tools (do not block for more details).",
"VERIFICATION: After tools run, re-read the latest state and confirm what actually changed.",
].join("\n");
return [
"ALWAYS ANSWER FROM SHARED STATE (GROUND TRUTH).",
"If a command does not specify which item to change, ask the user to clarify before proceeding.",
`Global Title: ${gTitle || "(none)"}`,
`Global Description: ${gDesc || "(none)"}`,
"Items (sample):",
summary || "(none)",
fieldSchema,
toolUsageHints,
].join("\n");
})(),
});
// HITL: dropdown selector for item choice using LangGraph interrupt
useLangGraphInterrupt({
enabled: ({ eventValue }) => {
try {
return (
typeof eventValue === "object" && eventValue?.type === "choose_item"
);
} catch {
return false;
}
},
render: ({ event, resolve }) => {
const items = viewState.items ?? initialState.items;
if (!items.length) {
return (
<div className="rounded-md border bg-white p-4 text-sm shadow">
<p>No items available.</p>
<button
className="mt-3 rounded border px-3 py-1"
onClick={() => resolve("")}
>
Close
</button>
</div>
);
}
let selectedId = items[0].id;
return (
<div className="rounded-md border bg-white p-4 text-sm shadow">
<p className="mb-2 font-medium">Select an item</p>
<p className="mb-3 text-xs text-gray-600">
{(event?.value as { content?: string })?.content ??
"Which item should I use?"}
</p>
<select
className="w-full rounded border px-2 py-1"
defaultValue={selectedId}
onChange={(e) => {
selectedId = e.target.value;
}}
>
{items.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.id})
</option>
))}
</select>
<div className="mt-3 flex justify-end gap-2">
<button
className="rounded border px-3 py-1"
onClick={() => resolve("")}
>
Cancel
</button>
<button
className="rounded border bg-blue-600 px-3 py-1 text-white"
onClick={() => resolve(selectedId)}
>
Use item
</button>
</div>
</div>
);
},
});
// HITL: choose a card type when not specified
useLangGraphInterrupt({
enabled: ({ eventValue }) => {
try {
return (
typeof eventValue === "object" &&
eventValue?.type === "choose_card_type"
);
} catch {
return false;
}
},
render: ({ event, resolve }) => {
const options: { id: CardType; label: string }[] = [
{ id: "project", label: "Project" },
{ id: "entity", label: "Entity" },
{ id: "note", label: "Note" },
{ id: "chart", label: "Chart" },
];
let selected: CardType | "" = "";
return (
<div className="rounded-md border bg-white p-4 text-sm shadow">
<p className="mb-2 font-medium">Select a card type</p>
<p className="mb-3 text-xs text-gray-600">
{(event?.value as { content?: string })?.content ??
"Which type of card should I create?"}
</p>
<select
className="w-full rounded border px-2 py-1"
defaultValue=""
onChange={(e) => {
selected = e.target.value as CardType;
}}
>
<option value="" disabled>
Select an item type
</option>
{options.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</select>
<div className="mt-3 flex justify-end gap-2">
<button
className="rounded border px-3 py-1"
onClick={() => resolve("")}
>
Cancel
</button>
<button
className="rounded border bg-blue-600 px-3 py-1 text-white"
onClick={() => selected && resolve(selected)}
disabled={!selected}
>
Use type
</button>
</div>
</div>
);
},
});
const updateItem = useCallback(
(itemId: string, updates: Partial<Item>) => {
setState((prev) => {
const base = prev ?? initialState;
const items: Item[] = base.items ?? [];
const nextItems = items.map((p) =>
p.id === itemId ? { ...p, ...updates } : p,
);
return { ...base, items: nextItems } as AgentState;
});
},
[setState],
);
const updateItemData = useCallback(
(itemId: string, updater: (prev: ItemData) => ItemData) => {
setState((prev) => {
const base = prev ?? initialState;
const items: Item[] = base.items ?? [];
const nextItems = items.map((p) =>
p.id === itemId ? { ...p, data: updater(p.data) } : p,
);
return { ...base, items: nextItems } as AgentState;
});
},
[setState],
);
const deleteItem = useCallback(
(itemId: string) => {
setState((prev) => {
const base = prev ?? initialState;
const existed = (base.items ?? []).some((p) => p.id === itemId);
const items: Item[] = (base.items ?? []).filter((p) => p.id !== itemId);
return {
...base,
items,
lastAction: existed ? `deleted:${itemId}` : `not_found:${itemId}`,
} as AgentState;
});
},
[setState],
);
// Checklist item local helper removed; Copilot actions handle checklist CRUD
const toggleTag = useCallback(
(itemId: string, tag: string) => {
updateItemData(itemId, (prev) => {
const anyPrev = prev as { field3?: string[] };
if (Array.isArray(anyPrev.field3)) {
const selected = new Set<string>(anyPrev.field3 ?? []);
if (selected.has(tag)) selected.delete(tag);
else selected.add(tag);
return { ...anyPrev, field3: Array.from(selected) } as ItemData;
}
return prev;
});
},
[updateItemData],
);
// Remove checklist item local helper removed; use Copilot action instead
// Helper to generate default data by type
const defaultDataFor = useCallback((type: CardType): ItemData => {
switch (type) {
case "project":
return {
field1: "",
field2: "",
field3: "",
field4: [],
field4_id: 0,
} as ProjectData;
case "entity":
return {
field1: "",
field2: "",
field3: [],
field3_options: ["Tag 1", "Tag 2", "Tag 3"],
} as EntityData;
case "note":
return { field1: "" } as NoteData;
case "chart":
return { field1: [], field1_id: 0 } as ChartData;
default:
return { content: "" } as NoteData;
}
}, []);
const addItem = useCallback(
(type: CardType, name?: string) => {
const t: CardType = type;
let createdId = "";
setState((prev) => {
const base = prev ?? initialState;
const items: Item[] = base.items ?? [];
// Derive next numeric id robustly from both itemsCreated counter and max existing id
const maxExisting = items.reduce((max, it) => {
const parsed = Number.parseInt(String(it.id ?? "0"), 10);
return Number.isFinite(parsed) ? Math.max(max, parsed) : max;
}, 0);
const priorCount = Number.isFinite(base.itemsCreated)
? (base.itemsCreated as number)
: 0;
const nextNumber = Math.max(priorCount, maxExisting) + 1;
createdId = String(nextNumber).padStart(4, "0");
const item: Item = {
id: createdId,
type: t,
name: name && name.trim() ? name.trim() : "",
subtitle: "",
data: defaultDataFor(t),
};
const nextItems = [...items, item];
// clamp to one per type when plan is active
const planActive = String(base?.planStatus ?? "") === "in_progress";
let deduped = nextItems;
if (planActive) {
const seen = new Set<string>();
deduped = [];
for (const it of nextItems) {
const key = it.type;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(it);
}
}
return {
...base,
items: deduped,
itemsCreated: nextNumber,
lastAction: `created:${createdId}`,
} as AgentState;
});
return createdId;
},
[defaultDataFor, setState],
);
// Frontend Actions (exposed as tools to the agent via CopilotKit)
useCopilotAction({
name: "setGlobalTitle",
description: "Set the global title/name (outside of items).",
available: "remote",
parameters: [
{
name: "title",
type: "string",
required: true,
description: "The new global title/name.",
},
],
handler: ({ title }: { title: string }) => {
setState((prev) => ({ ...(prev ?? initialState), globalTitle: title }));
},
});
useCopilotAction({
name: "setGlobalDescription",
description: "Set the global description/subtitle (outside of items).",
available: "remote",
parameters: [
{
name: "description",
type: "string",
required: true,
description: "The new global description/subtitle.",
},
],
handler: ({ description }: { description: string }) => {
setState((prev) => ({
...(prev ?? initialState),
globalDescription: description,
}));
},
});
// Frontend Actions (item-scoped)
useCopilotAction({
name: "setItemName",
description: "Set an item's name/title.",
available: "remote",
parameters: [
{
name: "name",
type: "string",
required: true,
description: "The new item name/title.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id.",
},
],
handler: ({ name, itemId }: { name: string; itemId: string }) => {
updateItem(itemId, { name });
},
});
// Set item subtitle
useCopilotAction({
name: "setItemSubtitleOrDescription",
description:
"Set an item's description/subtitle (short description or subtitle).",
available: "remote",
parameters: [
{
name: "subtitle",
type: "string",
required: true,
description: "The new item description/subtitle.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id.",
},
],
handler: ({ subtitle, itemId }: { subtitle: string; itemId: string }) => {
updateItem(itemId, { subtitle });
},
});
// Note-specific field updates (field numbering)
useCopilotAction({
name: "setNoteField1",
description: "Update note content (note.data.field1).",
available: "remote",
parameters: [
{
name: "value",
type: "string",
required: true,
description: "New content for note.data.field1.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (note).",
},
],
handler: ({ value, itemId }: { value: string; itemId: string }) => {
updateItemData(itemId, (prev) => {
const nd = prev as NoteData;
if (Object.prototype.hasOwnProperty.call(nd, "field1")) {
return { ...(nd as NoteData), field1: value } as NoteData;
}
return prev;
});
},
});
useCopilotAction({
name: "appendNoteField1",
description: "Append text to note content (note.data.field1).",
available: "remote",
parameters: [
{
name: "value",
type: "string",
required: true,
description: "Text to append to note.data.field1.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (note).",
},
{
name: "withNewline",
type: "boolean",
required: false,
description: "If true, prefix with a newline.",
},
],
handler: ({
value,
itemId,
withNewline,
}: {
value: string;
itemId: string;
withNewline?: boolean;
}) => {
updateItemData(itemId, (prev) => {
const nd = prev as NoteData;
if (Object.prototype.hasOwnProperty.call(nd, "field1")) {
const existing = nd.field1 ?? "";
const next = existing + (withNewline ? "\n" : "") + value;
return { ...(nd as NoteData), field1: next } as NoteData;
}
return prev;
});
},
});
useCopilotAction({
name: "clearNoteField1",
description: "Clear note content (note.data.field1).",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (note).",
},
],
handler: ({ itemId }: { itemId: string }) => {
updateItemData(itemId, (prev) => {
const nd = prev as NoteData;
if (Object.prototype.hasOwnProperty.call(nd, "field1")) {
return { ...(nd as NoteData), field1: "" } as NoteData;
}
return prev;
});
},
});
useCopilotAction({
name: "setProjectField1",
description: "Update project field1 (text).",
available: "remote",
parameters: [
{
name: "value",
type: "string",
required: true,
description: "New value for field1.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id.",
},
],
handler: ({ value, itemId }: { value: string; itemId: string }) => {
const safeValue = String((value as unknown as string) ?? "");
updateItemData(itemId, (prev) => {
const anyPrev = prev as { field1?: string };
if (typeof anyPrev.field1 === "string") {
return { ...anyPrev, field1: safeValue } as ItemData;
}
return prev;
});
},
});
// Project-specific field updates
useCopilotAction({
name: "setProjectField2",
description: "Update project field2 (select).",
available: "remote",
parameters: [
{
name: "value",
type: "string",
required: true,
description: "New value for field2.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id.",
},
],
handler: ({ value, itemId }: { value: string; itemId: string }) => {
const safeValue = String((value as unknown as string) ?? "");
updateItemData(itemId, (prev) => {
const anyPrev = prev as { field2?: string };
if (typeof anyPrev.field2 === "string") {
return { ...anyPrev, field2: safeValue } as ItemData;
}
return prev;
});
},
});
useCopilotAction({
name: "setProjectField3",
description: "Update project field3 (date, YYYY-MM-DD).",
available: "remote",
parameters: [
{
name: "date",
type: "string",
required: true,
description: "Date in YYYY-MM-DD format.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id.",
},
],
handler: (
args: { date?: string; itemId: string } & Record<string, unknown>,
) => {
const itemId = String(args.itemId);
const dictArgs = args as Record<string, unknown>;
const rawInput =
dictArgs["date"] ??
dictArgs["value"] ??
dictArgs["val"] ??
dictArgs["text"];
const normalizeDate = (input: unknown): string | null => {
if (input == null) return null;
if (input instanceof Date && !isNaN(input.getTime())) {
const yyyy = input.getUTCFullYear();
const mm = String(input.getUTCMonth() + 1).padStart(2, "0");
const dd = String(input.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
const asString = String(input);
// Already in YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(asString)) return asString;
const parsed = new Date(asString);
if (!isNaN(parsed.getTime())) {
const yyyy = parsed.getUTCFullYear();
const mm = String(parsed.getUTCMonth() + 1).padStart(2, "0");
const dd = String(parsed.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
return null;
};
const normalized = normalizeDate(rawInput);
if (!normalized) return;
updateItemData(itemId, (prev) => {
const anyPrev = prev as { field3?: string };
if (typeof anyPrev.field3 === "string") {
return { ...anyPrev, field3: normalized } as ItemData;
}
return prev;
});
},
});
// Clear project field3 (date)
useCopilotAction({
name: "clearProjectField3",
description: "Clear project field3 (date).",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id.",
},
],
handler: ({ itemId }: { itemId: string }) => {
updateItemData(itemId, (prev) => {
const anyPrev = prev as { field3?: string };
if (typeof anyPrev.field3 === "string") {
return { ...anyPrev, field3: "" } as ItemData;
}
return prev;
});
},
});
// Project field4 (checklist) CRUD
useCopilotAction({
name: "addProjectChecklistItem",
description: "Add a new checklist item to a project.",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (project).",
},
{
name: "text",
type: "string",
required: false,
description: "Initial checklist text (optional).",
},
],
handler: ({ itemId, text }: { itemId: string; text?: string }) => {
const norm = (text ?? "").trim();
// 1) If a checklist item with same text exists, return its id
const project = (viewState.items ?? initialState.items).find(
(it) => it.id === itemId,
);
if (project && project.type === "project") {
const list = (project.data as ProjectData).field4 ?? [];
const dup = norm
? list.find((c) => (c.text ?? "").trim() === norm)
: undefined;
if (dup) return dup.id;
}
// 2) Per-project throttle to avoid rapid duplicates
const now = Date.now();
const key = `${itemId}`;
const recent = lastChecklistCreationRef.current[key];
if (recent && recent.text === norm && now - recent.ts < 800) {
return recent.id;
}
let createdId = "";
updateItemData(itemId, (prev) => {
const { next, createdId: id } = projectAddField4Item(
prev as ProjectData,
text,
);
createdId = id;
return next;
});
lastChecklistCreationRef.current[key] = {
text: norm,
id: createdId,
ts: now,
};
return createdId;
},
});
useCopilotAction({
name: "setProjectChecklistItem",
description: "Update a project's checklist item text and/or done state.",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (project).",
},
{
name: "checklistItemId",
type: "string",
required: true,
description: "Checklist item id.",
},
{
name: "text",
type: "string",
required: false,
description: "New text (optional).",
},
{
name: "done",
type: "boolean",
required: false,
description: "Done status (optional).",
},
],
handler: (args) => {
const itemId = String(args.itemId ?? "");
const target = args.checklistItemId ?? args.itemId;
let targetId = target != null ? String(target) : "";
const maybeDone = args.done;
const text: string | undefined =
args.text != null ? String(args.text) : undefined;
const toBool = (v: unknown): boolean | undefined => {
if (typeof v === "boolean") return v;
if (typeof v === "string") {
const s = v.trim().toLowerCase();
if (s === "true") return true;
if (s === "false") return false;
}
return undefined;
};
const done = toBool(maybeDone);
updateItemData(itemId, (prev) => {
let next = prev as ProjectData;
const list = next.field4 ?? [];
// If a plain numeric was provided, allow using it as index (0- or 1-based)
if (!list.some((c) => c.id === targetId) && /^\d+$/.test(targetId)) {
const n = parseInt(targetId, 10);
let idx = -1;
if (n >= 0 && n < list.length)
idx = n; // 0-based
else if (n > 0 && n - 1 < list.length) idx = n - 1; // 1-based
if (idx >= 0) targetId = list[idx].id;
}
if (typeof text === "string")
next = projectSetField4ItemText(next, targetId, text);
if (typeof done === "boolean")
next = projectSetField4ItemDone(next, targetId, done);
return next;
});
},
});
useCopilotAction({
name: "removeProjectChecklistItem",
description: "Remove a checklist item from a project by id.",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (project).",
},
{
name: "checklistItemId",
type: "string",
required: true,
description: "Checklist item id to remove.",
},
],
handler: ({
itemId,
checklistItemId,
}: {
itemId: string;
checklistItemId: string;
}) => {
updateItemData(itemId, (prev) =>
projectRemoveField4Item(prev as ProjectData, checklistItemId),
);
},
});
// Entity field updates and field3 (tags)
useCopilotAction({
name: "setEntityField1",
description: "Update entity field1 (text).",
available: "remote",
parameters: [
{
name: "value",
type: "string",
required: true,
description: "New value for field1.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (entity).",
},
],
handler: ({ value, itemId }: { value: string; itemId: string }) => {
updateItemData(itemId, (prev) => {
const anyPrev = prev;
if (typeof anyPrev.field1 === "string") {
return { ...anyPrev, field1: value } as ItemData;
}
return prev;
});
},
});
useCopilotAction({
name: "setEntityField2",
description: "Update entity field2 (select).",
available: "remote",
parameters: [
{
name: "value",
type: "string",
required: true,
description: "New value for field2.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (entity).",
},
],
handler: ({ value, itemId }: { value: string; itemId: string }) => {
updateItemData(itemId, (prev) => {
const anyPrev = prev as { field2?: string };
if (typeof anyPrev.field2 === "string") {
return { ...anyPrev, field2: value } as ItemData;
}
return prev;
});
},
});
useCopilotAction({
name: "addEntityField3",
description: "Add a tag to entity field3 (tags) if not present.",
available: "remote",
parameters: [
{
name: "tag",
type: "string",
required: true,
description: "Tag to add.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (entity).",
},
],
handler: ({ tag, itemId }: { tag: string; itemId: string }) => {
updateItemData(itemId, (prev) => {
const e = prev as EntityData;
const current = new Set<string>((e.field3 ?? []) as string[]);
current.add(tag);
return { ...e, field3: Array.from(current) } as EntityData;
});
},
});
useCopilotAction({
name: "removeEntityField3",
description: "Remove a tag from entity field3 (tags) if present.",
available: "remote",
parameters: [
{
name: "tag",
type: "string",
required: true,
description: "Tag to remove.",
},
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (entity).",
},
],
handler: ({ tag, itemId }: { tag: string; itemId: string }) => {
updateItemData(itemId, (prev) => {
const e = prev as EntityData;
return {
...e,
field3: ((e.field3 ?? []) as string[]).filter((t) => t !== tag),
} as EntityData;
});
},
});
// Chart field1 (metrics) CRUD
useCopilotAction({
name: "addChartField1",
description: "Add a new metric (field1 entries).",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (chart).",
},
{
name: "label",
type: "string",
required: false,
description: "Metric label (optional).",
},
{
name: "value",
type: "number",
required: false,
description: "Metric value 0..100 (optional).",
},
],
handler: ({
itemId,
label,
value,
}: {
itemId: string;
label?: string;
value?: number;
}) => {
const normLabel = (label ?? "").trim();
// 1) If a metric with same label exists, return its id
const item = (viewState.items ?? initialState.items).find(
(it) => it.id === itemId,
);
if (item && item.type === "chart") {
const list = (item.data as ChartData).field1 ?? [];
const dup = normLabel
? list.find((m) => (m.label ?? "").trim() === normLabel)
: undefined;
if (dup) return dup.id;
}
// 2) Per-chart throttle to avoid rapid duplicates
const now = Date.now();
const key = `${itemId}`;
const recent = lastMetricCreationRef.current[key];
const valKey: number | "" =
typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.min(100, value))
: "";
if (
recent &&
recent.label === normLabel &&
recent.value === valKey &&
now - recent.ts < 800
) {
return recent.id;
}
let createdId = "";
updateItemData(itemId, (prev) => {
const { next, createdId: id } = chartAddField1Metric(
prev as ChartData,
label,
value,
);
createdId = id;
return next;
});
lastMetricCreationRef.current[key] = {
label: normLabel,
value: valKey,
id: createdId,
ts: now,
};
return createdId;
},
});
useCopilotAction({
name: "setChartField1Label",
description: "Update chart field1 entry label by index.",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (chart).",
},
{
name: "index",
type: "number",
required: true,
description: "Metric index (0-based).",
},
{
name: "label",
type: "string",
required: true,
description: "New metric label.",
},
],
handler: ({
itemId,
index,
label,
}: {
itemId: string;
index: number;
label: string;
}) => {
updateItemData(itemId, (prev) =>
chartSetField1Label(prev as ChartData, index, label),
);
},
});
useCopilotAction({
name: "setChartField1Value",
description: "Update chart field1 entry value by index (0..100).",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (chart).",
},
{
name: "index",
type: "number",
required: true,
description: "Metric index (0-based).",
},
{
name: "value",
type: "number",
required: true,
description: "Metric value 0..100.",
},
],
handler: ({
itemId,
index,
value,
}: {
itemId: string;
index: number;
value: number;
}) => {
updateItemData(itemId, (prev) =>
chartSetField1Value(prev as ChartData, index, value),
);
},
});
// Clear chart metric value by index
useCopilotAction({
name: "clearChartField1Value",
description: "Clear chart field1 entry value by index (sets to empty).",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (chart).",
},
{
name: "index",
type: "number",
required: true,
description: "Metric index (0-based).",
},
],
handler: ({ itemId, index }: { itemId: string; index: number }) => {
updateItemData(itemId, (prev) =>
chartSetField1Value(prev as ChartData, index, ""),
);
},
});
useCopilotAction({
name: "removeChartField1",
description: "Remove a chart field1 entry by index.",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id (chart).",
},
{
name: "index",
type: "number",
required: true,
description: "Metric index (0-based).",
},
],
handler: ({ itemId, index }: { itemId: string; index: number }) => {
updateItemData(itemId, (prev) =>
chartRemoveField1Metric(prev as ChartData, index),
);
},
});
useCopilotAction({
name: "createItem",
description: "Create a new item.",
available: "remote",
parameters: [
{
name: "type",
type: "string",
required: true,
description: "One of: project, entity, note, chart.",
},
{
name: "name",
type: "string",
required: false,
description: "Optional item name.",
},
],
handler: ({ type, name }: { type: string; name?: string }) => {
const t = type as CardType;
const normalized = (name ?? "").trim();
const planStatus = String(viewState?.planStatus ?? "");
// Per-plan strict idempotency: during an active plan, only one creation per type
if (planStatus === "in_progress") {
// If any item of this type already exists, return its id instead of creating another
const existingOfType = (viewState.items ?? initialState.items).find(
(it) => it.type === t,
);
if (existingOfType) {
createdByTypeRef.current[t] = existingOfType.id;
return existingOfType.id;
}
const existingCreatedId = createdByTypeRef.current[t];
if (existingCreatedId) {
return existingCreatedId;
}
}
// 1) Name-based idempotency: if an item with same type+name exists, return it
if (normalized) {
const existing = (viewState.items ?? initialState.items).find(
(it) => it.type === t && (it.name ?? "").trim() === normalized,
);
if (existing) {
return existing.id;
}
}
// 2) Per-run throttle: avoid duplicate creations within a short window for identical type+name
const now = Date.now();
const recent = lastCreationRef.current;
if (
recent &&
recent.type === t &&
(recent.name ?? "") === normalized &&
now - recent.ts < 5000
) {
return recent.id;
}
const id = addItem(t, name);
lastCreationRef.current = { type: t, name: normalized, id, ts: now };
if (planStatus === "in_progress") {
createdByTypeRef.current[t] = id;
}
return id;
},
});
// Frontend action: delete an item by id
useCopilotAction({
name: "deleteItem",
description: "Delete an item by id.",
available: "remote",
parameters: [
{
name: "itemId",
type: "string",
required: true,
description: "Target item id.",
},
],
handler: ({ itemId }: { itemId: string }) => {
const existed = (viewState.items ?? initialState.items).some(
(p) => p.id === itemId,
);
deleteItem(itemId);
return existed ? `deleted:${itemId}` : `not_found:${itemId}`;
},
});
const titleClasses = cn(
/* base styles */
"w-full outline-none rounded-md px-2 py-1",
"bg-transparent placeholder:text-gray-400",
"ring-1 ring-transparent transition-all ease-out",
/* hover styles */
"hover:ring-border",
/* focus styles */
"focus:ring-2 focus:ring-accent/50 focus:shadow-sm focus:bg-accent/10",
"focus:shadow-accent focus:placeholder:text-accent/65 focus:text-accent",
);
return (
<div
style={
{ "--copilot-kit-primary-color": "#2563eb" } as CopilotKitCSSProperties
}
className="h-screen flex flex-col"
>
{/* Main Layout */}
<div className="flex flex-1 overflow-hidden">
{/* Chat Sidebar */}
<aside className="-order-1 max-md:hidden flex flex-col min-w-80 w-[30vw] max-w-120 p-4 pr-0">
<div className="h-full flex flex-col align-start w-full shadow-lg rounded-2xl border border-sidebar-border overflow-hidden">
{/* Chat Header */}
<AppChatHeader />
{/* Sidebar Plan Tracker or Completed Summary */}
{(() => {
const steps = planStepsMemo;
const count = steps.length;
const status = String(planStatusMemo ?? "");
if (
!Array.isArray(steps) ||
count === 0 ||
status === "completed" ||
status === "failed" ||
status === ""
)
return null;
if (status === "completed") {
return (
<div className="px-4 pt-3 border-b">
<Accordion type="single" collapsible>
<AccordionItem value="done">
<AccordionTrigger className="text-xs pt-0 pb-3">
<span className="inline-flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span className="font-medium">
{count} steps completed
</span>
</span>
</AccordionTrigger>
<AccordionContent>
<div className="rounded-xl border bg-card p-3">
<div className="mb-1 text-xs font-semibold">
Plan{" "}
<span className="ml-2 rounded-full px-2 py-0.5 text-[10px] font-medium border text-green-700 border-green-300 bg-green-50">
completed
</span>
</div>
<ol className="space-y-1">
{steps.map((s, i) => (
<li
key={`${s.title ?? "step"}-${i}`}
className="flex items-start gap-2"
>
<span className="mt-0.5 inline-flex h-4 w-4 items-center justify-center">
<Check className="h-4 w-4 text-green-600" />
</span>
<div className="flex-1 text-xs">
<div className="leading-5 text-green-700">
{s.title ?? `Step ${i + 1}`}
</div>
</div>
</li>
))}
</ol>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
return (
<div className="p-4 py-3 border-b">
<div className="rounded-xl border bg-card p-3">
<div className="mb-1 text-xs font-semibold">
Plan{" "}
<span className="ml-2 rounded-full px-2 py-0.5 text-[10px] font-medium border text-blue-700 border-blue-300 bg-blue-50">
in_progress
</span>
</div>
<ol className="space-y-1">
{steps.map((s, i) => {
const st = String(s?.status ?? "pending").toLowerCase();
const isActive =
typeof currentStepIndexMemo === "number" &&
currentStepIndexMemo === i &&
st === "in_progress";
const isDone = st === "completed";
const isFailed = st === "failed";
return (
<li
key={`${s.title ?? "step"}-${i}`}
className="flex items-start gap-2"
>
<span className="mt-0.5 inline-flex h-4 w-4 items-center justify-center">
{isDone ? (
<Check className="h-4 w-4 text-green-600" />
) : isActive ? (
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
) : isFailed ? (
<X className="h-4 w-4 text-red-600" />
) : (
<span className="block h-2 w-2 rounded-full bg-gray-300" />
)}
</span>
<div className="flex-1 text-xs">
<div
className={cn(
"leading-5",
isDone && "text-green-700",
isActive && "text-blue-700",
isFailed && "text-red-700",
)}
>
{s.title ?? `Step ${i + 1}`}
</div>
</div>
</li>
);
})}
</ol>
</div>
</div>
);
})()}
{/* Chat Content - conditionally rendered to avoid duplicate rendering */}
{isDesktop && (
<CopilotChat
className="flex-1 overflow-auto w-full"
labels={{
title: "Agent",
initial:
"👋 Share a brief or ask to extract fields. Changes will sync with the canvas in real time.",
}}
suggestions={[
{
title: "Add a Project",
message: "Create a new project.",
},
{
title: "Add an Entity",
message: "Create a new entity.",
},
{
title: "Add a Note",
message: "Create a new note.",
},
{
title: "Add a Chart",
message: "Create a new chart.",
},
]}
/>
)}
</div>
</aside>
{/* Main Content */}
<main className="relative flex flex-1 h-full">
<div
ref={scrollAreaRef}
className="relative overflow-auto size-full px-4 sm:px-8 md:px-10 py-4"
>
<div
className={cn(
"relative mx-auto max-w-7xl h-full min-h-8",
(showJsonView || (viewState.items ?? []).length === 0) &&
"flex flex-col",
)}
>
{/* Global Title & Description (hidden in JSON view) */}
{!showJsonView && (
<motion.div
style={{ opacity: headerOpacity }}
className="sticky top-0 mb-6"
>
<input
ref={titleInputRef}
disabled={headerDisabled}
value={viewState?.globalTitle ?? initialState.globalTitle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setState((prev) => ({
...(prev ?? initialState),
globalTitle: e.target.value,
}))
}
placeholder="Canvas title..."
className={cn(titleClasses, "text-2xl font-semibold")}
/>
<input
ref={descTextareaRef}
disabled={headerDisabled}
value={
viewState?.globalDescription ??
initialState.globalDescription
}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setState((prev) => ({
...(prev ?? initialState),
globalDescription: e.target.value,
}))
}
placeholder="Canvas description..."
className={cn(
titleClasses,
"mt-2 text-sm leading-6 resize-none overflow-hidden",
)}
/>
</motion.div>
)}
{(viewState.items ?? []).length === 0 ? (
<EmptyState className="flex-1">
<div className="mx-auto max-w-lg text-center">
<h2 className="text-lg font-semibold text-foreground">
Nothing here yet
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Create your first item to get started.
</p>
<div className="mt-6 flex justify-center">
<NewItemMenu
onSelect={(t: CardType) => addItem(t)}
align="center"
className="md:h-10"
/>
</div>
</div>
</EmptyState>
) : (
<div className="flex-1 py-0 overflow-hidden">
{showJsonView ? (
<div className="pb-16 size-full">
<div className="rounded-2xl border shadow-sm bg-card size-full overflow-auto max-md:text-sm">
<ShikiHighlighter language="json" theme="github-light">
{JSON.stringify(
getStatePreviewJSON(viewState),
null,
2,
)}
</ShikiHighlighter>
</div>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-2 pb-20">
{(viewState.items ?? []).map((item) => (
<article
key={item.id}
className="relative rounded-2xl border p-5 shadow-sm transition-colors ease-out bg-card hover:border-accent/40 focus-within:border-accent/60"
>
<button
type="button"
aria-label="Delete card"
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-card text-gray-400 hover:bg-accent/10 hover:text-accent transition-colors"
onClick={() => deleteItem(item.id)}
>
<X className="h-4 w-4" />
</button>
<ItemHeader
id={item.id}
name={item.name}
subtitle={item.subtitle}
description={""}
onNameChange={(v) =>
updateItem(item.id, { name: v })
}
onSubtitleChange={(v) =>
updateItem(item.id, { subtitle: v })
}
/>
<div className="mt-6">
<CardRenderer
item={item}
onUpdateData={(updater) =>
updateItemData(item.id, updater)
}
onToggleTag={(tag) => toggleTag(item.id, tag)}
/>
</div>
</article>
))}
</div>
)}
</div>
)}
</div>
</div>
{(viewState.items ?? []).length > 0 ? (
<div
className={cn(
"absolute left-1/2 -translate-x-1/2 bottom-4",
"inline-flex rounded-lg shadow-lg bg-card",
"[&_button]:bg-card [&_button]:w-22 md:[&_button]:h-10",
"[&_button]:shadow-none! [&_button]:hover:bg-accent",
"[&_button]:hover:border-accent [&_button]:hover:text-accent",
"[&_button]:hover:bg-accent/10!",
)}
>
<NewItemMenu
onSelect={(t: CardType) => addItem(t)}
align="center"
className="rounded-r-none border-r-0 peer"
/>
<Button
type="button"
variant="outline"
className={cn(
"gap-1.25 text-base font-semibold rounded-l-none",
"peer-hover:border-l-accent!",
)}
onClick={() => setShowJsonView((v) => !v)}
>
{showJsonView ? "Canvas" : <>JSON</>}
</Button>
</div>
) : null}
</main>
</div>
<div className="md:hidden">
{/* Mobile Chat Popup - conditionally rendered to avoid duplicate rendering */}
{!isDesktop && (
<CopilotPopup
Header={PopupHeader}
labels={{
title: "Agent",
initial:
"👋 Share a brief or ask to extract fields. Changes will sync with the canvas in real time.",
}}
suggestions={[
{
title: "Add a Project",
message: "Create a new project.",
},
{
title: "Add an Entity",
message: "Create a new entity.",
},
{
title: "Add a Note",
message: "Create a new note.",
},
{
title: "Add a Chart",
message: "Create a new chart.",
},
]}
/>
)}
</div>
</div>
);
}