/// import { computed, Default, equals, handler, ifElse, NAME, OpaqueRef, pattern, Stream, UI, type VNode, Writable, } from "commontools"; import Suggestion from "../system/suggestion.tsx"; // ===== Types ===== /** A #do item — a task that may do itself */ export interface DoItem { title: string; done: Default; indent: Default; // 0 = root, 1 = child, 2 = grandchild... aiEnabled: Default; // future: flag for AI auto-completion attachments: Default[], []>; } interface DoListInput { items?: Writable>; } interface DoListOutput { [NAME]: string; [UI]: VNode; compactUI: VNode; isHidden: true; items: DoItem[]; itemCount: number; summary: string; mentionable: { [NAME]: string; summary: string; [UI]: VNode }[]; // UI handlers (use cell references via equals()) addItem: OpaqueRef< Stream<{ title: string; indent?: number; attachments?: Writable[] }> >; removeItem: OpaqueRef>; updateItem: OpaqueRef< Stream<{ item: DoItem; title?: string; done?: boolean }> >; addItems: OpaqueRef< Stream<{ items: Array<{ title: string; indent?: number; attachments?: Writable[]; }>; }> >; // LLM-friendly handlers (use title matching) /** Remove a task and its subtasks by title */ removeItemByTitle: OpaqueRef>; /** Update a task by title. Set done to mark complete, newTitle to rename, attachments to add references. */ updateItemByTitle: OpaqueRef< Stream<{ title: string; newTitle?: string; done?: boolean; attachments?: Writable[]; }> >; } // ===== Module-scope Handlers ===== const addItemHandler = handler< { title: string; indent?: number; attachments?: Writable[] }, { items: Writable } >(({ title, indent, attachments }, { items }) => { const trimmed = title.trim(); if (!trimmed) return; items.push({ title: trimmed, done: false, indent: indent ?? 0, aiEnabled: false, attachments: attachments ?? [], }); }); const removeItemHandler = handler< { item: DoItem }, { items: Writable } >(({ item }, { items }) => { const currentItems = items.get(); const index = currentItems.findIndex((i) => equals(i, item)); if (index === -1) return; // Count consecutive children (items with higher indent) const itemIndent = item.indent ?? 0; let childCount = 0; for (let i = index + 1; i < currentItems.length; i++) { const nextIndent = currentItems[i].indent ?? 0; if (nextIndent > itemIndent) { childCount++; } else { break; } } // Remove item and all its children const newItems = [ ...currentItems.slice(0, index), ...currentItems.slice(index + 1 + childCount), ]; items.set(newItems); }); const updateItemHandler = handler< { item: DoItem; title?: string; done?: boolean }, { items: Writable } >(({ item, title, done }, { items }) => { const currentItems = items.get(); const newItems = currentItems.map((i) => { if (!equals(i, item)) return i; return { ...i, ...(title !== undefined ? { title } : {}), ...(done !== undefined ? { done } : {}), }; }); items.set(newItems); }); const addItemsHandler = handler< { items: Array<{ title: string; indent?: number; attachments?: Writable[]; }>; }, { items: Writable } >(({ items: newItems }, { items }) => { newItems.forEach(({ title, indent, attachments }) => { const trimmed = title.trim(); if (trimmed) { items.push({ title: trimmed, done: false, indent: indent ?? 0, aiEnabled: false, attachments: attachments ?? [], }); } }); }); // ===== LLM-friendly Handlers (title-based matching) ===== /** Remove a task and its subtasks by title */ const removeItemByTitleHandler = handler< { title: string }, { items: Writable } >(({ title }, { items }) => { const currentItems = items.get(); const index = currentItems.findIndex( (i) => i.title?.toLowerCase() === title.toLowerCase(), ); if (index === -1) return; const itemIndent = currentItems[index].indent ?? 0; let childCount = 0; for (let i = index + 1; i < currentItems.length; i++) { const nextIndent = currentItems[i].indent ?? 0; if (nextIndent > itemIndent) { childCount++; } else { break; } } const newItems = [ ...currentItems.slice(0, index), ...currentItems.slice(index + 1 + childCount), ]; items.set(newItems); }); /** Update a task by title. Set done to mark complete, newTitle to rename, attachments to add references. */ const updateItemByTitleHandler = handler< { title: string; newTitle?: string; done?: boolean; attachments?: Writable[]; }, { items: Writable } >(({ title, newTitle, done, attachments }, { items }) => { const currentItems = items.get(); const newItems = currentItems.map((i) => { if (i.title?.toLowerCase() !== title.toLowerCase()) return i; return { ...i, ...(newTitle !== undefined ? { title: newTitle } : {}), ...(done !== undefined ? { done } : {}), ...(attachments !== undefined ? { attachments: [...(i.attachments ?? []), ...attachments] } : {}), }; }); items.set(newItems); }); const addAttachment = handler< { detail: { sourceCell: Writable } }, { item: DoItem; items: Writable } >((e, { item, items }) => { const cell = e.detail?.sourceCell; if (!cell) return; const currentItems = items.get(); const idx = currentItems.findIndex((i) => equals(i, item)); if (idx >= 0) { items.key(idx).key("attachments").push(cell); } }); const removeAttachment = handler< unknown, { item: DoItem; attachment: Writable; items: Writable } >((_, { item, attachment, items }) => { const currentItems = items.get(); const idx = currentItems.findIndex((i) => equals(i, item)); if (idx >= 0) { const attachments = items.key(idx).key("attachments"); const currentAttachments = attachments.get(); const attIdx = currentAttachments.findIndex((a: any) => equals(a, attachment) ); if (attIdx >= 0) { attachments.set(currentAttachments.toSpliced(attIdx, 1)); } } }); // ===== Sub-pattern for item rendering ===== const DoItemCard = pattern< { item: DoItem; removeItem: Stream<{ item: DoItem }>; items: Writable; }, { [UI]: VNode; [NAME]: string; summary: string } >(({ item, removeItem, items }) => { const attachments = computed(() => item.attachments ?? []); const hasAttachments = computed(() => attachments.length > 0); return { [NAME]: computed(() => item.title), summary: computed(() => item.title), [UI]: ( removeItem.send({ item })} > x {ifElse( hasAttachments, {attachments.map((att: any) => ( × ))} , null, )}
AI Suggestions `Help the user complete this task: "${item.title}"`, )} context={{ title: item.title, attachments: attachments }} initialResults={[]} />
), }; }); // ===== Pattern ===== export default pattern(({ items }) => { // Computed values const itemCount = computed(() => items.get().length); const hasNoItems = computed(() => items.get().length === 0); const summary = computed(() => { return items.get() .map((item) => `${item.done ? "✓" : "○"} ${item.title}`) .join(", "); }); // Bind handlers const addItem = addItemHandler({ items }); const removeItem = removeItemHandler({ items }); const updateItem = updateItemHandler({ items }); const addItems = addItemsHandler({ items }); const removeItemByTitle = removeItemByTitleHandler({ items }); const updateItemByTitle = updateItemByTitleHandler({ items }); // Map items to sub-pattern instances once — reused for UI and mentionable const itemCards = items.map((item) => ( )); // Compact UI - embeddable widget without ct-screen wrapper const compactUI = ( {itemCards} {hasNoItems ? (
No items yet. Add one below!
) : null}
{ const title = e.detail?.message?.trim(); if (title) { addItem.send({ title }); } }} />
); return { [NAME]: computed(() => `Do List (${items.get().length})`), [UI]: ( Do List {itemCount} items {itemCards} {hasNoItems ? (
No items yet. Add one below!
) : null}
{ const title = e.detail?.message?.trim(); if (title) { addItem.send({ title }); } }} />
), compactUI, isHidden: true, items, itemCount, summary, mentionable: itemCards, addItem, removeItem, updateItem, addItems, removeItemByTitle, updateItemByTitle, }; });