diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 8497ae89..6823bd22 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -15,7 +15,11 @@ import { bus } from '@x/core/dist/runs/bus.js'; import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; import z from 'zod'; + +const execAsync = promisify(exec); import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; @@ -27,6 +31,7 @@ import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; +import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; @@ -34,6 +39,7 @@ import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo. 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; @@ -414,6 +420,30 @@ export function setupIpcHandlers() { return { success: true }; }, + 'slack:getConfig': async () => { + const repo = container.resolve('slackConfigRepo'); + const config = await repo.getConfig(); + return { enabled: config.enabled, workspaces: config.workspaces }; + }, + 'slack:setConfig': async (_event, args) => { + const repo = container.resolve('slackConfigRepo'); + await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces }); + return { success: true }; + }, + 'slack:listWorkspaces': async () => { + try { + const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 }); + const parsed = JSON.parse(stdout); + const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({ + url: w.workspace_url || '', + name: w.workspace_name || '', + })); + return { workspaces }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces'; + return { workspaces: [], error: message }; + } + }, 'onboarding:getStatus': async () => { // Show onboarding if it hasn't been completed yet const complete = isOnboardingComplete(); @@ -536,5 +566,10 @@ export function setupIpcHandlers() { '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 92f43577..768ff02b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -12,6 +12,7 @@ import { ChatSidebar } from './components/chat-sidebar'; 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'; @@ -46,6 +47,7 @@ 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' @@ -105,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)) @@ -232,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 @@ -469,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: [], @@ -509,6 +514,9 @@ 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<{ @@ -616,6 +624,8 @@ function App() { 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 }, []) @@ -813,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) @@ -892,12 +927,14 @@ function App() { 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 } } }) @@ -915,6 +952,31 @@ 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) const hasBaseline = initialContentByPathRef.current.has(selectedPath) @@ -934,39 +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 knownBaseline = initialContentByPathRef.current.get(pathToLoad) - const hasKnownBaseline = knownBaseline !== undefined - const hasUnsavedEdits = - hasKnownBaseline - && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline) - const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits - if (!shouldPreserveActiveDraft) { - 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) @@ -1006,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 { @@ -1036,16 +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) { @@ -1070,8 +1144,9 @@ function App() { }) } if (selectedPathRef.current === pathAtStart) { - editorContentRef.current = contentToSave - setEditorContent(contentToSave) + const bodyForEditor = splitFrontmatter(contentToSave).body + editorContentRef.current = bodyForEditor + setEditorContent(bodyForEditor) } } } @@ -1083,7 +1158,8 @@ function App() { opts: { encoding: 'utf8' } }) markRecentLocalMarkdownWrite(pathToSave) - initialContentByPathRef.current.set(pathToSave, contentToSave) + // 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). @@ -1104,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) { @@ -2158,21 +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 @@ -2186,7 +2270,7 @@ function App() { setIsGraphOpen(true) } else { setIsGraphOpen(false) - setSelectedPath(newActiveTab.path) + setSelectedPath(newActiveTab.path) } } return next @@ -2294,7 +2378,7 @@ 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 ))) @@ -2439,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') { @@ -2751,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) => { @@ -2768,6 +2899,12 @@ function App() { 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) @@ -2805,6 +2942,7 @@ function App() { 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) @@ -3243,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 ? ( <> @@ -3345,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 ? (
{ + 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) 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/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 251781c4..fe1d58ae 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -17,7 +17,6 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" -import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" @@ -55,11 +54,15 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) - // Composio/Slack state - const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [slackConnected, setSlackConnected] = useState(false) + // Slack state (agent-slack CLI) + const [slackEnabled, setSlackEnabled] = useState(false) const [slackLoading, setSlackLoading] = useState(true) - const [slackConnecting, setSlackConnecting] = useState(false) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) // Load available providers on mount useEffect(() => { @@ -107,76 +110,76 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } }, []) - // Load Slack connection status - const refreshSlackStatus = useCallback(async () => { + // Load Slack config + const refreshSlackConfig = useCallback(async () => { try { setSlackLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) - setSlackConnected(result.isConnected) + const result = await window.ipc.invoke('slack:getConfig', null) + setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) } catch (error) { - console.error('Failed to load Slack status:', error) - setSlackConnected(false) + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) } finally { setSlackLoading(false) } }, []) - // Connect to Slack via Composio - const startSlackConnect = useCallback(async () => { + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) try { - setSlackConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Slack') - setSlackConnecting(false) + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) } - // Success will be handled by composio:didConnect event } catch (error) { - console.error('Failed to connect to Slack:', error) - toast.error('Failed to connect to Slack') - setSlackConnecting(false) + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) } }, []) - // Handle Slack connect button click - const handleConnectSlack = useCallback(async () => { - // Check if Composio is configured - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyOpen(true) - return - } - await startSlackConnect() - }, [startSlackConnect]) - - // Handle Composio API key submission - const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { - try { - await window.ipc.invoke('composio:set-api-key', { apiKey }) - setComposioApiKeyOpen(false) - toast.success('Composio API key saved') - // Now start the Slack connection - await startSlackConnect() - } catch (error) { - console.error('Failed to save Composio API key:', error) - toast.error('Failed to save API key') - } - }, [startSlackConnect]) - - // Disconnect from Slack - const handleDisconnectSlack = useCallback(async () => { + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) try { setSlackLoading(true) - const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' }) - if (result.success) { - setSlackConnected(false) - toast.success('Disconnected from Slack') - } else { - toast.error('Failed to disconnect from Slack') - } + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') } catch (error) { - console.error('Failed to disconnect from Slack:', error) - toast.error('Failed to disconnect from Slack') + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') } finally { setSlackLoading(false) } @@ -187,8 +190,8 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha // Refresh Granola refreshGranolaConfig() - // Refresh Slack status - refreshSlackStatus() + // Refresh Slack config + refreshSlackConfig() // Refresh OAuth providers if (providers.length === 0) return @@ -226,7 +229,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshSlackConfig]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -270,26 +273,6 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha return cleanup }, [refreshAllStatuses]) - // Listen for Composio connection events - useEffect(() => { - const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event - - if (toolkitSlug === 'slack') { - setSlackConnected(success) - setSlackConnecting(false) - - if (success) { - toast.success('Connected to Slack') - } else { - toast.error(error || 'Failed to connect to Slack') - } - } - }) - - return cleanup - }, []) - const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, @@ -585,62 +568,90 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
{/* Slack */} -
-
-
- +
+
+
+
+ +
+
+ Slack + {slackEnabled && slackWorkspaces.length > 0 ? ( + + {slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
-
- Slack - {slackLoading ? ( - Checking... +
+ {(slackLoading || slackDiscovering) && ( + + )} + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> ) : ( - - Send messages and view channels - + )}
-
- {slackLoading ? ( - - ) : slackConnected ? ( - - ) : ( - - )} -
+ {slackPickerOpen && ( +
+ {slackDiscoverError ? ( +

{slackDiscoverError}

+ ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )}
)}
- ) } 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 37a827cb..6b5e0c08 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -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[] @@ -200,6 +217,8 @@ interface MarkdownEditorProps { editorSessionKey?: number onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void editable?: boolean + frontmatter?: string | null + onFrontmatterChange?: (raw: string | null) => void } type WikiLinkMatch = { @@ -288,6 +307,8 @@ export function MarkdownEditor({ editorSessionKey = 0, onHistoryHandlersChange, editable = true, + frontmatter, + onFrontmatterChange, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -299,6 +320,17 @@ export function MarkdownEditor({ 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 @@ -394,6 +426,7 @@ export function MarkdownEditor({ }, }), ImageUploadPlaceholderExtension, + TaskBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -466,6 +499,39 @@ 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 } @@ -487,7 +553,7 @@ export function MarkdownEditor({ 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) @@ -570,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) @@ -580,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) { @@ -670,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(() => { @@ -700,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)) { @@ -721,6 +1072,13 @@ export function MarkdownEditor({ onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} /> + {(frontmatter !== undefined) && onFrontmatterChange && ( + + )}
{wikiLinks ? ( @@ -777,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 dbe98642..cc58102c 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,8 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2, ArrowLeft } from "lucide-react" -// import { MessageSquare } from "lucide-react" +import { Loader2, Mic, Mail, CheckCircle2, ArrowLeft, MessageSquare } from "lucide-react" import { Dialog, @@ -23,7 +22,6 @@ import { SelectValue, } from "@/components/ui/select" import { cn } from "@/lib/utils" -import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" @@ -83,11 +81,15 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [granolaLoading, setGranolaLoading] = useState(true) const [showMoreProviders, setShowMoreProviders] = useState(false) - // Composio/Slack state - const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [slackConnected, setSlackConnected] = useState(false) - // const [slackLoading, setSlackLoading] = useState(true) - const [slackConnecting, setSlackConnecting] = useState(false) + // Slack state (agent-slack CLI) + const [slackEnabled, setSlackEnabled] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) const updateProviderConfig = useCallback( (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { @@ -215,63 +217,80 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) - // Load Slack connection status - const refreshSlackStatus = useCallback(async () => { + // Load Slack config + const refreshSlackConfig = useCallback(async () => { try { - // setSlackLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) - setSlackConnected(result.isConnected) + setSlackLoading(true) + const result = await window.ipc.invoke('slack:getConfig', null) + setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) } catch (error) { - console.error('Failed to load Slack status:', error) - setSlackConnected(false) + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) } finally { - // setSlackLoading(false) + setSlackLoading(false) } }, []) - // Start Slack connection - const startSlackConnect = useCallback(async () => { + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) try { - setSlackConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Slack') - setSlackConnecting(false) + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) } - // Success will be handled by composio:didConnect event } catch (error) { - console.error('Failed to connect to Slack:', error) - toast.error('Failed to connect to Slack') - setSlackConnecting(false) + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) } }, []) - // Connect to Slack via Composio (checks if configured first) - /* - const handleConnectSlack = useCallback(async () => { - // Check if Composio is configured - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyOpen(true) - return - } - await startSlackConnect() - }, [startSlackConnect]) - */ - - // Handle Composio API key submission - const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) try { - await window.ipc.invoke('composio:set-api-key', { apiKey }) - setComposioApiKeyOpen(false) - toast.success('Composio API key saved') - // Now start the Slack connection - await startSlackConnect() + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') } catch (error) { - console.error('Failed to save Composio API key:', error) - toast.error('Failed to save API key') + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) } - }, [startSlackConnect]) + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') + } finally { + setSlackLoading(false) + } + }, []) const handleNext = () => { if (currentStep < 4) { @@ -340,8 +359,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Refresh Granola refreshGranolaConfig() - // Refresh Slack status - refreshSlackStatus() + // Refresh Slack config + refreshSlackConfig() // Refresh OAuth providers if (providers.length === 0) return @@ -370,7 +389,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshSlackConfig]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -437,6 +456,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) + const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, @@ -590,50 +610,85 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) // Render Slack row - /* const renderSlackRow = () => ( -
-
-
- +
+
+
+
+ +
+
+ Slack + {slackEnabled && slackWorkspaces.length > 0 ? ( + + {slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
-
- Slack - {slackLoading ? ( - Checking... +
+ {(slackLoading || slackDiscovering) && ( + + )} + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> ) : ( - - Send messages and view channels - + )}
-
- {slackLoading ? ( - - ) : slackConnected ? ( -
- - Connected -
- ) : ( - - )} -
+ {slackPickerOpen && ( +
+ {slackDiscoverError ? ( +

{slackDiscoverError}

+ ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )}
) - */ // Step 0: Sign in to Rowboat (with BYOK option) const renderSignInStep = () => { @@ -983,6 +1038,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')}
+ {/* Team Communication Section */} +
+
+ Team Communication +
+ {renderSlackRow()} +
)}
@@ -1006,7 +1068,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step 4: Completion const renderCompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled return (
@@ -1047,7 +1109,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Granola (Local meeting notes)
)} - {slackConnected && ( + {slackEnabled && (
Slack (Team communication) @@ -1073,12 +1135,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onSubmit={handleGoogleClientIdSubmit} isSubmitting={providerStates.google?.isConnecting ?? false} /> - {}}> 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 +