diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index b6e49cf0..b22425c6 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -1,5 +1,8 @@ import { skillCatalog } from "./skills/index.js"; import { WorkDir as BASE_DIR } from "../../config/config.js"; +import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; + +const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks. @@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear - Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. - Keep user data safe—double-check before editing or deleting important resources. +${runtimeContextPrompt} + ## Workspace access & scope - You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually. - If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location. diff --git a/apps/cli/src/application/assistant/runtime-context.ts b/apps/cli/src/application/assistant/runtime-context.ts new file mode 100644 index 00000000..f1011c2c --- /dev/null +++ b/apps/cli/src/application/assistant/runtime-context.ts @@ -0,0 +1,69 @@ +export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh'; +export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown'; + +export interface RuntimeContext { + platform: NodeJS.Platform; + osName: RuntimeOsName; + shellDialect: RuntimeShellDialect; + shellExecutable: string; +} + +export function getExecutionShell(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh'; +} + +export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext { + if (platform === 'win32') { + return { + platform, + osName: 'Windows', + shellDialect: 'windows-cmd', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'darwin') { + return { + platform, + osName: 'macOS', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'linux') { + return { + platform, + osName: 'Linux', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + return { + platform, + osName: 'Unknown', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; +} + +export function getRuntimeContextPrompt(runtime: RuntimeContext): string { + if (runtime.shellDialect === 'windows-cmd') { + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax) +- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`). +- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`). +- Do not assume macOS/Linux command syntax when the runtime is Windows.`; + } + + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax) +- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`). +- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux). +- Do not assume Windows command syntax when the runtime is POSIX.`; +} diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts index 814d9801..cd16f05e 100644 --- a/apps/cli/src/application/lib/command-executor.ts +++ b/apps/cli/src/application/lib/command-executor.ts @@ -1,11 +1,13 @@ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js'; +import { getExecutionShell } from '../assistant/runtime-context.js'; const execPromise = promisify(exec); const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); +const EXECUTION_SHELL = getExecutionShell(); function sanitizeToken(token: string): string { return token.trim().replace(/^['"]+|['"]+$/g, ''); @@ -91,7 +93,7 @@ export async function executeCommand( cwd: options?.cwd, timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB - shell: '/bin/sh', // use sh for cross-platform compatibility + shell: EXECUTION_SHELL, }); return { @@ -125,7 +127,7 @@ export function executeCommandSync( cwd: options?.cwd, timeout: options?.timeout, encoding: 'utf-8', - shell: '/bin/sh', + shell: EXECUTION_SHELL, }); return { diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 79645f8f..b9064380 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -36,6 +36,8 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { search } from '@x/core/dist/search/search.js'; +import { versionHistory } from '@x/core'; +import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -110,6 +112,18 @@ let watcher: FSWatcher | null = null; const changeQueue = new Set(); let debounceTimer: ReturnType | null = null; +/** + * Emit knowledge commit event to all renderer windows + */ +function emitKnowledgeCommitEvent(): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('knowledge:didCommit', {}); + } + } +} + /** * Emit workspace change event to all renderer windows */ @@ -288,6 +302,9 @@ export function stopServicesWatcher(): void { * Add new handlers here as you add channels to IPCChannels */ export function setupIpcHandlers() { + // Forward knowledge commit events to renderer for panel refresh + versionHistory.onCommit(() => emitKnowledgeCommitEvent()); + registerIpcHandlers({ 'app:getVersions': async () => { // args is null for this channel (no request payload) @@ -527,9 +544,27 @@ export function setupIpcHandlers() { const mimeType = mimeMap[ext] || 'application/octet-stream'; return { data: buffer.toString('base64'), mimeType, size: stat.size }; }, + // Knowledge version history handlers + 'knowledge:history': async (_event, args) => { + const commits = await versionHistory.getFileHistory(args.path); + return { commits }; + }, + 'knowledge:fileAtCommit': async (_event, args) => { + const content = await versionHistory.getFileAtCommit(args.path, args.oid); + return { content }; + }, + 'knowledge:restore': async (_event, args) => { + await versionHistory.restoreFile(args.path, args.oid); + return { ok: true }; + }, // Search handler 'search:query': async (_event, args) => { return search(args.query, args.limit, args.types); }, + // Inline task schedule classification + 'inline-task:classifySchedule': async (_event, args) => { + const schedule = await classifySchedule(args.instruction); + return { schedule }; + }, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 34363b28..08160a23 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -17,6 +17,9 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; +import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js"; +import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; +import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; @@ -170,6 +173,15 @@ app.whenReady().then(async () => { // start knowledge graph builder initGraphBuilder(); + // start email labeling service + initEmailLabeling(); + + // start note tagging service + initNoteTagging(); + + // start inline task service (@rowboat: mentions) + initInlineTasks(); + // start background agent runner (scheduled agents) initAgentRunner(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4f8859ef..768ff02b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,12 +5,14 @@ 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 } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; -import { ChatInputWithMentions } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; +import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; @@ -45,13 +47,16 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' +import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { OnboardingModal } from '@/components/onboarding-modal' import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { + type ChatMessage, type ChatTabViewState, type ConversationItem, type ToolCall, @@ -102,6 +107,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 const TITLEBAR_BUTTONS_COLLAPSED = 5 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4 const GRAPH_TAB_PATH = '__rowboat_graph_view__' +const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) @@ -229,6 +235,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { } const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH +const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null @@ -452,6 +459,7 @@ function ContentHeader({ function App() { type ShortcutPane = 'left' | 'right' + type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) @@ -465,6 +473,7 @@ function App() { const [recentWikiFiles, setRecentWikiFiles] = useState([]) const [isGraphOpen, setIsGraphOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) + const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [], @@ -487,6 +496,8 @@ function App() { const editorPathRef = useRef(null) const fileLoadRequestIdRef = useRef(0) const initialContentByPathRef = useRef>(new Map()) + const recentLocalMarkdownWritesRef = useRef>(new Map()) + const untitledRenameReadyPathsRef = useRef>(new Set()) // Global navigation history (back/forward) across views (chat/file/graph/task) const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] }) @@ -503,6 +514,16 @@ function App() { const initialContentRef = useRef('') const renameInProgressRef = useRef(false) + // Frontmatter state: store raw frontmatter per file path + const frontmatterByPathRef = useRef>(new Map()) + + // Version history state + const [versionHistoryPath, setVersionHistoryPath] = useState(null) + const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{ + oid: string + content: string + } | null>(null) + // Chat state const [, setMessage] = useState('') const [conversation, setConversation] = useState([]) @@ -596,11 +617,15 @@ function App() { // File tab state const [fileTabs, setFileTabs] = useState([]) const [activeFileTabId, setActiveFileTabId] = useState(null) + const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) + const fileHistoryHandlersRef = useRef>(new Map()) const fileTabIdCounterRef = useRef(0) const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}` const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' + 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 }, []) @@ -725,6 +750,7 @@ function App() { const removeEditorCacheForPath = useCallback((path: string) => { editorContentByPathRef.current.delete(path) + untitledRenameReadyPathsRef.current.delete(path) setEditorContentByPath((prev) => { if (!(path in prev)) return prev const next = { ...prev } @@ -733,6 +759,29 @@ function App() { }) }, []) + const markRecentLocalMarkdownWrite = useCallback((path: string) => { + if (!path.endsWith('.md')) return + const now = Date.now() + recentLocalMarkdownWritesRef.current.set(path, now) + if (recentLocalMarkdownWritesRef.current.size > 200) { + for (const [knownPath, timestamp] of recentLocalMarkdownWritesRef.current.entries()) { + if (now - timestamp > 10_000) { + recentLocalMarkdownWritesRef.current.delete(knownPath) + } + } + } + }, []) + + const consumeRecentLocalMarkdownWrite = useCallback((path: string, windowMs: number = 2_500) => { + const timestamp = recentLocalMarkdownWritesRef.current.get(path) + if (timestamp === undefined) return false + const isRecent = Date.now() - timestamp <= windowMs + if (!isRecent) { + recentLocalMarkdownWritesRef.current.delete(path) + } + return isRecent + }, []) + const handleEditorChange = useCallback((path: string, markdown: string) => { setEditorCacheForPath(path, markdown) const nextSelectedPath = selectedPathRef.current @@ -774,20 +823,45 @@ function App() { } }, [runId, processingRunIds]) - // Load directory tree + // Load directory tree (knowledge + bases) const loadDirectory = useCallback(async () => { try { - const result = await window.ipc.invoke('workspace:readdir', { - path: 'knowledge', - opts: { recursive: true, includeHidden: false } - }) - return buildTree(result) + const [knowledgeResult, basesResult] = await Promise.all([ + window.ipc.invoke('workspace:readdir', { + path: 'knowledge', + opts: { recursive: true, includeHidden: false, includeStats: true } + }), + window.ipc.invoke('workspace:readdir', { + path: 'bases', + opts: { recursive: false, includeHidden: false, includeStats: true } + }).catch(() => [] as DirEntry[]), + ]) + const knowledgeTree = buildTree(knowledgeResult) + const basesChildren: TreeNode[] = (basesResult as DirEntry[]) + .filter((e) => e.name.endsWith('.base')) + .map((e) => ({ ...e, kind: 'file' as const })) + if (basesChildren.length > 0) { + const basesFolder: TreeNode = { + name: 'Bases', + path: 'bases', + kind: 'dir', + children: basesChildren, + } + return [...knowledgeTree, basesFolder] + } + return knowledgeTree } catch (err) { console.error('Failed to load directory:', err) return [] } }, []) + // Ensure bases/ directory exists on startup + useEffect(() => { + window.ipc.invoke('workspace:mkdir', { path: 'bases', recursive: true }) + .catch((err: unknown) => console.error('Failed to ensure bases directory:', err)) + }, []) + // Load initial tree useEffect(() => { loadDirectory().then(setTree) @@ -843,18 +917,24 @@ function App() { changedPath === pathToReload || changedPaths.includes(pathToReload) if (isCurrentFileChanged) { + // Ignore immediate watcher echoes of our own autosaves to preserve undo history. + if (consumeRecentLocalMarkdownWrite(pathToReload)) { + return + } // Only reload if no unsaved edits const baseline = initialContentByPathRef.current.get(pathToReload) ?? initialContentRef.current if (editorContentRef.current === baseline) { const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload }) if (selectedPathRef.current !== pathToReload) return setFileContent(result.data) - setEditorContent(result.data) - setEditorCacheForPath(pathToReload, result.data) - editorContentRef.current = result.data + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(pathToReload, fm) + setEditorContent(body) + setEditorCacheForPath(pathToReload, body) + editorContentRef.current = body editorPathRef.current = pathToReload - initialContentByPathRef.current.set(pathToReload, result.data) - initialContentRef.current = result.data + initialContentByPathRef.current.set(pathToReload, body) + initialContentRef.current = body } } }) @@ -872,9 +952,37 @@ function App() { setLastSaved(null) return } + if (selectedPath === BASES_DEFAULT_TAB_PATH) { + // Virtual default base — no file to load, use DEFAULT_BASE_CONFIG + if (!baseConfigByPath[selectedPath]) { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + } + return + } + if (selectedPath.endsWith('.base')) { + // Load base config from file only if not already cached + if (!baseConfigByPath[selectedPath]) { + window.ipc.invoke('workspace:readFile', { path: selectedPath, encoding: 'utf8' }) + .then((result: { data: string }) => { + try { + const parsed = JSON.parse(result.data) as BaseConfig + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: parsed })) + } catch { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + } + }) + .catch(() => { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + }) + } + return + } if (selectedPath.endsWith('.md')) { const cachedContent = editorContentByPathRef.current.get(selectedPath) - if (cachedContent !== undefined) { + const hasBaseline = initialContentByPathRef.current.has(selectedPath) + // Only trust cache after we've loaded/saved this file at least once. + // This avoids a first-open race where an early empty editor update can poison the cache. + if (cachedContent !== undefined && hasBaseline) { setFileContent(cachedContent) setEditorContent(cachedContent) editorContentRef.current = cachedContent @@ -888,36 +996,46 @@ function App() { let cancelled = false ;(async () => { try { - const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) - if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return - if (stat.kind === 'file') { - const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + // For .md files (from the knowledge tree), skip stat and read directly. + // For other file types, stat first to check if it's a file vs directory. + const isKnownFile = pathToLoad.endsWith('.md') + if (!isKnownFile) { + const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return - setFileContent(result.data) - const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() - const isSameEditorFile = editorPathRef.current === pathToLoad - const wouldClobberActiveEdits = - isSameEditorFile - && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(result.data) - if (!wouldClobberActiveEdits) { - setEditorContent(result.data) - if (pathToLoad.endsWith('.md')) { - setEditorCacheForPath(pathToLoad, result.data) - } - editorContentRef.current = result.data - editorPathRef.current = pathToLoad - initialContentByPathRef.current.set(pathToLoad, result.data) - initialContentRef.current = result.data - setLastSaved(null) - } else { - // Still update the editor's path so subsequent autosaves write to the correct file. - editorPathRef.current = pathToLoad + if (stat.kind !== 'file') { + setFileContent('') + setEditorContent('') + editorContentRef.current = '' + initialContentRef.current = '' + return } + } + const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return + setFileContent(result.data) + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(pathToLoad, fm) + const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() + const isSameEditorFile = editorPathRef.current === pathToLoad + const knownBaseline = initialContentByPathRef.current.get(pathToLoad) + const hasKnownBaseline = knownBaseline !== undefined + const hasUnsavedEdits = + hasKnownBaseline + && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline) + const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits + if (!shouldPreserveActiveDraft) { + setEditorContent(body) + if (pathToLoad.endsWith('.md')) { + setEditorCacheForPath(pathToLoad, body) + } + editorContentRef.current = body + editorPathRef.current = pathToLoad + initialContentByPathRef.current.set(pathToLoad, body) + initialContentRef.current = body + setLastSaved(null) } else { - setFileContent('') - setEditorContent('') - editorContentRef.current = '' - initialContentRef.current = '' + // Still update the editor's path so subsequent autosaves write to the correct file. + editorPathRef.current = pathToLoad } } catch (err) { console.error('Failed to load file:', err) @@ -957,7 +1075,7 @@ function App() { const wasActiveAtStart = selectedPathRef.current === pathAtStart if (wasActiveAtStart) setIsSaving(true) let pathToSave = pathAtStart - let contentToSave = debouncedContent + let contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, debouncedContent) let renamedFrom: string | null = null let renamedTo: string | null = null try { @@ -972,7 +1090,8 @@ function App() { if (isUntitledPlaceholderName(currentBase)) { const headingTitle = getHeadingTitle(debouncedContent) const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null - if (desiredName && desiredName !== currentBase) { + const shouldAutoRename = untitledRenameReadyPathsRef.current.has(pathAtStart) + if (shouldAutoRename && desiredName && desiredName !== currentBase) { const parentDir = pathAtStart.split('/').slice(0, -1).join('/') let targetPath = `${parentDir}/${desiredName}.md` if (targetPath !== pathAtStart) { @@ -986,15 +1105,21 @@ function App() { renameInProgressRef.current = true await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath }) pathToSave = targetPath - contentToSave = rewriteWikiLinksForRenamedFileInMarkdown( + const rewrittenBody = rewriteWikiLinksForRenamedFileInMarkdown( debouncedContent, pathAtStart, targetPath ) + contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, rewrittenBody) renamedFrom = pathAtStart renamedTo = targetPath editorPathRef.current = targetPath + untitledRenameReadyPathsRef.current.delete(pathAtStart) setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab))) + // Migrate frontmatter entry + const fmEntry = frontmatterByPathRef.current.get(pathAtStart) + frontmatterByPathRef.current.delete(pathAtStart) + frontmatterByPathRef.current.set(targetPath, fmEntry ?? null) initialContentByPathRef.current.delete(pathAtStart) const cachedContent = editorContentByPathRef.current.get(pathAtStart) if (cachedContent !== undefined) { @@ -1019,8 +1144,9 @@ function App() { }) } if (selectedPathRef.current === pathAtStart) { - editorContentRef.current = contentToSave - setEditorContent(contentToSave) + const bodyForEditor = splitFrontmatter(contentToSave).body + editorContentRef.current = bodyForEditor + setEditorContent(bodyForEditor) } } } @@ -1031,7 +1157,9 @@ function App() { data: contentToSave, opts: { encoding: 'utf8' } }) - initialContentByPathRef.current.set(pathToSave, contentToSave) + markRecentLocalMarkdownWrite(pathToSave) + // Store body-only baseline (matches what debouncedContent compares against) + initialContentByPathRef.current.set(pathToSave, splitFrontmatter(contentToSave).body) // If we renamed the active file, update state/history AFTER the write completes so the editor // doesn't reload stale on-disk content mid-typing (which can drop the latest character). @@ -1052,7 +1180,7 @@ function App() { // Only update "current file" UI state if we're still on this file if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) { - initialContentRef.current = contentToSave + initialContentRef.current = splitFrontmatter(contentToSave).body setLastSaved(new Date()) } } catch (err) { @@ -1065,7 +1193,15 @@ function App() { } } saveFile() - }, [debouncedContent, setHistory]) + }, [debouncedContent, markRecentLocalMarkdownWrite, setHistory]) + + // Close version history panel when switching files + useEffect(() => { + if (versionHistoryPath && selectedPath !== versionHistoryPath) { + setVersionHistoryPath(null) + setViewingHistoricalVersion(null) + } + }, [selectedPath, versionHistoryPath]) // Load runs list (all pages) const loadRuns = useCallback(async () => { @@ -1168,19 +1304,41 @@ function App() { if (msg.role === 'user' || msg.role === 'assistant') { // Extract text content from message let textContent = '' + let msgAttachments: ChatMessage['attachments'] = undefined if (typeof msg.content === 'string') { textContent = msg.content } else if (Array.isArray(msg.content)) { - // Extract text parts - textContent = msg.content - .filter((part: { type: string }) => part.type === 'text') - .map((part: { type: string; text?: string }) => part.text || '') + const contentParts = msg.content as Array<{ + type: string + text?: string + path?: string + filename?: string + mimeType?: string + size?: number + toolCallId?: string + toolName?: string + arguments?: ToolUIPart['input'] + }> + + textContent = contentParts + .filter((part) => part.type === 'text') + .map((part) => part.text || '') .join('') - + + const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.path) + if (attachmentParts.length > 0) { + msgAttachments = attachmentParts.map((part) => ({ + path: part.path!, + filename: part.filename || part.path!.split('/').pop() || part.path!, + mimeType: part.mimeType || 'application/octet-stream', + size: part.size, + })) + } + // Also extract tool-call parts from assistant messages if (msg.role === 'assistant') { - for (const part of msg.content) { - if (part.type === 'tool-call') { + for (const part of contentParts) { + if (part.type === 'tool-call' && part.toolCallId && part.toolName) { const toolCall: ToolCall = { id: part.toolCallId, name: part.toolName, @@ -1194,11 +1352,12 @@ function App() { } } } - if (textContent) { + if (textContent || msgAttachments) { items.push({ id: event.messageId, role: msg.role, content: textContent, + attachments: msgAttachments, timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), }) } @@ -1615,20 +1774,35 @@ function App() { return cleanup }, [handleRunEvent]) - const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => { + const handlePromptSubmit = async ( + message: PromptInputMessage, + mentions?: FileMention[], + stagedAttachments: StagedAttachment[] = [] + ) => { if (isProcessing) return - const { text } = message; + const { text } = message const userMessage = text.trim() - if (!userMessage) return + const hasAttachments = stagedAttachments.length > 0 + if (!userMessage && !hasAttachments) return setMessage('') const userMessageId = `user-${Date.now()}` - setConversation(prev => [...prev, { + const displayAttachments: ChatMessage['attachments'] = hasAttachments + ? stagedAttachments.map((attachment) => ({ + path: attachment.path, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + thumbnailUrl: attachment.thumbnailUrl, + })) + : undefined + setConversation((prev) => [...prev, { id: userMessageId, role: 'user', content: userMessage, + attachments: displayAttachments, timestamp: Date.now(), }]) @@ -1644,42 +1818,98 @@ function App() { newRunCreatedAt = run.createdAt setRunId(currentRunId) // Update active chat tab's runId to the new run - setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t)) + setChatTabs((prev) => prev.map((tab) => ( + tab.id === activeChatTabId + ? { ...tab, runId: currentRunId } + : tab + ))) isNewRun = true } - // Read mentioned file contents and format message with XML context - let formattedMessage = userMessage - if (mentions && mentions.length > 0) { - const attachedFiles = await Promise.all( - mentions.map(async (m) => { - try { - const result = await window.ipc.invoke('workspace:readFile', { path: m.path }) - return { path: m.path, content: result.data as string } - } catch (err) { - console.error('Failed to read mentioned file:', m.path, err) - return { path: m.path, content: `[Error reading file: ${m.path}]` } - } - }) - ) + let titleSource = userMessage - if (attachedFiles.length > 0) { - const filesXml = attachedFiles - .map(f => `\n${f.content}\n`) - .join('\n') - formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + if (hasAttachments) { + type ContentPart = + | { type: 'text'; text: string } + | { + type: 'attachment' + path: string + filename: string + mimeType: string + size?: number + } + + const contentParts: ContentPart[] = [] + + if (mentions && mentions.length > 0) { + for (const mention of mentions) { + contentParts.push({ + type: 'attachment', + path: mention.path, + filename: mention.displayName || mention.path.split('/').pop() || mention.path, + mimeType: 'text/markdown', + }) + } } + + for (const attachment of stagedAttachments) { + contentParts.push({ + type: 'attachment', + path: attachment.path, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + }) + } + + if (userMessage) { + contentParts.push({ type: 'text', text: userMessage }) + } else { + titleSource = stagedAttachments[0]?.filename ?? '' + } + + // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema. + const attachmentPayload = contentParts as unknown as string + await window.ipc.invoke('runs:createMessage', { + runId: currentRunId, + message: attachmentPayload, + }) + } else { + // Legacy path: plain string with optional XML-formatted @mentions. + let formattedMessage = userMessage + if (mentions && mentions.length > 0) { + const attachedFiles = await Promise.all( + mentions.map(async (mention) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: mention.path }) + return { path: mention.path, content: result.data as string } + } catch (err) { + console.error('Failed to read mentioned file:', mention.path, err) + return { path: mention.path, content: `[Error reading file: ${mention.path}]` } + } + }) + ) + + if (attachedFiles.length > 0) { + const filesXml = attachedFiles + .map((file) => `\n${file.content}\n`) + .join('\n') + formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + } + } + + await window.ipc.invoke('runs:createMessage', { + runId: currentRunId, + message: formattedMessage, + }) + + titleSource = formattedMessage } - await window.ipc.invoke('runs:createMessage', { - runId: currentRunId, - message: formattedMessage, - }) - if (isNewRun) { - const inferredTitle = inferRunTitleFromMessage(formattedMessage) - setRuns(prev => { - const withoutCurrent = prev.filter(run => run.id !== currentRunId) + const inferredTitle = inferRunTitleFromMessage(titleSource) + setRuns((prev) => { + const withoutCurrent = prev.filter((run) => run.id !== currentRunId) return [{ id: currentRunId!, title: inferredTitle, @@ -2004,20 +2234,29 @@ function App() { const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) + untitledRenameReadyPathsRef.current.delete(closingTab.path) + frontmatterByPathRef.current.delete(closingTab.path) if (editorPathRef.current === closingTab.path) { editorPathRef.current = null } } + if (closingTab && isBaseFilePath(closingTab.path)) { + setBaseConfigByPath((prev) => { + const next = { ...prev } + delete next[closingTab.path] + return next + }) + } setFileTabs(prev => { if (prev.length <= 1) { // Last file tab - close it and go back to chat setActiveFileTabId(null) setSelectedPath(null) setIsGraphOpen(false) - return [] + return [] } const idx = prev.findIndex(t => t.id === tabId) if (idx === -1) return prev @@ -2031,11 +2270,18 @@ function App() { setIsGraphOpen(true) } else { setIsGraphOpen(false) - setSelectedPath(newActiveTab.path) + setSelectedPath(newActiveTab.path) } } return next }) + setEditorSessionByTabId((prev) => { + if (!(tabId in prev)) return prev + const next = { ...prev } + delete next[tabId] + return next + }) + fileHistoryHandlersRef.current.delete(tabId) }, [activeFileTabId, fileTabs, removeEditorCacheForPath]) const handleNewChatTab = useCallback(() => { @@ -2132,10 +2378,15 @@ function App() { if (activeFileTabId) { const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) - if (activeTab && !isGraphTabPath(activeTab.path)) { + if (activeTab && !isGraphTabPath(activeTab.path) && !isBaseFilePath(activeTab.path)) { setFileTabs((prev) => prev.map((tab) => ( tab.id === activeFileTabId ? { ...tab, path } : tab ))) + // Rebinds this tab to a different note path: reset editor session to clear undo history. + setEditorSessionByTabId((prev) => ({ + ...prev, + [activeFileTabId]: (prev[activeFileTabId] ?? 0) + 1, + })) return } } @@ -2272,6 +2523,46 @@ function App() { void navigateToView({ type: 'file', path }) }, [navigateToView]) + const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { + setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) + }, []) + + const handleBaseSave = useCallback(async (name: string | null) => { + if (!selectedPath) return + const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH + const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG + + if (isDefault && name) { + // Save as new base file + const safeName = name.replace(/[\\/]/g, '-').trim() + const newPath = `bases/${safeName}.base` + const fileConfig = { ...config, name: safeName } + try { + await window.ipc.invoke('workspace:writeFile', { + path: newPath, + data: JSON.stringify(fileConfig, null, 2), + }) + setBaseConfigByPath((prev) => ({ ...prev, [newPath]: fileConfig })) + // Refresh tree then navigate to the new file + const newTree = await loadDirectory() + setTree(newTree) + void navigateToView({ type: 'file', path: newPath }) + } catch (err) { + console.error('Failed to save base:', err) + } + } else if (!isDefault) { + // Save in place + try { + await window.ipc.invoke('workspace:writeFile', { + path: selectedPath, + data: JSON.stringify(config, null, 2), + }) + } catch (err) { + console.error('Failed to save base:', err) + } + } + }, [selectedPath, baseConfigByPath, loadDirectory, navigateToView]) + const navigateToFullScreenChat = useCallback(() => { // Only treat this as navigation when coming from another view if (currentViewState.type !== 'chat') { @@ -2352,6 +2643,46 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, []) + // Route undo/redo to the active markdown tab only (prevents cross-tab browser undo behavior). + useEffect(() => { + const handleHistoryKeyDown = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey + if (!mod || e.altKey) return + + const key = e.key.toLowerCase() + const wantsUndo = key === 'z' && !e.shiftKey + const wantsRedo = (key === 'z' && e.shiftKey) || (!isMac && key === 'y') + if (!wantsUndo && !wantsRedo) return + + if (!selectedPath || !selectedPath.endsWith('.md') || !activeFileTabId) return + + const target = e.target as EventTarget | null + if (target instanceof HTMLElement) { + const inTipTapEditor = Boolean(target.closest('.tiptap-editor')) + const inOtherTextInput = ( + target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || target.isContentEditable + ) && !inTipTapEditor + if (inOtherTextInput) return + } + + const handlers = fileHistoryHandlersRef.current.get(activeFileTabId) + if (!handlers) return + + e.preventDefault() + e.stopPropagation() + if (wantsUndo) { + handlers.undo() + } else { + handlers.redo() + } + } + + document.addEventListener('keydown', handleHistoryKeyDown, true) + return () => document.removeEventListener('keydown', handleHistoryKeyDown, true) + }, [activeFileTabId, isMac, selectedPath]) + // Keyboard shortcuts for tab management useEffect(() => { const handleTabKeyDown = (e: KeyboardEvent) => { @@ -2544,6 +2875,13 @@ function App() { } void navigateToView({ type: 'graph' }) }, + openBases: () => { + if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) + }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), rename: async (oldPath: string, newName: string, isDir: boolean) => { @@ -2554,12 +2892,19 @@ function App() { parts[parts.length - 1] = finalName const newPath = parts.join('/') await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath }) + untitledRenameReadyPathsRef.current.delete(oldPath) const rewriteForRename = (content: string) => isDir ? content : rewriteWikiLinksForRenamedFileInMarkdown(content, oldPath, newPath) setFileTabs(prev => prev.map(tab => (tab.path === oldPath ? { ...tab, path: newPath } : tab))) if (editorPathRef.current === oldPath) { editorPathRef.current = newPath } + // Migrate frontmatter entry + const fmEntry = frontmatterByPathRef.current.get(oldPath) + if (fmEntry !== undefined) { + frontmatterByPathRef.current.delete(oldPath) + frontmatterByPathRef.current.set(newPath, fmEntry) + } const baseline = initialContentByPathRef.current.get(oldPath) if (baseline !== undefined) { initialContentByPathRef.current.delete(oldPath) @@ -2596,6 +2941,8 @@ function App() { if (path.endsWith('.md')) { removeEditorCacheForPath(path) initialContentByPathRef.current.delete(path) + untitledRenameReadyPathsRef.current.delete(path) + frontmatterByPathRef.current.delete(path) } // Close any file tab showing the deleted file const tabForFile = fileTabs.find(t => t.path === path) @@ -2794,6 +3141,18 @@ function App() { const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { + if (item.attachments && item.attachments.length > 0) { + return ( + + + + + {item.content && ( + {item.content} + )} + + ) + } const { message, files } = parseAttachedFiles(item.content) return ( @@ -3022,7 +3381,7 @@ function App() { getTabId={(t) => t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && isGraphOpen} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( )} - {selectedPath && ( + {selectedPath && selectedPath.endsWith('.md') && (
{isSaving ? ( <> @@ -3050,6 +3409,31 @@ function App() { ) : null}
)} + {selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && ( + + + + + Version history + + )} {!selectedPath && !isGraphOpen && !selectedTask && ( @@ -3099,7 +3483,18 @@ function App() { )} - {isGraphOpen ? ( + {selectedPath && isBaseFilePath(selectedPath) ? ( +
+ navigateToFile(path)} + config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG} + onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)} + isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH} + onSave={(name) => void handleBaseSave(name)} + /> +
+ ) : isGraphOpen ? (
) : selectedPath ? ( selectedPath.endsWith('.md') ? ( -
- {openMarkdownTabs.map((tab) => { - const isActive = activeFileTabId - ? tab.id === activeFileTabId || tab.path === selectedPath - : tab.path === selectedPath - const tabContent = editorContentByPath[tab.path] - ?? (isActive && editorPathRef.current === tab.path ? editorContent : '') - return ( -
- handleEditorChange(tab.path, markdown)} - placeholder="Start writing..." - wikiLinks={wikiLinkConfig} - onImageUpload={handleImageUpload} - /> -
- ) - })} +
+
+ {openMarkdownTabs.map((tab) => { + const isActive = activeFileTabId + ? tab.id === activeFileTabId || tab.path === selectedPath + : tab.path === selectedPath + const isViewingHistory = viewingHistoricalVersion && isActive && versionHistoryPath === tab.path + const tabContent = isViewingHistory + ? viewingHistoricalVersion.content + : editorContentByPath[tab.path] + ?? (isActive && editorPathRef.current === tab.path ? editorContent : '') + return ( +
+ { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} + onPrimaryHeadingCommit={() => { + untitledRenameReadyPathsRef.current.add(tab.path) + }} + preserveUntitledTitleHeading={isUntitledPlaceholderName(getBaseName(tab.path))} + placeholder="Start writing..." + wikiLinks={wikiLinkConfig} + onImageUpload={handleImageUpload} + editorSessionKey={editorSessionByTabId[tab.id] ?? 0} + frontmatter={frontmatterByPathRef.current.get(tab.path) ?? null} + onFrontmatterChange={(newRaw) => { + frontmatterByPathRef.current.set(tab.path, newRaw) + // Write updated frontmatter to disk immediately + const currentBody = editorContentRef.current + const fullContent = joinFrontmatter(newRaw, currentBody) + initialContentByPathRef.current.set(tab.path, splitFrontmatter(fullContent).body) + initialContentRef.current = splitFrontmatter(fullContent).body + void window.ipc.invoke('workspace:writeFile', { + path: tab.path, + data: fullContent, + opts: { encoding: 'utf8' }, + }) + }} + onHistoryHandlersChange={(handlers) => { + if (handlers) { + fileHistoryHandlersRef.current.set(tab.id, handlers) + } else { + fileHistoryHandlersRef.current.delete(tab.id) + } + }} + editable={!isViewingHistory} + /> +
+ ) + })} +
+ {versionHistoryPath && ( + { + setVersionHistoryPath(null) + setViewingHistoricalVersion(null) + }} + onSelectVersion={(oid, content) => { + if (oid === null) { + setViewingHistoricalVersion(null) + } else { + setViewingHistoricalVersion({ oid, content }) + } + }} + onRestore={async (oid) => { + try { + await window.ipc.invoke('knowledge:restore', { + path: versionHistoryPath.startsWith('knowledge/') + ? versionHistoryPath.slice('knowledge/'.length) + : versionHistoryPath, + oid, + }) + // Reload file content + const result = await window.ipc.invoke('workspace:readFile', { path: versionHistoryPath }) + handleEditorChange(versionHistoryPath, result.data) + setViewingHistoricalVersion(null) + setVersionHistoryPath(null) + } catch (err) { + console.error('Failed to restore version:', err) + } + }} + /> + )}
) : (
diff --git a/apps/x/apps/renderer/src/components/bases-view.tsx b/apps/x/apps/renderer/src/components/bases-view.tsx new file mode 100644 index 00000000..83fc07c0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/bases-view.tsx @@ -0,0 +1,820 @@ +import * as React from 'react' +import { useEffect, useState, useMemo, useCallback, useRef } from 'react' +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { cn } from '@/lib/utils' +import { splitFrontmatter, extractAllFrontmatterValues } from '@/lib/frontmatter' +import { useDebounce } from '@/hooks/use-debounce' + +interface TreeNode { + path: string + name: string + kind: 'file' | 'dir' + children?: TreeNode[] + stat?: { size: number; mtimeMs: number } +} + +type NoteEntry = { + path: string + name: string + folder: string + fields: Record + mtimeMs: number +} + +type SortDir = 'asc' | 'desc' +type ActiveFilter = { category: string; value: string } + +export type BaseConfig = { + name: string + visibleColumns: string[] + columnWidths: Record + sort: { field: string; dir: SortDir } + filters: ActiveFilter[] +} + +export const DEFAULT_BASE_CONFIG: BaseConfig = { + name: 'All Notes', + visibleColumns: ['name', 'folder', 'relationship', 'topic', 'status', 'mtimeMs'], + columnWidths: {}, + sort: { field: 'mtimeMs', dir: 'desc' }, + filters: [], +} + +const PAGE_SIZE = 25 + +/** Built-in columns that don't come from frontmatter */ +const BUILTIN_COLUMNS = ['name', 'folder', 'mtimeMs'] as const +type BuiltinColumn = (typeof BUILTIN_COLUMNS)[number] + +const BUILTIN_LABELS: Record = { + name: 'Name', + folder: 'Folder', + mtimeMs: 'Last Modified', +} + +/** Default pixel widths for columns */ +const DEFAULT_WIDTHS: Record = { + name: 200, + folder: 140, + mtimeMs: 140, +} +const DEFAULT_FRONTMATTER_WIDTH = 150 + +/** Convert key to title case: `first_met` → `First Met` */ +function toTitleCase(key: string): string { + if (key in BUILTIN_LABELS) return BUILTIN_LABELS[key as BuiltinColumn] + return key + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} + +type BasesViewProps = { + tree: TreeNode[] + onSelectNote: (path: string) => void + config: BaseConfig + onConfigChange: (config: BaseConfig) => void + isDefaultBase: boolean + onSave: (name: string | null) => void +} + +function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] { + return nodes.flatMap((n) => + n.kind === 'file' && n.name.endsWith('.md') + ? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }] + : n.children + ? collectFiles(n.children) + : [], + ) +} + +function getFolder(path: string): string { + const parts = path.split('/') + if (parts.length >= 3) return parts[1] + return '' +} + +function formatDate(ms: number): string { + if (!ms) return '' + const d = new Date(ms) + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} + +function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean { + return a.category === b.category && a.value === b.value +} + +function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean { + return filters.some((x) => filtersEqual(x, f)) +} + +/** Get the string values for a column from a note */ +function getColumnValues(note: NoteEntry, column: string): string[] { + if (column === 'name') return [note.name] + if (column === 'folder') return [note.folder] + if (column === 'mtimeMs') return [] + const v = note.fields[column] + if (!v) return [] + return Array.isArray(v) ? v : [v] +} + +/** Get a single sortable string for a column */ +function getSortValue(note: NoteEntry, column: string): string | number { + if (column === 'name') return note.name + if (column === 'folder') return note.folder + if (column === 'mtimeMs') return note.mtimeMs + const v = note.fields[column] + if (!v) return '' + return Array.isArray(v) ? v[0] ?? '' : v +} + +const isBuiltin = (col: string): col is BuiltinColumn => + (BUILTIN_COLUMNS as readonly string[]).includes(col) + +export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave }: BasesViewProps) { + // Build notes instantly from tree + const notes = useMemo(() => { + return collectFiles(tree).map((f) => ({ + path: f.path, + name: f.name, + folder: getFolder(f.path), + fields: {}, + mtimeMs: f.mtimeMs, + })) + }, [tree]) + + // Frontmatter fields loaded async, keyed by path + const [fieldsByPath, setFieldsByPath] = useState>>(new Map()) + const loadGenRef = useRef(0) + + // Load frontmatter in background batches + useEffect(() => { + const gen = ++loadGenRef.current + let cancelled = false + const paths = notes.map((n) => n.path) + + async function load() { + const BATCH = 30 + for (let i = 0; i < paths.length; i += BATCH) { + if (cancelled) return + const batch = paths.slice(i, i + BATCH) + const results = await Promise.all( + batch.map(async (p) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: p, encoding: 'utf8' }) + const { raw } = splitFrontmatter(result.data) + return { path: p, fields: extractAllFrontmatterValues(raw) } + } catch { + return { path: p, fields: {} as Record } + } + }), + ) + if (cancelled || gen !== loadGenRef.current) return + setFieldsByPath((prev) => { + const next = new Map(prev) + for (const r of results) next.set(r.path, r.fields) + return next + }) + } + } + + load() + return () => { cancelled = true } + }, [notes]) + + // Merge tree-derived notes with async-loaded fields + const enrichedNotes = useMemo(() => { + if (fieldsByPath.size === 0) return notes + return notes.map((n) => { + const f = fieldsByPath.get(n.path) + return f ? { ...n, fields: f } : n + }) + }, [notes, fieldsByPath]) + + // Collect all unique frontmatter property keys across all notes + const allPropertyKeys = useMemo(() => { + const keys = new Set() + for (const fields of fieldsByPath.values()) { + for (const k of Object.keys(fields)) keys.add(k) + } + return Array.from(keys).sort() + }, [fieldsByPath]) + + // Filterable categories: "folder" + all frontmatter keys + const filterCategories = useMemo(() => { + return ['folder', ...allPropertyKeys] + }, [allPropertyKeys]) + + // All unique values per category, across all enriched notes + const valuesByCategory = useMemo>(() => { + const result: Record> = {} + for (const cat of filterCategories) result[cat] = new Set() + for (const note of enrichedNotes) { + for (const cat of filterCategories) { + for (const v of getColumnValues(note, cat)) { + if (v) result[cat]?.add(v) + } + } + } + const out: Record = {} + for (const [cat, set] of Object.entries(result)) { + out[cat] = Array.from(set).sort((a, b) => a.localeCompare(b)) + } + return out + }, [filterCategories, enrichedNotes]) + + const visibleColumns = config.visibleColumns + const columnWidths = config.columnWidths + const filters = config.filters + const sortField = config.sort.field + const sortDir = config.sort.dir + const [page, setPage] = useState(0) + const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const [saveName, setSaveName] = useState('') + const saveInputRef = useRef(null) + const [filterCategory, setFilterCategory] = useState(null) + + const handleSaveClick = useCallback(() => { + if (isDefaultBase) { + setSaveName('') + setSaveDialogOpen(true) + } else { + onSave(null) + } + }, [isDefaultBase, onSave]) + + const handleSaveConfirm = useCallback(() => { + const name = saveName.trim() + if (!name) return + setSaveDialogOpen(false) + onSave(name) + }, [saveName, onSave]) + + const getColWidth = useCallback((col: string) => { + return columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH + }, [columnWidths]) + + // Column resize via drag + const resizingRef = useRef<{ col: string; startX: number; startW: number } | null>(null) + + const configRef = useRef(config) + configRef.current = config + + const onResizeStart = useCallback((col: string, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startW = configRef.current.columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH + resizingRef.current = { col, startX, startW } + + const onMouseMove = (ev: MouseEvent) => { + if (!resizingRef.current) return + const delta = ev.clientX - resizingRef.current.startX + const newW = Math.max(60, resizingRef.current.startW + delta) + const c = configRef.current + const updated = { ...c, columnWidths: { ...c.columnWidths, [resizingRef.current!.col]: newW } } + onConfigChange(updated) + } + + const onMouseUp = () => { + resizingRef.current = null + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, [onConfigChange]) + + // Search + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const debouncedSearch = useDebounce(searchQuery, 250) + const [searchMatchPaths, setSearchMatchPaths] = useState | null>(null) + const searchInputRef = useRef(null) + + useEffect(() => { + if (!debouncedSearch.trim()) { + setSearchMatchPaths(null) + return + } + let cancelled = false + window.ipc.invoke('search:query', { query: debouncedSearch, limit: 200, types: ['knowledge'] }) + .then((res: { results: { path: string }[] }) => { + if (!cancelled) { + setSearchMatchPaths(new Set(res.results.map((r) => r.path))) + } + }) + .catch(() => { + if (!cancelled) setSearchMatchPaths(new Set()) + }) + return () => { cancelled = true } + }, [debouncedSearch]) + + const toggleSearch = useCallback(() => { + setSearchOpen((prev) => { + if (prev) { + setSearchQuery('') + setSearchMatchPaths(null) + } + return !prev + }) + }, []) + + // Focus input when search opens + useEffect(() => { + if (searchOpen) searchInputRef.current?.focus() + }, [searchOpen]) + + // Reset page when filters or search change + useEffect(() => { setPage(0) }, [filters, searchMatchPaths]) + + // Filter (search + badge filters) + const filteredNotes = useMemo(() => { + let result = enrichedNotes + // Apply search filter + if (searchMatchPaths) { + result = result.filter((note) => searchMatchPaths.has(note.path)) + } + // Apply badge filters + if (filters.length > 0) { + const byCategory = new Map() + for (const f of filters) { + const vals = byCategory.get(f.category) ?? [] + vals.push(f.value) + byCategory.set(f.category, vals) + } + result = result.filter((note) => { + for (const [category, requiredValues] of byCategory) { + const noteValues = getColumnValues(note, category) + if (!requiredValues.some((v) => noteValues.includes(v))) return false + } + return true + }) + } + return result + }, [enrichedNotes, filters, searchMatchPaths]) + + // Sort + const sortedNotes = useMemo(() => { + return [...filteredNotes].sort((a, b) => { + const va = getSortValue(a, sortField) + const vb = getSortValue(b, sortField) + let cmp: number + if (typeof va === 'number' && typeof vb === 'number') { + cmp = va - vb + } else { + cmp = String(va).localeCompare(String(vb)) + } + return sortDir === 'asc' ? cmp : -cmp + }) + }, [filteredNotes, sortField, sortDir]) + + // Paginate + const totalPages = Math.max(1, Math.ceil(sortedNotes.length / PAGE_SIZE)) + const clampedPage = Math.min(page, totalPages - 1) + const pageNotes = useMemo( + () => sortedNotes.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE), + [sortedNotes, clampedPage], + ) + + const toggleFilter = useCallback((category: string, value: string) => { + const c = configRef.current + const f: ActiveFilter = { category, value } + const next = hasFilter(c.filters, f) + ? c.filters.filter((x) => !filtersEqual(x, f)) + : [...c.filters, f] + onConfigChange({ ...c, filters: next }) + }, [onConfigChange]) + + const clearFilters = useCallback(() => { + onConfigChange({ ...configRef.current, filters: [] }) + }, [onConfigChange]) + + const handleSort = useCallback((field: string) => { + const c = configRef.current + if (field === c.sort.field) { + onConfigChange({ ...c, sort: { field, dir: c.sort.dir === 'asc' ? 'desc' : 'asc' } }) + } else { + onConfigChange({ ...c, sort: { field, dir: field === 'mtimeMs' ? 'desc' : 'asc' } }) + } + }, [onConfigChange]) + + const toggleColumn = useCallback((key: string) => { + const c = configRef.current + const next = c.visibleColumns.includes(key) + ? c.visibleColumns.filter((col) => col !== key) + : [...c.visibleColumns, key] + onConfigChange({ ...c, visibleColumns: next }) + }, [onConfigChange]) + + const SortIcon = ({ field }: { field: string }) => { + if (sortField !== field) return null + return sortDir === 'asc' + ? + : + } + + return ( +
+ {/* Toolbar */} +
+ + + + + + + + + No properties found. + + {BUILTIN_COLUMNS.map((col) => ( + toggleColumn(col)}> + + {BUILTIN_LABELS[col]} + + ))} + + + {allPropertyKeys.map((key) => ( + toggleColumn(key)}> + + {toTitleCase(key)} + + ))} + + + + + + + { if (!open) setFilterCategory(null) }}> + + + + +
+ {/* Left: categories */} +
+
+ Attributes + {filters.length > 0 && ( + + )} +
+ {filterCategories.map((cat) => { + const activeCount = filters.filter((f) => f.category === cat).length + const isSelected = filterCategory === cat + return ( + + ) + })} +
+ {/* Right: values for selected category */} + {filterCategory && ( +
+ + + + No values found. + + {(valuesByCategory[filterCategory] ?? []).map((val) => { + const active = hasFilter(filters, { category: filterCategory, value: val }) + return ( + toggleFilter(filterCategory, val)}> + + {val} + + ) + })} + + + +
+ )} +
+
+
+ + + + {searchOpen && ( +
+ setSearchQuery(e.target.value)} + placeholder="Search notes..." + className="flex-1 min-w-0 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" + /> + {searchQuery && ( + + {searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'} + + )} + +
+ )} + +
+ + +
+ + {/* Filter bar */} + {filters.length > 0 && ( +
+
+ + {sortedNotes.length} of {enrichedNotes.length} notes + + {filters.map((f) => ( + + ))} + +
+
+ )} + + {/* Table */} +
+ + + {visibleColumns.map((col) => ( + + ))} + + + + {visibleColumns.map((col) => ( + + ))} + + + + {pageNotes.map((note) => ( + onSelectNote(note.path)} + > + {visibleColumns.map((col) => ( + + ))} + + ))} + {pageNotes.length === 0 && ( + + + + )} + +
handleSort(col)} + > + {toTitleCase(col)} + {/* Resize handle */} +
onResizeStart(col, e)} + onClick={(e) => e.stopPropagation()} + /> +
+ +
+ No notes found +
+
+ + {/* Pagination */} +
+ + {sortedNotes.length === 0 + ? '0 notes' + : `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`} + + {totalPages > 1 && ( +
+ + + Page {clampedPage + 1} of {totalPages} + + +
+ )} +
+ + {/* Save As dialog */} + + + + Save Base + Choose a name for this base view. + + setSaveName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSaveConfirm() }} + placeholder="e.g. Contacts, Projects..." + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring" + autoFocus + /> + + + + + + +
+ ) +} + +/** Renders a single table cell based on the column type */ +function CellRenderer({ + note, + column, + filters, + toggleFilter, +}: { + note: NoteEntry + column: string + filters: ActiveFilter[] + toggleFilter: (category: string, value: string) => void +}) { + if (column === 'name') { + return {note.name} + } + if (column === 'folder') { + return {note.folder} + } + if (column === 'mtimeMs') { + return {formatDate(note.mtimeMs)} + } + + // Frontmatter column + const value = note.fields[column] + if (!value) return null + + if (Array.isArray(value)) { + return ( +
+ {value.map((v) => ( + + ))} +
+ ) + } + + // Single string value — render as badge for filterability + return ( + + ) +} + +function CategoryBadge({ + category, + value, + active, + onClick, +}: { + category: string + value: string + active: boolean + onClick: (category: string, value: string) => void +}) { + return ( + { + e.stopPropagation() + onClick(category, value) + }} + > + {value} + + ) +} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 31bcba17..42ea45bb 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -1,7 +1,36 @@ -import { useCallback, useEffect } from 'react' -import { ArrowUp, LoaderIcon, Square } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + ArrowUp, + AudioLines, + ChevronDown, + FileArchive, + FileCode2, + FileIcon, + FileSpreadsheet, + FileText, + FileVideo, + LoaderIcon, + Plus, + Square, + X, +} from 'lucide-react' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + type AttachmentIconKind, + getAttachmentDisplayName, + getAttachmentIconKind, + getAttachmentToneClass, + getAttachmentTypeLabel, +} from '@/lib/attachment-presentation' +import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils' import { cn } from '@/lib/utils' import { type FileMention, @@ -10,9 +39,60 @@ import { PromptInputTextarea, usePromptInputController, } from '@/components/ai-elements/prompt-input' +import { toast } from 'sonner' + +export type StagedAttachment = { + id: string + path: string + filename: string + mimeType: string + isImage: boolean + size: number + thumbnailUrl?: string +} + +const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB + +const providerDisplayNames: Record = { + openai: 'OpenAI', + anthropic: 'Anthropic', + google: 'Gemini', + ollama: 'Ollama', + openrouter: 'OpenRouter', + aigateway: 'AI Gateway', + 'openai-compatible': 'OpenAI-Compatible', +} + +interface ConfiguredModel { + flavor: string + model: string + apiKey?: string + baseURL?: string + headers?: Record + knowledgeGraphModel?: string +} + +function getAttachmentIcon(kind: AttachmentIconKind) { + switch (kind) { + case 'audio': + return AudioLines + case 'video': + return FileVideo + case 'spreadsheet': + return FileSpreadsheet + case 'archive': + return FileArchive + case 'code': + return FileCode2 + case 'text': + return FileText + default: + return FileIcon + } +} interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -38,7 +118,94 @@ function ChatInputInner({ }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value - const canSubmit = Boolean(message.trim()) && !isProcessing + const [attachments, setAttachments] = useState([]) + const [focusNonce, setFocusNonce] = useState(0) + const fileInputRef = useRef(null) + const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing + + const [configuredModels, setConfiguredModels] = useState([]) + const [activeModelKey, setActiveModelKey] = useState('') + + // Load model config from disk (on mount and whenever tab becomes active) + const loadModelConfig = useCallback(async () => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + const models: ConfiguredModel[] = [] + if (parsed?.providers) { + for (const [flavor, entry] of Object.entries(parsed.providers)) { + const e = entry as Record + const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] + const singleModel = typeof e.model === 'string' ? e.model : '' + const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] + for (const model of allModels) { + if (model) { + models.push({ + flavor, + model, + apiKey: (e.apiKey as string) || undefined, + baseURL: (e.baseURL as string) || undefined, + headers: (e.headers as Record) || undefined, + knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, + }) + } + } + } + } + const defaultKey = parsed?.provider?.flavor && parsed?.model + ? `${parsed.provider.flavor}/${parsed.model}` + : '' + models.sort((a, b) => { + const aKey = `${a.flavor}/${a.model}` + const bKey = `${b.flavor}/${b.model}` + if (aKey === defaultKey) return -1 + if (bKey === defaultKey) return 1 + return 0 + }) + setConfiguredModels(models) + if (defaultKey) { + setActiveModelKey(defaultKey) + } + } catch { + // No config yet + } + }, []) + + useEffect(() => { + loadModelConfig() + }, [isActive, loadModelConfig]) + + // Reload when model config changes (e.g. from settings dialog) + useEffect(() => { + const handler = () => { loadModelConfig() } + window.addEventListener('models-config-changed', handler) + return () => window.removeEventListener('models-config-changed', handler) + }, [loadModelConfig]) + + const handleModelChange = useCallback(async (key: string) => { + const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) + if (!entry) return + setActiveModelKey(key) + // Collect all models for this provider so the full list is preserved + const providerModels = configuredModels + .filter((m) => m.flavor === entry.flavor) + .map((m) => m.model) + try { + await window.ipc.invoke('models:saveConfig', { + provider: { + flavor: entry.flavor, + apiKey: entry.apiKey, + baseURL: entry.baseURL, + headers: entry.headers, + }, + model: entry.model, + models: providerModels, + knowledgeGraphModel: entry.knowledgeGraphModel, + }) + } catch { + toast.error('Failed to switch model') + } + }, [configuredModels]) // Restore the tab draft when this input mounts. useEffect(() => { @@ -59,12 +226,48 @@ function ChatInputInner({ } }, [presetMessage, controller.textInput, onPresetMessageConsumed]) + const addFiles = useCallback(async (paths: string[]) => { + const newAttachments: StagedAttachment[] = [] + for (const filePath of paths) { + try { + const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath }) + if (result.size > MAX_ATTACHMENT_SIZE) { + toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`) + continue + } + const mime = result.mimeType || getMimeFromExtension(getExtension(filePath)) + const image = isImageMime(mime) + newAttachments.push({ + id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + path: filePath, + filename: getFileDisplayName(filePath), + mimeType: mime, + isImage: image, + size: result.size, + thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined, + }) + } catch (err) { + console.error('Failed to read file:', filePath, err) + toast.error(`Failed to read: ${getFileDisplayName(filePath)}`) + } + } + if (newAttachments.length > 0) { + setAttachments((prev) => [...prev, ...newAttachments]) + setFocusNonce((value) => value + 1) + } + }, []) + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) + }, []) + const handleSubmit = useCallback(() => { if (!canSubmit) return - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions) + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments) controller.textInput.clear() controller.mentions.clearMentions() - }, [canSubmit, message, onSubmit, controller]) + setAttachments([]) + }, [attachments, canSubmit, controller, message, onSubmit]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -88,11 +291,9 @@ function ChatInputInner({ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { const paths = Array.from(e.dataTransfer.files) .map((file) => window.electronUtils?.getPathForFile(file)) - .filter(Boolean) + .filter(Boolean) as string[] if (paths.length > 0) { - const currentText = controller.textInput.value - const pathText = paths.join(' ') - controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText) + void addFiles(paths) } } } @@ -103,50 +304,150 @@ function ChatInputInner({ document.removeEventListener('dragover', onDragOver) document.removeEventListener('drop', onDrop) } - }, [controller, isActive]) + }, [addFiles, isActive]) return ( -
- - {isProcessing ? ( - - ) : ( - +
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment) => { + const attachmentType = getAttachmentTypeLabel(attachment) + const attachmentName = getAttachmentDisplayName(attachment) + const Icon = getAttachmentIcon(getAttachmentIconKind(attachment)) + + return ( + + + {attachment.isImage && attachment.thumbnailUrl ? ( + + ) : ( + + )} + + + {attachmentName} + {attachmentType} + + + + ) + })} +
)} + { + const files = e.target.files + if (!files || files.length === 0) return + const paths = Array.from(files) + .map((file) => window.electronUtils?.getPathForFile(file)) + .filter(Boolean) as string[] + if (paths.length > 0) { + void addFiles(paths) + } + e.target.value = '' + }} + /> +
+ +
+
+ +
+ {configuredModels.length > 0 && ( + + + + + + + {configuredModels.map((m) => { + const key = `${m.flavor}/${m.model}` + return ( + + {m.model} + {providerDisplayNames[m.flavor] || m.flavor} + + ) + })} + + + + )} + {isProcessing ? ( + + ) : ( + + )} +
) } @@ -155,7 +456,7 @@ export interface ChatInputWithMentionsProps { knowledgeFiles: string[] recentFiles: string[] visibleFiles: string[] - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onStop?: () => void isProcessing: boolean isStopping?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-message-attachments.tsx b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx new file mode 100644 index 00000000..298e5f03 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx @@ -0,0 +1,137 @@ +import { + AudioLines, + FileArchive, + FileCode2, + FileIcon, + FileSpreadsheet, + FileText, + FileVideo, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import type { MessageAttachment } from '@/lib/chat-conversation' +import { + type AttachmentIconKind, + getAttachmentDisplayName, + getAttachmentIconKind, + getAttachmentToneClass, + getAttachmentTypeLabel, +} from '@/lib/attachment-presentation' +import { isImageMime, toFileUrl } from '@/lib/file-utils' +import { cn } from '@/lib/utils' + +function getAttachmentIcon(kind: AttachmentIconKind) { + switch (kind) { + case 'audio': + return AudioLines + case 'video': + return FileVideo + case 'spreadsheet': + return FileSpreadsheet + case 'archive': + return FileArchive + case 'code': + return FileCode2 + case 'text': + return FileText + default: + return FileIcon + } +} + +function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) { + const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path]) + const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl) + const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl)) + + useEffect(() => { + const nextSrc = attachment.thumbnailUrl || fallbackFileUrl + setSrc(nextSrc) + setTriedBase64(Boolean(attachment.thumbnailUrl)) + }, [attachment.thumbnailUrl, fallbackFileUrl]) + + const loadBase64 = useMemo( + () => async () => { + try { + const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path }) + const mimeType = result.mimeType || attachment.mimeType || 'image/*' + setSrc(`data:${mimeType};base64,${result.data}`) + } catch { + // Keep current src; fallback rendering (broken image icon) is better than crashing. + } + }, + [attachment.mimeType, attachment.path] + ) + + useEffect(() => { + if (attachment.thumbnailUrl || triedBase64) return + setTriedBase64(true) + void loadBase64() + }, [attachment.thumbnailUrl, loadBase64, triedBase64]) + + return ( + Image attachment { + if (triedBase64) return + setTriedBase64(true) + void loadBase64() + }} + /> + ) +} + +interface ChatMessageAttachmentsProps { + attachments: MessageAttachment[] + className?: string +} + +export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) { + if (attachments.length === 0) return null + + const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType)) + const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType)) + + return ( +
+ {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + + ))} +
+ )} + {fileAttachments.length > 0 && ( +
+ {fileAttachments.map((attachment, index) => { + const Icon = getAttachmentIcon(getAttachmentIconKind(attachment)) + const attachmentName = getAttachmentDisplayName(attachment) + const attachmentType = getAttachmentTypeLabel(attachment) + return ( + + + + + + {attachmentName} + {attachmentType} + + + ) + })} +
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 170e8869..f020cdae 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -25,7 +25,8 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions' +import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { type ChatTabViewState, @@ -89,7 +90,7 @@ interface ChatSidebarProps { isProcessing: boolean isStopping?: boolean onStop?: () => void - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void knowledgeFiles?: string[] recentFiles?: string[] visibleFiles?: string[] @@ -256,6 +257,18 @@ export function ChatSidebar({ const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { + if (item.attachments && item.attachments.length > 0) { + return ( + + + + + {item.content && ( + {item.content} + )} + + ) + } const { message, files } = parseAttachedFiles(item.content) return ( diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx new file mode 100644 index 00000000..280d45f1 --- /dev/null +++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx @@ -0,0 +1,252 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { ChevronRight, X, Plus } from 'lucide-react' +import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' + +interface FrontmatterPropertiesProps { + raw: string | null + onRawChange: (raw: string | null) => void + editable?: boolean +} + +type FieldEntry = { key: string; value: string | string[] } + +function fieldsFromRaw(raw: string | null): FieldEntry[] { + const record = extractAllFrontmatterValues(raw) + return Object.entries(record).map(([key, value]) => ({ key, value })) +} + +function fieldsToRaw(fields: FieldEntry[]): string | null { + const record: Record = {} + for (const { key, value } of fields) { + if (key.trim()) record[key.trim()] = value + } + return buildFrontmatter(record) +} + +export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) { + const [expanded, setExpanded] = useState(false) + const [fields, setFields] = useState(() => fieldsFromRaw(raw)) + const [editingNewKey, setEditingNewKey] = useState(false) + const newKeyRef = useRef(null) + const lastCommittedRaw = useRef(raw) + + // Sync local fields when raw changes externally (e.g. tab switch) + useEffect(() => { + if (raw !== lastCommittedRaw.current) { + setFields(fieldsFromRaw(raw)) + lastCommittedRaw.current = raw + } + }, [raw]) + + useEffect(() => { + if (editingNewKey && newKeyRef.current) { + newKeyRef.current.focus() + } + }, [editingNewKey]) + + const commit = useCallback((updated: FieldEntry[]) => { + const newRaw = fieldsToRaw(updated) + lastCommittedRaw.current = newRaw + onRawChange(newRaw) + }, [onRawChange]) + + // For scalar fields: update local state immediately, commit on blur + const updateLocalValue = useCallback((index: number, newValue: string) => { + setFields(prev => { + const next = [...prev] + next[index] = { ...next[index], value: newValue } + return next + }) + }, []) + + const commitField = useCallback((index: number) => { + setFields(prev => { + commit(prev) + return prev + }) + }, [commit]) + + // For array fields and structural changes: update + commit immediately + const updateAndCommit = useCallback((updater: (prev: FieldEntry[]) => FieldEntry[]) => { + setFields(prev => { + const next = updater(prev) + commit(next) + return next + }) + }, [commit]) + + const removeField = useCallback((index: number) => { + updateAndCommit(prev => prev.filter((_, i) => i !== index)) + }, [updateAndCommit]) + + const addField = useCallback((key: string) => { + const trimmed = key.trim() + if (!trimmed) return + if (fields.some(f => f.key === trimmed)) return + updateAndCommit(prev => [...prev, { key: trimmed, value: '' }]) + setEditingNewKey(false) + }, [fields, updateAndCommit]) + + const count = fields.length + + return ( +
+ + + {expanded && ( +
+ {fields.map((field, index) => ( +
+ + {field.key} + +
+ {Array.isArray(field.value) ? ( + updateAndCommit(prev => { + const next = [...prev] + next[index] = { ...next[index], value: v } + return next + })} + /> + ) : ( + updateLocalValue(index, e.target.value)} + onBlur={() => commitField(index)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + /> + )} +
+ {editable && ( + + )} +
+ ))} + + {editable && ( + editingNewKey ? ( +
+ { + if (e.key === 'Enter') { + addField(e.currentTarget.value) + } else if (e.key === 'Escape') { + setEditingNewKey(false) + } + }} + onBlur={(e) => { + if (e.currentTarget.value.trim()) { + addField(e.currentTarget.value) + } else { + setEditingNewKey(false) + } + }} + /> +
+ ) : ( + + ) + )} +
+ )} +
+ ) +} + +function ArrayField({ + value, + editable, + onChange, +}: { + value: string[] + editable: boolean + onChange: (v: string[]) => void +}) { + const removeItem = (index: number) => { + onChange(value.filter((_, i) => i !== index)) + } + + const addItem = (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + onChange([...value, trimmed]) + } + + return ( +
+ {value.map((item, i) => ( + + {item} + {editable && ( + + )} + + ))} + {editable && ( + { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + addItem(e.currentTarget.value) + e.currentTarget.value = '' + } else if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) { + removeItem(value.length - 1) + } + }} + onBlur={(e) => { + if (e.currentTarget.value.trim()) { + addItem(e.currentTarget.value) + e.currentTarget.value = '' + } + }} + /> + )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 74ad10b8..6b5e0c08 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -1,6 +1,6 @@ import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' import { Plugin, PluginKey } from '@tiptap/pm/state' -import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' @@ -8,6 +8,7 @@ import Placeholder from '@tiptap/extension-placeholder' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' +import { TaskBlockExtension } from '@/extensions/task-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' @@ -133,6 +134,8 @@ function getMarkdownWithBlankLines(editor: Editor): string { }) }) blocks.push(listLines.join('\n')) + } else if (node.type === 'taskBlock') { + blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'codeBlock') { const lang = (node.attrs?.language as string) || '' blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') @@ -176,12 +179,26 @@ function getMarkdownWithBlankLines(editor: Editor): string { return result } import { EditorToolbar } from './editor-toolbar' +import { FrontmatterProperties } from './frontmatter-properties' import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' +import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' +import { RowboatMentionPopover } from './rowboat-mention-popover' import '@/styles/editor.css' +type RowboatMentionMatch = { + range: { from: number; to: number } +} + +type RowboatBlockEdit = { + /** ProseMirror position of the taskBlock node */ + nodePos: number + /** Existing instruction text */ + existingText: string +} + type WikiLinkConfig = { files: string[] recent: string[] @@ -192,9 +209,16 @@ type WikiLinkConfig = { interface MarkdownEditorProps { content: string onChange: (markdown: string) => void + onPrimaryHeadingCommit?: () => void + preserveUntitledTitleHeading?: boolean placeholder?: string wikiLinks?: WikiLinkConfig onImageUpload?: (file: File) => Promise + editorSessionKey?: number + onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void + editable?: boolean + frontmatter?: string | null + onFrontmatterChange?: (raw: string | null) => void } type WikiLinkMatch = { @@ -275,9 +299,16 @@ const TabIndentExtension = Extension.create({ export function MarkdownEditor({ content, onChange, + onPrimaryHeadingCommit, + preserveUntitledTitleHeading = false, placeholder = 'Start writing...', wikiLinks, onImageUpload, + editorSessionKey = 0, + onHistoryHandlersChange, + editable = true, + frontmatter, + onFrontmatterChange, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -286,8 +317,20 @@ export function MarkdownEditor({ const [selectionHighlight, setSelectionHighlight] = useState(null) const selectionHighlightRef = useRef(null) const [wikiCommandValue, setWikiCommandValue] = useState('') + const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit) const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {}) + const [activeRowboatMention, setActiveRowboatMention] = useState(null) + const [rowboatBlockEdit, setRowboatBlockEdit] = useState(null) + const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null) + const rowboatBlockEditRef = useRef(null) + + // @ mention autocomplete state (analogous to wiki-link state) + const [activeAtMention, setActiveAtMention] = useState<{ range: { from: number; to: number }; query: string } | null>(null) + const [atAnchorPosition, setAtAnchorPosition] = useState<{ left: number; top: number } | null>(null) + const [atCommandValue, setAtCommandValue] = useState('') + const atKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) + const handleSelectAtMentionRef = useRef<(value: string) => void>(() => {}) // Keep ref in sync with state for the plugin to access selectionHighlightRef.current = selectionHighlight @@ -298,7 +341,70 @@ export function MarkdownEditor({ [] ) + useEffect(() => { + onPrimaryHeadingCommitRef.current = onPrimaryHeadingCommit + }, [onPrimaryHeadingCommit]) + + const maybeCommitPrimaryHeading = useCallback((view: EditorView) => { + const onCommit = onPrimaryHeadingCommitRef.current + if (!onCommit) return + const { selection, doc } = view.state + if (!selection.empty) return + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return + if (!['heading', 'paragraph'].includes($from.parent.type.name)) return + + const firstNode = doc.firstChild + if (!firstNode || !['heading', 'paragraph'].includes(firstNode.type.name)) return + + onCommit() + }, []) + + const preventTitleHeadingDemotion = useCallback((view: EditorView, event: KeyboardEvent) => { + if (!preserveUntitledTitleHeading) return false + if (event.key !== 'Backspace' || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false + + const { selection } = view.state + if (!selection.empty) return false + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return false + if ($from.parent.type.name !== 'heading') return false + + const headingLevel = (( + $from.parent.attrs as { level?: number } | null | undefined + )?.level) ?? 0 + if (headingLevel !== 1) return false + if ($from.parentOffset !== 0) return false + if ($from.parent.textContent.length > 0) return false + + event.preventDefault() + return true + }, [preserveUntitledTitleHeading]) + + const promoteFirstParagraphToTitleHeading = useCallback((view: EditorView) => { + if (!preserveUntitledTitleHeading) return + + const { state, dispatch } = view + const { selection } = state + if (!selection.empty) return + + const { $from } = selection + if ($from.depth < 1 || $from.index(0) !== 0) return + if ($from.parent.type.name !== 'paragraph') return + if ($from.parentOffset !== 0) return + if ($from.parent.textContent.length > 0) return + + const headingType = state.schema.nodes.heading + if (!headingType) return + + const tr = state.tr.setNodeMarkup($from.before(1), headingType, { level: 1 }) + dispatch(tr) + }, [preserveUntitledTitleHeading]) + const editor = useEditor({ + editable, extensions: [ StarterKit.configure({ heading: { @@ -320,6 +426,7 @@ export function MarkdownEditor({ }, }), ImageUploadPlaceholderExtension, + TaskBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -352,11 +459,14 @@ export function MarkdownEditor({ markdown = postprocessMarkdown(markdown) onChange(markdown) }, + onBlur: ({ editor }) => { + maybeCommitPrimaryHeading(editor.view) + }, editorProps: { attributes: { class: 'prose prose-sm max-w-none focus:outline-none', }, - handleKeyDown: (_view, event) => { + handleKeyDown: (view, event) => { const state = wikiKeyStateRef.current if (state.open) { if (event.key === 'Escape') { @@ -389,9 +499,61 @@ export function MarkdownEditor({ } } + // @ mention autocomplete keyboard handling + const atState = atKeyStateRef.current + if (atState.open) { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + setActiveAtMention(null) + setAtAnchorPosition(null) + setAtCommandValue('') + return true + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (atState.options.length === 0) return true + event.preventDefault() + event.stopPropagation() + const currentIndex = Math.max(0, atState.options.indexOf(atState.value)) + const delta = event.key === 'ArrowDown' ? 1 : -1 + const nextIndex = (currentIndex + delta + atState.options.length) % atState.options.length + setAtCommandValue(atState.options[nextIndex]) + return true + } + + if (event.key === 'Enter' || event.key === 'Tab') { + if (atState.options.length === 0) return true + event.preventDefault() + event.stopPropagation() + const selected = atState.options.includes(atState.value) ? atState.value : atState.options[0] + handleSelectAtMentionRef.current(selected) + return true + } + } + + if (preventTitleHeadingDemotion(view, event)) { + return true + } + + const isPrintableKey = event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey + if (isPrintableKey) { + promoteFirstParagraphToTitleHeading(view) + } + + if ( + event.key === 'Enter' + && !event.shiftKey + && !event.ctrlKey + && !event.metaKey + && !event.altKey + ) { + maybeCommitPrimaryHeading(view) + } + return false }, - handleClickOn: (_view, _pos, node, _nodePos, event) => { + handleClickOn: (_view, _pos, node, nodePos, event) => { if (node.type.name === 'wikiLink') { event.preventDefault() wikiLinks?.onOpen?.(node.attrs.path) @@ -400,7 +562,12 @@ export function MarkdownEditor({ return false }, }, - }) + }, [ + editorSessionKey, + maybeCommitPrimaryHeading, + preventTitleHeadingDemotion, + promoteFirstParagraphToTitleHeading, + ]) const orderedFiles = useMemo(() => { if (!wikiLinks) return [] @@ -469,6 +636,118 @@ export function MarkdownEditor({ }) }, [editor, wikiLinks]) + const updateRowboatMentionState = useCallback(() => { + if (!editor) return + const { selection } = editor.state + if (!selection.empty) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const { $from } = selection + if ($from.parent.type.spec.code) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n') + const textBefore = text.slice(0, $from.parentOffset) + + // Match @rowboat at a word boundary (preceded by nothing or whitespace) + const match = textBefore.match(/(^|\s)@rowboat$/) + if (!match) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const triggerStart = textBefore.length - '@rowboat'.length + const from = selection.from - (textBefore.length - triggerStart) + const to = selection.from + setActiveRowboatMention({ range: { from, to } }) + + const wrapper = wrapperRef.current + if (!wrapper) { + setRowboatAnchorTop(null) + return + } + + const coords = editor.view.coordsAtPos(selection.from) + const wrapperRect = wrapper.getBoundingClientRect() + const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null + const pmRect = proseMirrorEl?.getBoundingClientRect() + setRowboatAnchorTop({ + top: coords.top - wrapperRect.top + wrapper.scrollTop, + left: pmRect ? pmRect.left - wrapperRect.left : 0, + width: pmRect ? pmRect.width : wrapperRect.width, + }) + }, [editor]) + + // Detect @ trigger for autocomplete popover (similar to [[ detection) + const updateAtMentionState = useCallback(() => { + if (!editor) return + const { selection } = editor.state + if (!selection.empty) { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + + const { $from } = selection + // Skip code blocks + if ($from.parent.type.spec.code) { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + // Skip inline code marks + if ($from.marks().some((mark) => mark.type.spec.code)) { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + + const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n') + const textBefore = text.slice(0, $from.parentOffset) + + // Find @ at a word boundary (start of line or preceded by whitespace) + const atMatch = textBefore.match(/(^|[\s])@([a-zA-Z0-9]*)$/) + if (!atMatch) { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + + const query = atMatch[2] // text after @ + + // If the full "@rowboat" is already typed, let updateRowboatMentionState handle it + if (query === 'rowboat') { + setActiveAtMention(null) + setAtAnchorPosition(null) + return + } + + const atSymbolOffset = textBefore.lastIndexOf('@') + const matchText = textBefore.slice(atSymbolOffset) + const range = { from: selection.from - matchText.length, to: selection.from } + setActiveAtMention({ range, query }) + + const wrapper = wrapperRef.current + if (!wrapper) { + setAtAnchorPosition(null) + return + } + + const coords = editor.view.coordsAtPos(selection.from) + const wrapperRect = wrapper.getBoundingClientRect() + setAtAnchorPosition({ + left: coords.left - wrapperRect.left, + top: coords.bottom - wrapperRect.top, + }) + }, [editor]) + useEffect(() => { if (!editor || !wikiLinks) return editor.on('update', updateWikiLinkState) @@ -479,6 +758,42 @@ export function MarkdownEditor({ } }, [editor, wikiLinks, updateWikiLinkState]) + useEffect(() => { + if (!editor) return + editor.on('update', updateRowboatMentionState) + editor.on('selectionUpdate', updateRowboatMentionState) + return () => { + editor.off('update', updateRowboatMentionState) + editor.off('selectionUpdate', updateRowboatMentionState) + } + }, [editor, updateRowboatMentionState]) + + useEffect(() => { + if (!editor) return + editor.on('update', updateAtMentionState) + editor.on('selectionUpdate', updateAtMentionState) + return () => { + editor.off('update', updateAtMentionState) + editor.off('selectionUpdate', updateAtMentionState) + } + }, [editor, updateAtMentionState]) + + // When a tell-rowboat block is clicked, compute anchor and open popover + useEffect(() => { + if (!rowboatBlockEdit || !editor) return + const wrapper = wrapperRef.current + if (!wrapper) return + const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos) + const wrapperRect = wrapper.getBoundingClientRect() + const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null + const pmRect = proseMirrorEl?.getBoundingClientRect() + setRowboatAnchorTop({ + top: coords.top - wrapperRect.top + wrapper.scrollTop, + left: pmRect ? pmRect.left - wrapperRect.left : 0, + width: pmRect ? pmRect.width : wrapperRect.width, + }) + }, [editor, rowboatBlockEdit]) + // Update editor content when prop changes (e.g., file selection changes) useEffect(() => { if (editor && content !== undefined) { @@ -489,12 +804,37 @@ export function MarkdownEditor({ isInternalUpdate.current = true // Pre-process to preserve blank lines const preprocessed = preprocessMarkdown(content) - editor.commands.setContent(preprocessed) + // Treat tab-open content as baseline: do not add hydration to undo history. + editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() isInternalUpdate.current = false } } }, [editor, content]) + useEffect(() => { + if (!onHistoryHandlersChange) return + if (!editor) { + onHistoryHandlersChange(null) + return + } + + onHistoryHandlersChange({ + undo: () => editor.chain().focus().undo().run(), + redo: () => editor.chain().focus().redo().run(), + }) + + return () => { + onHistoryHandlersChange(null) + } + }, [editor, onHistoryHandlersChange]) + + // Update editable state when prop changes + useEffect(() => { + if (editor) { + editor.setEditable(editable) + } + }, [editor, editable]) + // Force re-render decorations when selection highlight changes useEffect(() => { if (editor) { @@ -544,9 +884,89 @@ export function MarkdownEditor({ handleSelectWikiLinkRef.current = handleSelectWikiLink }, [handleSelectWikiLink]) + const handleRowboatAdd = useCallback(async (instruction: string) => { + if (!editor) return + + if (rowboatBlockEdit) { + // Editing existing taskBlock — update its data attribute + const { nodePos } = rowboatBlockEdit + const node = editor.state.doc.nodeAt(nodePos) + if (node && node.type.name === 'taskBlock') { + // Preserve existing schedule data + let updated: Record = { instruction } + try { + const existing = JSON.parse(node.attrs.data || '{}') + updated = { ...existing, instruction } + } catch { + // Invalid JSON — just write new + } + const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) }) + editor.view.dispatch(tr) + } + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + return + } + + if (activeRowboatMention) { + // Classify schedule intent for new blocks + const blockData: Record = { instruction } + try { + const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction }) + if (result.schedule) { + const { label, ...rest } = result.schedule + blockData.schedule = rest + blockData['schedule-label'] = label + } + } catch (error) { + console.error('[RowboatAdd] Schedule classification failed:', error) + } + + editor + .chain() + .focus() + .insertContentAt( + { from: activeRowboatMention.range.from, to: activeRowboatMention.range.to }, + [ + { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } }, + { type: 'paragraph' }, + ], + ) + .run() + + // Mark note as live + if (onFrontmatterChange) { + const fields = extractAllFrontmatterValues(frontmatter ?? null) + fields['live_note'] = 'true' + onFrontmatterChange(buildFrontmatter(fields)) + } + + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + } + }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange]) + + const handleRowboatRemove = useCallback(() => { + if (!editor || !rowboatBlockEdit) return + const { nodePos } = rowboatBlockEdit + const node = editor.state.doc.nodeAt(nodePos) + if (node) { + editor + .chain() + .focus() + .deleteRange({ from: nodePos, to: nodePos + node.nodeSize }) + .run() + } + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + }, [editor, rowboatBlockEdit]) + const handleScroll = useCallback(() => { updateWikiLinkState() - }, [updateWikiLinkState]) + updateAtMentionState() + }, [updateWikiLinkState, updateAtMentionState]) const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition) const wikiOptions = useMemo(() => { @@ -574,6 +994,63 @@ export function MarkdownEditor({ setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0])) }, [showWikiPopover, wikiOptions]) + // @ mention autocomplete options + const atMentionOptions = useMemo(() => [ + { value: 'rowboat', label: '@rowboat', description: 'Research, schedule, or run tasks with AI' }, + ], []) + + const filteredAtOptions = useMemo(() => { + if (!activeAtMention) return [] + const q = activeAtMention.query.toLowerCase() + if (!q) return atMentionOptions + return atMentionOptions.filter((opt) => opt.value.toLowerCase().startsWith(q)) + }, [activeAtMention, atMentionOptions]) + + const atOptionValues = useMemo(() => filteredAtOptions.map((o) => o.value), [filteredAtOptions]) + const showAtPopover = Boolean(activeAtMention && atAnchorPosition && filteredAtOptions.length > 0) + + useEffect(() => { + atKeyStateRef.current = { open: showAtPopover, options: atOptionValues, value: atCommandValue } + }, [showAtPopover, atOptionValues, atCommandValue]) + + // Keep @ cmdk selection in sync + useEffect(() => { + if (!showAtPopover) { + setAtCommandValue('') + return + } + if (atOptionValues.length === 0) { + setAtCommandValue('') + return + } + setAtCommandValue((prev) => (atOptionValues.includes(prev) ? prev : atOptionValues[0])) + }, [showAtPopover, atOptionValues]) + + // @ mention selection handler + const handleSelectAtMention = useCallback((value: string) => { + if (!editor || !activeAtMention) return + + if (value === 'rowboat') { + // Replace "@" with "@rowboat" — this triggers updateRowboatMentionState + editor + .chain() + .focus() + .insertContentAt( + { from: activeAtMention.range.from, to: activeAtMention.range.to }, + '@rowboat' + ) + .run() + } + + setActiveAtMention(null) + setAtAnchorPosition(null) + setAtCommandValue('') + }, [editor, activeAtMention]) + + useEffect(() => { + handleSelectAtMentionRef.current = handleSelectAtMention + }, [handleSelectAtMention]) + // Handle keyboard shortcuts const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 's' && (event.metaKey || event.ctrlKey)) { @@ -595,6 +1072,13 @@ export function MarkdownEditor({ onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} /> + {(frontmatter !== undefined) && onFrontmatterChange && ( + + )}
{wikiLinks ? ( @@ -651,6 +1135,64 @@ export function MarkdownEditor({ ) : null} + {/* @ mention autocomplete popover */} + { + if (!open) { + setActiveAtMention(null) + setAtAnchorPosition(null) + setAtCommandValue('') + } + }} + > + + + + event.preventDefault()} + > + + + {filteredAtOptions.map((opt) => ( + handleSelectAtMention(opt.value)} + > +
+ {opt.label} + {opt.description} +
+
+ ))} +
+
+
+
+ { + setActiveRowboatMention(null) + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + }} + />
) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 464d8b96..3e663b91 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -55,14 +55,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", model: "" }, - anthropic: { apiKey: "", baseURL: "", model: "" }, - google: { apiKey: "", baseURL: "", model: "" }, - openrouter: { apiKey: "", baseURL: "", model: "" }, - aigateway: { apiKey: "", baseURL: "", model: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, + 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 [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle", @@ -89,7 +89,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [slackDiscoverError, setSlackDiscoverError] = useState(null) const updateProviderConfig = useCallback( - (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [provider]: { ...prev[provider], ...updates }, @@ -306,6 +306,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const apiKey = activeConfig.apiKey.trim() || undefined const baseURL = activeConfig.baseURL.trim() || undefined const model = activeConfig.model.trim() + const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined const providerConfig = { provider: { flavor: llmProvider, @@ -313,6 +314,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { baseURL, }, model, + knowledgeGraphModel, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -691,39 +693,74 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { )}
-
- Model - {modelsLoading ? ( -
- - Loading models... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { model: e.target.value })} - placeholder="Enter model" - /> - ) : ( - - )} - {modelsError && ( -
{modelsError}
- )} +
+
+ Assistant model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { model: e.target.value })} + placeholder="Enter model" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ +
+ Knowledge graph model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{showApiKey && ( diff --git a/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx new file mode 100644 index 00000000..a5a63bc7 --- /dev/null +++ b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx @@ -0,0 +1,109 @@ +import { useState, useRef, useEffect } from 'react' +import { Loader2 } from 'lucide-react' + +interface RowboatMentionPopoverProps { + open: boolean + anchor: { top: number; left: number; width: number } | null + initialText?: string + onAdd: (instruction: string) => void | Promise + onRemove?: () => void + onClose: () => void +} + +export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) { + const [text, setText] = useState('') + const [loading, setLoading] = useState(false) + const textareaRef = useRef(null) + const containerRef = useRef(null) + + useEffect(() => { + if (open) { + setText(initialText) + setLoading(false) + requestAnimationFrame(() => { + textareaRef.current?.focus() + }) + } + }, [open, initialText]) + + // Close on outside click + useEffect(() => { + if (!open) return + const handleMouseDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose() + } + } + document.addEventListener('mousedown', handleMouseDown) + return () => document.removeEventListener('mousedown', handleMouseDown) + }, [open, onClose]) + + if (!open || !anchor) return null + + const handleSubmit = async () => { + const trimmed = text.trim() + if (!trimmed || loading) return + setLoading(true) + try { + await onAdd(trimmed) + } finally { + setLoading(false) + } + setText('') + } + + return ( +
+
+
+ @rowboat +