diff --git a/packages/patterns/chatbot-list-view.tsx b/packages/patterns/chatbot-list-view.tsx index 06d9386ba..cf4d24aff 100644 --- a/packages/patterns/chatbot-list-view.tsx +++ b/packages/patterns/chatbot-list-view.tsx @@ -11,11 +11,18 @@ import { lift, NAME, navigateTo, + OpaqueRef, recipe, 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 +33,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 +74,7 @@ const storeCharm = lift( if (!isInitialized.get()) { console.log( "storeCharm storing charm:", - JSON.stringify(charm), + charm, ); selectedCharm.set({ charm }); @@ -83,14 +93,24 @@ 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: [ + ...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 }); @@ -132,42 +152,96 @@ 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 + +
+ ))} +
+ -
--- end chat list ---
-
{selectedCharm.charm}
-
+ + + ), 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/chatbot-note.tsx b/packages/patterns/chatbot-note.tsx index 328007eaf..c3e4ab985 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: { @@ -175,6 +197,31 @@ export default recipe( content, }); + const sidebar = ( + <> +
+ + + {backlinks.map((charm: MentionableCharm) => ( + + {charm[NAME]} + + ))} + +
+
+ Mentioned Charms + + {mentioned.map((charm: MentionableCharm) => ( + + {charm[NAME]} + + ))} + +
+ + ); + return { [NAME]: title, [UI]: ( @@ -200,38 +247,30 @@ export default recipe( $mentionable={allCharms} $mentioned={mentioned} onbacklink-click={handleCharmLinkClick({})} + onbacklink-create={handleNewBacklink({ + allCharms: allCharms as unknown as OpaqueRef< + MentionableCharm[] + >, + })} 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 +281,5 @@ export default recipe( }; }, ); + +export default ChatbotNote; diff --git a/packages/patterns/note.tsx b/packages/patterns/note.tsx index cb604ff51..5b317ca3c 100644 --- a/packages/patterns/note.tsx +++ b/packages/patterns/note.tsx @@ -5,8 +5,10 @@ import { Default, h, handler, + lift, NAME, navigateTo, + OpaqueRef, recipe, toSchema, UI, @@ -27,6 +29,7 @@ type Input = { type Output = { mentioned: Default, []>; content: Default; + backlinks: Default, []>; }; const updateTitle = handler< @@ -58,11 +61,65 @@ 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 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]: ( @@ -79,6 +136,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 @@ -89,6 +149,9 @@ export default recipe( title, content, mentioned, + backlinks, }; }, ); + +export default Note; 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..b8fa13f92 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,16 @@ 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,26 +221,81 @@ 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`