From a42a0edea1b629d164d20920f0464be3090baca9 Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Wed, 12 Nov 2025 13:48:46 -0800 Subject: [PATCH 1/4] feat(api): update core patterns + specs for recent API changes (#2062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: update API to use computed(), Cell.of(), and inline handlers Updated all documentation in docs/common/ to reflect recent API changes: API Changes: - Replace cell() with Cell.of() for creating reactive state - Replace derive() with computed() for reactive transformations - Add Cell.equals(a, b) for convenient cell comparison - Add Cell.for(cause) for explicit cell creation in lift contexts - Support inline handlers without handler() wrapper Key Updates: - Inline handlers preferred for simple cases: onClick={() => count.set(count.get() + 1)} - handler() now optional, only for complex/reusable logic - Emphasized: declare cells as Cell in recipe inputs for inline handlers - computed() closes over variables, not needed in JSX (automatic reactivity) - Updated all code examples to use new API Decision Hierarchy: 1. Bidirectional binding ($checked, $value) for simple UI ↔ data sync 2. Inline handlers for simple operations with custom logic 3. handler() function only for complex or reusable logic Files updated: - docs/common/RECIPES.md: Core concepts, handlers, computed(), examples - docs/common/HANDLERS.md: Inline handler guide, decision matrix - docs/common/PATTERNS.md: Level 1-3 examples, pitfalls, performance - docs/common/COMPONENTS.md: ct-button, ct-input examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * updated core patterns to new syntax --- docs/common/COMPONENTS.md | 110 +++++--- docs/common/HANDLERS.md | 66 ++++- docs/common/PATTERNS.md | 257 +++++++++-------- docs/common/RECIPES.md | 350 ++++++++++++++---------- packages/patterns/chatbot-list-view.tsx | 17 +- packages/patterns/chatbot.tsx | 113 ++++---- packages/patterns/default-app.tsx | 9 +- packages/patterns/omnibox-fab.tsx | 68 +++-- 8 files changed, 577 insertions(+), 413 deletions(-) diff --git a/docs/common/COMPONENTS.md b/docs/common/COMPONENTS.md index e1abe44c3..4753bc435 100644 --- a/docs/common/COMPONENTS.md +++ b/docs/common/COMPONENTS.md @@ -1,18 +1,44 @@ # ct-button -A styled button component matching the regular `button` API. Pass a handler to the `onClick` prop to bind it. +A styled button component matching the regular `button` API. You can use inline handlers or the `handler()` function. + +## Inline Handler (Preferred for Simple Cases) + +```tsx +interface Input { + count: Cell; +} + +const MyRecipe = recipe(({ count }) => { + return { + [UI]: ( + count.set(count.get() + 1)}> + Increment + + ), + count, + }; +}); +``` + +**Key point:** Declare `count` as `Cell` in your input type to use it in inline handlers. + +## Using handler() for Complex Logic ```tsx -type InputSchema = { count: Cell }; -type OutputSchema = { count: Cell }; +interface Input { + count: Cell; +} -const MyRecipe = recipe(({ count }) => { - const handleClick = handler }>((_event, { count }) => { - count.set(count.get() + 1); - }); +const handleClick = handler }>((_event, { count }) => { + const newValue = count.get() + 1; + count.set(newValue); + console.log("Count updated to:", newValue); +}); +const MyRecipe = recipe(({ count }) => { return { - [UI]: , + [UI]: Increment, count, }; }); @@ -129,16 +155,17 @@ Consult the component for details. ### Validation Example -For validation, consider using two cells: a raw input cell and a validated derived cell: +For validation, consider using two cells: a raw input cell and a validated computed cell: ```tsx // Raw input with bidirectional binding -const rawInput = cell(""); +const rawInput = Cell.of(""); -// Validated output using derive -const validatedValue = derive(rawInput, (value) => { +// Validated output using computed +const validatedValue = computed(() => { + const value = rawInput; if (value.length < 3) return null; if (!value.match(/^[a-z]+$/i)) return null; return value; @@ -151,7 +178,7 @@ const validatedValue = derive(rawInput, (value) => { } ``` -This approach separates concerns: bidirectional binding handles the UI sync, while derive handles validation logic. +This approach separates concerns: bidirectional binding handles the UI sync, while computed handles validation logic. ## Styling: String vs Object Syntax @@ -245,36 +272,49 @@ CommonTools custom elements (`common-hstack`, `common-vstack`, `ct-card`, etc.) ## ct-input -The `ct-input` component demonstrates bidirectional binding perfectly: +The `ct-input` component demonstrates bidirectional binding and inline handlers: ```tsx -type InputSchema = { value: Cell }; -type OutputSchema = { value: Cell }; - -const MyRecipe = recipe(({ value }: InputSchema) => { - // Option 1: Bidirectional binding (simplest) - const simpleInput = ; - - // Option 2: With handler for additional logic - const handleChange = handler< - { detail: { value: string } }, - { value: Cell } - >((event, { value }) => { - value.set(event.detail.value); - console.log("Value changed:", event.detail.value); - }); - const inputWithHandler = ; +interface Input { + value: Cell; +} +const MyRecipe = recipe(({ value }) => { return { - [UI]:
- {simpleInput} - {inputWithHandler} -
, + [UI]: ( +
+ {/* Option 1: Bidirectional binding (simplest) */} + + + {/* Option 2: Inline handler for additional logic */} + { + value.set(e.detail.value); + console.log("Value changed:", e.detail.value); + }} + /> + + {/* Option 3: handler() for complex/reusable logic */} + +
+ ), + value, }; }); + +// If using handler() for complex logic +const handleChange = handler< + { detail: { value: string } }, + { value: Cell } +>((event, { value }) => { + value.set(event.detail.value); + console.log("Value changed:", event.detail.value); + // Additional complex logic... +}); ``` -Both inputs update the cell, but the second one logs changes. Use the simple bidirectional binding unless you need the extra logic. +**Recommendation:** Use bidirectional binding unless you need custom logic, then use inline handlers. Only use `handler()` for complex or reusable logic. ## ct-select diff --git a/docs/common/HANDLERS.md b/docs/common/HANDLERS.md index 4aa29bb00..9aa9c3342 100644 --- a/docs/common/HANDLERS.md +++ b/docs/common/HANDLERS.md @@ -1,5 +1,51 @@ # CommonTools Handler Patterns Guide +## Inline Handlers (Preferred for Simple Cases) + +**New in CommonTools**: You can now write event handlers directly inline without the `handler()` wrapper! + +```typescript +interface Input { + count: Cell; + title: Cell; +} + +export default recipe(({ count, title }) => { + return { + [UI]: ( +
+ {/* Simple click handler */} + count.set(count.get() + 1)}> + Increment + + + {/* Handler with event */} + title.set(e.detail.value)} + /> + + {/* Multi-line handler */} + { + const current = count.get(); + if (current < 10) { + count.set(current + 1); + } + }}> + Increment (max 10) + +
+ ), + }; +}); +``` + +**Key points:** +- Declare cells as `Cell` in your recipe input types +- Write arrow functions directly: `onClick={() => ...}` or `onClick={(e) => ...}` +- No `handler()` wrapper needed for simple cases +- Can still use `handler()` for complex/reusable logic + ## When Do You Need Handlers? **Important:** Many UI updates don't need handlers at all! CommonTools components support **bidirectional binding** with the `$` prefix, which automatically updates cells when users interact with components. @@ -11,18 +57,24 @@ | Update checkbox | ✅ Bidirectional binding | `` | | Update text input | ✅ Bidirectional binding | `` | | Update dropdown | ✅ Bidirectional binding | `` | -| Add item to list | ❌ Need handler | `addItem` handler with `items.set([...])` | -| Remove item from list | ❌ Need handler | `removeItem` handler with `toSpliced()` | -| Validate input | ❌ Need handler (or derive) | Handler with validation logic | -| Call API on change | ❌ Need handler | Handler with fetch/save logic | -| Log changes | ❌ Need handler | Handler with logging | - -**Rule of thumb:** If you're just syncing UI ↔ cell with no additional logic, use bidirectional binding. If you need side effects, validation, or structural changes (add/remove), use handlers. +| Add item to list | ✅ Inline handler | `onClick={() => items.push({ title: "New" })}` | +| Remove item from list | ✅ Inline handler | `onClick={() => items.set(items.get().filter(i => i !== item))}` | +| Simple counter | ✅ Inline handler | `onClick={() => count.set(count.get() + 1)}` | +| Validate input | ✅ Inline handler or computed | Inline handler or `computed()` | +| Call API on change | ✅ Inline handler or `handler()` | For complex logic, use `handler()` | +| Complex reusable logic | ✅ `handler()` function | Module-level `handler()` | + +**Rule of thumb:** +1. **Bidirectional binding** for simple UI ↔ cell sync (no logic needed) +2. **Inline handlers** for simple one-off operations +3. **`handler()` function** for complex or reusable logic See `COMPONENTS.md` for detailed bidirectional binding examples. ## Handler Function Structure +**Note:** This section describes the `handler()` function for complex/reusable handlers. For simple cases, prefer inline handlers (see above). + Handlers in CommonTools follow a specific pattern that creates a factory function: ```typescript diff --git a/docs/common/PATTERNS.md b/docs/common/PATTERNS.md index aab8968a2..3d576eb87 100644 --- a/docs/common/PATTERNS.md +++ b/docs/common/PATTERNS.md @@ -50,11 +50,11 @@ The simplest and most common pattern: a list where users can check items and edi **Key Concepts:** - Bidirectional binding with `$checked` and `$value` -- Simple add/remove operations with handlers +- Inline handlers for simple add/remove operations ```typescript /// -import { Cell, Default, handler, NAME, OpaqueRef, recipe, UI, cell } from "commontools"; +import { Cell, Default, NAME, recipe, UI } from "commontools"; interface ShoppingItem { title: string; @@ -62,32 +62,12 @@ interface ShoppingItem { } interface ShoppingListInput { - items: Default; + items: Cell; // Note: Cell for inline handlers } -interface ShoppingListOutput extends ShoppingListInput {} - -const addItem = handler< - { detail: { message: string } }, - { items: Cell } ->(({ detail }, { items }) => { - const itemName = detail?.message?.trim(); - if (!itemName) return; - - const currentItems = items.get(); - items.set([...currentItems, { title: itemName, done: false }]); -}); - -const removeItem = handler< - unknown, - { items: Cell>>; item: Cell } ->((_event, { items, item }) => { - const currentItems = items.get(); - const index = currentItems.findIndex((el) => item.equals(el)); - if (index >= 0) { - items.set(currentItems.toSpliced(index, 1)); - } -}); +interface ShoppingListOutput { + items: Cell; +} export default recipe( "Shopping List", @@ -105,14 +85,29 @@ export default recipe( {item.title} - × + {/* Inline handler for remove */} + { + const current = items.get(); + const index = current.findIndex((el) => Cell.equals(item, el)); + if (index >= 0) { + items.set(current.toSpliced(index, 1)); + } + }}> + × + ))} + {/* Inline handler for add */} { + const itemName = e.detail?.message?.trim(); + if (itemName) { + items.push({ title: itemName, done: false }); + } + }} /> ), @@ -126,20 +121,22 @@ export default recipe( - ✅ `$checked` automatically updates `item.done` - no handler needed - ✅ Ternary operator in `style` attribute works fine - ✅ Type inference automatically works in `.map()` - no type annotation needed! -- ✅ Handlers only for structural changes (add/remove) +- ✅ **Inline handlers** for add/remove operations - no `handler()` needed +- ✅ Declare `items` as `Cell` in input type for inline handlers ## Level 2: Filtered and Grouped Views Adding derived data transformations to create multiple views of the same data. **Key Concepts:** -- Using `derive()` for data transformations -- Direct property access on derived objects with `groupedItems[category]` +- Using `computed()` for data transformations +- Direct property access on computed objects with `groupedItems[category]` - Inline expressions like `(array ?? []).map(...)` +- Within JSX, you don't need `computed()` - reactivity is automatic ```typescript /// -import { Default, derive, NAME, OpaqueRef, recipe, UI } from "commontools"; +import { Default, computed, NAME, OpaqueRef, recipe, UI } from "commontools"; interface ShoppingItem { title: string; @@ -156,11 +153,11 @@ interface CategorizedListOutput extends CategorizedListInput {} export default recipe( "Shopping List (Categorized)", ({ items }) => { - // Group items by category using derive - const groupedItems = derive(items, (itemsList) => { + // Group items by category using computed + const groupedItems = computed(() => { const groups: Record = {}; - for (const item of itemsList) { + for (const item of items) { const category = item.category || "Uncategorized"; if (!groups[category]) { groups[category] = []; @@ -172,8 +169,8 @@ export default recipe( }); // Get sorted category names - const categories = derive(groupedItems, (groups) => { - return Object.keys(groups).sort(); + const categories = computed(() => { + return Object.keys(groupedItems).sort(); }); return { @@ -202,10 +199,10 @@ export default recipe( ``` **What to notice:** -- ✅ `derive()` creates reactive transformations -- ✅ `groupedItems[category]` - direct property access on derived object +- ✅ `computed()` creates reactive transformations +- ✅ `groupedItems[category]` - direct property access on computed object - ✅ `(groupedItems[category] ?? [])` - inline null coalescing instead of intermediate variable -- ✅ Multiple views of same data (categories derived from groupedItems) +- ✅ Multiple views of same data (categories computed from groupedItems) ## Level 3: Linked Charms (Master-Detail Pattern) @@ -215,7 +212,7 @@ Two separate recipes sharing the same data through charm linking. ```typescript /// -import { Cell, Default, handler, NAME, OpaqueRef, recipe, UI } from "commontools"; +import { Cell, Default, NAME, recipe, UI } from "commontools"; interface ShoppingItem { title: string; @@ -224,29 +221,17 @@ interface ShoppingItem { } interface EditorInput { - items: Default; + items: Cell; // Cell for inline handlers } -interface EditorOutput extends EditorInput {} - -const addItem = handler< - { detail: { message: string } }, - { items: Cell; newCategory: Cell } ->(({ detail }, { items, newCategory }) => { - const itemName = detail?.message?.trim(); - if (!itemName) return; - - items.push({ - title: itemName, - done: false, - category: newCategory.get(), - }); -}); +interface EditorOutput { + items: Cell; +} export default recipe( "Shopping List Editor", ({ items }) => { - const newCategory = cell("Uncategorized"); + const newCategory = Cell.of("Uncategorized"); return { [NAME]: "Editor", @@ -262,9 +247,19 @@ export default recipe( { label: "Other", value: "Uncategorized" }, ]} /> + {/* Inline handler */} { + const itemName = e.detail?.message?.trim(); + if (itemName) { + items.push({ + title: itemName, + done: false, + category: newCategory.get(), + }); + } + }} /> ), @@ -368,7 +363,7 @@ export default recipe( Filtering a list without creating intermediate variables. ```typescript -const searchQuery = cell(""); +const searchQuery = Cell.of(""); // Filter items inline {items @@ -388,10 +383,10 @@ const searchQuery = cell(""); **Wait, this looks wrong!** The `.filter()` will execute during recipe definition, not reactively. Here's the **correct** way: ```typescript -const searchQuery = cell(""); +const searchQuery = Cell.of(""); -// ✅ CORRECT - Use derive for reactive filtering -const filteredItems = derive({ items, searchQuery }, ({ items, searchQuery }) => { +// ✅ CORRECT - Use computed for reactive filtering +const filteredItems = computed(() => { const query = searchQuery.toLowerCase(); return items.filter((item) => item.title.toLowerCase().includes(query)); }); @@ -406,8 +401,8 @@ const filteredItems = derive({ items, searchQuery }, ({ items, searchQuery }) => **What to notice:** - ❌ Can't use `.filter()` directly on cells in JSX -- ✅ Must use `derive()` to create reactive filtered list -- ✅ The derived list updates when `searchQuery` or `items` changes +- ✅ Must use `computed()` to create reactive filtered list +- ✅ The computed list updates when `searchQuery` or `items` changes ## Decision Matrix: When to Use What @@ -415,23 +410,25 @@ const filteredItems = derive({ items, searchQuery }, ({ items, searchQuery }) => | Scenario | Use | |----------|-----| -| Toggle checkbox | `$checked` | -| Edit text field | `$value` | -| Select dropdown option | `$value` | -| Add item to array | `handler` | -| Remove item from array | `handler` | -| Reorder items | `handler` | -| Validate input | `handler` or `derive` | -| Call API on change | `handler` | - -### derive() vs lift() +| Toggle checkbox | `$checked` (bidirectional binding) | +| Edit text field | `$value` (bidirectional binding) | +| Select dropdown option | `$value` (bidirectional binding) | +| Add item to array | Inline handler: `onClick={() => items.push(...)}` | +| Remove item from array | Inline handler: `onClick={() => items.set(...)}` | +| Simple counter | Inline handler: `onClick={() => count.set(count.get() + 1)}` | +| Reorder items | Inline handler or `handler()` for complexity | +| Validate input | Inline handler or `computed()` | +| Call API on change | Inline handler or `handler()` for complex logic | + +### When to Use computed() | Scenario | Use | |----------|-----| -| Single transformation of specific cells | `derive(items, fn)` | -| Reusable transformation function | `lift(fn)` | -| Need to call with different inputs | `lift(fn)` | -| Simple one-off calculation | `derive(cells, fn)` | +| Transform data reactively | `computed(() => ...)` | +| Create derived values | `computed(() => ...)` | +| Filter or sort lists reactively | `computed(() => ...)` | +| Complex calculations | `computed(() => ...)` | +| **Within JSX** | Not needed - reactivity is automatic | ### Intermediate Variables vs Inline @@ -454,17 +451,17 @@ Don't optimize prematurely! Most patterns perform well without optimization. Con ### Common Optimizations -1. **Limit derived calculations**: Only derive what you need +1. **Limit computed calculations**: Only compute what you need ```typescript -// ❌ AVOID - Deriving entire sorted list when you only need count -const sortedItems = derive(items, (list) => { - return list.toSorted((a, b) => a.priority - b.priority); +// ❌ AVOID - Computing entire sorted list when you only need count +const sortedItems = computed(() => { + return items.toSorted((a, b) => a.priority - b.priority); }); -const itemCount = derive(sortedItems, (list) => list.length); +const itemCount = computed(() => sortedItems.length); -// ✅ BETTER - Derive just the count -const itemCount = derive(items, (list) => list.length); +// ✅ BETTER - Compute just the count +const itemCount = computed(() => items.length); ``` 2. **Avoid index-based removal, pass item references** @@ -481,21 +478,32 @@ const removeItem = handler((_, { items, item }: { items: Cell>> }); ``` -3. **Avoid creating handlers inside render** +3. **Use inline handlers for simple operations** ```typescript -// ❌ AVOID - Creates new handler instance for each item -{items.map((item) => { - const remove = handler(() => { /* ... */ }); - return ×; -})} +// ✅ BEST - Inline handler (no overhead) +{items.map((item) => ( + { + const current = items.get(); + const index = current.findIndex((el) => Cell.equals(item, el)); + if (index >= 0) items.set(current.toSpliced(index, 1)); + }}> + × + +))} -// ✅ CORRECT - Handler defined at module level +// ✅ ALSO GOOD - Module-level handler() for complex/reusable logic const removeItem = handler((_, { items, item }) => { /* ... */ }); {items.map((item) => ( × ))} + +// ❌ AVOID - Creating handler() inside map +{items.map((item) => { + const remove = handler(() => { /* ... */ }); + return ×; +})} ``` ## Debugging Patterns @@ -521,8 +529,8 @@ const removeItem = handler((_, { items, item }) => { /* ... */ }); // ❌ WRONG - Direct filter doesn't create reactive node {items.filter(item => !item.done).map(...)} -// ✅ CORRECT - Use derive -const activeItems = derive(items, (list) => list.filter(item => !item.done)); +// ✅ CORRECT - Use computed +const activeItems = computed(() => items.filter(item => !item.done)); {activeItems.map(...)} ``` @@ -532,16 +540,16 @@ const activeItems = derive(items, (list) => list.filter(item => !item.done)); // ❌ WRONG - category from outer scope not accessible {categories.map((category) => (
- {derive(items, (list) => - list.filter(item => item.category === category) // category not accessible! + {computed(() => + items.filter(item => item.category === category) // category not accessible! )}
))} // ✅ CORRECT - Pre-group the data -const groupedItems = derive(items, (list) => { +const groupedItems = computed(() => { const groups = {}; - for (const item of list) { + for (const item of items) { if (!groups[item.category]) groups[item.category] = []; groups[item.category].push(item); } @@ -585,11 +593,10 @@ These are the most frequent mistakes developers make when building patterns: **Rule:** HTML elements use object styles, custom elements use string styles. See "Styling: String vs Object Syntax" in `COMPONENTS.md` for details. -#### 2. Using Handlers Instead of Bidirectional Binding +#### 2. Using handler() Instead of Inline Handlers or Bidirectional Binding ```typescript -// ❌ AVOID - Unnecessary handler for simple toggle -// ❌ AVOID - Rewriting the whole array instead of just toggling one item +// ❌ AVOID - Unnecessary handler() for simple toggle const toggleDone = handler }>( (_, { item }) => { const current = item.get(); @@ -598,13 +605,34 @@ const toggleDone = handler }>( ); -// ✅ PREFERRED - Bidirectional binding handles it +// ✅ BEST - Bidirectional binding (no handler at all) + +// ✅ GOOD - Inline handler if you need custom logic + { + item.set({ ...item.get(), done: e.detail.checked }); + console.log("Toggled:", item.get().title); + }} +/> + +// ❌ AVOID - handler() for simple increment +const increment = handler }>( + (_, { count }) => count.set(count.get() + 1) +); ++1 + +// ✅ PREFERRED - Inline handler + count.set(count.get() + 1)}>+1 ``` -**Why this is a pitfall:** Writing unnecessary code that the framework handles automatically. +**Why this is a pitfall:** Over-engineering with `handler()` when inline handlers or bidirectional binding suffice. -**Remember:** If you're just syncing UI ↔ data, use `$` binding. Only use handlers for side effects, validation, or structural changes. +**Remember:** +1. **Bidirectional binding** (`$checked`, `$value`) for simple UI ↔ data sync +2. **Inline handlers** for simple operations with custom logic +3. **`handler()`** only for complex or reusable logic #### 3. Trying to Use [ID] When You Don't Need It @@ -856,14 +884,14 @@ Remember: HTML elements use object syntax, custom elements use string syntax. See "Styling: String vs Object Syntax" in `COMPONENTS.md` for details. -### Direct Property Access on Derived Objects +### Direct Property Access on Computed Objects -When you derive an object (not an array), you can access its properties directly: +When you compute an object (not an array), you can access its properties directly: ```typescript -const groupedItems = derive(items, (list) => { +const groupedItems = computed(() => { const groups: Record = {}; - for (const item of list) { + for (const item of items) { const category = item.category || "Uncategorized"; if (!groups[category]) groups[category] = []; groups[category].push(item); @@ -871,7 +899,7 @@ const groupedItems = derive(items, (list) => { return groups; }); -const categories = derive(groupedItems, (groups) => Object.keys(groups).sort()); +const categories = computed(() => Object.keys(groupedItems).sort()); // ✅ Direct property access with inline null coalescing {categories.map((category) => ( @@ -885,9 +913,9 @@ const categories = derive(groupedItems, (groups) => Object.keys(groups).sort()); ``` **What to notice:** -- ✅ `groupedItems[category]` - direct property access works on derived objects +- ✅ `groupedItems[category]` - direct property access works on computed objects - ✅ `(groupedItems[category] ?? [])` - inline null coalescing for safety -- ✅ No intermediate `derive` needed for simple property access +- ✅ No intermediate `computed()` needed for simple property access - ✅ Type inference works automatically, even in nested maps! ## Summary @@ -898,9 +926,10 @@ const categories = derive(groupedItems, (groups) => Object.keys(groups).sort()); - Automatic type inference in `.map()` (no manual annotations needed!) **Level 2 patterns:** -- `derive()` for data transformations +- `computed()` for data transformations - Inline expressions for simple operations - Multiple views of same data +- Within JSX, reactivity is automatic - no need for `computed()` **Level 3 patterns:** - Charm linking for data sharing @@ -915,6 +944,6 @@ const categories = derive(groupedItems, (groups) => Object.keys(groups).sort()); **Key principles:** 1. Use bidirectional binding when possible 2. Use handlers for side effects and structural changes -3. Use `derive()` for reactive transformations +3. Use `computed()` for reactive transformations (but not in JSX - it's automatic there) 4. Keep it simple - don't over-engineer 5. Test incrementally with `deno task ct dev` and `deno task ct charm setsrc` diff --git a/docs/common/RECIPES.md b/docs/common/RECIPES.md index a1a5358bf..cd3fc3f6a 100644 --- a/docs/common/RECIPES.md +++ b/docs/common/RECIPES.md @@ -40,12 +40,31 @@ Importantly, the framework automatically handles type validation and serializati The framework uses a reactive programming model: -- `cell`: Represents a reactive state container that can be updated and observed -- `derive`: Creates a derived value that updates when its dependencies change -- `lift`: Similar to derive, but lifts a regular function to work on reactive values - - `derive(param, function)` is an alias to `lift(function)(param)` -- `handler`: Creates an event handler that always fires with up-to-date dependencies (possibly mutating them) - - takes two parameters, an event and state (bound variables) +- `Cell.of()`: Creates a reactive state container that can be updated and + observed + - `Cell.of()` - typed cell with no initial value + - `Cell.of(defaultValue)` - cell with initial value + - `Cell.of(defaultValue)` - typed cell with initial value +- `computed()`: Creates a computed value that updates when its dependencies + change + - Use `computed(() => ...)` closing over variables for reactive + transformations + - Not needed within JSX - the framework handles reactivity automatically +- `Cell.equals(a, b)`: Convenient way to compare two things that are cells or + were returned by `.get()` + - Neither parameter has to be of type Cell +- `Cell.for(cause)`: Explicit cell creation in lift contexts (rarely needed) + - Typically used as `Cell.for(cause).set(value)` + - Sets the cell to that value on every reactive change, not just the initial + value + - Difference from `.of()`: `.of()` sets only the initial value +- **Inline handlers**: You can write event handlers directly inline without the `handler()` wrapper + - Simply write arrow functions inline: `onClick={() => counter.set(counter.get() + 1)}` + - Works with events too: `onClick={(e) => handleClick(e, counter)}` + - **Important**: Pass cells as `Cell` in your recipe inputs to use them in inline handlers +- `handler()`: Legacy function for creating reusable handler factories (optional) + - Still useful for complex handlers that need to be reused + - Takes two parameters: an event and state (bound variables) - e.g. `` ### Handlers vs Reactive Functions @@ -56,11 +75,28 @@ There are important differences between the types of functions in the framework: (For even more detail, see `HANDLERS.md`) -Handlers are functions that declare node types in the reactive graph that -respond to events: +Handlers respond to events and can update cells: -- Created with `handler()` function -- Use `Cell<>` to indicate you want a reactive value (for mutation, usually): +- **Inline handlers** (preferred for simple cases): + + ```typescript + // Simple click handler + count.set(count.get() + 1)}> + Increment + + + // Handler with event + title.set(e.detail.value)} + value={title} + /> + ``` + + - **Important**: Declare cells as `Cell` in your recipe input types + - Works with any event type + - No `handler()` wrapper needed + +- **`handler()` function** (for complex/reusable handlers): ```typescript const updateCounter = handler }>( @@ -69,55 +105,40 @@ respond to events: count.set(count.get() + 1); }, ); - ``` -- Instantiated in recipes by passing parameters: + // Instantiated in recipes by passing parameters: + const stream = updateCounter({ count }); - ```typescript - const stream = definedHandler({ cell1, cell2 }); + // Used in JSX: + Increment ``` -- Return a stream that can be: - - Passed to JSX components as event handlers (e.g., `onClick={stream}`) - - Returned by a recipe for external consumption - - Passed to another handler which can call `.send(...)` on it to generate - events + - Returns a stream that can be: + - Passed to JSX components as event handlers + - Returned by a recipe for external consumption + - Passed to another handler which can call `.send(...)` on it + - Can update cells and trigger side effects - Support async operations for data processing - React to outside events (user interactions, API responses) - Cannot directly call built-in functions like `llm` -#### Reactive Functions (lift/derive) - -- `lift`: Declares a reactive node type that transforms data in the reactive - graph +#### Reactive Functions (computed) - ```typescript - const transformData = lift( - ({ value, multiplier }: { value: number, multiplier: number }) => value * multiplier, - ); - ``` - - - When instantiated, it inserts a reactive node in the graph: +- `computed()`: Creates a computed value that automatically updates when dependencies change ```typescript - const newCell = liftedFunction({ cell1, cell2 }); + const multipliedValue = computed(() => value * multiplier); ``` - - The result is a proxy cell that can be further referenced: + - Closes over variables to capture dependencies + - Not needed within JSX - reactivity is automatic there + - Returns a reactive value that can be used elsewhere ```typescript - const compound = { data: newCell.field }; + const compound = { data: multipliedValue }; ``` -- `derive`: A convenience wrapper around lift: - - ```typescript - // These are equivalent: - const result1 = derive({ x, y }, ({ x, y }) => x + y); - const result2 = lift(({ x, y }) => x + y)({ x, y }); - ``` - - React to data changes within the reactive graph - Cannot directly call built-in functions like `llm` @@ -294,9 +315,9 @@ logic. items.push({ title: value }); }); - // ✅ DO THIS - Use cells for input state - const name = cell(""); - const category = cell("Other"); + // ✅ DO THIS - Use Cell.of() for input state + const name = Cell.of(""); + const category = Cell.of("Other"); // Bind to inputs @@ -393,12 +414,43 @@ logic. ))} ``` - **Why?** Using `cell.equals(other)` is more reliable than index-based operations, especially when items are reordered or modified. + **Why?** Using `Cell.equals(item, el)` is more reliable than index-based operations, especially when items are reordered or modified. -6. **Define Handlers and Lifts at Module Level**: Place `handler` and `lift` definitions outside the recipe function for reusability and performance: +6. **Prefer Inline Handlers for Simple Cases**: Use inline arrow functions for simple event handlers: ```typescript - // ✅ CORRECT - Module level + // ✅ PREFERRED - Inline handler for simple operations + interface Input { + items: Cell; + name: Cell; + } + + export default recipe(({ items, name }) => { + // Use computed() for reactive transformations + const grouped = computed(() => { + return items.reduce((acc, item) => { + if (!acc[item.category]) acc[item.category] = []; + acc[item.category].push(item); + return acc; + }, {} as Record); + }); + + return { + [UI]: ( + { + if (name.get().trim()) { + items.push({ title: name.get() }); + name.set(""); + } + }}> + Add + + ), + grouped, + }; + }); + + // ✅ ALSO GOOD - Module-level handler() for complex/reusable logic const addItem = handler( (_event, { items, name }: { items: Cell; name: Cell }) => { if (name.get().trim()) { @@ -408,30 +460,17 @@ logic. } ); - const groupByCategory = lift((items: Item[]) => { - return items.reduce((acc, item) => { - if (!acc[item.category]) acc[item.category] = []; - acc[item.category].push(item); - return acc; - }, {} as Record); - }); - export default recipe(({ items, name }) => { - const grouped = groupByCategory(items); return { [UI]: Add, - grouped, }; }); - - // ❌ INCORRECT - Inside recipe function - export default recipe(({ items, name }) => { - const addItem = handler((_event, { items, name }) => { /* ... */ }); - const grouped = lift((items) => { /* ... */ })(items); - // This creates new function instances on each evaluation - }); ``` + **Rule of thumb:** + - **Simple, one-off handlers**: Use inline arrow functions + - **Complex or reusable handlers**: Use `handler()` at module level + 7. **Type Array Map Parameters as OpaqueRef**: When mapping over cell arrays with bidirectional binding, you **must** add the `OpaqueRef` type annotation to make it type-check correctly: ```typescript @@ -505,11 +544,11 @@ logic. - **Ternaries for elements**: ❌ Use `ifElse()` instead - **if statements**: ❌ Never work, use `ifElse()` instead -9. **Understand lift vs derive**: Know when to use each reactive function: +9. **Use computed() for Reactive Transformations**: Use `computed()` to create reactive values: ```typescript - // lift - Creates a reusable function that can be called multiple times - const groupByCategory = lift((items: Item[]) => { + // computed() - Closes over variables to capture dependencies + const grouped = computed(() => { return items.reduce((acc, item) => { if (!acc[item.category]) acc[item.category] = []; acc[item.category].push(item); @@ -517,28 +556,28 @@ logic. }, {} as Record); }); - // Call it with different inputs - const grouped1 = groupByCategory(items); - const grouped2 = groupByCategory(otherItems); - - // derive - Directly computes a value from cells (convenience wrapper) - const categories = derive(itemsByCategory, (grouped) => { + // Compute derived values from other computed values + const categories = computed(() => { return Object.keys(grouped).sort(); }); - // These are equivalent: - const result1 = derive({ x, y }, ({ x, y }) => x + y); - const result2 = lift(({ x, y }) => x + y)({ x, y }); + // Simple reactive computation + const sum = computed(() => x + y); ``` - **When to use lift:** When you need a reusable transformation function that you'll call with different inputs. - - **When to use derive:** When you're computing a single value from specific cells. + **Note:** Within JSX, you don't need `computed()` - reactivity is automatic there. -10. **Access Properties Directly on Derived Objects**: You can access properties on derived objects without additional helpers: +10. **Access Properties Directly on Computed Objects**: You can access properties on computed objects without additional helpers: ```typescript - const itemsByCategory = groupByCategory(items); + const itemsByCategory = computed(() => { + const grouped = {}; + for (const item of items) { + if (!grouped[item.category]) grouped[item.category] = []; + grouped[item.category].push(item); + } + return grouped; + }); // Returns Record // ✅ DO THIS - Direct property access works @@ -551,8 +590,8 @@ logic. ))} - // ❌ NOT NEEDED - Don't create unnecessary helpers - const getCategoryItems = lift((grouped, category) => grouped[category]); + // ❌ NOT NEEDED - Don't create unnecessary computed values for simple access + const getCategoryItems = computed(() => itemsByCategory[categoryName]); {itemsByCategory[categoryName].map((item) => ...)} ``` @@ -567,18 +606,14 @@ logic. // ❌ AVOID - Unnecessary intermediate variable - {derive(groupedItems, (groups) => { - const categoryItems = groups[category] || []; - return ( -
- {categoryItems.map(item => ...)} -
- ); - })} + // (Note: Within JSX, you don't need computed() - reactivity is automatic) +
+ {(groupedItems[category] ?? []).map(item => ...)} +
// ✅ GOOD USE - When expression is complex or reused - const sortedItems = derive(items, (list) => { - return list + const sortedItems = computed(() => { + return items .filter(item => !item.done) .sort((a, b) => a.priority - b.priority) .slice(0, 10); @@ -634,17 +669,25 @@ logic. - Items only added to end or removed from end - Most basic list patterns -13. **Understand Variable Scoping Limitations**: Variables from outer scopes don't work as expected inside `.map()` callbacks: +13. **Understand Variable Scoping Limitations**: Variables from outer scopes don't work as expected inside nested reactive contexts: ```typescript - // ❌ DOESN'T WORK - Can't access `category` from outer map + // ❌ DOESN'T WORK - Can't access `category` from outer scope in computed {categories.map((category) => ( - {derive(items, (arr) => arr.filter(i => i.category === category))} + {computed(() => items.filter(i => i.category === category))} // category is not accessible here ))} // ✅ WORKS - Use property access or pre-computed values - const itemsByCategory = groupByCategory(items); + const itemsByCategory = computed(() => { + const grouped = {}; + for (const item of items) { + if (!grouped[item.category]) grouped[item.category] = []; + grouped[item.category].push(item); + } + return grouped; + }); + {categories.map((category) => ( {itemsByCategory[category].map((item) => (
{item.name}
@@ -652,26 +695,21 @@ logic. ))} ``` -14. **Understand lift Currying with Multiple Parameters**: Multi-parameter lift creates curried functions: +14. **Use computed() for Complex Transformations**: For complex transformations, use `computed()`: ```typescript - // lift with multiple parameters creates curried function - const formatValue = lift((label: string, value: number) => `${label}: ${value}`); - - // ✅ CORRECT - Call with currying - const result = formatValue("count")(42); // "count: 42" - - // ❌ INCORRECT - This won't work - const result = formatValue("count", 42); - - // Usually better to use single parameter with object - const formatValue = lift(({ label, value }: { label: string; value: number }) => - `${label}: ${value}` - ); - const result = formatValue({ label: "count", value: 42 }); + // ✅ CORRECT - Use computed() to close over variables + const formattedValue = computed(() => `${label}: ${value}`); + + // For more complex formatting with multiple inputs + const formatValue = computed(() => { + const formattedLabel = label.trim(); + const formattedValue = value.toFixed(2); + return `${formattedLabel}: ${formattedValue}`; + }); ``` - **Recommendation:** In most cases, direct property access or single-parameter lifts are clearer than multi-parameter curried functions. + **Note:** Within JSX, you don't need `computed()` - reactivity is automatic there. 15. **Reference Data Instead of Copying**: When transforming data, reference the original objects rather than copying all their properties. This maintains @@ -889,12 +927,12 @@ interface Item { ❌ **Only add [ID] when you need:** -#### 1. Creating Items Within lift Functions +#### 1. Creating Referenceable Items in Reactive Contexts -When generating new items inside `lift`: +When you need stable references to items created in computed values or handlers: ```typescript -const generateItems = lift((count: number) => { +const generateItems = computed(() => { return Array.from({ length: count }, (_, i) => ({ [ID]: i, // Needed for stable references title: `Item ${i}`, @@ -995,8 +1033,7 @@ const addBacklink = handler< **Start without `[ID]`. Only add it if:** -1. You're generating new items within a `lift` function that have to be - references elsewhere. +1. You're generating new items within reactive contexts (like `computed()` or handlers) that need to be referenced elsewhere. **Don't add `[ID]` just because you see it in examples.** The `list-operations.tsx` example demonstrates advanced features, but your basic shopping list, todo list, or simple CRUD pattern doesn't need it. @@ -1065,24 +1102,52 @@ flow isolation. ## Example Pattern ```typescript -export default recipe( - InputSchema, - OutputSchema, - ({ input1, input2 }) => { - const state = cell([]); +interface Input { + items: Cell; + title: Cell; +} - // Define handlers and side effects +interface Output { + items: Cell; + processedItems: any; +} - return { - [NAME]: "Recipe Name", - [UI]: ( - // JSX component - ), - outputField1: state, - outputField2: derivedValue - }; - } -); +export default recipe(({ items, title }) => { + // Create computed values + const processedItems = computed(() => { + return items.filter(item => !item.done); + }); + + return { + [NAME]: "Recipe Name", + [UI]: ( +
+ {/* Inline handler for simple operations */} + { + if (title.get().trim()) { + items.push({ title: title.get(), done: false }); + title.set(""); + } + }}> + Add Item + + + {/* Display items */} + {processedItems.map(item => ( +
+ {item.title} + {/* Inline handler with event */} + items.set(items.get().filter(i => i !== item))}> + Remove + +
+ ))} +
+ ), + items, + processedItems + }; +}); ``` ## Integration Between Recipes @@ -1134,8 +1199,8 @@ The result object includes: A common pattern in recipes is: -1. Initialize state using `cell()` -2. Create derived values with `derive()` +1. Initialize state using `Cell.of()` +2. Create computed values with `computed()` 3. Define handlers for UI events and data processing 4. Create async functions for complex operations 5. Return a recipe object with UI and exported values @@ -1147,7 +1212,7 @@ export default recipe( "Recipe Name", ({ inputData, settings }) => { // Initialize state - const processedData = cell([]); + const processedData = Cell.of([]); // Process data with LLM (directly in recipe) // Notice we call map() directly on the cell - inputData is a cell @@ -1161,9 +1226,9 @@ export default recipe( }; }); - // Create derived value from LLM results - const summaries = derive(processedItems, items => - items.map(item => ({ + // Create computed value from LLM results + const summaries = computed(() => + processedItems.map(item => ({ id: item.originalItem.id, summary: item.llmResult.result || "Processing...", })) @@ -1181,6 +1246,11 @@ export default recipe( [NAME]: "Recipe Name", [UI]: ( // JSX UI component +
+ {summaries.map(summary => ( +
{summary.summary}
+ ))} +
), processedData, }; diff --git a/packages/patterns/chatbot-list-view.tsx b/packages/patterns/chatbot-list-view.tsx index fc684f058..dd5a819c2 100644 --- a/packages/patterns/chatbot-list-view.tsx +++ b/packages/patterns/chatbot-list-view.tsx @@ -1,9 +1,7 @@ /// import { Cell, - cell, Default, - derive, handler, ID, ifElse, @@ -128,7 +126,7 @@ const populateChatList = lift( { charmsList, allCharms, selectedCharm }, ) => { if (charmsList.length === 0) { - const isInitialized = cell(false); + const isInitialized = Cell.of(false); return storeCharm({ charm: Chat({ title: "New Chat", @@ -154,7 +152,7 @@ const createChatRecipe = handler< } >( (_, { selectedCharm, charmsList, allCharms }) => { - const isInitialized = cell(false); + const isInitialized = Cell.of(false); const charm = Chat({ title: "New Chat", @@ -238,10 +236,7 @@ const extractLocalMentionable = lift< export default recipe( "Launcher", ({ selectedCharm, charmsList, theme }) => { - const allCharms = derive( - wish("#allCharms", []), - (c) => c, - ); + const allCharms = wish("#allCharms", []); logCharmsList({ charmsList: charmsList as unknown as Cell }); populateChatList({ @@ -259,9 +254,9 @@ export default recipe( const localMentionable = extractLocalMentionable({ list: charmsList }); const localTheme = theme ?? { - accentColor: cell("#3b82f6"), - fontFace: cell("system-ui, -apple-system, sans-serif"), - borderRadius: cell("0.5rem"), + accentColor: Cell.of("#3b82f6"), + fontFace: Cell.of("system-ui, -apple-system, sans-serif"), + borderRadius: Cell.of("0.5rem"), }; return { diff --git a/packages/patterns/chatbot.tsx b/packages/patterns/chatbot.tsx index 2a69f586e..6b48fc40a 100644 --- a/packages/patterns/chatbot.tsx +++ b/packages/patterns/chatbot.tsx @@ -2,9 +2,8 @@ import { BuiltInLLMMessage, Cell, - cell, + computed, Default, - derive, fetchData, generateObject, handler, @@ -20,7 +19,7 @@ import { import { type MentionableCharm } from "./backlinks-index.tsx"; function schemaifyWish(path: string) { - return derive(wish(path), (i) => i); + return wish(path); } const addAttachment = handler< @@ -153,11 +152,11 @@ type ChatOutput = { export const TitleGenerator = recipe< { model?: string; messages: Array } >("Title Generator", ({ model, messages }) => { - const titleMessages = derive(messages, (m) => { - if (!m || m.length === 0) return ""; + const titleMessages = computed(() => { + if (!messages || messages.length === 0) return ""; const messageCount = 2; - const selectedMessages = m.slice(0, messageCount).filter(Boolean); + const selectedMessages = messages.slice(0, messageCount).filter(Boolean); if (selectedMessages.length === 0) return ""; @@ -181,8 +180,8 @@ export const TitleGenerator = recipe< }, }); - const title = derive(result, (t) => { - return t?.title || "Untitled Chat"; + const title = computed(() => { + return result?.title || "Untitled Chat"; }); return title; @@ -291,55 +290,50 @@ const listRecent = handler< export default recipe( "Chat", ({ messages, tools, theme, system }) => { - const model = cell("anthropic:claude-sonnet-4-5"); - const allAttachments = cell>([]); + const model = Cell.of("anthropic:claude-sonnet-4-5"); + const allAttachments = Cell.of>([]); const mentionable = schemaifyWish("#mentionable"); const recentCharms = schemaifyWish("#recent"); // Auto-attach the most recent charm (union with user attachments) - const attachmentsWithRecent = derive( - [allAttachments, recentCharms], - ([userAttachments, recent]: [ - Array, - Array, - ]): Array => { - const attachments = userAttachments || []; - - // If there's a most recent charm, auto-inject it - if (recent && recent.length > 0) { - const mostRecent = recent[0]; - const mostRecentName = mostRecent[NAME]; - - // Check if it's already in the attachments - const alreadyAttached = attachments.some( - (a) => a.type === "mention" && a.name === mostRecentName, - ); - - if (!alreadyAttached && mostRecentName) { - // Add the most recent charm to the beginning - const id = `attachment-auto-recent-${mostRecentName}`; - return [ - { - id, - name: mostRecentName, - type: "mention" as const, - charm: mostRecent, - removable: false, // Auto-attached charm cannot be removed - }, - ...attachments, - ]; - } + const attachmentsWithRecent = computed((): Array => { + const userAttachments = allAttachments.get(); + const attachments = [...(userAttachments || [])]; + + // If there's a most recent charm, auto-inject it + if (recentCharms && recentCharms.length > 0) { + const mostRecent = recentCharms[0]; + const mostRecentName = mostRecent[NAME]; + + // Check if it's already in the attachments + const alreadyAttached = attachments.some( + (a) => a.type === "mention" && a.name === mostRecentName, + ); + + if (!alreadyAttached && mostRecentName) { + // Add the most recent charm to the beginning + const id = `attachment-auto-recent-${mostRecentName}`; + return [ + { + id, + name: mostRecentName, + type: "mention" as const, + charm: mostRecent, + removable: false, // Auto-attached charm cannot be removed + }, + ...attachments, + ]; } + } - return attachments; - }, - ); + return attachments; + }); // Derive tools from attachments (including auto-attached recent charm) - const dynamicTools = derive(attachmentsWithRecent, (attachments) => { + const dynamicTools = computed(() => { const tools: Record = {}; - for (const attachment of attachments || []) { + for (const attachment of attachmentsWithRecent || []) { if (attachment.type === "mention" && attachment.charm) { const charmName = attachment.charm[NAME] || "Charm"; tools[charmName] = { @@ -385,20 +379,17 @@ export default recipe( }; // Merge static and dynamic tools - const mergedTools = derive( - [tools, dynamicTools, attachmentTools], - ([staticTools, dynamic, attachments]: [any, any, any]) => ({ - ...staticTools, - ...dynamic, - ...attachments, - }), - ); + const mergedTools = computed(() => ({ + ...tools, + ...dynamicTools, + ...attachmentTools, + })); const { addMessage, cancelGeneration, pending, flattenedTools } = llmDialog( { - system: derive(system, (s) => - s ?? - "You are a polite but efficient assistant."), + system: computed(() => { + return system ?? "You are a polite but efficient assistant."; + }), messages, tools: mergedTools, model, @@ -410,9 +401,9 @@ export default recipe( mode: "json", }); - const items = derive(result, (models) => { - if (!models) return []; - const items = Object.keys(models as any).map((key) => ({ + const items = computed(() => { + if (!result) return []; + const items = Object.keys(result as any).map((key) => ({ label: key, value: key, })); diff --git a/packages/patterns/default-app.tsx b/packages/patterns/default-app.tsx index f40a0422e..60b863a25 100644 --- a/packages/patterns/default-app.tsx +++ b/packages/patterns/default-app.tsx @@ -1,7 +1,6 @@ /// import { Cell, - derive, handler, NAME, navigateTo, @@ -101,13 +100,7 @@ const spawnNote = handler((_, __) => { export default recipe( "DefaultCharmList", (_) => { - const { allCharms } = derive< - { allCharms: MentionableCharm[] }, - { allCharms: MentionableCharm[] } - >( - wish<{ allCharms: MentionableCharm[] }>("/"), - (c) => c, - ); + const { allCharms } = wish<{ allCharms: MentionableCharm[] }>("/"); const index = BacklinksIndex({ allCharms }); const fab = OmniboxFAB({ diff --git a/packages/patterns/omnibox-fab.tsx b/packages/patterns/omnibox-fab.tsx index 4e8d0f1d1..914e94c2e 100644 --- a/packages/patterns/omnibox-fab.tsx +++ b/packages/patterns/omnibox-fab.tsx @@ -1,8 +1,8 @@ /// import { Cell, - cell, compileAndRun, + computed, derive, fetchProgram, handler, @@ -86,22 +86,21 @@ export default recipe( }, }); - const fabExpanded = cell(false); - const showHistory = cell(false); - const peekDismissedIndex = cell(-1); // Track which message index was dismissed + const fabExpanded = Cell.of(false); + const showHistory = Cell.of(false); + const peekDismissedIndex = Cell.of(-1); // Track which message index was dismissed // Derive assistant message count for dismiss tracking - const assistantMessageCount = derive( - omnibot.messages, - (messages) => messages.filter((m) => m.role === "assistant").length, - ); + const assistantMessageCount = computed(() => { + return omnibot.messages.filter((m) => m.role === "assistant").length; + }); // Derive latest assistant message for peek - const latestAssistantMessage = derive(omnibot.messages, (messages) => { - if (!messages || messages.length === 0) return null; + const latestAssistantMessage = computed(() => { + if (!omnibot.messages || omnibot.messages.length === 0) return null; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; + for (let i = omnibot.messages.length - 1; i >= 0; i--) { + const msg = omnibot.messages[i]; if (msg.role === "assistant") { const content = typeof msg.content === "string" ? msg.content @@ -121,7 +120,7 @@ export default recipe( messages: omnibot.messages, [UI]: ( fabExpanded.get())} variant="primary" position="bottom-right" pending={omnibot.pending} @@ -134,26 +133,25 @@ export default recipe( {/* Chevron at top - the "handle" for the drawer */}
showHistory.get())} loading={omnibot.pending} onct-toggle={toggle({ value: showHistory })} />
- `flex: ${ - show ? "1" : "0" - }; min-height: 0; display: flex; flex-direction: column; opacity: ${ - show ? "1" : "0" - }; max-height: ${ - show ? "480px" : "0" - }; overflow: hidden; transition: opacity 300ms ease, max-height 400ms cubic-bezier(0.34, 1.56, 0.64, 1), flex 400ms cubic-bezier(0.34, 1.56, 0.64, 1); pointer-events: ${ - show ? "auto" : "none" - };`, - )} + style={computed(() => { + const show = showHistory.get(); + return `flex: ${ + show ? "1" : "0" + }; min-height: 0; display: flex; flex-direction: column; opacity: ${ + show ? "1" : "0" + }; max-height: ${ + show ? "480px" : "0" + }; overflow: hidden; transition: opacity 300ms ease, max-height 400ms cubic-bezier(0.34, 1.56, 0.64, 1), flex 400ms cubic-bezier(0.34, 1.56, 0.64, 1); pointer-events: ${ + show ? "auto" : "none" + };`; + })} >
{omnibot.ui.attachmentsAndTools} @@ -164,16 +162,12 @@ export default recipe(
{ifElse( - derive( - [ - showHistory, - latestAssistantMessage, - peekDismissedIndex, - assistantMessageCount, - ], - ([show, msg, dismissedIdx, count]) => - !show && msg && count !== dismissedIdx, - ), + computed(() => { + const show = showHistory.get(); + const dismissedIdx = peekDismissedIndex.get(); + return !show && latestAssistantMessage && + assistantMessageCount !== dismissedIdx; + }),
Date: Thu, 13 Nov 2025 07:56:12 +1000 Subject: [PATCH 2/4] Add support for `tools` to `generateText` (#2060) * Add support for `tools` to `generateText` * Fix cache behaviour for tool-call-only response * Fix lint * Format * Format --- packages/api/index.ts | 2 + packages/patterns/tool-call-examples.tsx | 34 +++ packages/runner/src/builtins/llm-dialog.ts | 13 ++ packages/runner/src/builtins/llm.ts | 202 ++++++++++++++---- .../toolshed/routes/ai/llm/generateText.ts | 39 +++- 5 files changed, 247 insertions(+), 43 deletions(-) create mode 100644 packages/patterns/tool-call-examples.tsx diff --git a/packages/api/index.ts b/packages/api/index.ts index 0e73cc2e5..f6308e9ac 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -928,6 +928,7 @@ export type BuiltInGenerateTextParams = system?: string; model?: string; maxTokens?: number; + tools?: Record; } | { prompt?: never; @@ -935,6 +936,7 @@ export type BuiltInGenerateTextParams = system?: string; model?: string; maxTokens?: number; + tools?: Record; }; export interface BuiltInGenerateTextState { diff --git a/packages/patterns/tool-call-examples.tsx b/packages/patterns/tool-call-examples.tsx new file mode 100644 index 000000000..ef62bed5e --- /dev/null +++ b/packages/patterns/tool-call-examples.tsx @@ -0,0 +1,34 @@ +/// +import { cell, generateText, NAME, recipe, str, UI } from "commontools"; + +import { calculator } from "./common-tools.tsx"; + +export default recipe("ToolCallExamples", () => { + const expression = cell("1+1"); + + const text = generateText({ + system: + "You are a concise assistant. Call tools when you need precise data and reply with only the final answer.", + prompt: str`Calculate: ${expression}`, + tools: { + calculator: { + pattern: calculator, + }, + }, + }); + + return { + [NAME]: "Tool Call Examples", + [UI]: ( +
+
+ +
+
+

Text Generation

+

{text.result}

+
+
+ ), + }; +}); diff --git a/packages/runner/src/builtins/llm-dialog.ts b/packages/runner/src/builtins/llm-dialog.ts index 8d29ca2c5..7cc11de6f 100644 --- a/packages/runner/src/builtins/llm-dialog.ts +++ b/packages/runner/src/builtins/llm-dialog.ts @@ -723,6 +723,19 @@ export const llmDialogTestHelpers = { hasValidContent, }; +/** + * Shared tool execution utilities for use by other LLM built-ins (llm, generateText). + * These functions handle tool catalog building, tool call resolution, and execution. + */ +export const llmToolExecutionHelpers = { + buildToolCatalog, + executeToolCalls, + extractToolCallParts, + buildAssistantMessage, + createToolResultMessages, + hasValidContent, +}; + /** * Performs a mutation on the storage if the pending flag is active and the * request ID matches. This ensures the pending flag has final say over whether diff --git a/packages/runner/src/builtins/llm.ts b/packages/runner/src/builtins/llm.ts index 1693180ab..1c29d3e82 100644 --- a/packages/runner/src/builtins/llm.ts +++ b/packages/runner/src/builtins/llm.ts @@ -18,6 +18,7 @@ import { type Cell } from "../cell.ts"; import { type Action } from "../scheduler.ts"; import type { IRuntime } from "../runtime.ts"; import type { IExtendedStorageTransaction } from "../storage/interface.ts"; +import { llmToolExecutionHelpers } from "./llm-dialog.ts"; const client = new LLMClient(); @@ -130,7 +131,7 @@ export function llm( const partialWithLog = partial.withTx(tx); const requestHashWithLog = requestHash.withTx(tx); - const { system, messages, stop, maxTokens, model } = + const { system, messages, stop, maxTokens, model, tools } = inputsCell.getAsQueryResult([], tx) ?? {}; const llmParams: LLMRequest = { @@ -146,6 +147,7 @@ export function llm( context: "charm", }, cache: true, + // tools will be added below if present }; const hash = refer(llmParams).toString(); @@ -176,12 +178,59 @@ export function llm( partialWithLog.set(text); }; - const resultPromise = client.sendRequest(llmParams, updatePartial); + // Start the LLM request with tool execution loop + const executeWithTools = async ( + currentMessages: BuiltInLLMMessage[], + toolCatalog?: Awaited< + ReturnType + >, + ): Promise => { + if (thisRun !== currentRun) return; - resultPromise - .then(async (llmResult) => { - if (thisRun !== currentRun) return; + const requestParams: LLMRequest = { + ...llmParams, + messages: currentMessages, + tools: toolCatalog?.llmTools, + }; + + const llmResult = await client.sendRequest(requestParams, updatePartial); + if (thisRun !== currentRun) return; + + // Check if there are tool calls in the response + const toolCallParts = llmToolExecutionHelpers.extractToolCallParts( + llmResult.content, + ); + const hasToolCalls = toolCallParts.length > 0; + + if (hasToolCalls && toolCatalog) { + // Execute tools and continue conversation + const assistantMessage = llmToolExecutionHelpers.buildAssistantMessage( + llmResult.content, + toolCallParts, + ); + + const toolResults = await llmToolExecutionHelpers.executeToolCalls( + runtime, + parentCell.space, + toolCatalog, + toolCallParts, + ); + + const toolResultMessages = llmToolExecutionHelpers + .createToolResultMessages(toolResults); + + // Build new message history with assistant message + tool results + const updatedMessages = [ + ...currentMessages, + assistantMessage, + ...toolResultMessages, + ]; + + // Continue conversation with tool results + await executeWithTools(updatedMessages, toolCatalog); + } else { + // No more tool calls, finish await runtime.idle(); await runtime.editWithRetry((tx) => { @@ -190,27 +239,39 @@ export function llm( partial.withTx(tx).set(extractTextFromLLMResponse(llmResult)); requestHash.withTx(tx).set(hash); }); - }) - .catch(async (error) => { - if (thisRun !== currentRun) return; + } + }; - console.error("Error generating data", error); + // Build tool catalog if tools are present, then start execution + const resultPromise = (async () => { + const toolsCell = tools ? inputsCell.key("tools") : undefined; + const toolCatalog = toolsCell + ? await llmToolExecutionHelpers.buildToolCatalog(runtime, toolsCell) + : undefined; - await runtime.idle(); + await executeWithTools(messages ?? [], toolCatalog); + })(); - await runtime.editWithRetry((tx) => { - pending.withTx(tx).set(false); - result.withTx(tx).set(undefined); - partial.withTx(tx).set(undefined); - }); + resultPromise.catch(async (error) => { + if (thisRun !== currentRun) return; - // Reset previousCallHash to allow retry after error - previousCallHash = undefined; + console.error("Error generating data", error); - // TODO(seefeld): Not writing now, so we retry the request after failure. - // Replace this with more fine-grained retry logic. - // requestHash.setAtPath([], hash, log); + await runtime.idle(); + + await runtime.editWithRetry((tx) => { + pending.withTx(tx).set(false); + result.withTx(tx).set(undefined); + partial.withTx(tx).set(undefined); }); + + // Reset previousCallHash to allow retry after error + previousCallHash = undefined; + + // TODO(seefeld): Not writing now, so we retry the request after failure. + // Replace this with more fine-grained retry logic. + // requestHash.setAtPath([], hash, log); + }); }; } @@ -271,7 +332,7 @@ export function generateText( const partialWithLog = partial.withTx(tx); const requestHashWithLog = requestHash.withTx(tx); - const { system, prompt, messages, model, maxTokens } = + const { system, prompt, messages, model, maxTokens, tools } = inputsCell.getAsQueryResult([], tx) ?? {}; // If neither prompt nor messages is provided, don't make a request @@ -297,6 +358,7 @@ export function generateText( context: "charm", }, cache: true, + // tools will be added below if present }; const hash = refer(llmParams).toString(); @@ -335,15 +397,61 @@ export function generateText( partialWithLog.set(text); }; - const resultPromise = client.sendRequest(llmParams, updatePartial); + // Start the LLM request with tool execution loop + const executeWithTools = async ( + currentMessages: BuiltInLLMMessage[], + toolCatalog?: Awaited< + ReturnType + >, + ): Promise => { + if (thisRun !== currentRun) return; - resultPromise - .then(async (llmResult) => { - if (thisRun !== currentRun) return; + const requestParams: LLMRequest = { + ...llmParams, + messages: currentMessages, + tools: toolCatalog?.llmTools, + }; + + const llmResult = await client.sendRequest(requestParams, updatePartial); + if (thisRun !== currentRun) return; + + // Check if there are tool calls in the response + const toolCallParts = llmToolExecutionHelpers.extractToolCallParts( + llmResult.content, + ); + const hasToolCalls = toolCallParts.length > 0; + + if (hasToolCalls && toolCatalog) { + // Execute tools and continue conversation + const assistantMessage = llmToolExecutionHelpers.buildAssistantMessage( + llmResult.content, + toolCallParts, + ); + + const toolResults = await llmToolExecutionHelpers.executeToolCalls( + runtime, + parentCell.space, + toolCatalog, + toolCallParts, + ); + + const toolResultMessages = llmToolExecutionHelpers + .createToolResultMessages(toolResults); + + // Build new message history with assistant message + tool results + const updatedMessages = [ + ...currentMessages, + assistantMessage, + ...toolResultMessages, + ]; + + // Continue conversation with tool results + await executeWithTools(updatedMessages, toolCatalog); + } else { + // No more tool calls, finish - extract text from final response await runtime.idle(); - // Extract text from the LLM response const textResult = extractTextFromLLMResponse(llmResult); await runtime.editWithRetry((tx) => { @@ -352,27 +460,39 @@ export function generateText( partial.withTx(tx).set(textResult); requestHash.withTx(tx).set(hash); }); - }) - .catch(async (error) => { - if (thisRun !== currentRun) return; + } + }; - console.error("Error generating text", error); + // Build tool catalog if tools are present, then start execution + const resultPromise = (async () => { + const toolsCell = tools ? inputsCell.key("tools") : undefined; + const toolCatalog = toolsCell + ? await llmToolExecutionHelpers.buildToolCatalog(runtime, toolsCell) + : undefined; - await runtime.idle(); + await executeWithTools(requestMessages, toolCatalog); + })(); - await runtime.editWithRetry((tx) => { - pending.withTx(tx).set(false); - result.withTx(tx).set(undefined); - partial.withTx(tx).set(undefined); - }); + resultPromise.catch(async (error) => { + if (thisRun !== currentRun) return; - // Reset previousCallHash to allow retry after error - previousCallHash = undefined; + console.error("Error generating text", error); - // TODO(seefeld): Not writing now, so we retry the request after failure. - // Replace this with more fine-grained retry logic. - // requestHash.setAtPath([], hash, log); + await runtime.idle(); + + await runtime.editWithRetry((tx) => { + pending.withTx(tx).set(false); + result.withTx(tx).set(undefined); + partial.withTx(tx).set(undefined); }); + + // Reset previousCallHash to allow retry after error + previousCallHash = undefined; + + // TODO(seefeld): Not writing now, so we retry the request after failure. + // Replace this with more fine-grained retry logic. + // requestHash.setAtPath([], hash, log); + }); }; } diff --git a/packages/toolshed/routes/ai/llm/generateText.ts b/packages/toolshed/routes/ai/llm/generateText.ts index 5798dc6b0..fd2a279a3 100644 --- a/packages/toolshed/routes/ai/llm/generateText.ts +++ b/packages/toolshed/routes/ai/llm/generateText.ts @@ -302,6 +302,12 @@ export async function generateText( const stream = new ReadableStream({ async start(controller) { let result = ""; + const toolCalls: Array<{ + toolCallId: string; + toolName: string; + input: any; + }> = []; + // If last message was from assistant, send it first if (messages[messages.length - 1].role === "assistant") { const content = messages[messages.length - 1].content; @@ -329,6 +335,12 @@ export async function generateText( ), ); } else if (part.type === "tool-call") { + // Track tool calls for message history + toolCalls.push({ + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.input, + }); // Send tool call event to client controller.enqueue( new TextEncoder().encode( @@ -370,11 +382,34 @@ export async function generateText( result = cleanJsonResponse(result); } + // Update message history with proper content structure + let assistantContent: BuiltInLLMMessage["content"]; + + if (toolCalls.length > 0) { + // Build structured content when tool calls are present + const contentParts: BuiltInLLMMessage["content"] = []; + if (result.trim()) { + contentParts.push({ type: "text", text: result } as any); + } + for (const tc of toolCalls) { + contentParts.push({ + type: "tool-call", + toolCallId: tc.toolCallId, + toolName: tc.toolName, + input: tc.input, + } as any); + } + assistantContent = contentParts as any; + } else { + // Plain text response + assistantContent = result; + } + // Update message history if (messages[messages.length - 1].role === "user") { - messages.push({ role: "assistant", content: result }); + messages.push({ role: "assistant", content: assistantContent } as any); } else { - messages[messages.length - 1].content = result; + messages[messages.length - 1].content = assistantContent as any; } // Send finish event to client From 69447104c1b00660e0f1a2752645b295773a626c Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Thu, 13 Nov 2025 08:41:18 +1000 Subject: [PATCH 3/4] Fix model picker display in `ct-prompt-input` (#2064) * Fix model picker display in `ct-prompt-input` * Format * Fix `generateObject` 400 error * Handle `undefined` case --- packages/patterns/chatbot.tsx | 11 +++---- .../ct-prompt-input/ct-prompt-input.ts | 32 ++++++++++++++++++- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/patterns/chatbot.tsx b/packages/patterns/chatbot.tsx index 6b48fc40a..746b86e0b 100644 --- a/packages/patterns/chatbot.tsx +++ b/packages/patterns/chatbot.tsx @@ -152,21 +152,20 @@ type ChatOutput = { export const TitleGenerator = recipe< { model?: string; messages: Array } >("Title Generator", ({ model, messages }) => { - const titleMessages = computed(() => { + const previewMessage = computed(() => { if (!messages || messages.length === 0) return ""; - const messageCount = 2; - const selectedMessages = messages.slice(0, messageCount).filter(Boolean); + const firstMessage = messages[0]; - if (selectedMessages.length === 0) return ""; + if (!firstMessage) return ""; - return selectedMessages; + return JSON.stringify(firstMessage); }); const { result } = generateObject({ system: "Generate at most a 3-word title based on the following content, respond with NOTHING but the literal title text.", - messages: titleMessages, + prompt: previewMessage, model, schema: { type: "object", diff --git a/packages/ui/src/v2/components/ct-prompt-input/ct-prompt-input.ts b/packages/ui/src/v2/components/ct-prompt-input/ct-prompt-input.ts index 01fe9a1d5..04442abb0 100644 --- a/packages/ui/src/v2/components/ct-prompt-input/ct-prompt-input.ts +++ b/packages/ui/src/v2/components/ct-prompt-input/ct-prompt-input.ts @@ -343,6 +343,7 @@ export class CTPromptInput extends BaseElement { declare theme?: CTTheme; private _textareaElement?: HTMLElement; + private _modelSelectElement?: HTMLSelectElement; // Attachment management private attachments: Map = new Map(); @@ -415,7 +416,11 @@ export class CTPromptInput extends BaseElement { this._textareaElement = this.shadowRoot?.querySelector( "textarea", ) as HTMLTextAreaElement; + this._modelSelectElement = this.shadowRoot?.querySelector( + ".model-select", + ) as HTMLSelectElement; this._updateThemeProperties(); + this._applyModelValueToDom(); } override updated( @@ -438,6 +443,12 @@ export class CTPromptInput extends BaseElement { if (changedProperties.has("model") && this.model != null) { this._modelController.bind(this.model); } + if ( + changedProperties.has("model") || + changedProperties.has("modelItems") + ) { + this._applyModelValueToDom(); + } // Manage mentions overlay based on controller state // The MentionController will trigger requestUpdate when state changes @@ -737,7 +748,6 @@ export class CTPromptInput extends BaseElement { ? html`