diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 111eb5a5..274cfb2a 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -44,6 +44,7 @@ export async function isConfigured(): Promise<{ configured: boolean }> { export function setApiKey(apiKey: string): { success: boolean; error?: string } { try { composioClient.setApiKey(apiKey); + invalidateCopilotInstructionsCache(); return { success: true }; } catch (error) { return { diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ec1a0aaa..a9de9572 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -455,7 +455,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 97704225..eea21481 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -25,6 +25,7 @@ import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; +import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; @@ -116,7 +117,7 @@ protocol.registerSchemesAsPrivileged([ }, ]); -const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture"]); +const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]); function configureSessionPermissions(targetSession: Session): void { targetSession.setPermissionCheckHandler((_webContents, permission) => { @@ -291,6 +292,11 @@ app.whenReady().then(async () => { // start chrome extension sync server initChromeSync(); + // start local sites server for iframe dashboards and other mini apps + initLocalSites().catch((error) => { + console.error('[LocalSites] Failed to start:', error); + }); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); @@ -309,4 +315,7 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + shutdownLocalSites().catch((error) => { + console.error('[LocalSites] Failed to shut down cleanly:', error); + }); }); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 4bb837c9..d9216de1 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -28,6 +28,7 @@ "@tiptap/extension-image": "^3.16.0", "@tiptap/extension-link": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3", + "@tiptap/extension-table": "^3.22.4", "@tiptap/extension-task-item": "^3.15.3", "@tiptap/extension-task-list": "^3.15.3", "@tiptap/pm": "^3.15.3", @@ -48,6 +49,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "^3.8.0", + "remark-breaks": "^4.0.0", "sonner": "^2.0.7", "streamdown": "^1.6.10", "tailwind-merge": "^3.4.0", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1ada0045..67f3f06a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon, Globe } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -15,6 +15,7 @@ import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-vi import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; +import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -61,6 +62,8 @@ import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' +import { defaultRemarkPlugins } from 'streamdown' +import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { type ChatMessage, @@ -88,7 +91,7 @@ import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' -import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' +import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' @@ -103,6 +106,11 @@ interface TreeNode extends DirEntry { const streamdownComponents = { pre: MarkdownPreOverride } +// Render user messages with markdown so bullets, bold, links, etc. survive the +// round-trip from the input textarea. `remarkBreaks` turns single newlines +// into
so typed line breaks are preserved without requiring blank lines. +const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] + function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) { const smoothText = useSmoothedText(text) return {smoothText} @@ -126,9 +134,10 @@ const TITLEBAR_BUTTON_PX = 32 const TITLEBAR_BUTTON_GAP_PX = 4 const TITLEBAR_HEADER_GAP_PX = 8 const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 -const TITLEBAR_BUTTONS_COLLAPSED = 4 -const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3 +const TITLEBAR_BUTTONS_COLLAPSED = 1 +const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' +const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -257,8 +266,63 @@ const getAncestorDirectoryPaths = (path: string): string[] => { } const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH +const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH +const getSuggestedTopicTargetFolder = (category?: string) => { + const normalized = category?.trim().toLowerCase() + switch (normalized) { + case 'people': + case 'person': + return 'People' + case 'organizations': + case 'organization': + return 'Organizations' + case 'projects': + case 'project': + return 'Projects' + case 'meetings': + case 'meeting': + return 'Meetings' + case 'topics': + case 'topic': + default: + return 'Topics' + } +} + +const buildSuggestedTopicExplorePrompt = ({ + title, + description, + category, +}: { + title: string + description: string + category?: string +}) => { + const folder = getSuggestedTopicTargetFolder(category) + const categoryLabel = category?.trim() || 'Topics' + return [ + 'I am exploring a suggested topic card from the Suggested Topics panel.', + 'This card may represent a person, organization, topic, or project.', + '', + 'Card context:', + `- Title: ${title}`, + `- Category: ${categoryLabel}`, + `- Description: ${description}`, + `- Target folder if we set this up: knowledge/${folder}/`, + '', + `Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`, + 'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.', + 'Do not create or modify anything yet.', + 'Treat a clear confirmation from me as explicit approval to proceed.', + `If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`, + `If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`, + 'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.', + 'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.', + ].join('\n') +} + const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') @@ -439,6 +503,7 @@ type ViewState = | { type: 'file'; path: string } | { type: 'graph' } | { type: 'task'; name: string } + | { type: 'suggested-topics' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -448,38 +513,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { return true // both graph } -/** Sidebar toggle + utility buttons (fixed position, top-left) */ +/** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ - onNavigateBack, - onNavigateForward, - canNavigateBack, - canNavigateForward, - onNewChat, - onOpenSearch, - meetingState, - meetingSummarizing, - meetingAvailable, - onToggleMeeting, - isBrowserOpen, - onToggleBrowser, leftInsetPx, }: { - onNavigateBack: () => void - onNavigateForward: () => void - canNavigateBack: boolean - canNavigateForward: boolean - onNewChat: () => void - onOpenSearch: () => void - meetingState: MeetingTranscriptionState - meetingSummarizing: boolean - meetingAvailable: boolean - onToggleMeeting: () => void - isBrowserOpen: boolean - onToggleBrowser: () => void leftInsetPx: number }) { - const { toggleSidebar, state } = useSidebar() - const isCollapsed = state === "collapsed" + const { toggleSidebar } = useSidebar() return (
) } @@ -664,7 +612,8 @@ function App() { const [recentWikiFiles, setRecentWikiFiles] = useState([]) const [isGraphOpen, setIsGraphOpen] = useState(false) const [isBrowserOpen, setIsBrowserOpen] = useState(false) - const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) + const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) + const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null) const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], @@ -875,6 +824,7 @@ function App() { const chatTabIdCounterRef = useRef(0) const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}` const chatDraftsRef = useRef(new Map()) + const selectedModelByTabRef = useRef(new Map()) const chatScrollTopByTabRef = useRef(new Map()) const [toolOpenByTab, setToolOpenByTab] = useState>>({}) const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({}) @@ -959,6 +909,7 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' + if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -2154,6 +2105,34 @@ function App() { return cleanup }, [handleRunEvent]) + type MiddlePaneContextPayload = + | { kind: 'note'; path: string; content: string } + | { kind: 'browser'; url: string; title: string } + const buildMiddlePaneContext = async (): Promise => { + // Nothing visible in the middle pane when the right pane is maximized. + if (isRightPaneMaximized) return undefined + + // Browser is an overlay on top of any note — when it's open, it's what the user is looking at. + if (isBrowserOpen) { + try { + const state = await window.ipc.invoke('browser:getState', null) + const activeTab = state.tabs.find((t) => t.id === state.activeTabId) + if (activeTab) { + return { kind: 'browser', url: activeTab.url, title: activeTab.title } + } + } catch { + // fall through to no-context if browser state is unavailable + } + return undefined + } + + // Note case: only markdown files are meaningfully readable as context. + const path = selectedPathRef.current + if (!path || !path.endsWith('.md')) return undefined + const content = editorContentRef.current ?? '' + return { kind: 'note', path, content } + } + const handlePromptSubmit = async ( message: PromptInputMessage, mentions?: FileMention[], @@ -2194,8 +2173,10 @@ function App() { let isNewRun = false let newRunCreatedAt: string | null = null if (!currentRunId) { + const selected = selectedModelByTabRef.current.get(submitTabId) const run = await window.ipc.invoke('runs:create', { agentId, + ...(selected ? { model: selected.model, provider: selected.provider } : {}), }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2257,12 +2238,14 @@ function App() { // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema. const attachmentPayload = contentParts as unknown as string + const middlePaneContext = await buildMiddlePaneContext() await window.ipc.invoke('runs:createMessage', { runId: currentRunId, message: attachmentPayload, voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + middlePaneContext, }) analytics.chatMessageSent({ voiceInput: pendingVoiceInputRef.current || undefined, @@ -2270,12 +2253,14 @@ function App() { searchEnabled: searchEnabled || undefined, }) } else { + const middlePaneContext = await buildMiddlePaneContext() await window.ipc.invoke('runs:createMessage', { runId: currentRunId, message: userMessage, voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + middlePaneContext, }) analytics.chatMessageSent({ voiceInput: pendingVoiceInputRef.current || undefined, @@ -2496,6 +2481,7 @@ function App() { return next }) chatDraftsRef.current.delete(tabId) + selectedModelByTabRef.current.delete(tabId) chatScrollTopByTabRef.current.delete(tabId) setToolOpenByTab((prev) => { if (!(tabId in prev)) return prev @@ -2622,9 +2608,17 @@ function App() { if (isGraphTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(true) + setIsSuggestedTopicsOpen(false) + return + } + if (isSuggestedTopicsTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(true) return } setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) setSelectedPath(tab.path) }, [fileTabs, isRightPaneMaximized]) @@ -2652,6 +2646,7 @@ function App() { setActiveFileTabId(null) setSelectedPath(null) setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2664,8 +2659,14 @@ function App() { if (isGraphTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(true) + setIsSuggestedTopicsOpen(false) + } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(true) } else { setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2695,15 +2696,16 @@ function App() { } handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { + setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) } else { setExpandedFrom(null) } setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) - }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen]) + setIsSuggestedTopicsOpen(false) + }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -2767,6 +2769,27 @@ function App() { return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener) }, [submitFromPalette]) + // Listener for prompt-block "Run" events + // (dispatched by apps/renderer/src/extensions/prompt-block.tsx) + useEffect(() => { + const handler = (e: Event) => { + const ev = e as CustomEvent<{ + instruction?: string + filePath?: string + label?: string + }> + const instruction = ev.detail?.instruction + const filePath = ev.detail?.filePath + if (!instruction) return + const mention = filePath + ? { path: filePath, displayName: filePath.split('/').pop() ?? filePath } + : null + submitFromPalette(instruction, mention) + } + window.addEventListener('rowboat:open-copilot-prompt', handler as EventListener) + return () => window.removeEventListener('rowboat:open-copilot-prompt', handler as EventListener) + }, [submitFromPalette]) + const toggleKnowledgePane = useCallback(() => { setIsRightPaneMaximized(false) setIsChatSidebarOpen(prev => !prev) @@ -2797,19 +2820,26 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { + setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) } setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) - }, [selectedPath, isGraphOpen]) + setIsSuggestedTopicsOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) + setIsSuggestedTopicsOpen(false) + } else if (expandedFrom.suggestedTopics) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(true) } else if (expandedFrom.path) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -2819,10 +2849,11 @@ function App() { const currentViewState = React.useMemo(() => { if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } + if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -2868,6 +2899,17 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureSuggestedTopicsFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isSuggestedTopicsTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: SUGGESTED_TOPICS_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -2876,6 +2918,7 @@ function App() { // Navigating to a file dismisses the browser overlay so the file is // visible in the middle pane. setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -2889,6 +2932,7 @@ function App() { setSelectedBackgroundTask(null) setSelectedPath(null) setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -2900,10 +2944,21 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) return + case 'suggested-topics': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(true) + ensureSuggestedTopicsFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -2912,6 +2967,7 @@ function App() { setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -2919,7 +2975,7 @@ function App() { } return } - }, [ensureFileTabForPath, ensureGraphFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -3184,7 +3240,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3262,12 +3318,16 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen) - const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen) + const selectedKnowledgePath = isGraphOpen + ? GRAPH_TAB_PATH + : isSuggestedTopicsOpen + ? SUGGESTED_TOPICS_TAB_PATH + : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath ? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null) @@ -3321,7 +3381,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3329,9 +3389,9 @@ function App() { return } - // Top-level knowledge folders (except Notes) open as a bases view with folder filter + // Top-level knowledge folders open as a bases view with folder filter const parts = path.split('/') - if (parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes') { + if (parts.length === 2 && parts[0] === 'knowledge') { const folderName = parts[1] const folderCfg = FOLDER_BASE_CONFIGS[folderName] setBaseConfigByPath((prev) => ({ @@ -3346,7 +3406,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3468,14 +3528,14 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3921,7 +3981,14 @@ function App() { {item.content && ( - {item.content} + + + {item.content} + + )} ) @@ -3942,7 +4009,12 @@ function App() { ))}
)} - {message} + + {message} + ) @@ -4047,7 +4119,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4080,6 +4152,14 @@ function App() { selectedPath={selectedPath} expandedPaths={expandedPaths} onSelectFile={toggleExpand} + onToggleFolder={(path) => { + setExpandedPaths((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + }} knowledgeActions={knowledgeActions} onVoiceNoteCreated={handleVoiceNoteCreated} runs={runs} @@ -4089,7 +4169,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4100,7 +4180,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4124,14 +4204,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4149,6 +4229,16 @@ function App() { }} backgroundTasks={backgroundTasks} selectedBackgroundTask={selectedBackgroundTask} + onNewChat={handleNewChatTab} + onOpenSearch={() => setIsSearchOpen(true)} + meetingState={meetingTranscription.state} + meetingSummarizing={meetingSummarizing} + meetingAvailable={voiceAvailable} + onToggleMeeting={() => { void handleToggleMeeting() }} + isBrowserOpen={isBrowserOpen} + onToggleBrowser={handleToggleBrowser} + isSuggestedTopicsOpen={isSuggestedTopicsOpen} + onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} /> - {(selectedPath || isGraphOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && ( @@ -571,18 +538,18 @@ function ChatInputInner({ {configuredModels.map((m) => { - const key = `${m.flavor}/${m.model}` + const key = `${m.provider}/${m.model}` return ( {m.model} - {providerDisplayNames[m.flavor] || m.flavor} + {providerDisplayNames[m.provider] || m.provider} ) })} - )} + ) : null} {onToggleTts && ttsAvailable && (
@@ -729,6 +696,7 @@ export interface ChatInputWithMentionsProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + onSelectedModelChange?: (model: SelectedModel | null) => void } export function ChatInputWithMentions({ @@ -757,6 +725,7 @@ export function ChatInputWithMentions({ ttsMode, onToggleTts, onTtsModeChange, + onSelectedModelChange, }: ChatInputWithMentionsProps) { return ( @@ -783,6 +752,7 @@ export function ChatInputWithMentions({ ttsMode={ttsMode} onToggleTts={onToggleTts} onTtsModeChange={onTtsModeChange} + onSelectedModelChange={onSelectedModelChange} /> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index e51d7c8f..0a407d5d 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -25,8 +25,10 @@ import { Suggestions } from '@/components/ai-elements/suggestions' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' +import { defaultRemarkPlugins } from 'streamdown' +import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { @@ -49,6 +51,11 @@ import { const streamdownComponents = { pre: MarkdownPreOverride } +// Render user messages with markdown so bullets, bold, links, etc. survive the +// round-trip from the input textarea. `remarkBreaks` turns single newlines +// into
so typed line breaks are preserved without requiring blank lines. +const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] + /* ─── Billing error helpers ─── */ const BILLING_ERROR_PATTERNS = [ @@ -158,6 +165,7 @@ interface ChatSidebarProps { onPresetMessageConsumed?: () => void getInitialDraft?: (tabId: string) => string | undefined onDraftChangeForTab?: (tabId: string, text: string) => void + onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] @@ -211,6 +219,7 @@ export function ChatSidebar({ onPresetMessageConsumed, getInitialDraft, onDraftChangeForTab, + onSelectedModelChangeForTab, pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), @@ -351,7 +360,14 @@ export function ChatSidebar({ {item.content && ( - {item.content} + + + {item.content} + + )} ) @@ -372,7 +388,12 @@ export function ChatSidebar({ ))}
)} - {message} + + {message} + ) @@ -662,6 +683,7 @@ export function ChatSidebar({ runId={tabState.runId} initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} + onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} isRecording={isActive && isRecording} recordingText={isActive ? recordingText : undefined} recordingState={isActive ? recordingState : undefined} diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 3d22c646..e97f7c6e 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -7,12 +7,16 @@ import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' +import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table' +import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' import { TrackBlockExtension } from '@/extensions/track-block' +import { PromptBlockExtension } from '@/extensions/prompt-block' import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target' import { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' +import { IframeBlockExtension } from '@/extensions/iframe-block' import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' @@ -54,17 +58,22 @@ function preprocessMarkdown(markdown: string): string { // line until a blank line terminates it, and markdown inline rules (bold, // italics, links) don't apply inside the block. Without surrounding blank // lines, the line right after our placeholder div gets absorbed as HTML and -// its markdown is not parsed. We consume any adjacent newlines in the match -// and emit exactly `\n\n
\n\n` so the HTML block starts and ends on -// its own line. +// its markdown is not parsed. +// +// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n` +// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks +// on save; a `\n?` regex on reload would only consume one of those two +// newlines, so every cycle would add a net newline on each side of every +// marker — causing tracks running on an open note to steadily inflate the +// file with blank lines around target regions. function preprocessTrackTargets(md: string): string { return md .replace( - /\n?\n?/g, + /\n*\n*/g, (_m, id: string) => `\n\n
\n\n`, ) .replace( - /\n?\n?/g, + /\n*\n*/g, (_m, id: string) => `\n\n
\n\n`, ) } @@ -148,6 +157,17 @@ function serializeList(listNode: JsonNode, indent: number): string[] { return lines } +// Adapter for tiptap's first-party renderTableToMarkdown. Only renderChildren is +// actually invoked — the other helpers are stubs to satisfy the type. +const tableRenderHelpers: MarkdownRendererHelpers = { + renderChildren: (nodes) => { + const arr = Array.isArray(nodes) ? nodes : [nodes] + return arr.map(n => n.type === 'paragraph' ? nodeToText(n as JsonNode) : '').join('') + }, + wrapInBlock: (prefix, content) => prefix + content, + indent: (content) => content, +} + // Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker // paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown. function blockToMarkdown(node: JsonNode): string { @@ -167,6 +187,8 @@ function blockToMarkdown(node: JsonNode): string { return serializeList(node, 0).join('\n') case 'taskBlock': return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'promptBlock': + return '```prompt\n' + (node.attrs?.data as string || '') + '\n```' case 'trackBlock': return '```track\n' + (node.attrs?.data as string || '') + '\n```' case 'trackTargetOpen': @@ -177,6 +199,8 @@ function blockToMarkdown(node: JsonNode): string { return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' case 'embedBlock': return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'iframeBlock': + return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```' case 'chartBlock': return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```' case 'tableBlock': @@ -189,6 +213,8 @@ function blockToMarkdown(node: JsonNode): string { return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```' case 'mermaidBlock': return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```' + case 'table': + return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim() case 'codeBlock': { const lang = (node.attrs?.language as string) || '' return '```' + lang + '\n' + nodeToText(node) + '\n```' @@ -672,10 +698,12 @@ export const MarkdownEditor = forwardRef>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, }) const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle", @@ -109,7 +109,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) const updateProviderConfig = useCallback( - (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [provider]: { ...prev[provider], ...updates }, @@ -458,6 +458,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const baseURL = activeConfig.baseURL.trim() || undefined const model = activeConfig.model.trim() const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined + const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined + const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined const providerConfig = { provider: { flavor: llmProvider, @@ -466,6 +468,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { }, model, knowledgeGraphModel, + meetingNotesModel, + trackBlockModel, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -1157,6 +1161,72 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { )} + +
+ Meeting notes model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
+ +
+ Track block model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{showApiKey && ( diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx index a9956245..a11b0d5f 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -221,6 +221,76 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) { )} + +
+ + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
+ +
+ + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{showApiKey && ( diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index a55b23fe..edb3616b 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, }) const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle", @@ -81,7 +81,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) const updateProviderConfig = useCallback( - (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [provider]: { ...prev[provider], ...updates }, @@ -435,6 +435,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const baseURL = activeConfig.baseURL.trim() || undefined const model = activeConfig.model.trim() const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined + const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined + const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined const providerConfig = { provider: { flavor: llmProvider, @@ -443,6 +445,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { }, model, knowledgeGraphModel, + meetingNotesModel, + trackBlockModel, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -459,7 +463,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { setTestState({ status: "error", error: "Connection test failed" }) toast.error("Connection test failed") } - }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext]) + }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.trackBlockModel, canTest, llmProvider, handleNext]) // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 143c6292..ddc506c9 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -196,14 +196,14 @@ const defaultBaseURLs: Partial> = { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState("openai") const [defaultProvider, setDefaultProvider] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) @@ -229,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) const updateConfig = useCallback( - (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => { + (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [prov]: { ...prev[prov], ...updates }, @@ -302,6 +302,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), models: savedModels, knowledgeGraphModel: e.knowledgeGraphModel || "", + meetingNotesModel: e.meetingNotesModel || "", + trackBlockModel: e.trackBlockModel || "", }; } } @@ -318,6 +320,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), models: activeModels.length > 0 ? activeModels : [""], knowledgeGraphModel: parsed.knowledgeGraphModel || "", + meetingNotesModel: parsed.meetingNotesModel || "", + trackBlockModel: parsed.trackBlockModel || "", }; } return next; @@ -391,6 +395,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { model: allModels[0] || "", models: allModels, knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, + meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, + trackBlockModel: activeConfig.trackBlockModel.trim() || undefined, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -423,6 +429,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { model: allModels[0], models: allModels, knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, + meetingNotesModel: config.meetingNotesModel.trim() || undefined, + trackBlockModel: config.trackBlockModel.trim() || undefined, }) setDefaultProvider(prov) window.dispatchEvent(new Event('models-config-changed')) @@ -452,6 +460,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { parsed.model = defModels[0] || "" parsed.models = defModels parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined + parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined + parsed.trackBlockModel = defConfig.trackBlockModel.trim() || undefined } await window.ipc.invoke("workspace:writeFile", { path: "config/models.json", @@ -459,7 +469,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }) setProviderConfigs(prev => ({ ...prev, - [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" }, + [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, })) setTestState({ status: "idle" }) window.dispatchEvent(new Event('models-config-changed')) @@ -649,6 +659,74 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )} + + {/* Meeting notes model */} +
+ Meeting notes model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { meetingNotesModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + + )} +
+ + {/* Track block model */} +
+ Track block model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { trackBlockModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + + )} +
{/* API Key */} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 3fcb1acc..41d6b622 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -12,13 +12,18 @@ import { FilePlus, Folder, FolderPlus, + Globe, AlertTriangle, HelpCircle, Mic, Network, Pencil, + Radio, + SearchIcon, + SquarePen, Table2, Plug, + Lightbulb, LoaderIcon, Settings, Square, @@ -58,6 +63,7 @@ import { SidebarGroupContent, SidebarHeader, SidebarMenu, + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, @@ -90,6 +96,7 @@ import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" import { useBilling } from "@/hooks/useBilling" import { ServiceEvent } from "@x/shared/src/service-events.js" +import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription" import z from "zod" interface TreeNode { @@ -164,6 +171,7 @@ type SidebarContentPanelProps = { selectedPath: string | null expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void + onToggleFolder?: (path: string) => void knowledgeActions: KnowledgeActions onVoiceNoteCreated?: (path: string) => void runs?: RunListItem[] @@ -172,6 +180,16 @@ type SidebarContentPanelProps = { tasksActions?: TasksActions backgroundTasks?: BackgroundTaskItem[] selectedBackgroundTask?: string | null + onNewChat?: () => void + onOpenSearch?: () => void + meetingState?: MeetingTranscriptionState + meetingSummarizing?: boolean + meetingAvailable?: boolean + onToggleMeeting?: () => void + isBrowserOpen?: boolean + onToggleBrowser?: () => void + isSuggestedTopicsOpen?: boolean + onOpenSuggestedTopics?: () => void } & React.ComponentProps const sectionTabs: { id: ActiveSection; label: string }[] = [ @@ -387,6 +405,7 @@ export function SidebarContentPanel({ selectedPath, expandedPaths, onSelectFile, + onToggleFolder, knowledgeActions, onVoiceNoteCreated, runs = [], @@ -395,6 +414,16 @@ export function SidebarContentPanel({ tasksActions, backgroundTasks = [], selectedBackgroundTask, + onNewChat, + onOpenSearch, + meetingState = 'idle', + meetingSummarizing = false, + meetingAvailable = false, + onToggleMeeting, + isBrowserOpen = false, + onToggleBrowser, + isSuggestedTopicsOpen = false, + onOpenSuggestedTopics, ...props }: SidebarContentPanelProps) { const { activeSection, setActiveSection } = useSidebarSection() @@ -488,6 +517,89 @@ export function SidebarContentPanel({ ))} + {/* Quick action buttons */} +
+ {onNewChat && ( + + )} + {onOpenSearch && ( + + )} + {meetingAvailable && onToggleMeeting && ( + + )} + {onToggleBrowser && ( + + )} + {onOpenSuggestedTopics && ( + + )} +
{activeSection === "knowledge" && ( @@ -496,6 +608,7 @@ export function SidebarContentPanel({ selectedPath={selectedPath} expandedPaths={expandedPaths} onSelectFile={onSelectFile} + onToggleFolder={onToggleFolder} actions={knowledgeActions} onVoiceNoteCreated={onVoiceNoteCreated} /> @@ -884,6 +997,7 @@ function KnowledgeSection({ selectedPath, expandedPaths, onSelectFile, + onToggleFolder, actions, onVoiceNoteCreated, }: { @@ -891,6 +1005,7 @@ function KnowledgeSection({ selectedPath: string | null expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void + onToggleFolder?: (path: string) => void actions: KnowledgeActions onVoiceNoteCreated?: (path: string) => void }) { @@ -980,6 +1095,7 @@ function KnowledgeSection({ selectedPath={selectedPath} expandedPaths={expandedPaths} onSelect={onSelectFile} + onToggleFolder={onToggleFolder} actions={actions} /> ))} @@ -1008,9 +1124,7 @@ function countFiles(node: TreeNode): number { } /** Display name overrides for top-level knowledge folders */ -const FOLDER_DISPLAY_NAMES: Record = { - Notes: 'My Notes', -} +const FOLDER_DISPLAY_NAMES: Record = {} // Tree component for file browser function Tree({ @@ -1018,12 +1132,14 @@ function Tree({ selectedPath, expandedPaths, onSelect, + onToggleFolder, actions, }: { item: TreeNode selectedPath: string | null expandedPaths: Set onSelect: (path: string, kind: "file" | "dir") => void + onToggleFolder?: (path: string) => void actions: KnowledgeActions }) { const isDir = item.kind === 'dir' @@ -1160,15 +1276,15 @@ function Tree({ ) } - // Top-level knowledge folders (except Notes) open bases view — render as flat items + // Top-level knowledge folders open bases view — render as flat items const parts = item.path.split('/') - const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes' + const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge' if (isBasesFolder) { return ( - + onSelect(item.path, item.kind)}>
@@ -1176,6 +1292,38 @@ function Tree({ {countFiles(item)}
+ {onToggleFolder && (item.children?.length ?? 0) > 0 && ( + { + e.stopPropagation() + onToggleFolder(item.path) + }} + > + + + )} + {isExpanded && ( + + {(item.children ?? []).map((subItem, index) => ( + + ))} + + )}
{contextMenuContent} @@ -1240,6 +1388,7 @@ function Tree({ selectedPath={selectedPath} expandedPaths={expandedPaths} onSelect={onSelect} + onToggleFolder={onToggleFolder} actions={actions} /> ))} diff --git a/apps/x/apps/renderer/src/components/suggested-topics-view.tsx b/apps/x/apps/renderer/src/components/suggested-topics-view.tsx new file mode 100644 index 00000000..4440aba9 --- /dev/null +++ b/apps/x/apps/renderer/src/components/suggested-topics-view.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react' +import { ArrowRight, Lightbulb, Loader2 } from 'lucide-react' +import { SuggestedTopicBlockSchema, type SuggestedTopicBlock } from '@x/shared/dist/blocks.js' + +const SUGGESTED_TOPICS_PATH = 'suggested-topics.md' +const LEGACY_SUGGESTED_TOPICS_PATHS = [ + 'config/suggested-topics.md', + 'knowledge/Notes/Suggested Topics.md', +] + +/** Parse suggestedtopic code-fence blocks from the markdown file content. */ +function parseTopics(content: string): SuggestedTopicBlock[] { + const topics: SuggestedTopicBlock[] = [] + const regex = /```suggestedtopic\s*\n([\s\S]*?)```/g + let match: RegExpExecArray | null + while ((match = regex.exec(content)) !== null) { + try { + const parsed = JSON.parse(match[1].trim()) + const topic = SuggestedTopicBlockSchema.parse(parsed) + topics.push(topic) + } catch { + // Skip malformed blocks + } + } + + if (topics.length > 0) return topics + + const lines = content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + + for (const line of lines) { + try { + const parsed = JSON.parse(line) + const topic = SuggestedTopicBlockSchema.parse(parsed) + topics.push(topic) + } catch { + // Skip malformed lines + } + } + + return topics +} + +function serializeTopics(topics: SuggestedTopicBlock[]): string { + const blocks = topics.map((topic) => [ + '```suggestedtopic', + JSON.stringify(topic), + '```', + ].join('\n')) + + return ['# Suggested Topics', ...blocks].join('\n\n') + '\n' +} + +const CATEGORY_COLORS: Record = { + Meetings: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', + Projects: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', + People: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + Organizations: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400', + Topics: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', +} + +function getCategoryColor(category?: string): string { + if (!category) return 'bg-muted text-muted-foreground' + return CATEGORY_COLORS[category] ?? 'bg-muted text-muted-foreground' +} + +interface TopicCardProps { + topic: SuggestedTopicBlock + onTrack: () => void + isRemoving: boolean +} + +function TopicCard({ topic, onTrack, isRemoving }: TopicCardProps) { + return ( +
+
+

+ {topic.title} +

+ {topic.category && ( + + {topic.category} + + )} +
+

+ {topic.description} +

+ +
+ ) +} + +interface SuggestedTopicsViewProps { + onExploreTopic: (topic: SuggestedTopicBlock) => void +} + +export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) { + const [topics, setTopics] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [removingIndex, setRemovingIndex] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + try { + let result + try { + result = await window.ipc.invoke('workspace:readFile', { + path: SUGGESTED_TOPICS_PATH, + }) + } catch { + let legacyResult: { data?: string } | null = null + let legacyPath: string | null = null + for (const path of LEGACY_SUGGESTED_TOPICS_PATHS) { + try { + legacyResult = await window.ipc.invoke('workspace:readFile', { path }) + legacyPath = path + break + } catch { + // Try next legacy location. + } + } + if (!legacyResult || !legacyPath || legacyResult.data === undefined) { + throw new Error('Suggested topics file not found') + } + await window.ipc.invoke('workspace:writeFile', { + path: SUGGESTED_TOPICS_PATH, + data: legacyResult.data, + opts: { encoding: 'utf8' }, + }) + await window.ipc.invoke('workspace:remove', { + path: legacyPath, + opts: { trash: true }, + }) + result = legacyResult + } + if (cancelled) return + if (result.data) { + setTopics(parseTopics(result.data)) + } + } catch { + if (!cancelled) setError('No suggested topics yet. Check back once your knowledge graph has more data.') + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { cancelled = true } + }, []) + + const handleTrack = useCallback( + async (topic: SuggestedTopicBlock, topicIndex: number) => { + if (removingIndex !== null) return + const nextTopics = topics.filter((_, idx) => idx !== topicIndex) + setRemovingIndex(topicIndex) + setError(null) + try { + await window.ipc.invoke('workspace:writeFile', { + path: SUGGESTED_TOPICS_PATH, + data: serializeTopics(nextTopics), + opts: { encoding: 'utf8' }, + }) + setTopics(nextTopics) + } catch (err) { + console.error('Failed to remove suggested topic:', err) + setError('Failed to update suggested topics. Please try again.') + return + } finally { + setRemovingIndex(null) + } + + onExploreTopic(topic) + }, + [onExploreTopic, removingIndex, topics], + ) + + if (loading) { + return ( +
+ +
+ ) + } + + if (error || topics.length === 0) { + return ( +
+
+ +
+

+ {error ?? 'No suggested topics yet. Check back once your knowledge graph has more data.'} +

+
+ ) + } + + return ( +
+
+
+ +

Suggested Topics

+
+

+ Suggested notes surfaced from your knowledge graph. Track one to start a tracking note. +

+
+
+
+ {topics.map((topic, i) => ( + { void handleTrack(topic, i) }} + isRemoving={removingIndex === i} + /> + ))} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/track-modal.tsx b/apps/x/apps/renderer/src/components/track-modal.tsx index 8e261977..a4c0b512 100644 --- a/apps/x/apps/renderer/src/components/track-modal.tsx +++ b/apps/x/apps/renderer/src/components/track-modal.tsx @@ -156,6 +156,8 @@ export function TrackModal() { const lastRunAt = track?.lastRunAt ?? '' const lastRunId = track?.lastRunId ?? '' const lastRunSummary = track?.lastRunSummary ?? '' + const model = track?.model ?? '' + const provider = track?.provider ?? '' const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule]) const triggerType: 'scheduled' | 'event' | 'manual' = schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual' @@ -393,6 +395,12 @@ export function TrackModal() {
Track ID
{trackId}
File
{detail.filePath}
Status
{active ? 'Active' : 'Paused'}
+ {model && (<> +
Model
{model}
+ )} + {provider && (<> +
Provider
{provider}
+ )} {lastRunAt && (<>
Last run
{formatDateTime(lastRunAt)}
)} diff --git a/apps/x/apps/renderer/src/extensions/iframe-block.tsx b/apps/x/apps/renderer/src/extensions/iframe-block.tsx new file mode 100644 index 00000000..2ed5bd52 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/iframe-block.tsx @@ -0,0 +1,256 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { Globe, X } from 'lucide-react' +import { blocks } from '@x/shared' +import { useEffect, useRef, useState } from 'react' + +const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height' +const IFRAME_HEIGHT_CACHE_PREFIX = 'rowboat:iframe-height:' +const DEFAULT_IFRAME_HEIGHT = 560 +const MIN_IFRAME_HEIGHT = 240 +const HEIGHT_UPDATE_THRESHOLD = 4 +const AUTO_RESIZE_SETTLE_MS = 160 +const LOAD_FALLBACK_READY_MS = 180 +const DEFAULT_IFRAME_ALLOW = [ + 'accelerometer', + 'autoplay', + 'camera', + 'clipboard-read', + 'clipboard-write', + 'display-capture', + 'encrypted-media', + 'fullscreen', + 'geolocation', + 'microphone', +].join('; ') + +function getIframeHeightCacheKey(url: string): string { + return `${IFRAME_HEIGHT_CACHE_PREFIX}${url}` +} + +function readCachedIframeHeight(url: string, fallbackHeight: number): number { + try { + const raw = window.localStorage.getItem(getIframeHeightCacheKey(url)) + if (!raw) return fallbackHeight + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed)) return fallbackHeight + return Math.max(MIN_IFRAME_HEIGHT, parsed) + } catch { + return fallbackHeight + } +} + +function writeCachedIframeHeight(url: string, height: number): void { + try { + window.localStorage.setItem(getIframeHeightCacheKey(url), String(height)) + } catch { + // ignore storage failures + } +} + +function parseIframeHeightMessage(event: MessageEvent): { height: number } | null { + const data = event.data + if (!data || typeof data !== 'object') return null + + const candidate = data as { type?: unknown; height?: unknown } + if (candidate.type !== IFRAME_HEIGHT_MESSAGE) return null + if (typeof candidate.height !== 'number' || !Number.isFinite(candidate.height)) return null + + return { + height: Math.max(MIN_IFRAME_HEIGHT, Math.ceil(candidate.height)), + } +} + +function IframeBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.IframeBlock | null = null + + try { + config = blocks.IframeBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + if (!config) { + return ( + +
+ + Invalid iframe block +
+
+ ) + } + + const visibleTitle = config.title?.trim() || '' + const title = visibleTitle || 'Embedded page' + const allow = config.allow || DEFAULT_IFRAME_ALLOW + const initialHeight = config.height ?? DEFAULT_IFRAME_HEIGHT + const [frameHeight, setFrameHeight] = useState(() => readCachedIframeHeight(config.url, initialHeight)) + const [frameReady, setFrameReady] = useState(false) + const iframeRef = useRef(null) + const loadFallbackTimerRef = useRef(null) + const autoResizeReadyTimerRef = useRef(null) + const frameReadyRef = useRef(false) + + useEffect(() => { + setFrameHeight(readCachedIframeHeight(config.url, initialHeight)) + setFrameReady(false) + frameReadyRef.current = false + if (loadFallbackTimerRef.current !== null) { + window.clearTimeout(loadFallbackTimerRef.current) + loadFallbackTimerRef.current = null + } + if (autoResizeReadyTimerRef.current !== null) { + window.clearTimeout(autoResizeReadyTimerRef.current) + autoResizeReadyTimerRef.current = null + } + }, [config.url, initialHeight, raw]) + + useEffect(() => { + frameReadyRef.current = frameReady + }, [frameReady]) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const iframeWindow = iframeRef.current?.contentWindow + if (!iframeWindow || event.source !== iframeWindow) return + + const message = parseIframeHeightMessage(event) + if (!message) return + + if (loadFallbackTimerRef.current !== null) { + window.clearTimeout(loadFallbackTimerRef.current) + loadFallbackTimerRef.current = null + } + if (autoResizeReadyTimerRef.current !== null) { + window.clearTimeout(autoResizeReadyTimerRef.current) + } + writeCachedIframeHeight(config.url, message.height) + setFrameHeight((currentHeight) => ( + Math.abs(currentHeight - message.height) < HEIGHT_UPDATE_THRESHOLD ? currentHeight : message.height + )) + + if (!frameReadyRef.current) { + autoResizeReadyTimerRef.current = window.setTimeout(() => { + setFrameReady(true) + frameReadyRef.current = true + autoResizeReadyTimerRef.current = null + }, AUTO_RESIZE_SETTLE_MS) + } + } + + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [config.url]) + + useEffect(() => { + return () => { + if (loadFallbackTimerRef.current !== null) { + window.clearTimeout(loadFallbackTimerRef.current) + } + if (autoResizeReadyTimerRef.current !== null) { + window.clearTimeout(autoResizeReadyTimerRef.current) + } + } + }, []) + + return ( + +
+ + {visibleTitle &&
{visibleTitle}
} +
+ {!frameReady && ( +