From bcfce1936b3b4489e316b6d4131d36650840d1fd Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 19 Sep 2025 09:06:47 +1000 Subject: [PATCH 01/12] Fix enter-to-choose-completion --- .../ui/src/v2/components/ct-code-editor/ct-code-editor.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts index f5656981d..56aee1258 100644 --- a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts +++ b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts @@ -18,6 +18,7 @@ import { CompletionContext, CompletionResult, } from "@codemirror/autocomplete"; +import { acceptCompletion } from "@codemirror/autocomplete"; import { Decoration, DecorationSet, @@ -670,6 +671,11 @@ export class CTCodeEditor extends BaseElement { activateOnTyping: true, closeOnBlur: true, }), + // Ensure Enter accepts the currently selected completion + keymap.of([{ + key: "Enter", + run: (view) => acceptCompletion(view), + }]), ]; // Add placeholder extension if specified From 40c3fd39e8c7855e589ede2276457ea9b153e494 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:13:23 +1000 Subject: [PATCH 02/12] Create new note.tsx instances when mentioning a new title --- packages/patterns/note.tsx | 28 ++++++- .../ct-code-editor/ct-code-editor.ts | 75 +++++++++++++++++-- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/packages/patterns/note.tsx b/packages/patterns/note.tsx index cb604ff51..7518fdeb4 100644 --- a/packages/patterns/note.tsx +++ b/packages/patterns/note.tsx @@ -6,6 +6,7 @@ import { h, handler, NAME, + OpaqueRef, navigateTo, recipe, toSchema, @@ -58,7 +59,27 @@ const handleCharmLinkClick = handler< return navigateTo(detail.charm); }); -export default recipe( +const handleNewBacklink = handler< + { + detail: { + text: string; + }; + }, + { + allCharms: Cell; + } +>(({ detail }, { allCharms }) => { + console.log("new charm", detail.text); + const n = Note({ + title: detail.text, + content: "", + allCharms, + }); + + return navigateTo(n); +}); + +const Note = recipe( "Note", ({ title, content, allCharms }) => { const mentioned = cell([]); @@ -79,6 +100,9 @@ export default recipe( $mentionable={allCharms} $mentioned={mentioned} onbacklink-click={handleCharmLinkClick({})} + onbacklink-create={handleNewBacklink({ + allCharms: allCharms as unknown as OpaqueRef + })} language="text/markdown" wordWrap tabIndent @@ -92,3 +116,5 @@ export default recipe( }; }, ); + +export default Note; diff --git a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts index 56aee1258..efc94309d 100644 --- a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts +++ b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts @@ -17,8 +17,8 @@ import { Completion, CompletionContext, CompletionResult, + acceptCompletion, } from "@codemirror/autocomplete"; -import { acceptCompletion } from "@codemirror/autocomplete"; import { Decoration, DecorationSet, @@ -95,6 +95,9 @@ const getLangExtFromMimeType = (mime: MimeType) => { * @fires ct-focus - Fired on focus * @fires ct-blur - Fired on blur * @fires backlink-click - Fired when a backlink is clicked with Cmd/Ctrl+Enter with detail: { text, charm } + * @fires backlink-create - Fired when a novel backlink is activated (Cmd/Ctrl+Click) + * or confirmed with Enter during autocomplete with no matches. Detail: + * { text } * * @example * @@ -201,8 +204,7 @@ export class CTCodeEditor extends BaseElement { const mentionable = this.getFilteredMentionable(query); - if (mentionable.length === 0) return null; - + // Build options from existing mentionable items const options: Completion[] = mentionable.map((charm) => { const charmIdObj = getEntityId(charm); const charmId = charmIdObj?.["/"] || ""; @@ -216,6 +218,28 @@ export class CTCodeEditor extends BaseElement { }; }); + // Inject a "create new" option when the typed text doesn't exactly match + // any existing charm. This ensures there's a selectable option for + // keyboard users when creating a novel backlink. + const raw = query.trim(); + if (raw.length > 0) { + const lower = raw.toLowerCase(); + const hasExact = options.some((o) => o.label.toLowerCase() === lower); + if (!hasExact) { + options.push({ + label: raw, + detail: "Create", + type: "text", + info: "Create new backlink", + apply: () => { + this.emit("backlink-create", { text: raw }); + }, + }); + } + } + + if (options.length === 0) return null; + return { from: backlink.from + 2, // Start after [[ to: afterCursor === "]]" ? context.pos : undefined, @@ -296,11 +320,11 @@ export class CTCodeEditor extends BaseElement { // Check if cursor is within this backlink if (pos >= matchStart && pos <= matchEnd) { - const backlinkText = match[1]; // This is "Name (id)" format + const backlinkText = match[1]; // Extract ID from "Name (id)" format const idMatch = backlinkText.match(/\(([^)]+)\)$/); - const backlinkId = idMatch ? idMatch[1] : backlinkText; - const charm = this.findCharmById(backlinkId); + const backlinkId = idMatch ? idMatch[1] : undefined; + const charm = backlinkId ? this.findCharmById(backlinkId) : null; if (charm) { this.emit("backlink-click", { @@ -310,12 +334,29 @@ export class CTCodeEditor extends BaseElement { }); return true; } + + // No ID or no matching item: request creation + this.emit("backlink-create", { text: backlinkText }); + return true; } } return false; } + /** + * If the cursor is after an unclosed [[... token on the same line, + * return the current query text. Otherwise return null. + */ + private _currentBacklinkQuery(view: EditorView): string | null { + const pos = view.state.selection.main.head; + const line = view.state.doc.lineAt(pos); + const textBefore = view.state.doc.sliceString(line.from, pos); + const m = textBefore.match(/\[\[([^\]]*)$/); + if (!m) return null; + return m[1] ?? ""; + } + /** * Find a charm by ID in the mentionable list */ @@ -671,10 +712,28 @@ export class CTCodeEditor extends BaseElement { activateOnTyping: true, closeOnBlur: true, }), - // Ensure Enter accepts the currently selected completion + // Enter: accept selected completion, or create novel backlink keymap.of([{ key: "Enter", - run: (view) => acceptCompletion(view), + run: (view) => { + // Try accepting an active completion first + if (acceptCompletion(view)) return true; + + // If typing a backlink with no matches, emit create event + const query = this._currentBacklinkQuery(view); + if (query != null) { + const matches = this.getFilteredMentionable(query); + if (matches.length === 0) { + const text = query.trim(); + if (text.length > 0) { + this.emit("backlink-create", { text }); + return true; + } + } + } + + return false; + }, }]), ]; From e95fb39c1f36f397408b66abc7f1abb9ea33a52d Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:20:22 +1000 Subject: [PATCH 03/12] Extend autolayout for side panels --- packages/patterns/chatbot-note-composed.tsx | 208 ++++++++++++++++++ packages/patterns/chatbot-note.tsx | 90 +++++--- packages/patterns/note.tsx | 79 +++++-- .../components/ct-autolayout/ct-autolayout.ts | 203 ++++++++++++++--- 4 files changed, 503 insertions(+), 77 deletions(-) create mode 100644 packages/patterns/chatbot-note-composed.tsx diff --git a/packages/patterns/chatbot-note-composed.tsx b/packages/patterns/chatbot-note-composed.tsx new file mode 100644 index 000000000..46f72a90e --- /dev/null +++ b/packages/patterns/chatbot-note-composed.tsx @@ -0,0 +1,208 @@ +/// +import { + BuiltInLLMMessage, + Cell, + cell, + Default, + derive, + fetchData, + getRecipeEnvironment, + h, + handler, + ID, + ifElse, + JSONSchema, + lift, + llm, + llmDialog, + NAME, + navigateTo, + OpaqueRef, + recipe, + str, + Stream, + toSchema, + UI, +} from "commontools"; + +import Chat from "./chatbot.tsx"; +import Note from "./note.tsx"; + +export type MentionableCharm = { + [NAME]: string; + content?: string; + mentioned?: MentionableCharm[]; +}; + +type NoteResult = { + content: Default; +}; + +export type NoteInput = { + content: Default; + allCharms: Cell; +}; + +const handleCharmLinkClick = handler< + { + detail: { + charm: Cell; + }; + }, + Record +>(({ detail }, _) => { + return navigateTo(detail.charm); +}); + +const handleCharmLinkClicked = handler( + (_: any, { charm }: { charm: Cell }) => { + return navigateTo(charm); + }, +); + +type LLMTestInput = { + title: Default; + messages: Default, []>; + expandChat: Default; + content: Default; + allCharms: Cell; +}; + +type LLMTestResult = { + messages: Default, []>; + mentioned: Default, []>; + backlinks: Default, []>; + content: Default; +}; + +// put a note at the end of the outline (by appending to root.children) +const editNote = handler< + { + /** The text content of the note */ + body: string; + /** A cell to store the result message indicating success or error */ + result: Cell; + }, + { content: Cell } +>( + (args, state) => { + try { + state.content.set(args.body); + + args.result.set( + `Updated note!`, + ); + } catch (error) { + args.result.set(`Error: ${(error as any)?.message || ""}`); + } + }, +); + +const readNote = handler< + { + /** A cell to store the result text */ + result: Cell; + }, + { content: string } +>( + (args, state) => { + try { + args.result.set(state.content); + } catch (error) { + args.result.set(`Error: ${(error as any)?.message || ""}`); + } + }, +); + +export default recipe( + "Note", + ({ title, expandChat, messages, content, allCharms }) => { + const tools = { + editNote: { + description: "Modify the shared note.", + inputSchema: { + type: "object", + properties: { + body: { + type: "string", + description: "The content of the note.", + }, + }, + required: ["body"], + } as JSONSchema, + handler: editNote({ content }), + }, + readNote: { + description: "Read the shared note.", + inputSchema: { + type: "object", + properties: {}, + required: [], + } as JSONSchema, + handler: readNote({ content }), + }, + }; + + const chat = Chat({ messages, tools }); + const { addMessage, cancelGeneration, pending } = chat; + + const note = Note({ title, content, allCharms }); + + return { + [NAME]: title, + [UI]: ( + + +
+
+ Show Chat +
+
+ + + {note} + + + + + + + +
+ ), + content, + messages, + mentioned: note.mentioned, + backlinks: note.backlinks, + }; + }, +); diff --git a/packages/patterns/chatbot-note.tsx b/packages/patterns/chatbot-note.tsx index 328007eaf..94f4f2bad 100644 --- a/packages/patterns/chatbot-note.tsx +++ b/packages/patterns/chatbot-note.tsx @@ -59,6 +59,28 @@ const handleCharmLinkClicked = handler( }, ); +const handleNewBacklink = handler< + { + detail: { + text: string; + }; + }, + { + allCharms: Cell; + } +>(({ detail }, { allCharms }) => { + console.log("new charm", detail.text); + const n = ChatbotNote({ + title: detail.text, + content: "", + allCharms, + messages: [], + expandChat: false, + }); + + return navigateTo(n); +}); + type LLMTestInput = { title: Default; messages: Default, []>; @@ -113,8 +135,8 @@ const readNote = handler< }, ); -export default recipe( - "Note", +const ChatbotNote = recipe( + "Chatbot Note", ({ title, expandChat, messages, content, allCharms }) => { const tools = { editNote: { @@ -200,38 +222,50 @@ export default recipe( $mentionable={allCharms} $mentioned={mentioned} onbacklink-click={handleCharmLinkClick({})} + onbacklink-create={handleNewBacklink({ + allCharms: allCharms as unknown as OpaqueRef + })} language="text/markdown" wordWrap tabIndent lineNumbers /> -
- Mentioned Charms - - {mentioned.map((charm: MentionableCharm) => ( - - {charm[NAME]} - - ))} - -
-
- Backlinks - - {backlinks.map((charm: MentionableCharm) => ( - - {charm[NAME]} - - ))} - -
- {ifElse( - expandChat, - chat, - null, - )} + + + + + ), @@ -242,3 +276,5 @@ export default recipe( }; }, ); + +export default ChatbotNote; diff --git a/packages/patterns/note.tsx b/packages/patterns/note.tsx index 7518fdeb4..dd2cd7939 100644 --- a/packages/patterns/note.tsx +++ b/packages/patterns/note.tsx @@ -3,6 +3,7 @@ import { Cell, cell, Default, + lift, h, handler, NAME, @@ -28,6 +29,7 @@ type Input = { type Output = { mentioned: Default, []>; content: Default; + backlinks: Default, []>; }; const updateTitle = handler< @@ -79,40 +81,75 @@ const handleNewBacklink = handler< return navigateTo(n); }); +const handleCharmLinkClicked = handler( + (_: any, { charm }: { charm: Cell }) => { + return navigateTo(charm); + }, +); + const Note = recipe( "Note", ({ title, content, allCharms }) => { const mentioned = cell([]); + // why does MentionableCharm behave differently than any here? + // perhaps optional properties? + const computeBacklinks = lift( + toSchema< + { allCharms: Cell; content: Cell } + >(), + toSchema(), + ({ allCharms, content }) => { + const cs: MentionableCharm[] = allCharms.get(); + if (!cs) return []; + + const self = cs.find((c) => c.content === content.get()); + + const results = self + ? cs.filter((c) => + c.mentioned?.some((m) => m.content === self.content) ?? false + ) + : []; + + return results; + }, + ); + + const backlinks: OpaqueRef = computeBacklinks({ + allCharms, + content, + }); + return { [NAME]: title, [UI]: ( - -
- +
+ +
+ + + })} + language="text/markdown" + wordWrap + tabIndent + lineNumbers /> -
- - - })} - language="text/markdown" - wordWrap - tabIndent - lineNumbers - /> -
+ ), title, content, mentioned, + backlinks, }; }, ); diff --git a/packages/ui/src/v2/components/ct-autolayout/ct-autolayout.ts b/packages/ui/src/v2/components/ct-autolayout/ct-autolayout.ts index 4f35798c3..2976004f0 100644 --- a/packages/ui/src/v2/components/ct-autolayout/ct-autolayout.ts +++ b/packages/ui/src/v2/components/ct-autolayout/ct-autolayout.ts @@ -6,8 +6,8 @@ import { BaseElement } from "../../core/base-element.ts"; * CTAutoLayout - Responsive multi-panel layout component * * Automatically arranges children: - * - Desktop: Side-by-side columns - * - Mobile: Tabbed interface + * - Desktop: Optional left/right sidebars and center content grid + * - Mobile: Degrades to a tabbed interface (left | content | right) * * @element ct-autolayout * @@ -17,10 +17,24 @@ import { BaseElement } from "../../core/base-element.ts"; *
Calculator results
*
Todo items
* + * + * @example With sidebars + * + * + *
Chat
+ *
Tools
+ * + *
*/ export class CTAutoLayout extends BaseElement { static override properties = { tabNames: { type: Array, attribute: false }, + leftTabName: { type: String, attribute: false }, + rightTabName: { type: String, attribute: false }, }; static override styles = css` :host { @@ -30,6 +44,13 @@ export class CTAutoLayout extends BaseElement { box-sizing: border-box; } + .controls { + display: none; /* desktop-only */ + padding: 0.25rem 0.5rem; + gap: 0.5rem; + border-bottom: 1px solid #e0e0e0; + } + .tabs { display: none; /* Hidden by default (desktop) */ border-top: 1px solid #e0e0e0; @@ -55,14 +76,51 @@ export class CTAutoLayout extends BaseElement { font-weight: 500; } - .content { + .layout { flex: 1; overflow: hidden; order: 1; /* Content comes first, tabs at bottom */ + display: grid; + grid-template-columns: 0px 1fr 0px; /* toggled by classes below */ + gap: 1rem; + } + + .sidebar-left, + .sidebar-right, + .content { + min-height: 0; + min-width: 0; + } + + .sidebar-left, + .sidebar-right { + overflow: hidden; + } + + /* Indicate presence of sidebars; width controlled by -open flags */ + .has-left { + grid-template-columns: 0px 1fr 0px; + } + .has-right { + grid-template-columns: 0px 1fr 0px; + } + .left-open { + grid-template-columns: 280px 1fr 0px; + } + .right-open { + grid-template-columns: 0px 1fr 280px; + } + .left-open.right-open { + grid-template-columns: 280px 1fr 280px; } /* Desktop: Grid layout */ @media (min-width: 769px) { + .controls { + display: flex; + justify-content: flex-end; + align-items: center; + } .content { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); @@ -84,14 +142,32 @@ export class CTAutoLayout extends BaseElement { display: flex; } - /* Hide all children by default */ + /* Collapse to single pane; hide wrappers by default */ + .layout { + display: block; + } + .sidebar-left, + .sidebar-right, + .content { + display: none; + width: 100%; + height: 100%; + } + + .layout.active-left .sidebar-left, + .layout.active-right .sidebar-right, + .layout.active-content .content { + display: block; + } + + /* Hide all slotted children by default */ ::slotted(*) { display: none; height: 100%; } - /* Show only the active child */ - ::slotted(.active-tab) { + /* Show only the active child inside content */ + .layout.active-content ::slotted(.active-tab) { display: flex !important; width: 100%; flex-direction: column; @@ -101,12 +177,22 @@ export class CTAutoLayout extends BaseElement { private _activeTab = 0; private _children: Element[] = []; + private _leftEl: Element | null = null; + private _rightEl: Element | null = null; + private _hasLeft = false; + private _hasRight = false; + private _leftOpen = true; + private _rightOpen = true; declare tabNames: string[]; + declare leftTabName?: string; + declare rightTabName?: string; constructor() { super(); this.tabNames = []; + this.leftTabName = "Left"; + this.rightTabName = "Right"; } override connectedCallback() { @@ -117,6 +203,12 @@ export class CTAutoLayout extends BaseElement { private _updateChildren() { this._children = Array.from(this.children); + this._leftEl = this._children.find((el) => el.getAttribute("slot") === + "left") ?? null; + this._rightEl = this._children.find((el) => el.getAttribute("slot") === + "right") ?? null; + this._hasLeft = !!this._leftEl; + this._hasRight = !!this._rightEl; } private _handleTabClick(index: number) { @@ -125,42 +217,95 @@ export class CTAutoLayout extends BaseElement { this.requestUpdate(); } + private _toggleLeft() { + if (!this._hasLeft) return; + this._leftOpen = !this._leftOpen; + this.requestUpdate(); + } + + private _toggleRight() { + if (!this._hasRight) return; + this._rightOpen = !this._rightOpen; + this.requestUpdate(); + } + private _updateActiveTab() { + // Determine panes in order: left | defaults | right + const defaults = this._children.filter((el) => !el.getAttribute("slot")); + const panes: Element[] = []; + if (this._leftEl) panes.push(this._leftEl); + panes.push(...defaults); + if (this._rightEl) panes.push(this._rightEl); + // Remove active-tab class from all children - this._children.forEach((child) => { - child.classList.remove("active-tab"); - }); + this._children.forEach((child) => child.classList.remove("active-tab")); - // Add active-tab class to the current active child - if (this._children[this._activeTab]) { - this._children[this._activeTab].classList.add("active-tab"); - } + // Add active-tab class to current pane + const active = panes[this._activeTab]; + if (active) active.classList.add("active-tab"); } override render() { this._updateChildren(); + // Build tabs list: left | defaults | right + const defaults = this._children.filter((el) => !el.getAttribute("slot")); + const tabs: string[] = []; + if (this._hasLeft) tabs.push(this.leftTabName || "Left"); + const defaultNames = this.tabNames.length === defaults.length + ? this.tabNames + : defaults.map((_, i) => `Pane ${i + 1}`); + tabs.push(...defaultNames); + if (this._hasRight) tabs.push(this.rightTabName || "Right"); + + const layoutClass = classMap({ + layout: true, + "has-left": this._hasLeft, + "has-right": this._hasRight, + "left-open": this._hasLeft && this._leftOpen, + "right-open": this._hasRight && this._rightOpen, + "active-left": this._activeTab === 0 && this._hasLeft, + "active-right": this._activeTab === (tabs.length - 1) && this._hasRight, + "active-content": !((this._activeTab === 0 && this._hasLeft) || + (this._activeTab === (tabs.length - 1) && this._hasRight)), + }); + return html` + +
+ ${this._hasLeft + ? html`` + : null} + ${this._hasRight + ? html`` + : null} +
+
- ${this.tabNames.map((name, index) => { - return html` - - `; - })} + ${tabs.map((name, index) => html` + + `)}
- -
- + +
+ +
+ +
+
`; } From c7c3755ce51bc8917097cff251d71555ff6a6904 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:27:52 +1000 Subject: [PATCH 04/12] Catch Cmd/Ctrl+S and prevent browser default --- .../ui/src/v2/components/ct-code-editor/ct-code-editor.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts index efc94309d..2cc1bc4b8 100644 --- a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts +++ b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts @@ -735,6 +735,14 @@ export class CTCodeEditor extends BaseElement { return false; }, }]), + // Intercept Cmd/Ctrl+S when editor is focused + keymap.of([{ + key: "Mod-s", + run: () => { + console.log("[ct-code-editor] Intercepted save (Cmd/Ctrl+S)."); + return true; // prevent default browser save + }, + }]), ]; // Add placeholder extension if specified From 461fdc3d8ebbd71030be11fe4dc35b542f8ffab3 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:29:08 +1000 Subject: [PATCH 05/12] Format pass --- packages/patterns/chatbot-note-composed.tsx | 2 - packages/patterns/chatbot-note.tsx | 6 +-- packages/patterns/note.tsx | 46 +++++++++---------- .../components/ct-autolayout/ct-autolayout.ts | 46 ++++++++++++------- .../ct-code-editor/ct-code-editor.ts | 2 +- 5 files changed, 57 insertions(+), 45 deletions(-) diff --git a/packages/patterns/chatbot-note-composed.tsx b/packages/patterns/chatbot-note-composed.tsx index 46f72a90e..fa906ac40 100644 --- a/packages/patterns/chatbot-note-composed.tsx +++ b/packages/patterns/chatbot-note-composed.tsx @@ -194,8 +194,6 @@ export default recipe( , )} - - ), diff --git a/packages/patterns/chatbot-note.tsx b/packages/patterns/chatbot-note.tsx index 94f4f2bad..d857f1ffd 100644 --- a/packages/patterns/chatbot-note.tsx +++ b/packages/patterns/chatbot-note.tsx @@ -223,7 +223,9 @@ const ChatbotNote = recipe( $mentioned={mentioned} onbacklink-click={handleCharmLinkClick({})} onbacklink-create={handleNewBacklink({ - allCharms: allCharms as unknown as OpaqueRef + allCharms: allCharms as unknown as OpaqueRef< + MentionableCharm[] + >, })} language="text/markdown" wordWrap @@ -264,8 +266,6 @@ const ChatbotNote = recipe( , )} - - ), diff --git a/packages/patterns/note.tsx b/packages/patterns/note.tsx index dd2cd7939..5b317ca3c 100644 --- a/packages/patterns/note.tsx +++ b/packages/patterns/note.tsx @@ -3,12 +3,12 @@ import { Cell, cell, Default, - lift, h, handler, + lift, NAME, - OpaqueRef, navigateTo, + OpaqueRef, recipe, toSchema, UI, @@ -123,28 +123,28 @@ const Note = recipe( return { [NAME]: title, [UI]: ( - -
- -
- - - })} - language="text/markdown" - wordWrap - tabIndent - lineNumbers + +
+ - +
+ + , + })} + language="text/markdown" + wordWrap + tabIndent + lineNumbers + /> +
), title, content, diff --git a/packages/ui/src/v2/components/ct-autolayout/ct-autolayout.ts b/packages/ui/src/v2/components/ct-autolayout/ct-autolayout.ts index 2976004f0..b8fa13f92 100644 --- a/packages/ui/src/v2/components/ct-autolayout/ct-autolayout.ts +++ b/packages/ui/src/v2/components/ct-autolayout/ct-autolayout.ts @@ -203,10 +203,14 @@ export class CTAutoLayout extends BaseElement { private _updateChildren() { this._children = Array.from(this.children); - this._leftEl = this._children.find((el) => el.getAttribute("slot") === - "left") ?? null; - this._rightEl = this._children.find((el) => el.getAttribute("slot") === - "right") ?? null; + this._leftEl = this._children.find((el) => + el.getAttribute("slot") === + "left" + ) ?? null; + this._rightEl = this._children.find((el) => + el.getAttribute("slot") === + "right" + ) ?? null; this._hasLeft = !!this._leftEl; this._hasRight = !!this._rightEl; } @@ -274,25 +278,35 @@ export class CTAutoLayout extends BaseElement {
${this._hasLeft - ? html`` - : null} - ${this._hasRight - ? html` + ` + : null} ${this._hasRight + ? html` + ` + + ` : null}
- ${tabs.map((name, index) => html` - - `)} + ${tabs.map((name, index) => + html` + + ` + )}
diff --git a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts index 2cc1bc4b8..02938001c 100644 --- a/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts +++ b/packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts @@ -13,11 +13,11 @@ import { html as createHtml } from "@codemirror/lang-html"; import { json as createJson } from "@codemirror/lang-json"; import { oneDark } from "@codemirror/theme-one-dark"; import { + acceptCompletion, autocompletion, Completion, CompletionContext, CompletionResult, - acceptCompletion, } from "@codemirror/autocomplete"; import { Decoration, From 6ba10f45f1ee872ebd1b551d505836177927a15d Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:29:46 +1000 Subject: [PATCH 06/12] Remove composed recipe --- packages/patterns/chatbot-note-composed.tsx | 206 -------------------- 1 file changed, 206 deletions(-) delete mode 100644 packages/patterns/chatbot-note-composed.tsx diff --git a/packages/patterns/chatbot-note-composed.tsx b/packages/patterns/chatbot-note-composed.tsx deleted file mode 100644 index fa906ac40..000000000 --- a/packages/patterns/chatbot-note-composed.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/// -import { - BuiltInLLMMessage, - Cell, - cell, - Default, - derive, - fetchData, - getRecipeEnvironment, - h, - handler, - ID, - ifElse, - JSONSchema, - lift, - llm, - llmDialog, - NAME, - navigateTo, - OpaqueRef, - recipe, - str, - Stream, - toSchema, - UI, -} from "commontools"; - -import Chat from "./chatbot.tsx"; -import Note from "./note.tsx"; - -export type MentionableCharm = { - [NAME]: string; - content?: string; - mentioned?: MentionableCharm[]; -}; - -type NoteResult = { - content: Default; -}; - -export type NoteInput = { - content: Default; - allCharms: Cell; -}; - -const handleCharmLinkClick = handler< - { - detail: { - charm: Cell; - }; - }, - Record ->(({ detail }, _) => { - return navigateTo(detail.charm); -}); - -const handleCharmLinkClicked = handler( - (_: any, { charm }: { charm: Cell }) => { - return navigateTo(charm); - }, -); - -type LLMTestInput = { - title: Default; - messages: Default, []>; - expandChat: Default; - content: Default; - allCharms: Cell; -}; - -type LLMTestResult = { - messages: Default, []>; - mentioned: Default, []>; - backlinks: Default, []>; - content: Default; -}; - -// put a note at the end of the outline (by appending to root.children) -const editNote = handler< - { - /** The text content of the note */ - body: string; - /** A cell to store the result message indicating success or error */ - result: Cell; - }, - { content: Cell } ->( - (args, state) => { - try { - state.content.set(args.body); - - args.result.set( - `Updated note!`, - ); - } catch (error) { - args.result.set(`Error: ${(error as any)?.message || ""}`); - } - }, -); - -const readNote = handler< - { - /** A cell to store the result text */ - result: Cell; - }, - { content: string } ->( - (args, state) => { - try { - args.result.set(state.content); - } catch (error) { - args.result.set(`Error: ${(error as any)?.message || ""}`); - } - }, -); - -export default recipe( - "Note", - ({ title, expandChat, messages, content, allCharms }) => { - const tools = { - editNote: { - description: "Modify the shared note.", - inputSchema: { - type: "object", - properties: { - body: { - type: "string", - description: "The content of the note.", - }, - }, - required: ["body"], - } as JSONSchema, - handler: editNote({ content }), - }, - readNote: { - description: "Read the shared note.", - inputSchema: { - type: "object", - properties: {}, - required: [], - } as JSONSchema, - handler: readNote({ content }), - }, - }; - - const chat = Chat({ messages, tools }); - const { addMessage, cancelGeneration, pending } = chat; - - const note = Note({ title, content, allCharms }); - - return { - [NAME]: title, - [UI]: ( - - -
-
- Show Chat -
-
- - - {note} - - - - - -
- ), - content, - messages, - mentioned: note.mentioned, - backlinks: note.backlinks, - }; - }, -); From 2c94c8cf430f3b2237ed36a63a68527cc8e1e38d Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:42:46 +1000 Subject: [PATCH 07/12] Fix type error --- packages/patterns/chatbot-note.tsx | 49 ++++++++++++++++-------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/patterns/chatbot-note.tsx b/packages/patterns/chatbot-note.tsx index d857f1ffd..ed189243d 100644 --- a/packages/patterns/chatbot-note.tsx +++ b/packages/patterns/chatbot-note.tsx @@ -197,6 +197,29 @@ const ChatbotNote = recipe( content, }); + const sidebar = <> +
+ + + {backlinks.map((charm: MentionableCharm) => ( + + {charm[NAME]} + + ))} + +
+
+ Mentioned Charms + + {mentioned.map((charm: MentionableCharm) => ( + + {charm[NAME]} + + ))} + +
+ + return { [NAME]: title, [UI]: ( @@ -242,29 +265,9 @@ const ChatbotNote = recipe( {ifElse( expandChat, chat, - <> -
- - - {backlinks.map((charm: MentionableCharm) => ( - - {charm[NAME]} - - ))} - -
-
- Mentioned Charms - - {mentioned.map((charm: MentionableCharm) => ( - - {charm[NAME]} - - ))} - -
- , - )} + sidebar, + ) as any } + {/* TODO(bf): why is this not compliant with JSX types? */}
From b87105e691d3c8fe5db9a4220260c026f4670a40 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:57:07 +1000 Subject: [PATCH 08/12] Format pass --- packages/patterns/chatbot-note.tsx | 48 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/patterns/chatbot-note.tsx b/packages/patterns/chatbot-note.tsx index ed189243d..c3e4ab985 100644 --- a/packages/patterns/chatbot-note.tsx +++ b/packages/patterns/chatbot-note.tsx @@ -197,28 +197,30 @@ const ChatbotNote = recipe( content, }); - const sidebar = <> -
- - - {backlinks.map((charm: MentionableCharm) => ( - - {charm[NAME]} - - ))} - -
-
- Mentioned Charms - - {mentioned.map((charm: MentionableCharm) => ( - - {charm[NAME]} - - ))} - -
- + const sidebar = ( + <> +
+ + + {backlinks.map((charm: MentionableCharm) => ( + + {charm[NAME]} + + ))} + +
+
+ Mentioned Charms + + {mentioned.map((charm: MentionableCharm) => ( + + {charm[NAME]} + + ))} + +
+ + ); return { [NAME]: title, @@ -266,7 +268,7 @@ const ChatbotNote = recipe( expandChat, chat, sidebar, - ) as any } + ) as any} {/* TODO(bf): why is this not compliant with JSX types? */} From 2ca601ed79d3e7d509c800b12c29182c1904fe75 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:22:18 +1000 Subject: [PATCH 09/12] Integrate layout + chat list --- packages/patterns/chatbot-list-view.tsx | 111 +++++++++---- packages/patterns/chatbot-note-composed.tsx | 163 ++++++++++++++++++++ packages/patterns/default-app.tsx | 20 +++ 3 files changed, 264 insertions(+), 30 deletions(-) create mode 100644 packages/patterns/chatbot-note-composed.tsx diff --git a/packages/patterns/chatbot-list-view.tsx b/packages/patterns/chatbot-list-view.tsx index 06d9386ba..2cdcda726 100644 --- a/packages/patterns/chatbot-list-view.tsx +++ b/packages/patterns/chatbot-list-view.tsx @@ -15,7 +15,13 @@ import { UI, } from "commontools"; -import Chat from "./chatbot.tsx"; +import Chat from "./chatbot-note-composed.tsx"; + +export type MentionableCharm = { + [NAME]: string; + content?: string; + mentioned?: MentionableCharm[]; +}; type CharmEntry = { [ID]: string; // randomId is a string @@ -26,9 +32,12 @@ type CharmEntry = { type Input = { selectedCharm: Default<{ charm: any }, { charm: undefined }>; charmsList: Default; + allCharms: Cell; }; -type Output = Input; +type Output = { + selectedCharm: Default<{ charm: any }, { charm: undefined }>; +}; // this will be called whenever charm or selectedCharm changes // pass isInitialized to make sure we dont call this each time @@ -64,7 +73,7 @@ const storeCharm = lift( if (!isInitialized.get()) { console.log( "storeCharm storing charm:", - JSON.stringify(charm), + charm, ); selectedCharm.set({ charm }); @@ -83,14 +92,17 @@ const storeCharm = lift( const createChatRecipe = handler< unknown, - { selectedCharm: Cell<{ charm: any }>; charmsList: Cell } + { selectedCharm: Cell<{ charm: any }>; charmsList: Cell, allCharms: Cell } >( - (_, { selectedCharm, charmsList }) => { + (_, { selectedCharm, charmsList, allCharms }) => { const isInitialized = cell(false); const charm = Chat({ + title: "New Chat", messages: [], - tools: undefined, + expandChat: false, + content: "", + allCharms, }); // store the charm ref in a cell (pass isInitialized to prevent recursive calls) return storeCharm({ charm, selectedCharm, charmsList, isInitialized }); @@ -132,42 +144,81 @@ const logCharmsList = lift( }, ); +const handleCharmLinkClicked = handler( + (_: any, { charm }: { charm: Cell }) => { + return navigateTo(charm); + }, +); + // create the named cell inside the recipe body, so we do it just once export default recipe( "Launcher", - ({ selectedCharm, charmsList }) => { + ({ selectedCharm, charmsList, allCharms }) => { logCharmsList({ charmsList }); return { [NAME]: "Launcher", [UI]: ( -
- - Create New Chat - - -
-

Chat List

+ +
+ + Create New Chat +
-
- {charmsList.map((charmEntry, i) => ( + + { + selectedCharm.charm.chat[UI] // workaround: CT-987 + } + { + selectedCharm.charm.note[UI] // workaround: CT-987 + } + +
+
+ {charmsList.map((charmEntry, i) => ( +
+ index={i} chat ID: {charmEntry.local_id} + + LOAD + +
+ ))} +
+ + +
+ + + ), selectedCharm, charmsList, diff --git a/packages/patterns/chatbot-note-composed.tsx b/packages/patterns/chatbot-note-composed.tsx new file mode 100644 index 000000000..668bd713c --- /dev/null +++ b/packages/patterns/chatbot-note-composed.tsx @@ -0,0 +1,163 @@ +/// +import { + BuiltInLLMMessage, + Cell, + cell, + Default, + derive, + fetchData, + getRecipeEnvironment, + h, + handler, + ID, + ifElse, + JSONSchema, + lift, + llm, + llmDialog, + NAME, + navigateTo, + OpaqueRef, + recipe, + str, + Stream, + toSchema, + UI, +} from "commontools"; + +import Chat from "./chatbot.tsx"; +import Note from "./note.tsx"; + +export type MentionableCharm = { + [NAME]: string; + content?: string; + mentioned?: MentionableCharm[]; +}; + +type NoteResult = { + content: Default; +}; + +export type NoteInput = { + content: Default; + allCharms: Cell; +}; + +const handleCharmLinkClick = handler< + { + detail: { + charm: Cell; + }; + }, + Record +>(({ detail }, _) => { + return navigateTo(detail.charm); +}); + +const handleCharmLinkClicked = handler( + (_: any, { charm }: { charm: Cell }) => { + return navigateTo(charm); + }, +); + +type LLMTestInput = { + title: Default; + messages: Default, []>; + expandChat: Default; + content: Default; + allCharms: Cell; +}; + +type LLMTestResult = { + messages: Default, []>; + mentioned: Default, []>; + backlinks: Default, []>; + content: Default; + note: any; + chat: any; +}; + +// put a note at the end of the outline (by appending to root.children) +const editNote = handler< + { + /** The text content of the note */ + body: string; + /** A cell to store the result message indicating success or error */ + result: Cell; + }, + { content: Cell } +>( + (args, state) => { + try { + state.content.set(args.body); + + args.result.set( + `Updated note!`, + ); + } catch (error) { + args.result.set(`Error: ${(error as any)?.message || ""}`); + } + }, +); + +const readNote = handler< + { + /** A cell to store the result text */ + result: Cell; + }, + { content: string } +>( + (args, state) => { + try { + args.result.set(state.content); + } catch (error) { + args.result.set(`Error: ${(error as any)?.message || ""}`); + } + }, +); + +export default recipe( + "Note", + ({ title, expandChat, messages, content, allCharms }) => { + const tools = { + editNote: { + description: "Modify the shared note.", + inputSchema: { + type: "object", + properties: { + body: { + type: "string", + description: "The content of the note.", + }, + }, + required: ["body"], + } as JSONSchema, + handler: editNote({ content }), + }, + readNote: { + description: "Read the shared note.", + inputSchema: { + type: "object", + properties: {}, + required: [], + } as JSONSchema, + handler: readNote({ content }), + }, + }; + + const chat = Chat({ messages, tools }); + const { addMessage, cancelGeneration, pending } = chat; + + const note = Note({ title, content, allCharms }); + + return { + [NAME]: title, + chat, + note, + content, + messages, + mentioned: note.mentioned, + backlinks: note.backlinks, + }; + }, +); diff --git a/packages/patterns/default-app.tsx b/packages/patterns/default-app.tsx index 49d460c3e..aaade06e5 100644 --- a/packages/patterns/default-app.tsx +++ b/packages/patterns/default-app.tsx @@ -6,6 +6,7 @@ import { h, handler, NAME, + Opaque, navigateTo, OpaqueRef, recipe, @@ -22,6 +23,7 @@ import { type MentionableCharm, } from "./chatbot-note.tsx"; import { default as Note } from "./note.tsx"; +import ChatList from './chatbot-list-view.tsx'; export type Charm = { [NAME]?: string; @@ -65,6 +67,17 @@ const removeCharm = handler< } }); +const spawnChatList = handler< + Record, + { allCharms: Cell } +>((_, state) => { + return navigateTo(ChatList({ + selectedCharm: { charm: undefined }, + charmsList: [], + allCharms: state.allCharms + })); +}); + const spawnChatbot = handler< Record, Record @@ -136,6 +149,13 @@ export default recipe( {/* Quick Launch Toolbar */}

Quicklaunch:

+ , }) + } + > + 📂 Chat List + From d734b2e7a658bf28c17d008079e2d60e5ce65b4c Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:53:57 +1000 Subject: [PATCH 10/12] Combine all mentions --- packages/patterns/chatbot-list-view.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/patterns/chatbot-list-view.tsx b/packages/patterns/chatbot-list-view.tsx index 2cdcda726..f433b5253 100644 --- a/packages/patterns/chatbot-list-view.tsx +++ b/packages/patterns/chatbot-list-view.tsx @@ -5,6 +5,7 @@ import { Default, derive, h, + OpaqueRef, handler, ID, ifElse, @@ -102,7 +103,7 @@ const createChatRecipe = handler< messages: [], expandChat: false, content: "", - allCharms, + allCharms: [...allCharms.get(), ...charmsList.get().map(i => i.charm)] as unknown as OpaqueRef>, // TODO(bf): types... }); // store the charm ref in a cell (pass isInitialized to prevent recursive calls) return storeCharm({ charm, selectedCharm, charmsList, isInitialized }); From e334acb2f88bcee772e45be3b3b2244872ce74c1 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:54:14 +1000 Subject: [PATCH 11/12] Format pass --- packages/patterns/chatbot-list-view.tsx | 74 ++++++++++++++++--------- packages/patterns/default-app.tsx | 12 ++-- 2 files changed, 55 insertions(+), 31 deletions(-) diff --git a/packages/patterns/chatbot-list-view.tsx b/packages/patterns/chatbot-list-view.tsx index f433b5253..cf4d24aff 100644 --- a/packages/patterns/chatbot-list-view.tsx +++ b/packages/patterns/chatbot-list-view.tsx @@ -5,13 +5,13 @@ import { Default, derive, h, - OpaqueRef, handler, ID, ifElse, lift, NAME, navigateTo, + OpaqueRef, recipe, UI, } from "commontools"; @@ -93,7 +93,11 @@ const storeCharm = lift( const createChatRecipe = handler< unknown, - { selectedCharm: Cell<{ charm: any }>; charmsList: Cell, allCharms: Cell } + { + selectedCharm: Cell<{ charm: any }>; + charmsList: Cell; + allCharms: Cell; + } >( (_, { selectedCharm, charmsList, allCharms }) => { const isInitialized = cell(false); @@ -103,7 +107,10 @@ const createChatRecipe = handler< messages: [], expandChat: false, content: "", - allCharms: [...allCharms.get(), ...charmsList.get().map(i => i.charm)] as unknown as OpaqueRef>, // TODO(bf): types... + allCharms: [ + ...allCharms.get(), + ...charmsList.get().map((i) => i.charm), + ] as unknown as OpaqueRef>, // TODO(bf): types... }); // store the charm ref in a cell (pass isInitialized to prevent recursive calls) return storeCharm({ charm, selectedCharm, charmsList, isInitialized }); @@ -162,7 +169,13 @@ export default recipe( [UI]: (
- + Create New Chat
@@ -171,7 +184,7 @@ export default recipe( selectedCharm.charm.chat[UI] // workaround: CT-987 } { - selectedCharm.charm.note[UI] // workaround: CT-987 + selectedCharm.charm.note[UI] // workaround: CT-987 }
diff --git a/packages/patterns/default-app.tsx b/packages/patterns/default-app.tsx index aaade06e5..1ea12aa0f 100644 --- a/packages/patterns/default-app.tsx +++ b/packages/patterns/default-app.tsx @@ -6,8 +6,8 @@ import { h, handler, NAME, - Opaque, navigateTo, + Opaque, OpaqueRef, recipe, str, @@ -23,7 +23,7 @@ import { type MentionableCharm, } from "./chatbot-note.tsx"; import { default as Note } from "./note.tsx"; -import ChatList from './chatbot-list-view.tsx'; +import ChatList from "./chatbot-list-view.tsx"; export type Charm = { [NAME]?: string; @@ -74,7 +74,7 @@ const spawnChatList = handler< return navigateTo(ChatList({ selectedCharm: { charm: undefined }, charmsList: [], - allCharms: state.allCharms + allCharms: state.allCharms, })); }); @@ -151,8 +151,10 @@ export default recipe(

Quicklaunch:

, }) - } + allCharms: allCharms as unknown as OpaqueRef< + MentionableCharm[] + >, + })} > 📂 Chat List From e0964cdb013b0967a5cc231d35baf1a5079159cd Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:01:52 +1000 Subject: [PATCH 12/12] Revert default-app.tsx --- packages/patterns/default-app.tsx | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/packages/patterns/default-app.tsx b/packages/patterns/default-app.tsx index 1ea12aa0f..49d460c3e 100644 --- a/packages/patterns/default-app.tsx +++ b/packages/patterns/default-app.tsx @@ -7,7 +7,6 @@ import { handler, NAME, navigateTo, - Opaque, OpaqueRef, recipe, str, @@ -23,7 +22,6 @@ import { type MentionableCharm, } from "./chatbot-note.tsx"; import { default as Note } from "./note.tsx"; -import ChatList from "./chatbot-list-view.tsx"; export type Charm = { [NAME]?: string; @@ -67,17 +65,6 @@ const removeCharm = handler< } }); -const spawnChatList = handler< - Record, - { allCharms: Cell } ->((_, state) => { - return navigateTo(ChatList({ - selectedCharm: { charm: undefined }, - charmsList: [], - allCharms: state.allCharms, - })); -}); - const spawnChatbot = handler< Record, Record @@ -149,15 +136,6 @@ export default recipe( {/* Quick Launch Toolbar */}

Quicklaunch:

- , - })} - > - 📂 Chat List -