From 2eac542e193784504261f7df7c3ddeec34a9e766 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 20:11:26 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(rnotes):=20mobile=20stack=20navigation?= =?UTF-8?q?=20=E2=80=94=20Notion-style=20two-screen=20slide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace overlay sidebar with horizontal flex stack: full-width doc list slides to full-width editor with back bar on note tap. Resize-aware. Co-Authored-By: Claude Opus 4.6 --- modules/rnotes/components/folk-notes-app.ts | 132 ++++++++++++-------- 1 file changed, 83 insertions(+), 49 deletions(-) diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 15c10b5..f60fc0a 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -137,6 +137,8 @@ class FolkNotesApp extends HTMLElement { private expandedNotebooks = new Set(); private notebookNotes = new Map(); private sidebarOpen = true; + private mobileEditing = false; + private _resizeHandler: (() => void) | null = null; // Zone-based rendering private navZone!: HTMLDivElement; @@ -219,6 +221,18 @@ class FolkNotesApp extends HTMLElement { if (!localStorage.getItem("rnotes_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + + // Mobile resize handler — sync mobile-editing state on viewport change + this._resizeHandler = () => { + if (window.innerWidth > 768) { + // Switched to desktop — remove mobile-editing so both panels show + this.setMobileEditing(false); + } else if (this.selectedNote && this.editor) { + // Went back to mobile with a note open — restore editor screen + this.setMobileEditing(true); + } + }; + window.addEventListener('resize', this._resizeHandler); } private async subscribeOfflineRuntime() { @@ -285,27 +299,6 @@ class FolkNotesApp extends HTMLElement { this.shadow.appendChild(style); this.shadow.appendChild(layout); - - // Mobile sidebar toggle - const mobileToggle = document.createElement('button'); - mobileToggle.className = 'mobile-sidebar-toggle'; - mobileToggle.innerHTML = '\u{1F4C4}Docs'; - mobileToggle.addEventListener('click', () => { - this.sidebarOpen = !this.sidebarOpen; - this.navZone.querySelector('.notes-sidebar')?.classList.toggle('open', this.sidebarOpen); - this.shadow.querySelector('.sidebar-overlay')?.classList.toggle('open', this.sidebarOpen); - }); - this.shadow.appendChild(mobileToggle); - - // Mobile overlay - const overlay = document.createElement('div'); - overlay.className = 'sidebar-overlay'; - overlay.addEventListener('click', () => { - this.sidebarOpen = false; - this.navZone.querySelector('.notes-sidebar')?.classList.remove('open'); - overlay.classList.remove('open'); - }); - this.shadow.appendChild(overlay); } // ── Demo data ── @@ -577,9 +570,33 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.mountEditor(newNote); } + // ── Mobile stack navigation ── + + private setMobileEditing(editing: boolean) { + this.mobileEditing = editing; + this.shadow.getElementById('notes-layout')?.classList.toggle('mobile-editing', editing); + } + + private mobileGoBack() { + this.setMobileEditing(false); + } + + private mobileBackBarHtml(): string { + const title = this.selectedNotebook?.title || 'Notes'; + return `

`; + } + + private isMobile(): boolean { + return window.innerWidth <= 768; + } + disconnectedCallback() { this.destroyEditor(); this.cleanupPresence(); + if (this._resizeHandler) { + window.removeEventListener('resize', this._resizeHandler); + this._resizeHandler = null; + } this._offlineUnsub?.(); this._offlineUnsub = null; for (const unsub of this._offlineNotebookUnsubs) unsub(); @@ -1051,11 +1068,9 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF private async openNote(noteId: string, notebookId: string) { const isDemo = this.space === "demo"; - // Auto-close sidebar on mobile - if (window.innerWidth <= 768) { - this.sidebarOpen = false; - this.navZone.querySelector('.notes-sidebar')?.classList.remove('open'); - this.shadow.querySelector('.sidebar-overlay')?.classList.remove('open'); + // Mobile: slide to editor screen + if (this.isMobile()) { + this.setMobileEditing(true); } // Expand notebook if not expanded @@ -1210,6 +1225,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF case 'AUDIO': this.mountAudioView(note, isEditable, isDemo); break; default: this.mountTiptapEditor(note, isEditable, isDemo); break; } + + // Mobile: inject back bar and slide to editor + this.contentZone.insertAdjacentHTML('afterbegin', this.mobileBackBarHtml()); + this.contentZone.querySelector('.mobile-back-btn')?.addEventListener('click', () => this.mobileGoBack()); + if (this.isMobile()) this.setMobileEditing(true); } private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) { @@ -3229,22 +3249,9 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .editor-empty-state svg { width: 48px; height: 48px; opacity: 0.4; } .editor-empty-state p { font-size: 14px; } - /* Mobile sidebar */ - .mobile-sidebar-toggle { - display: none; position: fixed; bottom: 20px; left: 20px; z-index: 198; - min-width: 44px; height: 44px; border-radius: 22px; border: none; - padding: 0 14px; gap: 4px; - background: var(--rs-primary); color: #fff; font-size: 13px; - cursor: pointer; box-shadow: var(--rs-shadow-md); - align-items: center; justify-content: center; - } - .mobile-toggle-icon { font-size: 18px; } - .mobile-toggle-label { font-weight: 600; font-family: inherit; } - .sidebar-overlay { - display: none; position: fixed; inset: 0; - background: rgba(0,0,0,0.4); z-index: 199; - } - .sidebar-overlay.open { display: block; } + /* Mobile sidebar (legacy — hidden, replaced by stack nav) */ + .mobile-sidebar-toggle { display: none; } + .sidebar-overlay { display: none; } /* ── Navigation ── */ .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; } @@ -3521,15 +3528,42 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .tiptap-container .tiptap s { color: var(--rs-text-muted); } .tiptap-container .tiptap u { text-underline-offset: 3px; } + /* ── Mobile back bar (hidden on desktop) ── */ + .mobile-back-bar { display: none; } @media (max-width: 768px) { - #notes-layout { grid-template-columns: 1fr; } - .notes-sidebar { - position: fixed; left: 0; top: 0; bottom: 0; width: 280px; - z-index: 200; transform: translateX(-100%); - transition: transform 0.25s ease; box-shadow: var(--rs-shadow-lg); + /* Two-screen horizontal stack: nav (100%) + editor (100%) side-by-side */ + #notes-layout { + display: flex; overflow: hidden; + grid-template-columns: unset; } - .notes-sidebar.open { transform: translateX(0); } - .mobile-sidebar-toggle { display: flex !important; } + #nav-zone, .notes-right-col { + flex: 0 0 100%; min-width: 0; + transition: transform 0.3s ease; + } + /* Slide both panels left when editing */ + #notes-layout.mobile-editing > #nav-zone, + #notes-layout.mobile-editing > .notes-right-col { + transform: translateX(-100%); + } + /* Sidebar fills screen width */ + .notes-sidebar { width: 100%; position: static; transform: none; box-shadow: none; } + /* Hide old overlay FAB (no longer needed) */ + .mobile-sidebar-toggle, .sidebar-overlay { display: none !important; } + /* Hide empty state on mobile — user sees doc list */ + .editor-empty-state { display: none; } + /* Show back bar */ + .mobile-back-bar { + display: flex; align-items: center; + padding: 8px 12px; border-bottom: 1px solid var(--rs-border-subtle); + background: var(--rs-bg-surface); + } + .mobile-back-btn { + background: none; border: none; color: var(--rs-primary); + font-size: 15px; font-weight: 600; cursor: pointer; + padding: 4px 8px; border-radius: 6px; font-family: inherit; + } + .mobile-back-btn:hover { background: var(--rs-bg-surface-raised); } + /* Tighten editor padding */ .editor-wrapper .editable-title { padding: 12px 14px 0; } .tiptap-container .tiptap { padding: 14px 16px; } .sidebar-footer-btn { min-height: 36px; padding: 7px 12px; } From 77ac0c1e32be8fc95b2f8c3656f7e45ec14512b4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 20:11:31 -0700 Subject: [PATCH 2/2] feat(rsocials): newsletter drafts, subscribers + MI route updates Co-Authored-By: Claude Opus 4.6 --- modules/rsocials/mod.ts | 165 +++++++++++++++++++++++++++------ modules/rsocials/schemas.ts | 9 +- server/mi-routes.ts | 91 ++++++++++++++++++ shared/components/rstack-mi.ts | 149 ++++++++++++++++++++++++++++- 4 files changed, 380 insertions(+), 34 deletions(-) diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 337bf65..b0926a9 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -19,7 +19,7 @@ import type { RSpaceModule } from "../../shared/module"; import type { SyncServer } from "../../server/local-first/sync-server"; import { renderLanding } from "./landing"; import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data"; -import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard } from "./schemas"; +import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard, type NewsletterDraft } from "./schemas"; import { generateImageFromPrompt, downloadAndSaveImage, @@ -58,9 +58,18 @@ function ensureDoc(space: string): SocialsDoc { d.campaignWorkflows = {}; d.pendingApprovals = {}; d.campaignWizards = {}; + d.newsletterDrafts = {}; }); _syncServer!.setDoc(docId, doc); } + // Migrate existing docs missing newsletterDrafts + if (!doc.newsletterDrafts) { + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "add newsletterDrafts", (d) => { + d.newsletterDrafts = {} as any; + }); + doc = _syncServer!.getDoc(docId)!; + } return doc; } @@ -563,6 +572,112 @@ routes.delete("/api/newsletter/campaigns/:id", async (c) => { return c.json(data, res.status as any); }); +// ── Newsletter Drafts (Automerge-backed) ── + +routes.get("/api/newsletter/drafts", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + const drafts = Object.values(doc.newsletterDrafts || {}).sort((a, b) => b.updatedAt - a.updatedAt); + return c.json({ drafts }); +}); + +routes.post("/api/newsletter/drafts", async (c) => { + const auth = await requireNewsletterRole(c, "admin"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const body = await c.req.json<{ title?: string; subject?: string; body?: string }>(); + if (!body.title || !body.subject) return c.json({ error: "title and subject required" }, 400); + + const id = crypto.randomUUID(); + const now = Date.now(); + const draft: NewsletterDraft = { + id, + title: body.title, + subject: body.subject, + body: body.body || '', + status: 'draft', + subscribers: [], + createdAt: now, + updatedAt: now, + createdBy: auth.claims.did as string, + }; + + const docId = socialsDocId(dataSpace); + _syncServer!.changeDoc(docId, `create newsletter draft ${id}`, (d) => { + if (!d.newsletterDrafts) d.newsletterDrafts = {} as any; + d.newsletterDrafts[id] = draft; + }); + return c.json({ draft }, 201); +}); + +routes.get("/api/newsletter/drafts/:draftId", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const draftId = c.req.param("draftId"); + const doc = ensureDoc(dataSpace); + const draft = doc.newsletterDrafts?.[draftId]; + if (!draft) return c.json({ error: "Draft not found" }, 404); + return c.json({ draft }); +}); + +routes.put("/api/newsletter/drafts/:draftId", async (c) => { + const auth = await requireNewsletterRole(c, "admin"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const draftId = c.req.param("draftId"); + const doc = ensureDoc(dataSpace); + if (!doc.newsletterDrafts?.[draftId]) return c.json({ error: "Draft not found" }, 404); + + const body = await c.req.json<{ title?: string; subject?: string; body?: string; status?: string; subscribers?: { email: string; name?: string }[] }>(); + const docId = socialsDocId(dataSpace); + _syncServer!.changeDoc(docId, `update newsletter draft ${draftId}`, (d) => { + const draft = d.newsletterDrafts[draftId]; + if (body.title !== undefined) draft.title = body.title; + if (body.subject !== undefined) draft.subject = body.subject; + if (body.body !== undefined) draft.body = body.body; + if (body.status === 'draft' || body.status === 'ready' || body.status === 'sent') draft.status = body.status; + if (body.subscribers) { + draft.subscribers = body.subscribers.map(s => ({ + email: s.email, + name: s.name || '', + addedAt: Date.now(), + })) as any; + } + draft.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json({ draft: updated.newsletterDrafts[draftId] }); +}); + +routes.delete("/api/newsletter/drafts/:draftId", async (c) => { + const auth = await requireNewsletterRole(c, "admin"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const draftId = c.req.param("draftId"); + const doc = ensureDoc(dataSpace); + if (!doc.newsletterDrafts?.[draftId]) return c.json({ error: "Draft not found" }, 404); + + const docId = socialsDocId(dataSpace); + _syncServer!.changeDoc(docId, `delete newsletter draft ${draftId}`, (d) => { + delete d.newsletterDrafts[draftId]; + }); + return c.json({ ok: true }); +}); + // ── Newsletter Approval Bridge ── routes.post("/api/newsletter/campaigns/:id/submit-approval", async (c) => { @@ -1987,7 +2102,7 @@ routes.get("/scheduler", (c) => { })); }); -routes.get("/newsletter-list", async (c) => { +routes.get("/newsletter", async (c) => { const space = c.req.param("space") || "demo"; // Resolve caller role for UI gating (viewer fallback for unauthenticated) @@ -2013,6 +2128,15 @@ routes.get("/newsletter-list", async (c) => { })); }); +// Legacy redirect +routes.get("/newsletter-list", (c) => { + const space = c.req.param("space") || "demo"; + const host = c.req.header("host")?.split(":")[0] || ""; + const isSubdomain = (host.endsWith(".rspace.online") && host !== "rspace.online" && !host.startsWith("www.")) || host.endsWith(".rsocials.online"); + const base = isSubdomain ? "/rsocials" : `/${space}/rsocials`; + return c.redirect(`${base}/newsletter`, 301); +}); + routes.get("/feed", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; @@ -2081,13 +2205,6 @@ routes.get("/", (c) => {

Plan and manage multi-platform social media campaigns

- - 🧙 - - 🧵 - + 📧 @@ -2160,18 +2277,6 @@ export const socialsModule: RSpaceModule = { { icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." }, ], }, - { - path: "campaign-wizard", - title: "Campaign Wizard", - icon: "\uD83E\uDDD9", - tagline: "rSocials Tool", - description: "AI-guided step-by-step campaign creation. Paste a brief, review the proposed structure, approve generated content, and activate everything at once.", - features: [ - { icon: "\uD83E\uDD16", title: "AI Analysis", text: "AI extracts audience, platforms, tone, and key messages from your raw brief." }, - { icon: "\uD83D\uDCCB", title: "Progressive Approval", text: "Review and approve each step: structure, content, then full review before committing." }, - { icon: "\u26A1", title: "One-Click Activate", text: "Commits campaign, creates threads, drafts newsletters, and builds workflow DAG simultaneously." }, - ], - }, { path: "threads", title: "Posts & Threads", @@ -2185,15 +2290,15 @@ export const socialsModule: RSpaceModule = { ], }, { - path: "newsletter-list", - title: "Newsletter List", + path: "newsletter", + title: "Newsletter", icon: "📧", tagline: "rSocials Tool", - description: "Manage newsletter subscribers and send email campaigns via the embedded Listmonk interface.", + description: "Draft newsletters collaboratively, manage subscriber lists, and send email campaigns. Works standalone or with Listmonk integration.", features: [ - { icon: "👥", title: "Subscriber Management", text: "View, import, and manage newsletter subscribers across multiple mailing lists." }, - { icon: "📨", title: "Email Campaigns", text: "Compose and send email campaigns with templates and scheduling." }, - { icon: "📊", title: "Analytics", text: "Track open rates, click-throughs, and subscriber engagement." }, + { icon: "✍️", title: "Draft Editor", text: "Compose newsletter drafts with HTML editor, live preview, and collaborative editing." }, + { icon: "👥", title: "Subscriber Management", text: "Build and manage subscriber lists directly, or import from Listmonk." }, + { icon: "📨", title: "Send Campaigns", text: "Send newsletters via Listmonk when configured, or manage drafts independently." }, ], }, ], diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index fc21c52..6ffad31 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -447,6 +447,7 @@ export interface SocialsDoc { campaignWorkflows: Record; pendingApprovals: Record; campaignWizards: Record; + newsletterDrafts: Record; } // ── Schema registration ── @@ -454,12 +455,12 @@ export interface SocialsDoc { export const socialsSchema: DocSchema = { module: 'socials', collection: 'data', - version: 6, + version: 7, init: (): SocialsDoc => ({ meta: { module: 'socials', collection: 'data', - version: 6, + version: 7, spaceSlug: '', createdAt: Date.now(), }, @@ -470,6 +471,7 @@ export const socialsSchema: DocSchema = { campaignWorkflows: {}, pendingApprovals: {}, campaignWizards: {}, + newsletterDrafts: {}, }), migrate: (doc: SocialsDoc, _fromVersion: number): SocialsDoc => { if (!doc.campaignFlows) (doc as any).campaignFlows = {}; @@ -477,7 +479,8 @@ export const socialsSchema: DocSchema = { if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {}; if (!doc.pendingApprovals) (doc as any).pendingApprovals = {}; if (!doc.campaignWizards) (doc as any).campaignWizards = {}; - if (doc.meta) doc.meta.version = 6; + if (!doc.newsletterDrafts) (doc as any).newsletterDrafts = {}; + if (doc.meta) doc.meta.version = 7; return doc; }, }; diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 2269fc5..53eb64a 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -500,6 +500,97 @@ mi.post("/execute-server-action", async (c) => { } }); +// ── POST /suggestions — dynamic data-driven suggestions ── + +mi.post("/suggestions", async (c) => { + const { space, module: currentModule } = await c.req.json(); + const suggestions: { label: string; icon: string; prompt: string; autoSend?: boolean }[] = []; + + if (!space) return c.json({ suggestions }); + + try { + // Check upcoming events + const upcoming = getUpcomingEventsForMI(space, 1, 3); + if (upcoming.length > 0) { + const next = upcoming[0]; + const startMs = Date.parse(next.start); + const hoursUntil = Math.round((startMs - Date.now()) / 3600000); + if (hoursUntil > 0 && hoursUntil <= 24) { + const timeLabel = hoursUntil === 1 ? "1 hour" : `${hoursUntil} hours`; + suggestions.push({ + label: `${next.title} in ${timeLabel}`, + icon: "⏰", + prompt: `Tell me about the upcoming event "${next.title}"`, + autoSend: true, + }); + } + } + + // Check open tasks + const tasks = getRecentTasksForMI(space, 10); + const openTasks = tasks.filter((t) => t.status !== "DONE"); + if (openTasks.length > 0) { + suggestions.push({ + label: `${openTasks.length} open task${openTasks.length > 1 ? "s" : ""}`, + icon: "📋", + prompt: "Show my open tasks", + autoSend: true, + }); + } + + // Check if current module has zero content — "get started" suggestion + if (currentModule === "rnotes") { + const notes = getRecentNotesForMI(space, 1); + if (notes.length === 0) { + suggestions.push({ + label: "Create your first note", + icon: "📝", + prompt: "Help me create my first notebook", + autoSend: true, + }); + } + } else if (currentModule === "rtasks") { + const t = getRecentTasksForMI(space, 1); + if (t.length === 0) { + suggestions.push({ + label: "Create your first task", + icon: "✅", + prompt: "Help me create my first task board", + autoSend: true, + }); + } + } else if (currentModule === "rcal") { + const ev = getUpcomingEventsForMI(space, 30, 1); + if (ev.length === 0) { + suggestions.push({ + label: "Add your first event", + icon: "📅", + prompt: "Help me create my first calendar event", + autoSend: true, + }); + } + } + + // Recent note to continue editing + if (currentModule === "rnotes") { + const recent = getRecentNotesForMI(space, 1); + if (recent.length > 0) { + suggestions.push({ + label: `Continue "${recent[0].title}"`, + icon: "📝", + prompt: `Help me continue working on "${recent[0].title}"`, + autoSend: true, + }); + } + } + } catch (e: any) { + console.error("[mi/suggestions]", e.message); + } + + // Max 3 dynamic suggestions + return c.json({ suggestions: suggestions.slice(0, 3) }); +}); + // ── Fallback response (when AI is unavailable) ── function generateFallbackResponse( diff --git a/shared/components/rstack-mi.ts b/shared/components/rstack-mi.ts index 4e675a0..cf3881a 100644 --- a/shared/components/rstack-mi.ts +++ b/shared/components/rstack-mi.ts @@ -11,6 +11,8 @@ import type { MiAction } from "../../lib/mi-actions"; import { MiActionExecutor } from "../../lib/mi-action-executor"; import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema"; import { SpeechDictation } from "../../lib/speech-dictation"; +import { getContextSuggestions } from "../../lib/mi-suggestions"; +import type { MiSuggestion } from "../../lib/mi-suggestions"; interface MiMessage { role: "user" | "assistant"; @@ -39,6 +41,10 @@ export class RStackMi extends HTMLElement { #availableModels: MiModelConfig[] = []; #pendingConfirm: { actions: MiAction[]; resolve: (ok: boolean) => void } | null = null; #scaffoldProgress: { current: number; total: number; label: string } | null = null; + #suggestions: MiSuggestion[] = []; + #dynamicSuggestions: MiSuggestion[] = []; + #placeholderIdx = 0; + #placeholderTimer: ReturnType | null = null; constructor() { super(); @@ -54,6 +60,7 @@ export class RStackMi extends HTMLElement { disconnectedCallback() { document.removeEventListener("keydown", this.#keyHandler); + if (this.#placeholderTimer) clearInterval(this.#placeholderTimer); } #keyHandler = (e: KeyboardEvent) => { @@ -73,6 +80,7 @@ export class RStackMi extends HTMLElement { panel?.classList.add("open"); bar?.classList.add("focused"); } + this.#loadSuggestions(); const input = this.#shadow.getElementById("mi-input") as HTMLTextAreaElement | null; input?.focus(); } @@ -148,10 +156,11 @@ export class RStackMi extends HTMLElement {
-
+

Hi, I'm mi — your mycelial intelligence guide.

I can create content across all rApps, set up spaces, connect knowledge, and help you build.

+