diff --git a/AGENTS.md b/AGENTS.md index cecc47a1..89d28052 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Every agent-native app follows these rules. Violating them breaks the architectu ### 1. Data lives in SQL -All app state lives in SQLite (`data/app.db`) via Drizzle ORM or the core SQL stores. SQLite works locally out of the box and can be upgraded to a cloud database (Turso, Neon, Supabase, D1) by setting `DATABASE_URL`. Local and production behave identically — no filesystem dependency for data. +All app state lives in SQL via Drizzle ORM or the core SQL stores. In local dev, SQLite (`data/app.db`) is the default. In production, users set `DATABASE_URL` to any supported provider (Neon, Turso, Supabase, D1, or plain Postgres/SQLite). The framework is multi-tenant — multiple users share the same database, with data isolation handled by user-scoped keys and `AGENT_USER_EMAIL`. **Core SQL stores** (auto-created, available in all templates): @@ -140,7 +140,7 @@ Run with: `pnpm script my-script --name foo` ## Database Scripts (Core) -Most templates use SQLite via Drizzle ORM. These core scripts are available automatically — no local script files needed: +These core scripts are available automatically — no local script files needed: | Script | Purpose | Example | | ----------- | ------------------------------- | -------------------------------------------------- | @@ -150,6 +150,25 @@ Most templates use SQLite via Drizzle ORM. These core scripts are available auto Use `db-schema` first to understand the data model, then `db-query` and `db-exec` to read and write data. Scripts read `DATABASE_URL` from env (defaults to `file:./data/app.db`). Use `--db ` to override, and `--format json` for structured output. +### Multi-tenant data scoping + +In production mode, `db-query` and `db-exec` automatically scope data to the current user (`AGENT_USER_EMAIL`). This is transparent — the agent's SQL runs unmodified, but only sees/affects the current user's rows. + +**How it works:** Before running the agent's SQL, temporary views are created that shadow real tables with a `WHERE` filter on the user's identity. Temp views take precedence over real tables in both SQLite and Postgres, so the SQL runs against filtered data. + +**Convention for template tables:** Add an `owner_email TEXT` column to any table that stores per-user data. The scoping system will automatically detect it and filter. + +**Core tables** are handled automatically with their existing scoping patterns: + +- `settings` — filtered by key prefix (`u::`) +- `application_state` — filtered by `session_id` +- `oauth_tokens` — filtered by `owner` +- `sessions` — filtered by `email` + +For `db-exec` INSERTs, `owner_email` is auto-injected if the target table uses the convention and the column isn't already in the statement. + +In dev mode, no scoping is applied — all data is visible. + Local scripts in `scripts/` always take priority over core scripts. Run `pnpm script --help` to see all available scripts. ## TypeScript Everywhere diff --git a/app.json b/app.json new file mode 100644 index 00000000..306ca483 --- /dev/null +++ b/app.json @@ -0,0 +1,3 @@ +{ + "expo": {} +} diff --git a/eas.json b/eas.json new file mode 100644 index 00000000..f4cc4a00 --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 18.4.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/packages/core/package.json b/packages/core/package.json index ecad2d01..429dec06 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,6 +63,7 @@ "@react-router/fs-routes": "^7.13.1", "@tailwindcss/typography": "^0.5.19", "@tiptap/core": "^2.12.0", + "@tiptap/extension-link": "^2.12.0", "@tiptap/extension-placeholder": "^2.12.0", "@tiptap/pm": "^2.12.0", "@tiptap/react": "^2.12.0", @@ -80,7 +81,8 @@ "react-markdown": "^10.1.0", "react-router": "^7.13.1", "remark-gfm": "^4.0.1", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "tiptap-markdown": "^0.8.10" }, "peerDependencies": { "@assistant-ui/react": ">=0.12", diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index 4c780944..e480d441 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -16,7 +16,8 @@ export interface ScriptEntry { export interface ProductionAgentOptions { scripts: Record; - systemPrompt: string; + /** Static system prompt string, or async function called per-request with the H3 event */ + systemPrompt: string | ((event: any) => string | Promise); /** Falls back to ANTHROPIC_API_KEY env var */ apiKey?: string; /** Model to use. Default: claude-sonnet-4-6 */ @@ -91,6 +92,22 @@ export function createProductionAgentHandler( const client = new Anthropic({ apiKey }); + // Resolve system prompt (may be async function receiving the H3 event) + let systemPrompt: string; + try { + systemPrompt = + typeof options.systemPrompt === "function" + ? await options.systemPrompt(event) + : options.systemPrompt; + } catch (err: any) { + send({ + type: "error", + error: `Failed to load system prompt: ${err?.message ?? String(err)}`, + }); + controller.close(); + return; + } + // Build enriched user message with references let enrichedMessage = message; if (references.length > 0) { @@ -166,7 +183,7 @@ export function createProductionAgentHandler( { model, max_tokens: 4096, - system: options.systemPrompt, + system: systemPrompt, tools, messages, }, diff --git a/packages/core/src/application-state/script-helpers.ts b/packages/core/src/application-state/script-helpers.ts index 0e8e66ba..3d529e7e 100644 --- a/packages/core/src/application-state/script-helpers.ts +++ b/packages/core/src/application-state/script-helpers.ts @@ -16,7 +16,10 @@ import { } from "./store.js"; function getScriptSessionId(): string { - return process.env.AGENT_USER_EMAIL ?? "local"; + const email = process.env.AGENT_USER_EMAIL; + // Map "local@localhost" → "local" to match the server handler convention + if (!email || email === "local@localhost") return "local"; + return email; } export async function readAppState( diff --git a/packages/core/src/client/AgentPanel.tsx b/packages/core/src/client/AgentPanel.tsx index ba51a60d..961d4d9b 100644 --- a/packages/core/src/client/AgentPanel.tsx +++ b/packages/core/src/client/AgentPanel.tsx @@ -21,6 +21,7 @@ * */ +import ReactDOM from "react-dom"; import React, { useState, useEffect, @@ -441,6 +442,18 @@ function AgentSettingsPopover({ label: cli.label, })); + // Compute fixed position from the button so the popover escapes all + // stacking contexts (the CLI terminal otherwise paints over it). + const [pos, setPos] = useState<{ top: number; right: number } | null>(null); + useEffect(() => { + if (!open || !buttonRef.current) return; + const rect = buttonRef.current.getBoundingClientRect(); + setPos({ + top: rect.bottom + 6, + right: window.innerWidth - rect.right, + }); + }, [open]); + return (
- {open && ( -
-
- { - const nextIsDev = next === "development"; - if (nextIsDev !== isDevMode) onToggle(); - }} - /> - {IS_DEV && cliOptions.length > 0 && ( + {open && + pos && + ReactDOM.createPortal( +
+
{ + const nextIsDev = next === "development"; + if (nextIsDev !== isDevMode) onToggle(); + }} /> - )} -
-
- )} + {IS_DEV && cliOptions.length > 0 && ( + + )} +
+
, + document.body, + )}
); } @@ -744,7 +761,7 @@ export function AgentPanel({ {/* CLI terminal — only rendered in dev mode */} {IS_DEV && mode === "cli" && ( -
+
@@ -921,6 +938,18 @@ export function AgentSidebar({ }; }, [setOpenPersisted]); + // Cmd+I / Ctrl+I to focus the agent chat + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "i") { + e.preventDefault(); + focusAgentChat(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + const handleDrag = useCallback((delta: number) => { setWidth((prev) => { const next = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, prev + delta)); @@ -961,6 +990,28 @@ export function AgentSidebar({ ); } +/** + * Focus the agent chat composer input. + * Opens the sidebar if closed, then focuses the text input. + */ +export function focusAgentChat() { + window.dispatchEvent(new Event("agent-panel:open")); + // Wait for sidebar to render, then focus the composer + requestAnimationFrame(() => { + const panel = document.querySelector(".agent-sidebar-panel"); + if (!panel) return; + const prosemirror = panel.querySelector( + ".ProseMirror", + ) as HTMLElement | null; + if (prosemirror) { + prosemirror.focus(); + return; + } + const textarea = panel.querySelector("textarea") as HTMLElement | null; + if (textarea) textarea.focus(); + }); +} + /** * Button to toggle the agent sidebar. Place this in your app's header/toolbar. * Dispatches a custom event that AgentSidebar listens for. diff --git a/packages/core/src/client/AssistantChat.tsx b/packages/core/src/client/AssistantChat.tsx index a26c2329..c6514dfb 100644 --- a/packages/core/src/client/AssistantChat.tsx +++ b/packages/core/src/client/AssistantChat.tsx @@ -247,7 +247,7 @@ function ToolCallFallback({ + ))} +
+
+ ); +} + +// --- Inline Bubble Toolbar --- + +function InlineBubbleToolbar({ editor }: { editor: any }) { + const [visible, setVisible] = useState(false); + const [coords, setCoords] = useState({ top: 0, left: 0 }); + const [showLinkInput, setShowLinkInput] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const toolbarRef = useRef(null); + + useEffect(() => { + if (!editor) return; + const update = () => { + const { from, to } = editor.state.selection; + if (from === to || !editor.isFocused) { + setVisible(false); + return; + } + const domSelection = window.getSelection(); + if (!domSelection || domSelection.rangeCount === 0) { + setVisible(false); + return; + } + const range = domSelection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + if (rect.width === 0) { + setVisible(false); + return; + } + setCoords({ + top: rect.top + window.scrollY - 8, + left: rect.left + window.scrollX + rect.width / 2, + }); + setVisible(true); + }; + editor.on("selectionUpdate", update); + editor.on("blur", () => setVisible(false)); + return () => { + editor.off("selectionUpdate", update); + editor.off("blur", () => setVisible(false)); + }; + }, [editor]); + + const handleSetLink = () => { + if (linkUrl.trim()) { + editor + .chain() + .focus() + .extendMarkRange("link") + .setLink({ href: linkUrl.trim() }) + .run(); + } else { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + } + setShowLinkInput(false); + setLinkUrl(""); + }; + + const toggleLink = () => { + if (editor.isActive("link")) { + editor.chain().focus().unsetLink().run(); + return; + } + const previousUrl = editor.getAttributes("link").href || ""; + setLinkUrl(previousUrl); + setShowLinkInput(true); + }; + + const items = [ + { + label: "B", + title: "Bold", + action: () => editor.chain().focus().toggleBold().run(), + isActive: () => editor.isActive("bold"), + style: { fontWeight: 700 } as React.CSSProperties, + }, + { + label: "I", + title: "Italic", + action: () => editor.chain().focus().toggleItalic().run(), + isActive: () => editor.isActive("italic"), + style: { fontStyle: "italic" } as React.CSSProperties, + }, + { + label: "S", + title: "Strikethrough", + action: () => editor.chain().focus().toggleStrike().run(), + isActive: () => editor.isActive("strike"), + style: { textDecoration: "line-through" } as React.CSSProperties, + }, + { + label: "<>", + title: "Code", + action: () => editor.chain().focus().toggleCode().run(), + isActive: () => editor.isActive("code"), + style: { fontFamily: "monospace", fontSize: 11 } as React.CSSProperties, + }, + { type: "divider" as const }, + { + label: "H1", + title: "Heading 1", + action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), + isActive: () => editor.isActive("heading", { level: 1 }), + }, + { + label: "H2", + title: "Heading 2", + action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + isActive: () => editor.isActive("heading", { level: 2 }), + }, + { + label: "H3", + title: "Heading 3", + action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), + isActive: () => editor.isActive("heading", { level: 3 }), + }, + { type: "divider" as const }, + { + label: "🔗", + title: "Link", + action: toggleLink, + isActive: () => editor.isActive("link"), + }, + ]; + + if (!visible) return null; + + return ( +
+ {showLinkInput ? ( +
e.preventDefault()} + > + setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSetLink(); + if (e.key === "Escape") { + setShowLinkInput(false); + setLinkUrl(""); + } + }} + style={{ + background: "transparent", + border: "none", + outline: "none", + color: "white", + fontSize: 12, + width: 160, + padding: "2px 4px", + }} + /> + +
+ ) : ( +
e.preventDefault()} + > + {items.map((item, i) => { + if ("type" in item && item.type === "divider") { + return ( +
+ ); + } + const { label, title, action, isActive, style } = item as { + label: string; + title: string; + action: () => void; + isActive: () => boolean; + style?: React.CSSProperties; + }; + return ( + + ); + })} +
+ )} +
+ ); +} + +// --- Visual Markdown Editor --- + +function VisualMarkdownEditor({ + content, + onChange, + resourceId, +}: { + content: string; + onChange: (md: string) => void; + resourceId: string; +}) { + const isSettingContent = useRef(false); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + codeBlock: {}, + dropcursor: { color: "hsl(var(--ring))", width: 2 }, + }), + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + const level = node.attrs.level; + if (level === 1) return "Heading 1"; + if (level === 2) return "Heading 2"; + return "Heading 3"; + } + return "Type '/' for commands..."; + }, + showOnlyWhenEditable: true, + showOnlyCurrent: true, + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { class: "re-link" }, + }), + Markdown.configure({ + html: true, + transformPastedText: true, + transformCopiedText: true, + }), + ], + content, + editorProps: { + attributes: { + class: "re-prose", + }, + }, + onUpdate: ({ editor }) => { + if (isSettingContent.current) return; + try { + const md = (editor.storage as any).markdown.getMarkdown(); + onChangeRef.current(md); + } catch (err) { + console.error("Markdown serialization error:", err); + } + }, + }); + + useEffect(() => { + if (!editor || editor.isDestroyed) return; + const currentMd = (editor.storage as any).markdown.getMarkdown(); + if (currentMd !== content) { + if (editor.isFocused) return; + isSettingContent.current = true; + editor.commands.setContent(content); + isSettingContent.current = false; + } + }, [content, editor]); + + useEffect(() => { + return () => { + editor?.destroy(); + }; + }, [editor]); + + if (!editor) return null; + + const handleWrapperClick = (e: React.MouseEvent) => { + // If the click was on the wrapper (empty area), not on editor content, focus at end + const target = e.target as HTMLElement; + if ( + target.classList.contains("re-editor-clickable") || + target.classList.contains("re-editor-wrapper") + ) { + editor.chain().focus("end").run(); + } + }; + + return ( +
+ + + +
+ ); +} + +// --- Main ResourceEditor --- + export function ResourceEditor({ resource, onSave }: ResourceEditorProps) { const [content, setContent] = useState(resource.content); - const [tab, setTab] = useState<"edit" | "preview">("edit"); + const [view, setView] = useState<"visual" | "code">(getViewPref); const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">( "idle", ); @@ -43,6 +633,11 @@ export function ResourceEditor({ resource, onSave }: ResourceEditorProps) { [onSave], ); + const switchView = useCallback((v: "visual" | "code") => { + setView(v); + setViewPref(v); + }, []); + // Cleanup debounce on unmount useEffect(() => { return () => { @@ -74,35 +669,36 @@ export function ResourceEditor({ resource, onSave }: ResourceEditorProps) { ); } - // Markdown files get edit/preview tabs + // Markdown files get visual/code toggle if (isMarkdown) { return (
-
+ +
@@ -113,7 +709,18 @@ export function ResourceEditor({ resource, onSave }: ResourceEditorProps) { : ""}
- {tab === "edit" ? ( + {view === "visual" ? ( +
+ +
+ ) : (