import { computed, fetchText, handler, NAME, navigateTo, pattern, patternTool, Stream, UI, when, wish, Writable, } from "commonfabric"; import Chatbot from "../chatbot.tsx"; import { bash, calculator, fetchAndRunPattern, listMentionable, navigateToPattern, readWebpage, searchWeb, updateProfile, } from "./common-fabric.tsx"; import { MentionablePiece } from "./backlinks-index.tsx"; import { searchPattern as summarySearchPattern, type SummaryIndexEntry, } from "./summary-index.tsx"; interface OmniboxFABInput { mentionable: Writable; extraTools: any; extraSystemPrompt: string; } const toggle = handler }>((_, { value }) => { value.set(!value.get()); }); const closeFab = handler }>( (_, { fabExpanded }) => { fabExpanded.set(false); }, ); const dismissPeek = handler< any, { peekDismissedIndex: Writable; assistantMessageCount: number } >((_, { peekDismissedIndex, assistantMessageCount }) => { peekDismissedIndex.set(assistantMessageCount); }); const handleDropToPin = handler< any, { pinToChat: Stream<{ path: string; name: string; accumulate: boolean }>; fabExpanded: Writable; } >((event, { pinToChat, fabExpanded }) => { const ref = event.detail?.sourceCellRef; if (!ref) return; const path = ref.path && ref.path.length > 0 ? `/${ref.id}/${ref.path.join("/")}` : `/${ref.id}`; // Try to get name from the cell's value, fall back to truncated ID const sourceCell = event.detail?.sourceCell; const cellName = sourceCell?.[NAME]; const name = cellName || `Cell #${ref.id.slice(-6)}`; // Drop always replaces; use pin button + Shift to accumulate pinToChat.send({ path, name, accumulate: false }); fabExpanded.set(true); }); const handleExternalPin = handler< { path: string; name: string; accumulate: boolean }, { pinToChat: Stream<{ path: string; name: string; accumulate: boolean }>; fabExpanded: Writable; } >((event, { pinToChat, fabExpanded }) => { pinToChat.send({ path: event.path, name: event.name, accumulate: event.accumulate, }); fabExpanded.set(true); }); /** Wish for a #tag or a custom query with optional linked context. Automatically navigates to the result. */ type WishToolParameters = { query: string; context?: Record; }; const wishTool = pattern( ({ query, context }) => { // Context may not have been provided. const wishParams = computed(() => { return { query, ...(context && { context }) }; }); const wishResult = wish(wishParams); // Navigate to wishResult.result (the actual cell), not the entire wish state object return when(wishResult.result, navigateTo(wishResult.result)); }, ); export default pattern( ({ extraTools, extraSystemPrompt }) => { const mentionable = wish({ query: "#mentionable" }).result; const { entries: summaryEntries } = wish<{ entries: SummaryIndexEntry[]; }>({ query: "#summaryIndex" }).result!; const profile = wish({ query: "#learnedSummary" }); const patternIndexUrl = wish<{ url: Writable }>({ query: "#patternIndex", }); const resolvedPatternUrl = new Writable("/api/patterns/index.md"); computed(() => { const urlRef = patternIndexUrl?.result?.url; const urlValue = typeof urlRef?.get === "function" ? urlRef.get() : (typeof urlRef === "string" ? urlRef : undefined); if (typeof urlValue === "string" && urlValue.length > 0) { resolvedPatternUrl.set(urlValue); } }); const { result: patternIndex } = fetchText({ url: resolvedPatternUrl, }); const profileContext = computed(() => { const profileText = profile.result; return profileText ? `\n\n--- User Context ---\n${profileText}\n---\n` : ""; }); const systemPrompt = computed(() => { const profileSection = profileContext; const indexText = patternIndex ? `\n\n--- Available Patterns ---\n${patternIndex}\n---` : ""; return `You are a polite but efficient assistant. Think Star Trek computer - helpful and professional without unnecessary conversation. Let your actions speak for themselves. ${profileSection}${indexText} Tool usage priority: - For finding content in the space: use searchSpace with a query to search the indexed mentionable pieces that have nonempty summaries - For patterns: review the available patterns listed above, then use fetchAndRunPattern to instantiate one - For existing pages/notes/content: searchSpace first, then listMentionable to identify what they're referencing - Attach relevant items to conversation after instantiation/retrieval if they support ongoing tasks - Remove attachments when no longer relevant - Search web only as last resort when nothing exists in the space - Use bash to run shell commands in a persistent Linux sandbox (Ubuntu). Installed packages and files persist across calls. ${extraSystemPrompt} Be matter-of-fact. Prefer action to explanation.`; }); const baseTools = { searchWeb: { pattern: searchWeb, }, readWebpage: { pattern: readWebpage, }, calculator: { pattern: calculator, }, fetchAndRunPattern: patternTool(fetchAndRunPattern), navigateTo: patternTool(navigateToPattern), wishAndNavigate: patternTool(wishTool), listMentionable: patternTool(listMentionable, { mentionable }), updateProfile: patternTool(updateProfile), bash: patternTool(bash), searchSpace: patternTool(summarySearchPattern, { entries: summaryEntries, }), }; const allTools = computed(() => ({ ...baseTools, ...extraTools, })); const omnibot = Chatbot({ system: systemPrompt, tools: allTools, }); const fabExpanded = new Writable(false); const showHistory = new Writable(false); const peekDismissedIndex = new Writable(-1); // Derive pin count for collapsed state dots const pinCount = computed(() => { const pinned = omnibot.pinnedCells; return pinned ? pinned.length : 0; }); // Derive assistant message count for dismiss tracking const assistantMessageCount = computed(() => { return omnibot.messages ? omnibot.messages.filter((m) => m.role === "assistant").length : 0; }); // Derive latest assistant message for peek const latestAssistantMessage = computed(() => { if (!omnibot.messages || omnibot.messages.length === 0) return null; 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 : msg.content.map((part: any) => { if (part.type === "text") return part.text; return ""; }).join(""); return content; } } return null; }); return { [NAME]: "OmniboxFAB", messages: omnibot.messages, [UI]: ( {fabExpanded ? (
{/* Chevron to expand chat history */}
{/* Pinned items / attachments - always visible */}
{omnibot.ui.attachmentsAndTools}
{/* Expandable chat history */}
{ 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 200ms ease, max-height 300ms ease; pointer-events: ${ show ? "auto" : "none" };`; })} >
{omnibot.ui.chatLog}
{computed(() => { const show = showHistory.get(); const dismissedIdx = peekDismissedIndex.get(); return !show && latestAssistantMessage && assistantMessageCount !== dismissedIdx; }) ? (
×
) : null} {/* Prompt input */}
{omnibot.ui.promptInput}
) : null}
), fabExpanded, pinToChat: handleExternalPin({ pinToChat: omnibot.pinToChat, fabExpanded, }), }; }, );