rowboat/apps/x/apps/renderer/src/App.tsx
2026-04-14 11:00:44 +05:30

4652 lines
180 KiB
TypeScript

import * as React from 'react'
import { useCallback, useEffect, useState, useRef } from 'react'
import { workspace } from '@x/shared';
import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
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 { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
type PromptInputMessage,
type FileMention,
} from '@/components/ai-elements/prompt-input';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { useSmoothedText } from './hooks/useSmoothedText';
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card';
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions';
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
import {
SidebarInset,
SidebarProvider,
useSidebar,
} from "@/components/ui/sidebar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
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'
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } 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 ChatViewportAnchorState,
type ChatTabViewState,
type ConversationItem,
type ToolCall,
createEmptyChatTabViewState,
getWebSearchCardData,
getAppActionCardData,
getComposioConnectCardData,
getToolDisplayName,
inferRunTitleFromMessage,
isChatMessage,
isErrorMessage,
isToolCall,
normalizeToolInput,
normalizeToolOutput,
parseAttachedFiles,
toToolState,
} from '@/lib/chat-conversation'
import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js'
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner"
import { useVoiceMode } from '@/hooks/useVoiceMode'
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
import * as analytics from '@/lib/analytics'
type DirEntry = z.infer<typeof workspace.DirEntry>
type RunEventType = z.infer<typeof RunEvent>
type ListRunsResponseType = z.infer<typeof ListRunsResponse>
interface TreeNode extends DirEntry {
children?: TreeNode[]
loaded?: boolean
}
const streamdownComponents = { pre: MarkdownPreOverride }
function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) {
const smoothText = useSmoothedText(text)
return <MessageResponse components={components}>{smoothText}</MessageResponse>
}
const DEFAULT_SIDEBAR_WIDTH = 256
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
const graphPalette = [
{ hue: 210, sat: 72, light: 52 },
{ hue: 28, sat: 78, light: 52 },
{ hue: 120, sat: 62, light: 48 },
{ hue: 170, sat: 66, light: 46 },
{ hue: 280, sat: 70, light: 56 },
{ hue: 330, sat: 68, light: 54 },
{ hue: 55, sat: 80, light: 52 },
{ hue: 0, sat: 72, light: 52 },
]
const MACOS_TRAFFIC_LIGHTS_RESERVED_PX = 16 + 12 * 3 + 8 * 2
const TITLEBAR_BUTTON_PX = 32
const TITLEBAR_BUTTON_GAP_PX = 4
const TITLEBAR_HEADER_GAP_PX = 8
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
const TITLEBAR_BUTTONS_COLLAPSED = 4
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
const untitledBaseName = 'untitled'
const untitledIndexedNamePattern = /^untitled-\d+$/
const isUntitledPlaceholderName = (name: string) =>
name === untitledBaseName || untitledIndexedNamePattern.test(name)
const getHeadingTitle = (markdown: string) => {
const lines = markdown.split('\n')
for (const line of lines) {
const match = line.match(/^#\s+(.+)$/)
if (match) return match[1].trim()
const trimmed = line.trim()
if (trimmed !== '') return trimmed
}
return null
}
const sanitizeHeadingForFilename = (heading: string) => {
let name = heading.trim()
if (!name) return null
if (name.toLowerCase().endsWith('.md')) {
name = name.slice(0, -3)
}
name = name.replace(/[\\/]/g, '-').replace(/\s+/g, ' ').trim()
return name || null
}
const getBaseName = (path: string) => {
const file = path.split('/').pop() ?? ''
return file.replace(/\.md$/i, '')
}
const WIKI_LINK_TOKEN_REGEX = /\[\[([^[\]]+)\]\]/g
const KNOWLEDGE_PREFIX = 'knowledge/'
const normalizeRelPathForWiki = (relPath: string) =>
relPath.replace(/\\/g, '/').replace(/^\/+/, '')
const stripKnowledgePrefixForWiki = (relPath: string) => {
const normalized = normalizeRelPathForWiki(relPath)
return normalized.toLowerCase().startsWith(KNOWLEDGE_PREFIX)
? normalized.slice(KNOWLEDGE_PREFIX.length)
: normalized
}
const stripMarkdownExtensionForWiki = (wikiPath: string) =>
wikiPath.toLowerCase().endsWith('.md') ? wikiPath.slice(0, -3) : wikiPath
const wikiPathCompareKey = (wikiPath: string) =>
stripMarkdownExtensionForWiki(wikiPath).toLowerCase()
const splitWikiPathPrefix = (rawPath: string) => {
let normalized = rawPath.trim().replace(/^\/+/, '').replace(/^\.\//, '')
const hadKnowledgePrefix = /^knowledge\//i.test(normalized)
if (hadKnowledgePrefix) {
normalized = normalized.slice(KNOWLEDGE_PREFIX.length)
}
return { pathWithoutPrefix: normalized, hadKnowledgePrefix }
}
const rewriteWikiLinksForRenamedFileInMarkdown = (
markdown: string,
fromRelPath: string,
toRelPath: string
) => {
const normalizedFrom = normalizeRelPathForWiki(fromRelPath)
const normalizedTo = normalizeRelPathForWiki(toRelPath)
const lowerFrom = normalizedFrom.toLowerCase()
const lowerTo = normalizedTo.toLowerCase()
if (!lowerFrom.startsWith(KNOWLEDGE_PREFIX) || !lowerFrom.endsWith('.md')) return markdown
if (!lowerTo.startsWith(KNOWLEDGE_PREFIX) || !lowerTo.endsWith('.md')) return markdown
const fromWikiPath = stripKnowledgePrefixForWiki(normalizedFrom)
const toWikiPath = stripKnowledgePrefixForWiki(normalizedTo)
const fromCompareKey = wikiPathCompareKey(fromWikiPath)
const fromBaseName = stripMarkdownExtensionForWiki(fromWikiPath).split('/').pop()?.toLowerCase() ?? null
const toWikiPathWithoutExtension = stripMarkdownExtensionForWiki(toWikiPath)
const toBaseName = toWikiPathWithoutExtension.split('/').pop() ?? toWikiPathWithoutExtension
return markdown.replace(WIKI_LINK_TOKEN_REGEX, (fullMatch, innerRaw: string) => {
const pipeIndex = innerRaw.indexOf('|')
const pathAndAnchor = pipeIndex >= 0 ? innerRaw.slice(0, pipeIndex) : innerRaw
const aliasSuffix = pipeIndex >= 0 ? innerRaw.slice(pipeIndex) : ''
const hashIndex = pathAndAnchor.indexOf('#')
const pathPart = hashIndex >= 0 ? pathAndAnchor.slice(0, hashIndex) : pathAndAnchor
const anchorSuffix = hashIndex >= 0 ? pathAndAnchor.slice(hashIndex) : ''
const leadingWhitespace = pathPart.match(/^\s*/)?.[0] ?? ''
const trailingWhitespace = pathPart.match(/\s*$/)?.[0] ?? ''
const rawPath = pathPart.trim()
if (!rawPath) return fullMatch
const { pathWithoutPrefix, hadKnowledgePrefix } = splitWikiPathPrefix(rawPath)
if (!pathWithoutPrefix) return fullMatch
const matchesFullPath = wikiPathCompareKey(pathWithoutPrefix) === fromCompareKey
const isBareTarget = !pathWithoutPrefix.includes('/')
const targetBaseName = stripMarkdownExtensionForWiki(pathWithoutPrefix).toLowerCase()
const matchesBareSelfName = Boolean(fromBaseName && isBareTarget && targetBaseName === fromBaseName)
if (!matchesFullPath && !matchesBareSelfName) return fullMatch
const preserveMarkdownExtension = rawPath.toLowerCase().endsWith('.md')
const rewrittenTarget = matchesBareSelfName
? (preserveMarkdownExtension ? `${toBaseName}.md` : toBaseName)
: (preserveMarkdownExtension ? toWikiPath : toWikiPathWithoutExtension)
const finalPath = hadKnowledgePrefix ? `${KNOWLEDGE_PREFIX}${rewrittenTarget}` : rewrittenTarget
return `[[${leadingWhitespace}${finalPath}${trailingWhitespace}${anchorSuffix}${aliasSuffix}]]`
})
}
const getAncestorDirectoryPaths = (path: string): string[] => {
const parts = path.split('/').filter(Boolean)
if (parts.length <= 2) return []
const ancestors: string[] = []
for (let i = 1; i < parts.length - 1; i++) {
ancestors.push(parts.slice(0, i + 1).join('/'))
}
return ancestors
}
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
if (!usage) return null
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
if (!hasNumbers) return null
const inputTokens = usage.inputTokens ?? 0
const outputTokens = usage.outputTokens ?? 0
const reasoningTokens = usage.reasoningTokens ?? 0
const totalTokens = usage.totalTokens ?? inputTokens + outputTokens + reasoningTokens
return {
inputTokens,
outputTokens,
totalTokens,
cachedInputTokens: usage.cachedInputTokens ?? 0,
reasoningTokens,
}
}
// Sidebar folder ordering — listed folders appear in this order, unlisted ones follow alphabetically
const FOLDER_ORDER = ['People', 'Organizations', 'Projects', 'Topics', 'Meetings', 'Agent Notes', 'Notes']
/**
* Per-folder base view config: which columns to show and default sort.
* Folders not listed here fall back to DEFAULT_BASE_CONFIG.
*/
const FOLDER_BASE_CONFIGS: Record<string, { visibleColumns: string[]; sort: { field: string; dir: 'asc' | 'desc' } }> = {
'Agent Notes': {
visibleColumns: ['name', 'folder', 'mtimeMs'],
sort: { field: 'mtimeMs', dir: 'desc' },
},
People: {
visibleColumns: ['name', 'relationship', 'organization', 'mtimeMs'],
sort: { field: 'name', dir: 'asc' },
},
Organizations: {
visibleColumns: ['name', 'relationship', 'mtimeMs'],
sort: { field: 'name', dir: 'asc' },
},
Projects: {
visibleColumns: ['name', 'status', 'topic', 'mtimeMs'],
sort: { field: 'name', dir: 'asc' },
},
Topics: {
visibleColumns: ['name', 'mtimeMs'],
sort: { field: 'name', dir: 'asc' },
},
Meetings: {
visibleColumns: ['name', 'topic', 'mtimeMs'],
sort: { field: 'mtimeMs', dir: 'desc' },
},
}
// Sort nodes (dirs first, ordered folders by FOLDER_ORDER, then alphabetically)
function sortNodes(nodes: TreeNode[]): TreeNode[] {
return nodes.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
const aOrder = FOLDER_ORDER.indexOf(a.name)
const bOrder = FOLDER_ORDER.indexOf(b.name)
if (aOrder !== -1 && bOrder !== -1) return aOrder - bOrder
if (aOrder !== -1) return -1
if (bOrder !== -1) return 1
return a.name.localeCompare(b.name)
}).map(node => {
if (node.children) {
node.children = sortNodes(node.children)
}
return node
})
}
/**
* Organize Meetings/ source folders into date-grouped subfolders.
*
* - rowboat: rowboat/2026-03-20/meeting-xxx.md → keeps date folders as-is
* - granola: granola/2026/03/18/Title.md → collapses into "2026-03-18" folders
* - Files directly under a source folder (no date subfolder) are grouped
* by the date prefix in their filename (e.g. meeting-2026-03-17T...).
*/
function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
return nodes.flatMap(node => {
if (node.kind !== 'dir' || node.name !== 'Meetings') return [node]
const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => {
if (sourceNode.kind !== 'dir') return [sourceNode]
// Collect all files with their date group label
const dateGroups = new Map<string, TreeNode[]>()
function collectFiles(n: TreeNode, dateParts: string[]) {
for (const child of n.children ?? []) {
if (child.kind === 'file') {
const dateStr = dateParts.join('-')
// If file is at root of source folder, try to extract date from filename
const groupKey = dateStr || extractDateFromFilename(child.name) || 'other'
const group = dateGroups.get(groupKey) ?? []
group.push(child)
dateGroups.set(groupKey, group)
} else if (child.kind === 'dir') {
collectFiles(child, [...dateParts, child.name])
}
}
}
collectFiles(sourceNode, [])
if (dateGroups.size === 0) return []
// Build date folder nodes, sorted reverse chronologically
const dateFolderNodes: TreeNode[] = [...dateGroups.entries()]
.sort(([a], [b]) => b.localeCompare(a))
.map(([dateKey, files]) => {
// Sort files within each date group reverse chronologically
files.sort((a, b) => b.name.localeCompare(a.name))
return {
name: dateKey,
path: `${sourceNode.path}/${dateKey}`,
kind: 'dir' as const,
children: files,
loaded: true,
}
})
return [{ ...sourceNode, children: dateFolderNodes }]
})
// Hide Meetings folder entirely if no source folders have files
if (flattenedSourceChildren.length === 0) return []
return [{ ...node, children: flattenedSourceChildren }]
})
}
/** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */
function extractDateFromFilename(name: string): string | null {
const match = name.match(/(\d{4}-\d{2}-\d{2})/)
return match ? match[1] : null
}
// Build tree structure from flat entries
function buildTree(entries: DirEntry[]): TreeNode[] {
const treeMap = new Map<string, TreeNode>()
const roots: TreeNode[] = []
// Create nodes
entries.forEach(entry => {
const node: TreeNode = { ...entry, children: [], loaded: false }
treeMap.set(entry.path, node)
})
// Build hierarchy
entries.forEach(entry => {
const node = treeMap.get(entry.path)!
const parts = entry.path.split('/')
if (parts.length === 1) {
roots.push(node)
} else {
const parentPath = parts.slice(0, -1).join('/')
const parent = treeMap.get(parentPath)
if (parent) {
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
}
})
return sortNodes(roots)
}
const collectDirPaths = (nodes: TreeNode[]): string[] =>
nodes.flatMap(n => n.kind === 'dir' ? [n.path, ...(n.children ? collectDirPaths(n.children) : [])] : [])
const collectFilePaths = (nodes: TreeNode[]): string[] =>
nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : []))
/** A snapshot of which view the user is on */
type ViewState =
| { type: 'chat'; runId: string | null }
| { type: 'file'; path: string }
| { type: 'graph' }
| { type: 'task'; name: string }
| { type: 'suggested-topics' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId
if (a.type === 'file' && b.type === 'file') return a.path === b.path
if (a.type === 'task' && b.type === 'task') return a.name === b.name
return true // both graph
}
/** Sidebar toggle + utility buttons (fixed position, top-left) */
function FixedSidebarToggle({
onNewChat,
onOpenSearch,
meetingState,
meetingSummarizing,
meetingAvailable,
onToggleMeeting,
leftInsetPx,
}: {
onNewChat: () => void
onOpenSearch: () => void
meetingState: MeetingTranscriptionState
meetingSummarizing: boolean
meetingAvailable: boolean
onToggleMeeting: () => void
leftInsetPx: number
}) {
const { toggleSidebar } = useSidebar()
return (
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
{/* Sidebar toggle */}
<button
type="button"
onClick={toggleSidebar}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
style={{ marginLeft: TITLEBAR_TOGGLE_MARGIN_LEFT_PX }}
aria-label="Toggle Sidebar"
>
<PanelLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={onNewChat}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
aria-label="New chat"
>
<SquarePen className="size-5" />
</button>
<button
type="button"
onClick={onOpenSearch}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
aria-label="Search"
>
<SearchIcon className="size-5" />
</button>
{meetingAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleMeeting}
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md transition-colors disabled:pointer-events-none",
meetingSummarizing
? "text-muted-foreground"
: meetingState === 'recording'
? "text-red-500 hover:bg-accent"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
>
{meetingSummarizing || meetingState === 'connecting' ? (
<LoaderIcon className="size-4 animate-spin" />
) : meetingState === 'recording' ? (
<SquareIcon className="size-4 animate-pulse" />
) : (
<RadioIcon className="size-5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'}
</TooltipContent>
</Tooltip>
)}
</div>
)
}
/** Main content header that adjusts padding based on sidebar state */
function ContentHeader({
children,
onNavigateBack,
onNavigateForward,
canNavigateBack,
canNavigateForward,
collapsedLeftPaddingPx,
}: {
children: React.ReactNode
onNavigateBack?: () => void
onNavigateForward?: () => void
canNavigateBack?: boolean
canNavigateForward?: boolean
collapsedLeftPaddingPx?: number
}) {
const { state } = useSidebar()
const isCollapsed = state === "collapsed"
return (
<header
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar overflow-hidden"
style={{
paddingLeft: isCollapsed ? (collapsedLeftPaddingPx ?? 196) : 12,
paddingRight: 12,
transition: 'padding-left 200ms linear',
}}
>
{onNavigateBack && onNavigateForward ? (
<div className="titlebar-no-drag flex items-center gap-1 pr-2 shrink-0">
<button
type="button"
onClick={onNavigateBack}
disabled={!canNavigateBack}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Go back"
>
<ChevronLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={onNavigateForward}
disabled={!canNavigateForward}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Go forward"
>
<ChevronRightIcon className="size-5" />
</button>
</div>
) : null}
{onNavigateBack && onNavigateForward ? (
<div className="titlebar-no-drag self-stretch w-px bg-border/70" aria-hidden="true" />
) : null}
{children}
</header>
)
}
function App() {
type ShortcutPane = 'left' | 'right'
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
useAnalyticsIdentity()
// File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string>('')
const [editorContent, setEditorContent] = useState<string>('')
const editorContentRef = useRef<string>('')
const [editorContentByPath, setEditorContentByPath] = useState<Record<string, string>>({})
const editorContentByPathRef = useRef<Map<string, string>>(new Map())
const [tree, setTree] = useState<TreeNode[]>([])
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
nodes: [],
edges: [],
})
const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [graphError, setGraphError] = useState<string | null>(null)
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false)
const [activeShortcutPane, setActiveShortcutPane] = useState<ShortcutPane>('left')
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac')
const collapsedLeftPaddingPx =
(isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0) +
TITLEBAR_TOGGLE_MARGIN_LEFT_PX +
TITLEBAR_BUTTON_PX * TITLEBAR_BUTTONS_COLLAPSED +
TITLEBAR_BUTTON_GAP_PX * TITLEBAR_BUTTON_GAPS_COLLAPSED +
TITLEBAR_HEADER_GAP_PX
// Keep the latest selected path in a ref (avoids stale async updates when switching rapidly)
const selectedPathRef = useRef<string | null>(null)
const editorPathRef = useRef<string | null>(null)
const fileLoadRequestIdRef = useRef(0)
const initialContentByPathRef = useRef<Map<string, string>>(new Map())
const recentLocalMarkdownWritesRef = useRef<Map<string, number>>(new Map())
const untitledRenameReadyPathsRef = useRef<Set<string>>(new Set())
// Pending app-navigation result to process once navigation functions are ready
const pendingAppNavRef = useRef<Record<string, unknown> | null>(null)
// Global navigation history (back/forward) across views (chat/file/graph/task)
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
const [viewHistory, setViewHistory] = useState(historyRef.current)
const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => {
historyRef.current = next
setViewHistory(next)
}, [])
// Auto-save state
const [isSaving, setIsSaving] = useState(false)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
const debouncedContent = useDebounce(editorContent, 500)
const initialContentRef = useRef<string>('')
const renameInProgressRef = useRef(false)
// Frontmatter state: store raw frontmatter per file path
const frontmatterByPathRef = useRef<Map<string, string | null>>(new Map())
// Version history state
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
oid: string
content: string
} | null>(null)
// Chat state
const [, setMessage] = useState<string>('')
const [conversation, setConversation] = useState<ConversationItem[]>([])
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>('')
const [, setModelUsage] = useState<LanguageModelUsage | null>(null)
const [runId, setRunId] = useState<string | null>(null)
const runIdRef = useRef<string | null>(null)
const loadRunRequestIdRef = useRef(0)
const [isProcessing, setIsProcessing] = useState(false)
const [processingRunIds, setProcessingRunIds] = useState<Set<string>>(new Set())
const processingRunIdsRef = useRef<Set<string>>(new Set())
const streamingBuffersRef = useRef<Map<string, { assistant: string }>>(new Map())
const [isStopping, setIsStopping] = useState(false)
const [stopClickedAt, setStopClickedAt] = useState<number | null>(null)
const [agentId] = useState<string>('copilot')
const [presetMessage, setPresetMessage] = useState<string | undefined>(undefined)
// Voice mode state
const [voiceAvailable, setVoiceAvailable] = useState(false)
const [ttsAvailable, setTtsAvailable] = useState(false)
const [ttsEnabled, setTtsEnabled] = useState(false)
const ttsEnabledRef = useRef(false)
const [ttsMode, setTtsMode] = useState<'summary' | 'full'>('summary')
const ttsModeRef = useRef<'summary' | 'full'>('summary')
const [isRecording, setIsRecording] = useState(false)
const voiceTextBufferRef = useRef('')
const spokenIndexRef = useRef(0)
const isRecordingRef = useRef(false)
const tts = useVoiceTTS()
const ttsRef = useRef(tts)
ttsRef.current = tts
const voice = useVoiceMode()
const voiceRef = useRef(voice)
voiceRef.current = voice
const handleToggleMeetingRef = useRef<(() => void) | undefined>(undefined)
const meetingTranscription = useMeetingTranscription(() => {
handleToggleMeetingRef.current?.()
})
// Check if voice is available on mount and when OAuth state changes
const refreshVoiceAvailability = useCallback(() => {
Promise.all([
window.ipc.invoke('voice:getConfig', null),
window.ipc.invoke('oauth:getState', null),
]).then(([config, oauthState]) => {
const rowboatConnected = oauthState.config?.rowboat?.connected ?? false
const hasVoice = !!config.deepgram || rowboatConnected
setVoiceAvailable(hasVoice)
setTtsAvailable(!!config.elevenlabs || rowboatConnected)
// Pre-cache auth details so mic click skips IPC round-trips
if (hasVoice) {
voice.warmup()
}
}).catch(() => {
setVoiceAvailable(false)
setTtsAvailable(false)
})
}, [voice.warmup])
useEffect(() => {
refreshVoiceAvailability()
const cleanup = window.ipc.on('oauth:didConnect', () => {
refreshVoiceAvailability()
})
return cleanup
}, [refreshVoiceAvailability])
const handleStartRecording = useCallback(() => {
setIsRecording(true)
isRecordingRef.current = true
voice.start()
}, [voice])
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise<void>) | null>(null)
const pendingVoiceInputRef = useRef(false)
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
// queued across the new-chat-tab state flush before submit fires.
const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map())
const [paletteContext, setPaletteContext] = useState<CommandPaletteContext | null>(null)
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
const handleSubmitRecording = useCallback(() => {
const text = voice.submit()
setIsRecording(false)
isRecordingRef.current = false
if (text) {
pendingVoiceInputRef.current = true
handlePromptSubmitRef.current?.({ text, files: [] })
}
}, [voice])
const handleToggleTts = useCallback(() => {
setTtsEnabled(prev => {
const next = !prev
ttsEnabledRef.current = next
if (!next) {
ttsRef.current.cancel()
}
return next
})
}, [])
const handleTtsModeChange = useCallback((mode: 'summary' | 'full') => {
setTtsMode(mode)
ttsModeRef.current = mode
}, [])
const handleCancelRecording = useCallback(() => {
voice.cancel()
setIsRecording(false)
isRecordingRef.current = false
}, [voice])
// Enter to submit voice input, Escape to cancel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecordingRef.current) return
if (e.key === 'Enter') {
e.preventDefault()
handleSubmitRecording()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelRecording()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleSubmitRecording, handleCancelRecording])
// Helper to cancel recording from any navigation handler
const cancelRecordingIfActive = useCallback(() => {
if (isRecordingRef.current) {
voiceRef.current.cancel()
setIsRecording(false)
isRecordingRef.current = false
}
}, [])
// Runs history state
type RunListItem = { id: string; title?: string; createdAt: string; agentId: string }
const [runs, setRuns] = useState<RunListItem[]>([])
// Chat tab state
const [chatTabs, setChatTabs] = useState<ChatTab[]>([{ id: 'default-chat-tab', runId: null }])
const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab')
const [chatViewStateByTab, setChatViewStateByTab] = useState<Record<string, ChatTabViewState>>({
'default-chat-tab': createEmptyChatTabViewState(),
})
const chatViewStateByTabRef = useRef(chatViewStateByTab)
const chatTabIdCounterRef = useRef(0)
const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
const chatDraftsRef = useRef(new Map<string, string>())
const chatScrollTopByTabRef = useRef(new Map<string, number>())
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
const activeChatTabIdRef = useRef(activeChatTabId)
activeChatTabIdRef.current = activeChatTabId
const setChatDraftForTab = useCallback((tabId: string, text: string) => {
if (text) {
chatDraftsRef.current.set(tabId, text)
} else {
chatDraftsRef.current.delete(tabId)
}
}, [])
const isToolOpenForTab = useCallback((tabId: string, toolId: string): boolean => {
return toolOpenByTab[tabId]?.[toolId] ?? false
}, [toolOpenByTab])
const setToolOpenForTab = useCallback((tabId: string, toolId: string, open: boolean) => {
setToolOpenByTab((prev) => {
const prevForTab = prev[tabId] ?? {}
if (prevForTab[toolId] === open) return prev
return {
...prev,
[tabId]: {
...prevForTab,
[toolId]: open,
},
}
})
}, [])
const setChatViewportAnchor = useCallback((tabId: string, messageId: string | null) => {
setChatViewportAnchorByTab((prev) => {
const prevForTab = prev[tabId]
return {
...prev,
[tabId]: {
messageId,
requestKey: (prevForTab?.requestKey ?? 0) + 1,
},
}
})
}, [])
const getChatScrollContainer = useCallback((tabId: string): HTMLElement | null => {
if (typeof document === 'undefined') return null
const panel = document.querySelector<HTMLElement>(
`[data-chat-tab-panel="${tabId}"][aria-hidden="false"]`
)
if (!panel) return null
const logRoot = panel.querySelector<HTMLElement>('[role="log"]')
if (!logRoot) return null
const children = Array.from(logRoot.children) as HTMLElement[]
for (const child of children) {
const style = window.getComputedStyle(child)
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
return child
}
}
return null
}, [])
const saveChatScrollForTab = useCallback((tabId: string) => {
const container = getChatScrollContainer(tabId)
if (!container) return
chatScrollTopByTabRef.current.set(tabId, container.scrollTop)
}, [getChatScrollContainer])
const getChatTabTitle = useCallback((tab: ChatTab) => {
if (!tab.runId) return 'New chat'
return runs.find(r => r.id === tab.runId)?.title || '(Untitled chat)'
}, [runs])
const isChatTabProcessing = useCallback((tab: ChatTab) => {
return tab.runId ? processingRunIds.has(tab.runId) : false
}, [processingRunIds])
// File tab state
const [fileTabs, setFileTabs] = useState<FileTab[]>([])
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
const activeFileTabIdRef = useRef(activeFileTabId)
activeFileTabIdRef.current = activeFileTabId
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
const fileHistoryHandlersRef = useRef<Map<string, MarkdownHistoryHandlers>>(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 (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
}, [])
// Pending requests state
const [, setPendingPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
// Track ALL permission requests (for rendering with response status)
const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
// Track permission responses (toolCallId -> response)
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map())
useEffect(() => {
chatViewStateByTabRef.current = chatViewStateByTab
}, [chatViewStateByTab])
useEffect(() => {
const snapshot: ChatTabViewState = {
runId,
conversation,
currentAssistantMessage,
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
allPermissionRequests: new Map(allPermissionRequests),
permissionResponses: new Map(permissionResponses),
}
setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
}, [
activeChatTabId,
runId,
conversation,
currentAssistantMessage,
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
])
useEffect(() => {
const tabIds = new Set(chatTabs.map((tab) => tab.id))
setChatViewStateByTab((prev) => {
let changed = false
const next: Record<string, ChatTabViewState> = {}
for (const [tabId, state] of Object.entries(prev)) {
if (tabIds.has(tabId)) {
next[tabId] = state
} else {
changed = true
}
}
for (const tabId of tabIds) {
if (!next[tabId]) {
next[tabId] = createEmptyChatTabViewState()
changed = true
}
}
return changed ? next : prev
})
}, [chatTabs])
useEffect(() => {
const tabIds = new Set(chatTabs.map((tab) => tab.id))
setChatViewportAnchorByTab((prev) => {
let changed = false
const next: Record<string, ChatViewportAnchorState> = {}
for (const [tabId, state] of Object.entries(prev)) {
if (tabIds.has(tabId)) {
next[tabId] = state
} else {
changed = true
}
}
return changed ? next : prev
})
}, [chatTabs])
// Workspace root for full paths
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
// Onboarding state
const [showOnboarding, setShowOnboarding] = useState(false)
// Search state
const [isSearchOpen, setIsSearchOpen] = useState(false)
// Background tasks state
type BackgroundTaskItem = {
name: string
description?: string
schedule: z.infer<typeof AgentScheduleConfig>["agents"][string]["schedule"]
enabled: boolean
startingMessage?: string
status?: z.infer<typeof AgentScheduleState>["agents"][string]["status"]
nextRunAt?: string | null
lastRunAt?: string | null
lastError?: string | null
runCount?: number
}
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskItem[]>([])
const [selectedBackgroundTask, setSelectedBackgroundTask] = useState<string | null>(null)
// Keep selectedPathRef in sync for async guards
useEffect(() => {
selectedPathRef.current = selectedPath
if (!selectedPath) {
editorPathRef.current = null
}
}, [selectedPath])
// Keep active file visible in the Knowledge tree by auto-expanding its ancestor folders.
useEffect(() => {
if (!selectedPath) return
const ancestorDirs = getAncestorDirectoryPaths(selectedPath)
if (ancestorDirs.length === 0) return
setExpandedPaths((prev) => {
let changed = false
const next = new Set(prev)
for (const dirPath of ancestorDirs) {
if (!next.has(dirPath)) {
next.add(dirPath)
changed = true
}
}
return changed ? next : prev
})
}, [selectedPath])
// Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures)
useEffect(() => {
runIdRef.current = runId
}, [runId])
const setEditorCacheForPath = useCallback((path: string, content: string) => {
editorContentByPathRef.current.set(path, content)
setEditorContentByPath((prev) => {
if (prev[path] === content) return prev
return { ...prev, [path]: content }
})
}, [])
const removeEditorCacheForPath = useCallback((path: string) => {
editorContentByPathRef.current.delete(path)
untitledRenameReadyPathsRef.current.delete(path)
setEditorContentByPath((prev) => {
if (!(path in prev)) return prev
const next = { ...prev }
delete next[path]
return next
})
}, [])
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
if (nextSelectedPath !== path) {
return
}
// Avoid clobbering editorPath during rapid transitions (e.g. autosave rename) where refs may lag a tick.
if (!editorPathRef.current || (nextSelectedPath && editorPathRef.current === nextSelectedPath)) {
editorPathRef.current = nextSelectedPath
}
editorContentRef.current = markdown
setEditorContent(markdown)
}, [setEditorCacheForPath])
// Keep processingRunIdsRef in sync for use in async callbacks
useEffect(() => {
processingRunIdsRef.current = processingRunIds
}, [processingRunIds])
// Sync active run streaming UI with background processing tracking.
// Depend on both runId and processingRunIds so we don't miss late/early event ordering.
useEffect(() => {
if (!runId) {
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
setCurrentAssistantMessage('')
return
}
const isRunProcessing = processingRunIds.has(runId)
setIsProcessing(isRunProcessing)
if (isRunProcessing) {
const buffer = streamingBuffersRef.current.get(runId)
setCurrentAssistantMessage(buffer?.assistant ?? '')
} else {
setIsStopping(false)
setStopClickedAt(null)
setCurrentAssistantMessage('')
streamingBuffersRef.current.delete(runId)
}
}, [runId, processingRunIds])
// Load directory tree (knowledge + bases)
const loadDirectory = useCallback(async () => {
try {
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 = flattenMeetingsTree(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/ and knowledge/Notes/ directories exist on startup
useEffect(() => {
window.ipc.invoke('workspace:mkdir', { path: 'bases', recursive: true })
.catch((err: unknown) => console.error('Failed to ensure bases directory:', err))
window.ipc.invoke('workspace:mkdir', { path: 'knowledge/Notes', recursive: true })
.catch((err: unknown) => console.error('Failed to ensure Notes directory:', err))
}, [])
// Load initial tree
useEffect(() => {
loadDirectory().then(setTree)
}, [loadDirectory])
// Listen to workspace change events
useEffect(() => {
const cleanup = window.ipc.on('workspace:didChange', async (event) => {
loadDirectory().then(setTree)
const changedPath = event.type === 'changed' ? event.path : null
const changedPaths = (event.type === 'bulkChanged' ? event.paths : []) ?? []
const eventPaths = (() => {
if (event.type === 'changed') return [event.path]
if (event.type === 'bulkChanged') return event.paths ?? []
if (event.type === 'moved') return [event.from, event.to]
if (event.type === 'created' || event.type === 'deleted') return [event.path]
return []
})()
const selectedPathAtEvent = selectedPathRef.current
// Reload background tasks if agent-schedule.json changed
if (
changedPath === 'config/agent-schedule.json'
|| changedPaths.includes('config/agent-schedule.json')
) {
loadBackgroundTasks()
}
// Invalidate cached content for files changed outside the active editor.
// This prevents stale backlinks after rename-rewrite passes touch many files.
for (const path of eventPaths) {
if (!path.endsWith('.md')) continue
if (selectedPathAtEvent && path === selectedPathAtEvent) continue
removeEditorCacheForPath(path)
initialContentByPathRef.current.delete(path)
}
// Keep selection stable if a file is moved externally.
if (
event.type === 'moved'
&& selectedPathAtEvent
&& event.from === selectedPathAtEvent
) {
setSelectedPath(event.to)
}
// Reload current file if it was changed externally
if (!selectedPathAtEvent) return
const pathToReload = selectedPathAtEvent
const isCurrentFileChanged =
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)
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, body)
initialContentRef.current = body
}
}
})
return cleanup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadDirectory, removeEditorCacheForPath, setEditorCacheForPath])
// Load file content when selected
useEffect(() => {
if (!selectedPath) {
setFileContent('')
setEditorContent('')
editorContentRef.current = ''
initialContentRef.current = ''
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)
// 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
editorPathRef.current = selectedPath
initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
return
}
}
const requestId = (fileLoadRequestIdRef.current += 1)
const pathToLoad = selectedPath
let cancelled = false
;(async () => {
try {
// 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
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 {
// 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)
if (!cancelled && fileLoadRequestIdRef.current === requestId && selectedPathRef.current === pathToLoad) {
setFileContent('')
setEditorContent('')
editorContentRef.current = ''
initialContentRef.current = ''
}
}
})()
return () => {
cancelled = true
}
}, [selectedPath, setEditorCacheForPath])
// Track recently opened markdown files for wiki links
useEffect(() => {
if (!selectedPath || !selectedPath.endsWith('.md')) return
const wikiPath = stripKnowledgePrefix(selectedPath)
setRecentWikiFiles((prev) => {
const next = [wikiPath, ...prev.filter((path) => path !== wikiPath)]
return next.slice(0, 50)
})
}, [selectedPath])
// Auto-save when content changes
useEffect(() => {
const pathAtStart = editorPathRef.current
if (!pathAtStart || !pathAtStart.endsWith('.md')) return
const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current
if (debouncedContent === baseline) return
if (!debouncedContent) return
const saveFile = async () => {
const wasActiveAtStart = selectedPathRef.current === pathAtStart
if (wasActiveAtStart) setIsSaving(true)
let pathToSave = pathAtStart
let contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, debouncedContent)
let renamedFrom: string | null = null
let renamedTo: string | null = null
try {
// Only rename the currently active file (avoids renaming/jumping while user switches rapidly)
if (
wasActiveAtStart &&
selectedPathRef.current === pathAtStart &&
!renameInProgressRef.current &&
pathAtStart.startsWith('knowledge/')
) {
const currentBase = getBaseName(pathAtStart)
if (isUntitledPlaceholderName(currentBase)) {
const headingTitle = getHeadingTitle(debouncedContent)
const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null
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) {
let suffix = 1
while (true) {
const exists = await window.ipc.invoke('workspace:exists', { path: targetPath })
if (!exists.exists) break
targetPath = `${parentDir}/${desiredName}-${suffix}.md`
suffix += 1
}
renameInProgressRef.current = true
await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
pathToSave = targetPath
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) {
const rewrittenCachedContent = rewriteWikiLinksForRenamedFileInMarkdown(
cachedContent,
pathAtStart,
targetPath
)
editorContentByPathRef.current.delete(pathAtStart)
editorContentByPathRef.current.set(targetPath, rewrittenCachedContent)
setEditorContentByPath((prev) => {
const oldContent = prev[pathAtStart]
if (oldContent === undefined) return prev
const next = { ...prev }
delete next[pathAtStart]
next[targetPath] = rewriteWikiLinksForRenamedFileInMarkdown(
oldContent,
pathAtStart,
targetPath
)
return next
})
}
if (selectedPathRef.current === pathAtStart) {
const bodyForEditor = splitFrontmatter(contentToSave).body
editorContentRef.current = bodyForEditor
setEditorContent(bodyForEditor)
}
}
}
}
}
await window.ipc.invoke('workspace:writeFile', {
path: pathToSave,
data: contentToSave,
opts: { encoding: 'utf8' }
})
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).
if (renamedFrom && renamedTo) {
const fromPath = renamedFrom
const toPath = renamedTo
const replaceRenamedPath = (stack: ViewState[]) =>
stack.map((v) => (v.type === 'file' && v.path === fromPath ? ({ type: 'file', path: toPath } satisfies ViewState) : v))
setHistory({
back: replaceRenamedPath(historyRef.current.back),
forward: replaceRenamedPath(historyRef.current.forward),
})
if (selectedPathRef.current === fromPath) {
setSelectedPath(toPath)
}
}
// Only update "current file" UI state if we're still on this file
if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) {
initialContentRef.current = splitFrontmatter(contentToSave).body
setLastSaved(new Date())
}
} catch (err) {
console.error('Failed to save file:', err)
} finally {
renameInProgressRef.current = false
if (wasActiveAtStart && (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave)) {
setIsSaving(false)
}
}
}
saveFile()
}, [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 () => {
try {
const allRuns: RunListItem[] = []
let cursor: string | undefined = undefined
// Fetch all pages
do {
const result: ListRunsResponseType = await window.ipc.invoke('runs:list', { cursor })
allRuns.push(...result.runs)
cursor = result.nextCursor
} while (cursor)
// Filter for copilot runs only
const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot')
setRuns(copilotRuns)
} catch (err) {
console.error('Failed to load runs:', err)
}
}, [])
// Load runs on mount
useEffect(() => {
loadRuns()
}, [loadRuns])
// Load background tasks
const loadBackgroundTasks = useCallback(async () => {
try {
const [configResult, stateResult] = await Promise.all([
window.ipc.invoke('agent-schedule:getConfig', null),
window.ipc.invoke('agent-schedule:getState', null),
])
const tasks: BackgroundTaskItem[] = Object.entries(configResult.agents).map(([name, entry]) => {
const state = stateResult.agents[name]
return {
name,
description: entry.description,
schedule: entry.schedule,
enabled: entry.enabled ?? true,
startingMessage: entry.startingMessage,
status: state?.status,
nextRunAt: state?.nextRunAt,
lastRunAt: state?.lastRunAt,
lastError: state?.lastError,
runCount: state?.runCount ?? 0,
}
})
setBackgroundTasks(tasks)
} catch (err) {
console.error('Failed to load background tasks:', err)
}
}, [])
// Load background tasks on mount
useEffect(() => {
loadBackgroundTasks()
}, [loadBackgroundTasks])
// Handle toggling background task enabled state
const handleToggleBackgroundTask = useCallback(async (taskName: string, enabled: boolean) => {
const task = backgroundTasks.find(t => t.name === taskName)
if (!task) return
try {
await window.ipc.invoke('agent-schedule:updateAgent', {
agentName: taskName,
entry: {
schedule: task.schedule,
enabled,
startingMessage: task.startingMessage,
description: task.description,
},
})
// Reload to get updated state
await loadBackgroundTasks()
} catch (err) {
console.error('Failed to update background task:', err)
}
}, [backgroundTasks, loadBackgroundTasks])
// Load a specific run and populate conversation
const loadRun = useCallback(async (id: string) => {
const requestId = (loadRunRequestIdRef.current += 1)
try {
const run = await window.ipc.invoke('runs:fetch', { runId: id })
if (loadRunRequestIdRef.current !== requestId) return
// Parse the log events into conversation items
const items: ConversationItem[] = []
const toolCallMap = new Map<string, ToolCall>()
for (const event of run.log) {
switch (event.type) {
case 'message': {
const msg = event.message
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)) {
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 contentParts) {
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
const toolCall: ToolCall = {
id: part.toolCallId,
name: part.toolName,
input: normalizeToolInput(part.arguments),
status: 'pending',
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
}
toolCallMap.set(toolCall.id, toolCall)
items.push(toolCall)
}
}
}
}
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(),
})
}
}
break
}
case 'tool-invocation': {
// Update existing tool call status or create new one
const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
if (existingTool) {
existingTool.input = normalizeToolInput(event.input)
existingTool.status = 'running'
} else {
const toolCall: ToolCall = {
id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`,
name: event.toolName,
input: normalizeToolInput(event.input),
status: 'running',
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
}
toolCallMap.set(toolCall.id, toolCall)
items.push(toolCall)
}
break
}
case 'tool-result': {
const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
if (existingTool) {
existingTool.result = event.result
existingTool.status = 'completed'
}
break
}
case 'error': {
items.push({
id: `error-${Date.now()}-${Math.random()}`,
kind: 'error',
message: event.error,
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
})
break
}
case 'llm-stream-event': {
// We don't need to reconstruct streaming events for history
// Reasoning is captured in the final message
break
}
}
}
if (loadRunRequestIdRef.current !== requestId) return
// Track permission requests and responses from history
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
const permResponseMap = new Map<string, 'approve' | 'deny'>()
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
const respondedAskHumanIds = new Set<string>()
for (const event of run.log) {
if (event.type === 'tool-permission-request') {
allPermissionRequests.set(event.toolCall.toolCallId, event)
} else if (event.type === 'tool-permission-response') {
permResponseMap.set(event.toolCallId, event.response)
} else if (event.type === 'ask-human-request') {
askHumanRequests.set(event.toolCallId, event)
} else if (event.type === 'ask-human-response') {
respondedAskHumanIds.add(event.toolCallId)
}
}
if (loadRunRequestIdRef.current !== requestId) return
// Separate pending vs responded permission requests
const pendingPerms = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
for (const [id, req] of allPermissionRequests.entries()) {
if (!permResponseMap.has(id)) {
pendingPerms.set(id, req)
}
}
const pendingAsks = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
for (const [id, req] of askHumanRequests.entries()) {
if (!respondedAskHumanIds.has(id)) {
pendingAsks.set(id, req)
}
}
if (loadRunRequestIdRef.current !== requestId) return
// Set the conversation and runId
setConversation(items)
setRunId(id)
setMessage('')
setPendingPermissionRequests(pendingPerms)
setPendingAskHumanRequests(pendingAsks)
setAllPermissionRequests(allPermissionRequests)
setPermissionResponses(permResponseMap)
} catch (err) {
console.error('Failed to load run:', err)
}
}, [])
const getStreamingBuffer = useCallback((id: string) => {
const existing = streamingBuffersRef.current.get(id)
if (existing) return existing
const next = { assistant: '' }
streamingBuffersRef.current.set(id, next)
return next
}, [])
const appendStreamingBuffer = useCallback((id: string, delta: string) => {
if (!delta) return
const buffer = getStreamingBuffer(id)
buffer.assistant += delta
}, [getStreamingBuffer])
const clearStreamingBuffer = useCallback((id: string) => {
streamingBuffersRef.current.delete(id)
}, [])
const handleRunEvent = useCallback((event: RunEventType) => {
const activeRunId = runIdRef.current
const isActiveRun = event.runId === activeRunId
console.log('Run event:', event.type, event)
switch (event.type) {
case 'run-processing-start':
setProcessingRunIds(prev => {
const next = new Set(prev)
next.add(event.runId)
return next
})
if (!isActiveRun) return
setIsProcessing(true)
setModelUsage(null)
// Reset voice buffer for new response
voiceTextBufferRef.current = ''
spokenIndexRef.current = 0
break
case 'run-processing-end':
setProcessingRunIds(prev => {
const next = new Set(prev)
next.delete(event.runId)
return next
})
void loadRuns()
clearStreamingBuffer(event.runId)
if (!isActiveRun) return
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
break
case 'start':
setProcessingRunIds(prev => {
if (prev.has(event.runId)) return prev
const next = new Set(prev)
next.add(event.runId)
return next
})
if (!isActiveRun) return
setIsProcessing(true)
setCurrentAssistantMessage('')
setModelUsage(null)
break
case 'llm-stream-event':
{
const llmEvent = event.event
// Fallback: if processing-start is missed/out-of-order, stream activity still means run is active.
setProcessingRunIds(prev => {
if (prev.has(event.runId)) return prev
const next = new Set(prev)
next.add(event.runId)
return next
})
if (!isActiveRun) {
if (llmEvent.type === 'text-delta' && llmEvent.delta) {
appendStreamingBuffer(event.runId, llmEvent.delta)
}
return
}
setIsProcessing(true)
if (llmEvent.type === 'text-delta' && llmEvent.delta) {
appendStreamingBuffer(event.runId, llmEvent.delta)
setCurrentAssistantMessage(prev => prev + llmEvent.delta)
// Extract <voice> tags and send to TTS when enabled
voiceTextBufferRef.current += llmEvent.delta
const remaining = voiceTextBufferRef.current.substring(spokenIndexRef.current)
const voiceRegex = /<voice>([\s\S]*?)<\/voice>/g
let voiceMatch: RegExpExecArray | null
while ((voiceMatch = voiceRegex.exec(remaining)) !== null) {
const voiceContent = voiceMatch[1].trim()
console.log('[voice] extracted voice tag:', voiceContent)
if (voiceContent && ttsEnabledRef.current) {
ttsRef.current.speak(voiceContent)
}
spokenIndexRef.current += voiceMatch.index + voiceMatch[0].length
}
} else if (llmEvent.type === 'tool-call') {
setConversation(prev => [...prev, {
id: llmEvent.toolCallId || `tool-${Date.now()}`,
name: llmEvent.toolName || 'tool',
input: normalizeToolInput(llmEvent.input as ToolUIPart['input']),
status: 'running',
timestamp: Date.now(),
}])
} else if (llmEvent.type === 'finish-step') {
const nextUsage = normalizeUsage(llmEvent.usage)
if (nextUsage) {
setModelUsage(nextUsage)
}
}
}
break
case 'message':
{
const msg = event.message
if (msg.role === 'user' && typeof msg.content === 'string') {
const inferredTitle = inferRunTitleFromMessage(msg.content)
if (inferredTitle) {
setRuns(prev => prev.map(run => (
run.id === event.runId && !run.title
? { ...run, title: inferredTitle }
: run
)))
}
}
if (!isActiveRun) {
if (msg.role === 'assistant') {
clearStreamingBuffer(event.runId)
}
return
}
if (msg.role === 'assistant') {
setCurrentAssistantMessage(currentMsg => {
if (currentMsg) {
const cleanedContent = currentMsg.replace(/<\/?voice>/g, '')
setConversation(prev => {
const exists = prev.some(m =>
m.id === event.messageId && 'role' in m && m.role === 'assistant'
)
if (exists) return prev
return [...prev, {
id: event.messageId,
role: 'assistant',
content: cleanedContent,
timestamp: Date.now(),
}]
})
}
return ''
})
clearStreamingBuffer(event.runId)
}
}
break
case 'tool-invocation':
{
if (!isActiveRun) return
const parsedInput = normalizeToolInput(event.input)
setConversation(prev => {
let matched = false
const next = prev.map(item => {
if (
isToolCall(item)
&& (event.toolCallId ? item.id === event.toolCallId : item.name === event.toolName)
) {
matched = true
return { ...item, input: parsedInput, status: 'running' as const }
}
return item
})
if (!matched) {
next.push({
id: event.toolCallId ?? `tool-${Date.now()}`,
name: event.toolName,
input: parsedInput,
status: 'running',
timestamp: Date.now(),
})
}
return next
})
break
}
case 'tool-result':
{
if (!isActiveRun) return
setConversation(prev => {
let matched = false
const next = prev.map(item => {
if (
isToolCall(item)
&& (event.toolCallId ? item.id === event.toolCallId : item.name === event.toolName)
) {
matched = true
return {
...item,
result: event.result as ToolUIPart['output'],
status: 'completed' as const,
}
}
return item
})
if (!matched) {
next.push({
id: event.toolCallId ?? `tool-${Date.now()}`,
name: event.toolName,
input: {},
result: event.result as ToolUIPart['output'],
status: 'completed',
timestamp: Date.now(),
})
}
return next
})
// Handle app-navigation tool results — trigger UI side effects
if (event.toolName === 'app-navigation') {
const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined
if (result?.success) {
pendingAppNavRef.current = result
}
}
break
}
case 'tool-permission-request': {
if (!isActiveRun) return
const key = event.toolCall.toolCallId
setPendingPermissionRequests(prev => {
const next = new Map(prev)
next.set(key, event)
return next
})
setAllPermissionRequests(prev => {
const next = new Map(prev)
next.set(key, event)
return next
})
break
}
case 'tool-permission-response': {
if (!isActiveRun) return
setPendingPermissionRequests(prev => {
const next = new Map(prev)
next.delete(event.toolCallId)
return next
})
setPermissionResponses(prev => {
const next = new Map(prev)
next.set(event.toolCallId, event.response)
return next
})
break
}
case 'ask-human-request': {
if (!isActiveRun) return
const key = event.toolCallId
setPendingAskHumanRequests(prev => {
const next = new Map(prev)
next.set(key, event)
return next
})
break
}
case 'ask-human-response': {
if (!isActiveRun) return
setPendingAskHumanRequests(prev => {
const next = new Map(prev)
next.delete(event.toolCallId)
return next
})
break
}
case 'run-stopped':
setProcessingRunIds(prev => {
const next = new Set(prev)
next.delete(event.runId)
return next
})
clearStreamingBuffer(event.runId)
if (!isActiveRun) return
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
// Clear pending requests since they've been aborted
setPendingPermissionRequests(new Map())
setPendingAskHumanRequests(new Map())
// Flush any streaming content as a message
setCurrentAssistantMessage(currentMsg => {
if (currentMsg) {
setConversation(prev => [...prev, {
id: `assistant-stopped-${Date.now()}`,
role: 'assistant',
content: currentMsg,
timestamp: Date.now(),
}])
}
return ''
})
break
case 'error':
setProcessingRunIds(prev => {
const next = new Set(prev)
next.delete(event.runId)
return next
})
clearStreamingBuffer(event.runId)
if (!isActiveRun) return
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
setConversation(prev => [...prev, {
id: `error-${Date.now()}`,
kind: 'error',
message: event.error,
timestamp: Date.now(),
}])
toast.error(event.error.split('\n')[0] || 'Model error')
console.error('Run error:', event.error)
break
}
}, [appendStreamingBuffer, clearStreamingBuffer, loadRuns])
// Listen to run events - use refs/callbacks to avoid stale closure issues.
useEffect(() => {
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
handleRunEvent(event as RunEventType)
}) as (event: null) => void)
return cleanup
}, [handleRunEvent])
const handlePromptSubmit = async (
message: PromptInputMessage,
mentions?: FileMention[],
stagedAttachments: StagedAttachment[] = [],
searchEnabled?: boolean,
) => {
if (isProcessing) return
const submitTabId = activeChatTabIdRef.current
const { text } = message
const userMessage = text.trim()
const hasAttachments = stagedAttachments.length > 0
if (!userMessage && !hasAttachments) return
setMessage('')
const userMessageId = `user-${Date.now()}`
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(),
}])
setChatViewportAnchor(submitTabId, userMessageId)
try {
let currentRunId = runId
let isNewRun = false
let newRunCreatedAt: string | null = null
if (!currentRunId) {
const run = await window.ipc.invoke('runs:create', {
agentId,
})
currentRunId = run.id
newRunCreatedAt = run.createdAt
setRunId(currentRunId)
analytics.chatSessionCreated(currentRunId)
// Update active chat tab's runId to the new run
setChatTabs((prev) => prev.map((tab) => (
tab.id === submitTabId
? { ...tab, runId: currentRunId }
: tab
)))
isNewRun = true
}
let titleSource = userMessage
const hasMentions = (mentions?.length ?? 0) > 0
if (hasAttachments || hasMentions) {
type ContentPart =
| { type: 'text'; text: string }
| {
type: 'attachment'
path: string
filename: string
mimeType: string
size?: number
lineNumber?: 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',
...(mention.lineNumber !== undefined ? { lineNumber: mention.lineNumber } : {}),
})
}
}
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 ?? mentions?.[0]?.displayName ?? mentions?.[0]?.path ?? ''
}
// 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,
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
})
analytics.chatMessageSent({
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
})
} else {
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: userMessage,
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
})
analytics.chatMessageSent({
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
})
}
pendingVoiceInputRef.current = false
if (isNewRun) {
const inferredTitle = inferRunTitleFromMessage(titleSource)
setRuns((prev) => {
const withoutCurrent = prev.filter((run) => run.id !== currentRunId)
return [{
id: currentRunId!,
title: inferredTitle,
createdAt: newRunCreatedAt ?? new Date().toISOString(),
agentId,
}, ...withoutCurrent]
})
}
} catch (error) {
console.error('Failed to send message:', error)
}
}
handlePromptSubmitRef.current = handlePromptSubmit
const handleComposioConnected = useCallback((toolkitSlug: string) => {
// Auto-send a continuation message when a Composio toolkit connects
const name = composioDisplayNames[toolkitSlug] || toolkitSlug
handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] })
}, [])
const handleStop = useCallback(async () => {
if (!runId) return
const now = Date.now()
const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000
setStopClickedAt(now)
setIsStopping(true)
try {
await window.ipc.invoke('runs:stop', { runId, force: isForce })
} catch (error) {
console.error('Failed to stop run:', error)
}
}, [runId, isStopping, stopClickedAt])
const handlePermissionResponse = useCallback(async (
toolCallId: string,
subflow: string[],
response: 'approve' | 'deny',
scope?: 'once' | 'session' | 'always',
) => {
if (!runId) return
// Optimistically update the UI immediately
setPermissionResponses(prev => {
const next = new Map(prev)
next.set(toolCallId, response)
return next
})
setPendingPermissionRequests(prev => {
const next = new Map(prev)
next.delete(toolCallId)
return next
})
try {
await window.ipc.invoke('runs:authorizePermission', {
runId,
authorization: { subflow, toolCallId, response, scope }
})
} catch (error) {
console.error('Failed to authorize permission:', error)
// Revert the optimistic update on error
setPermissionResponses(prev => {
const next = new Map(prev)
next.delete(toolCallId)
return next
})
}
}, [runId])
const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
if (!runId) return
try {
await window.ipc.invoke('runs:provideHumanInput', {
runId,
reply: { subflow, toolCallId, response }
})
} catch (error) {
console.error('Failed to provide human input:', error)
}
}, [runId])
const handleNewChat = useCallback(() => {
// Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in)
loadRunRequestIdRef.current += 1
setConversation([])
setCurrentAssistantMessage('')
setRunId(null)
setMessage('')
setModelUsage(null)
setIsProcessing(false)
setPendingPermissionRequests(new Map())
setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
setSelectedBackgroundTask(null)
setChatViewportAnchor(activeChatTabIdRef.current, null)
setChatViewStateByTab(prev => ({
...prev,
[activeChatTabIdRef.current]: createEmptyChatTabViewState(),
}))
}, [setChatViewportAnchor])
// Chat tab operations
const applyChatTab = useCallback((tab: ChatTab) => {
if (tab.runId) {
loadRun(tab.runId)
} else {
loadRunRequestIdRef.current += 1
setConversation([])
setCurrentAssistantMessage('')
setRunId(null)
setMessage('')
setModelUsage(null)
setIsProcessing(false)
setPendingPermissionRequests(new Map())
setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
setChatViewportAnchor(tab.id, null)
}
}, [loadRun, setChatViewportAnchor])
const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => {
const cached = chatViewStateByTabRef.current[tabId]
if (!cached) return false
// Ignore stale cache snapshots that don't match the tab's current run binding.
if (cached.runId !== fallbackRunId) return false
const resolvedRunId = fallbackRunId
setRunId(resolvedRunId)
setConversation(cached.conversation)
setCurrentAssistantMessage(cached.currentAssistantMessage)
const pendingPermissions = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
for (const [toolCallId, request] of cached.allPermissionRequests.entries()) {
if (!cached.permissionResponses.has(toolCallId)) {
pendingPermissions.set(toolCallId, request)
}
}
setPendingPermissionRequests(pendingPermissions)
setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
setAllPermissionRequests(new Map(cached.allPermissionRequests))
setPermissionResponses(new Map(cached.permissionResponses))
setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
return true
}, [])
const openChatInNewTab = useCallback((targetRunId: string) => {
cancelRecordingIfActive()
const existingTab = chatTabs.find(t => t.runId === targetRunId)
if (existingTab) {
// Cancel stale in-flight loads from previously focused tabs.
loadRunRequestIdRef.current += 1
setActiveChatTabId(existingTab.id)
const restored = restoreChatTabState(existingTab.id, existingTab.runId)
if (processingRunIdsRef.current.has(targetRunId) || !restored) {
loadRun(targetRunId)
}
return
}
const id = newChatTabId()
setChatTabs(prev => [...prev, { id, runId: targetRunId }])
setActiveChatTabId(id)
loadRun(targetRunId)
}, [chatTabs, loadRun, restoreChatTabState, cancelRecordingIfActive])
const switchChatTab = useCallback((tabId: string) => {
const tab = chatTabs.find(t => t.id === tabId)
if (!tab) return
if (tabId === activeChatTabId) return
// Cancel any active recording when switching tabs
if (isRecordingRef.current) {
voiceRef.current.cancel()
setIsRecording(false)
isRecordingRef.current = false
}
saveChatScrollForTab(activeChatTabId)
// Cancel stale in-flight loads from previously focused tabs.
loadRunRequestIdRef.current += 1
setActiveChatTabId(tabId)
const restored = restoreChatTabState(tabId, tab.runId)
if (tab.runId && processingRunIdsRef.current.has(tab.runId)) {
loadRun(tab.runId)
return
}
if (!restored) {
applyChatTab(tab)
}
}, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
const closeChatTab = useCallback((tabId: string) => {
if (chatTabs.length <= 1) return
const idx = chatTabs.findIndex(t => t.id === tabId)
if (idx === -1) return
saveChatScrollForTab(tabId)
const nextTabs = chatTabs.filter(t => t.id !== tabId)
setChatTabs(nextTabs)
setChatViewStateByTab(prev => {
if (!(tabId in prev)) return prev
const next = { ...prev }
delete next[tabId]
return next
})
chatDraftsRef.current.delete(tabId)
chatScrollTopByTabRef.current.delete(tabId)
setToolOpenByTab((prev) => {
if (!(tabId in prev)) return prev
const next = { ...prev }
delete next[tabId]
return next
})
if (tabId === activeChatTabId && nextTabs.length > 0) {
const newIdx = Math.min(idx, nextTabs.length - 1)
const newActiveTab = nextTabs[newIdx]
// Cancel stale in-flight loads from the closing tab.
loadRunRequestIdRef.current += 1
setActiveChatTabId(newActiveTab.id)
const restored = restoreChatTabState(newActiveTab.id, newActiveTab.runId)
if (newActiveTab.runId && processingRunIdsRef.current.has(newActiveTab.runId)) {
loadRun(newActiveTab.runId)
} else if (!restored) {
applyChatTab(newActiveTab)
}
}
}, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
useEffect(() => {
let cleanupScrollListener: (() => void) | undefined
let pollRaf: number | undefined
let restoreRafA: number | undefined
let restoreRafB: number | undefined
let restoreTimeout: ReturnType<typeof setTimeout> | undefined
let cancelled = false
const restoreScrollTop = (container: HTMLElement, top: number) => {
const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight)
const clampedTop = clampNumber(top, 0, maxScroll)
container.scrollTop = clampedTop
}
const attach = (): boolean => {
if (cancelled) return true
const container = getChatScrollContainer(activeChatTabId)
if (!container) return false
const savedTop = chatScrollTopByTabRef.current.get(activeChatTabId)
if (savedTop !== undefined) {
// Reinforce restoration across a couple frames because stick-to-bottom
// may schedule scroll adjustments during mount/resize.
restoreScrollTop(container, savedTop)
restoreRafA = requestAnimationFrame(() => {
restoreScrollTop(container, savedTop)
restoreRafB = requestAnimationFrame(() => {
restoreScrollTop(container, savedTop)
})
})
restoreTimeout = setTimeout(() => {
restoreScrollTop(container, savedTop)
}, 220)
}
const onScroll = () => {
chatScrollTopByTabRef.current.set(activeChatTabId, container.scrollTop)
}
container.addEventListener('scroll', onScroll, { passive: true })
cleanupScrollListener = () => {
chatScrollTopByTabRef.current.set(activeChatTabId, container.scrollTop)
container.removeEventListener('scroll', onScroll)
}
return true
}
let attempts = 0
const maxAttempts = 60
const pollAttach = () => {
if (cancelled) return
if (attach()) return
if (attempts >= maxAttempts) return
attempts += 1
pollRaf = requestAnimationFrame(pollAttach)
}
pollAttach()
return () => {
cancelled = true
cleanupScrollListener?.()
if (pollRaf !== undefined) cancelAnimationFrame(pollRaf)
if (restoreRafA !== undefined) cancelAnimationFrame(restoreRafA)
if (restoreRafB !== undefined) cancelAnimationFrame(restoreRafB)
if (restoreTimeout !== undefined) clearTimeout(restoreTimeout)
}
}, [
activeChatTabId,
selectedPath,
isGraphOpen,
isChatSidebarOpen,
isRightPaneMaximized,
getChatScrollContainer,
])
// File tab operations
const openFileInNewTab = useCallback((path: string) => {
const existingTab = fileTabs.find(t => t.path === path)
if (existingTab) {
setActiveFileTabId(existingTab.id)
setIsGraphOpen(false)
setSelectedPath(path)
return
}
const id = newFileTabId()
setFileTabs(prev => [...prev, { id, path }])
setActiveFileTabId(id)
setIsGraphOpen(false)
setSelectedPath(path)
}, [fileTabs])
const switchFileTab = useCallback((tabId: string) => {
const tab = fileTabs.find(t => t.id === tabId)
if (!tab) return
setActiveFileTabId(tabId)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
// If chat-only maximize is active, drop back to a visible knowledge layout.
if (isRightPaneMaximized) {
setIsRightPaneMaximized(false)
}
if (isGraphTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
return
}
if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
return
}
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized])
const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId)
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)
setIsSuggestedTopicsOpen(false)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
if (idx === -1) return prev
const next = prev.filter(t => t.id !== tabId)
if (tabId === activeFileTabId && next.length > 0) {
const newIdx = Math.min(idx, next.length - 1)
const newActiveTab = next[newIdx]
setActiveFileTabId(newActiveTab.id)
if (isGraphTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
} else {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setSelectedPath(newActiveTab.path)
}
}
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(() => {
// If there's already an empty "New chat" tab, switch to it
const emptyTab = chatTabs.find(t => !t.runId)
if (emptyTab) {
if (emptyTab.id !== activeChatTabId) {
setActiveChatTabId(emptyTab.id)
}
} else {
// Create a new tab
const id = newChatTabId()
setChatTabs(prev => [...prev, { id, runId: null }])
setActiveChatTabId(id)
}
handleNewChat()
// Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
} else {
setExpandedFrom(null)
}
setIsRightPaneMaximized(false)
setSelectedPath(null)
setIsGraphOpen(false)
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => {
const emptyTab = chatTabs.find(t => !t.runId)
if (emptyTab) {
if (emptyTab.id !== activeChatTabId) {
setActiveChatTabId(emptyTab.id)
}
} else {
const id = newChatTabId()
setChatTabs(prev => [...prev, { id, runId: null }])
setActiveChatTabId(id)
}
handleNewChat()
}, [chatTabs, activeChatTabId, handleNewChat])
// Palette → sidebar submission. Opens the sidebar (if closed), forces a fresh chat tab,
// queues the message; the pending-submit effect (below) flushes it once state has settled
// so handlePromptSubmit sees the new tab's null runId.
const submitFromPalette = useCallback((text: string, mention: CommandPaletteMention | null) => {
if (!isChatSidebarOpen) setIsChatSidebarOpen(true)
handleNewChatTabInSidebar()
setPendingPaletteSubmit({ text, mention })
}, [isChatSidebarOpen, handleNewChatTabInSidebar])
useEffect(() => {
if (!pendingPaletteSubmit) return
const fileMention: FileMention | undefined = pendingPaletteSubmit.mention
? {
id: `palette-${Date.now()}`,
path: pendingPaletteSubmit.mention.path,
displayName: pendingPaletteSubmit.mention.displayName,
lineNumber: pendingPaletteSubmit.mention.lineNumber,
}
: undefined
void handlePromptSubmitRef.current?.(
{ text: pendingPaletteSubmit.text, files: [] },
fileMention ? [fileMention] : undefined,
)
setPendingPaletteSubmit(null)
}, [pendingPaletteSubmit])
const toggleKnowledgePane = useCallback(() => {
setIsRightPaneMaximized(false)
setIsChatSidebarOpen(prev => !prev)
}, [])
const toggleRightPaneMaximize = useCallback(() => {
setIsChatSidebarOpen(true)
setIsRightPaneMaximized(prev => !prev)
}, [])
const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return
if (selectedPath || isGraphOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
}
setIsRightPaneMaximized(false)
setSelectedPath(null)
setIsGraphOpen(false)
}, [selectedPath, isGraphOpen])
const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) {
if (expandedFrom.graph) {
setIsGraphOpen(true)
} else if (expandedFrom.path) {
setSelectedPath(expandedFrom.path)
}
setExpandedFrom(null)
setIsRightPaneMaximized(false)
}
}, [expandedFrom])
const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
if (last && viewStatesEqual(last, entry)) return stack
return [...stack, entry]
}, [])
const ensureFileTabForPath = useCallback((path: string) => {
const existingTab = fileTabs.find((tab) => tab.path === path)
if (existingTab) {
setActiveFileTabId(existingTab.id)
return
}
if (activeFileTabId) {
const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId)
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
}
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path }])
setActiveFileTabId(id)
}, [fileTabs, activeFileTabId])
const ensureGraphFileTab = useCallback(() => {
const existingGraphTab = fileTabs.find((tab) => isGraphTabPath(tab.path))
if (existingGraphTab) {
setActiveFileTabId(existingGraphTab.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: GRAPH_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const ensureSuggestedTopicsFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isSuggestedTopicsTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: SUGGESTED_TOPICS_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) {
case 'file':
setSelectedBackgroundTask(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file.
if (isRightPaneMaximized) {
setIsRightPaneMaximized(false)
}
setSelectedPath(view.path)
ensureFileTabForPath(view.path)
return
case 'graph':
setSelectedBackgroundTask(null)
setSelectedPath(null)
setIsSuggestedTopicsOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
if (isRightPaneMaximized) {
setIsRightPaneMaximized(false)
}
return
case 'task':
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
return
case 'suggested-topics':
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true)
ensureSuggestedTopicsFileTab()
return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
if (view.runId) {
await loadRun(view.runId)
} else {
handleNewChat()
}
return
}
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
if (viewStatesEqual(current, nextView)) return
cancelRecordingIfActive()
const nextHistory = {
back: appendUnique(historyRef.current.back, current),
forward: [] as ViewState[],
}
setHistory(nextHistory)
await applyViewState(nextView)
}, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory])
const navigateBack = useCallback(async () => {
const { back, forward } = historyRef.current
if (back.length === 0) return
let i = back.length - 1
while (i >= 0 && viewStatesEqual(back[i], currentViewState)) i -= 1
if (i < 0) {
setHistory({ back: [], forward })
return
}
const target = back[i]
const nextHistory = {
back: back.slice(0, i),
forward: appendUnique(forward, currentViewState),
}
setHistory(nextHistory)
await applyViewState(target)
}, [appendUnique, applyViewState, currentViewState, setHistory])
const navigateForward = useCallback(async () => {
const { back, forward } = historyRef.current
if (forward.length === 0) return
let i = forward.length - 1
while (i >= 0 && viewStatesEqual(forward[i], currentViewState)) i -= 1
if (i < 0) {
setHistory({ back, forward: [] })
return
}
const target = forward[i]
const nextHistory = {
back: appendUnique(back, currentViewState),
forward: forward.slice(0, i),
}
setHistory(nextHistory)
await applyViewState(target)
}, [appendUnique, applyViewState, currentViewState, setHistory])
const canNavigateBack = React.useMemo(() => {
for (let i = viewHistory.back.length - 1; i >= 0; i--) {
if (!viewStatesEqual(viewHistory.back[i], currentViewState)) return true
}
return false
}, [viewHistory.back, currentViewState])
const canNavigateForward = React.useMemo(() => {
for (let i = viewHistory.forward.length - 1; i >= 0; i--) {
if (!viewStatesEqual(viewHistory.forward[i], currentViewState)) return true
}
return false
}, [viewHistory.forward, currentViewState])
const navigateToFile = useCallback((path: string) => {
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])
// External search set by app-navigation tool (passed to BasesView)
const [externalBaseSearch, setExternalBaseSearch] = useState<string | undefined>(undefined)
// Process pending app-navigation results
useEffect(() => {
const result = pendingAppNavRef.current
if (!result) return
pendingAppNavRef.current = null
switch (result.action) {
case 'open-note':
navigateToFile(result.path as string)
break
case 'open-view':
if (result.view === 'graph') void navigateToView({ type: 'graph' })
if (result.view === 'bases') {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}
break
case 'update-base-view': {
// Navigate to bases if not already there
const targetPath = selectedPath && isBaseFilePath(selectedPath) ? selectedPath : BASES_DEFAULT_TAB_PATH
if (!selectedPath || !isBaseFilePath(selectedPath)) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}
// Apply updates to the base config
const updates = result.updates as Record<string, unknown> | undefined
if (updates) {
setBaseConfigByPath(prev => {
const current = prev[targetPath] ?? { ...DEFAULT_BASE_CONFIG }
const next = { ...current }
// Apply filter updates
const filterUpdates = updates.filters as Record<string, unknown> | undefined
if (filterUpdates) {
if (filterUpdates.clear) {
next.filters = []
}
if (filterUpdates.set) {
next.filters = filterUpdates.set as Array<{ category: string; value: string }>
}
if (filterUpdates.add) {
const toAdd = filterUpdates.add as Array<{ category: string; value: string }>
const existing = next.filters
for (const f of toAdd) {
if (!existing.some(e => e.category === f.category && e.value === f.value)) {
existing.push(f)
}
}
}
if (filterUpdates.remove) {
const toRemove = filterUpdates.remove as Array<{ category: string; value: string }>
next.filters = next.filters.filter(
e => !toRemove.some(r => r.category === e.category && r.value === e.value)
)
}
}
// Apply column updates
const colUpdates = updates.columns as Record<string, unknown> | undefined
if (colUpdates) {
if (colUpdates.set) {
next.visibleColumns = colUpdates.set as string[]
}
if (colUpdates.add) {
const toAdd = colUpdates.add as string[]
for (const col of toAdd) {
if (!next.visibleColumns.includes(col)) next.visibleColumns.push(col)
}
}
if (colUpdates.remove) {
const toRemove = new Set(colUpdates.remove as string[])
next.visibleColumns = next.visibleColumns.filter(c => !toRemove.has(c))
}
}
// Apply sort
if (updates.sort) {
next.sort = updates.sort as { field: string; dir: 'asc' | 'desc' }
}
return { ...prev, [targetPath]: next }
})
// Apply search externally
if (updates.search !== undefined) {
setExternalBaseSearch(updates.search as string || undefined)
}
}
break
}
case 'create-base':
if (result.path) {
navigateToFile(result.path as string)
}
break
}
})
const navigateToFullScreenChat = useCallback(() => {
// Only treat this as navigation when coming from another view
if (currentViewState.type !== 'chat') {
const nextHistory = {
back: appendUnique(historyRef.current.back, currentViewState),
forward: [] as ViewState[],
}
setHistory(nextHistory)
}
handleOpenFullScreenChat()
}, [appendUnique, currentViewState, handleOpenFullScreenChat, setHistory])
// Handle image upload for the markdown editor
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
try {
// Read file as data URL (includes mime type)
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
// Also save to .assets folder for persistence
const timestamp = Date.now()
const extension = file.name.split('.').pop() || 'png'
const filename = `image-${timestamp}.${extension}`
const assetsPath = 'knowledge/.assets'
const imagePath = `${assetsPath}/${filename}`
try {
// Extract base64 data (remove data URL prefix)
const base64Data = dataUrl.split(',')[1]
await window.ipc.invoke('workspace:writeFile', {
path: imagePath,
data: base64Data,
opts: { encoding: 'base64', mkdirp: true }
})
} catch (err) {
console.error('Failed to save image to disk:', err)
// Continue anyway - image will still display via data URL
}
// Return data URL for immediate display in editor
return dataUrl
} catch (error) {
console.error('Failed to upload image:', error)
return null
}
}, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
e.preventDefault()
if (isFullScreenChat && expandedFrom) {
handleCloseFullScreenChat()
} else {
navigateToFullScreenChat()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
// Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode).
// If an editor tab is currently active, capture cursor context so Chat mode shows the
// note + line as a removable chip.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
const activeId = activeFileTabIdRef.current
const handle = activeId ? editorRefsByTabId.current.get(activeId) : null
setPaletteContext(handle?.getCursorContext() ?? null)
setIsSearchOpen(true)
}
}
document.addEventListener('keydown', handleKeyDown)
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) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen)
const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath
const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath
? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null)
: null
)
// Cmd+W — close active tab
if (e.key === 'w') {
e.preventDefault()
if (inFileView && targetFileTabId) {
closeFileTab(targetFileTabId)
} else {
closeChatTab(activeChatTabId)
}
return
}
// Cmd+1..9 — switch to tab N (Cmd+9 always goes to last tab)
if (/^[1-9]$/.test(e.key)) {
e.preventDefault()
const n = parseInt(e.key, 10)
if (inFileView) {
const idx = e.key === '9' ? fileTabs.length - 1 : n - 1
const tab = fileTabs[idx]
if (tab) switchFileTab(tab.id)
} else {
const idx = e.key === '9' ? chatTabs.length - 1 : n - 1
const tab = chatTabs[idx]
if (tab) switchChatTab(tab.id)
}
return
}
// Cmd+Shift+] — next tab, Cmd+Shift+[ — previous tab
if (e.shiftKey && (e.key === ']' || e.key === '[')) {
e.preventDefault()
const direction = e.key === ']' ? 1 : -1
if (inFileView) {
const currentIdx = fileTabs.findIndex(t => t.id === targetFileTabId)
if (currentIdx === -1) return
const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length
switchFileTab(fileTabs[nextIdx].id)
} else {
const currentIdx = chatTabs.findIndex(t => t.id === activeChatTabId)
if (currentIdx === -1) return
const nextIdx = (currentIdx + direction + chatTabs.length) % chatTabs.length
switchChatTab(chatTabs[nextIdx].id)
}
return
}
}
document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
navigateToFile(path)
return
}
// Top-level knowledge folders (except Notes) open as a bases view with folder filter
const parts = path.split('/')
if (parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes') {
const folderName = parts[1]
const folderCfg = FOLDER_BASE_CONFIGS[folderName]
setBaseConfigByPath((prev) => ({
...prev,
[BASES_DEFAULT_TAB_PATH]: {
...DEFAULT_BASE_CONFIG,
name: folderName,
filters: [{ category: 'folder', value: folderName }],
...(folderCfg && {
visibleColumns: folderCfg.visibleColumns,
sort: folderCfg.sort,
}),
},
}))
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
return
}
const newExpanded = new Set(expandedPaths)
if (newExpanded.has(path)) {
newExpanded.delete(path)
} else {
newExpanded.add(path)
}
setExpandedPaths(newExpanded)
}
// Knowledge quick actions
const knowledgeFiles = React.useMemo(() => {
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
return Array.from(new Set(files.map(stripKnowledgePrefix)))
}, [tree])
const knowledgeFilePaths = React.useMemo(() => (
knowledgeFiles.reduce<string[]>((acc, filePath) => {
const resolved = toKnowledgePath(filePath)
if (resolved) acc.push(resolved)
return acc
}, [])
), [knowledgeFiles])
// Compute visible files (files whose parent directories are expanded)
const visibleKnowledgeFiles = React.useMemo(() => {
const visible: string[] = []
const isPathVisible = (path: string) => {
const parts = path.split('/')
// Root level files in knowledge are always visible
if (parts.length <= 2) return true
// Check if all parent directories are expanded
for (let i = 1; i < parts.length - 1; i++) {
const parentPath = parts.slice(0, i + 1).join('/')
if (!expandedPaths.has(parentPath)) return false
}
return true
}
for (const file of knowledgeFiles) {
const fullPath = toKnowledgePath(file)
if (fullPath && isPathVisible(fullPath)) {
visible.push(file)
}
}
return visible
}, [knowledgeFiles, expandedPaths])
// Load workspace root on mount
useEffect(() => {
window.ipc.invoke('workspace:getRoot', null).then(result => {
setWorkspaceRoot(result.root)
})
}, [])
// Check onboarding status on mount
useEffect(() => {
async function checkOnboarding() {
try {
const result = await window.ipc.invoke('onboarding:getStatus', null)
setShowOnboarding(result.showOnboarding)
} catch (err) {
console.error('Failed to check onboarding status:', err)
}
}
checkOnboarding()
}, [])
// Handler for onboarding completion
const handleOnboardingComplete = useCallback(async () => {
try {
await window.ipc.invoke('onboarding:markComplete', null)
setShowOnboarding(false)
} catch (err) {
console.error('Failed to mark onboarding complete:', err)
setShowOnboarding(false)
}
}, [])
const knowledgeActions = React.useMemo(() => ({
createNote: async (parentPath: string = 'knowledge/Notes') => {
try {
let index = 0
let name = untitledBaseName
let fullPath = `${parentPath}/${name}.md`
while (index < 1000) {
const exists = await window.ipc.invoke('workspace:exists', { path: fullPath })
if (!exists.exists) break
index += 1
name = `${untitledBaseName}-${index}`
fullPath = `${parentPath}/${name}.md`
}
await window.ipc.invoke('workspace:writeFile', {
path: fullPath,
data: `# ${name}\n\n`,
opts: { encoding: 'utf8' }
})
navigateToFile(fullPath)
} catch (err) {
console.error('Failed to create note:', err)
throw err
}
},
createFolder: async (parentPath: string = 'knowledge/Notes') => {
try {
await window.ipc.invoke('workspace:mkdir', {
path: `${parentPath}/new-folder-${Date.now()}`,
recursive: true
})
} catch (err) {
console.error('Failed to create folder:', err)
throw err
}
},
openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
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) => {
try {
const parts = oldPath.split('/')
// For files, ensure .md extension
const finalName = isDir ? newName : (newName.endsWith('.md') ? newName : `${newName}.md`)
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)
initialContentByPathRef.current.set(newPath, rewriteForRename(baseline))
}
const cachedContent = editorContentByPathRef.current.get(oldPath)
if (cachedContent !== undefined) {
const rewrittenCachedContent = rewriteForRename(cachedContent)
editorContentByPathRef.current.delete(oldPath)
editorContentByPathRef.current.set(newPath, rewrittenCachedContent)
setEditorContentByPath(prev => {
if (!(oldPath in prev)) return prev
const next = { ...prev }
delete next[oldPath]
next[newPath] = rewriteForRename(cachedContent)
return next
})
}
if (selectedPath === oldPath) {
const rewrittenEditorContent = rewriteForRename(editorContentRef.current)
editorContentRef.current = rewrittenEditorContent
setEditorContent(rewrittenEditorContent)
initialContentRef.current = rewriteForRename(initialContentRef.current)
}
if (selectedPath === oldPath) setSelectedPath(newPath)
} catch (err) {
console.error('Failed to rename:', err)
throw err
}
},
remove: async (path: string) => {
try {
await window.ipc.invoke('workspace:remove', { path, opts: { trash: true } })
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)
if (tabForFile) {
closeFileTab(tabForFile.id)
} else if (selectedPath === path) {
setSelectedPath(null)
}
} catch (err) {
console.error('Failed to remove:', err)
throw err
}
},
copyPath: (path: string) => {
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
navigator.clipboard.writeText(fullPath).catch(() => {
const textarea = document.createElement('textarea')
textarea.value = fullPath
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
})
},
onOpenInNewTab: (path: string) => {
openFileInNewTab(path)
},
}), [tree, selectedPath, isGraphOpen, selectedBackgroundTask, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath])
// Handler for when a voice note is created/updated
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
// Refresh the tree to show the new file/folder
const newTree = await loadDirectory()
setTree(newTree)
// Expand parent directories to show the file
const parts = notePath.split('/')
const parentPaths: string[] = []
for (let i = 1; i < parts.length; i++) {
parentPaths.push(parts.slice(0, i).join('/'))
}
setExpandedPaths(prev => {
const newSet = new Set(prev)
parentPaths.forEach(p => newSet.add(p))
return newSet
})
// If tab already exists for this path (e.g. second call after transcription),
// force a content reload instead of creating a duplicate tab.
const existingTab = fileTabs.find(tab => tab.path === notePath)
if (existingTab) {
setActiveFileTabId(existingTab.id)
// Read fresh content from disk and update the editor
try {
const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' })
const { raw: fm, body } = splitFrontmatter(result.data)
frontmatterByPathRef.current.set(notePath, fm)
setFileContent(body)
setEditorContent(body)
editorContentRef.current = body
editorPathRef.current = notePath
initialContentRef.current = body
initialContentByPathRef.current.set(notePath, body)
setEditorContentByPath(prev => ({ ...prev, [notePath]: body }))
editorContentByPathRef.current.set(notePath, body)
// Bump editor session to force TipTap to pick up the new content
setEditorSessionByTabId(prev => ({
...prev,
[existingTab.id]: (prev[existingTab.id] ?? 0) + 1,
}))
} catch {
// File read failed — ignore
}
return
}
// First call — open the file in a tab
navigateToFile(notePath)
}, [loadDirectory, navigateToFile, fileTabs])
const meetingNotePathRef = useRef<string | null>(null)
const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(undefined)
const [meetingSummarizing, setMeetingSummarizing] = useState(false)
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
const [checkingPermission, setCheckingPermission] = useState(false)
const startMeetingNow = useCallback(async () => {
const calEvent = pendingCalendarEventRef.current
pendingCalendarEventRef.current = undefined
const notePath = await meetingTranscription.start(calEvent)
if (notePath) {
meetingNotePathRef.current = notePath
await handleVoiceNoteCreated(notePath)
}
}, [meetingTranscription, handleVoiceNoteCreated])
const handleCheckPermissionAndRetry = useCallback(async () => {
setCheckingPermission(true)
try {
const { granted } = await window.ipc.invoke('meeting:checkScreenPermission', null)
if (granted) {
setShowMeetingPermissions(false)
await startMeetingNow()
}
} finally {
setCheckingPermission(false)
}
}, [startMeetingNow])
const handleOpenScreenRecordingSettings = useCallback(async () => {
await window.ipc.invoke('meeting:openScreenRecordingSettings', null)
}, [])
const handleToggleMeeting = useCallback(async () => {
if (meetingTranscription.state === 'recording') {
await meetingTranscription.stop()
// Read the final transcript and generate meeting notes via LLM
const notePath = meetingNotePathRef.current
if (notePath) {
setMeetingSummarizing(true)
try {
const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' })
const fileContent = result.data
if (fileContent && fileContent.trim()) {
// Extract meeting start time and calendar event from frontmatter
const dateMatch = fileContent.match(/^date:\s*"(.+)"$/m)
const meetingStartTime = dateMatch?.[1]
// If a calendar event was linked, pass it directly so the summarizer
// skips scanning and uses this event for attendee/title info.
const calEventMatch = fileContent.match(/^calendar_event:\s*'(.+)'$/m)
const calendarEventJson = calEventMatch?.[1]?.replace(/''/g, "'")
const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime, calendarEventJson })
if (notes) {
// Prepend meeting notes above the existing transcript block
const { raw: fm, body } = splitFrontmatter(fileContent)
const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m)
const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting Notes'
const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '')
// Extract the existing transcript block and preserve it as-is
const transcriptBlockMatch = body.match(/(```transcript\n[\s\S]*?\n```)/)
const transcriptBlock = transcriptBlockMatch?.[1] || ''
const newBody = `# ${noteTitle}\n\n` + cleanedNotes + (transcriptBlock ? '\n\n' + transcriptBlock : '')
const newContent = fm ? `${fm}\n${newBody}` : newBody
await window.ipc.invoke('workspace:writeFile', {
path: notePath,
data: newContent,
opts: { encoding: 'utf8' },
})
// Refresh the file view
await handleVoiceNoteCreated(notePath)
}
}
} catch (err) {
console.error('[meeting] Failed to generate meeting notes:', err)
}
setMeetingSummarizing(false)
meetingNotePathRef.current = null
}
} else if (meetingTranscription.state === 'idle') {
// On macOS, check screen recording permission before starting
if (isMac) {
const result = await window.ipc.invoke('meeting:checkScreenPermission', null)
console.log('[meeting] Permission check result:', result)
if (!result.granted) {
setShowMeetingPermissions(true)
return
}
}
await startMeetingNow()
}
}, [meetingTranscription, handleVoiceNoteCreated, startMeetingNow])
handleToggleMeetingRef.current = handleToggleMeeting
// Listen for calendar block "join meeting & take notes" events
useEffect(() => {
const handler = () => {
// Read calendar event data set by the calendar block on window
const pending = window.__pendingCalendarEvent
window.__pendingCalendarEvent = undefined
if (pending) {
pendingCalendarEventRef.current = {
summary: pending.summary,
start: pending.start,
end: pending.end,
location: pending.location,
htmlLink: pending.htmlLink,
conferenceLink: pending.conferenceLink,
source: pending.source,
}
}
// Use the same toggle flow — it will pick up pendingCalendarEventRef
handleToggleMeetingRef.current?.()
}
window.addEventListener('calendar-block:join-meeting', handler)
return () => window.removeEventListener('calendar-block:join-meeting', handler)
}, [])
// Email block: draft with assistant
useEffect(() => {
const handler = () => {
const pending = window.__pendingEmailDraft
if (pending) {
setPresetMessage(pending.prompt)
setIsChatSidebarOpen(true)
window.__pendingEmailDraft = undefined
}
}
window.addEventListener('email-block:draft-with-assistant', handler)
return () => window.removeEventListener('email-block:draft-with-assistant', handler)
}, [])
const ensureWikiFile = useCallback(async (wikiPath: string) => {
const resolvedPath = toKnowledgePath(wikiPath)
if (!resolvedPath) return null
try {
const exists = await window.ipc.invoke('workspace:exists', { path: resolvedPath })
if (!exists.exists) {
const title = wikiLabel(wikiPath) || 'New Note'
await window.ipc.invoke('workspace:writeFile', {
path: resolvedPath,
data: `# ${title}\n\n`,
opts: { encoding: 'utf8', mkdirp: true },
})
}
return resolvedPath
} catch (err) {
console.error('Failed to ensure wiki link target:', err)
return null
}
}, [])
const openWikiLink = useCallback(async (wikiPath: string) => {
const resolvedPath = await ensureWikiFile(wikiPath)
if (resolvedPath) {
navigateToFile(resolvedPath)
}
}, [ensureWikiFile, navigateToFile])
const wikiLinkConfig = React.useMemo(() => ({
files: knowledgeFiles,
recent: recentWikiFiles,
onOpen: (path: string) => {
void openWikiLink(path)
},
onCreate: (path: string) => {
void ensureWikiFile(path)
},
}), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile])
useEffect(() => {
if (!isGraphOpen) return
let cancelled = false
const buildGraph = async () => {
setGraphStatus('loading')
setGraphError(null)
if (knowledgeFilePaths.length === 0) {
setGraphData({ nodes: [], edges: [] })
setGraphStatus('ready')
return
}
const graphFilePaths = knowledgeFilePaths.filter((p) => {
const normalized = stripKnowledgePrefix(p)
return !normalized.toLowerCase().startsWith('meetings/')
})
const nodeSet = new Set(graphFilePaths)
const edges: GraphEdge[] = []
const edgeKeys = new Set<string>()
const contents = await Promise.all(
graphFilePaths.map(async (path) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path })
return { path, data: result.data as string }
} catch (err) {
console.error('Failed to read file for graph:', path, err)
return { path, data: '' }
}
})
)
for (const { path, data } of contents) {
for (const match of data.matchAll(wikiLinkRegex)) {
const rawTarget = match[1]?.trim() ?? ''
const targetPath = toKnowledgePath(rawTarget)
if (!targetPath || targetPath === path) continue
if (!nodeSet.has(targetPath)) continue
const edgeKey = path < targetPath ? `${path}|${targetPath}` : `${targetPath}|${path}`
if (edgeKeys.has(edgeKey)) continue
edgeKeys.add(edgeKey)
edges.push({ source: path, target: targetPath })
}
}
const degreeMap = new Map<string, number>()
edges.forEach((edge) => {
degreeMap.set(edge.source, (degreeMap.get(edge.source) ?? 0) + 1)
degreeMap.set(edge.target, (degreeMap.get(edge.target) ?? 0) + 1)
})
const groupIndexMap = new Map<string, number>()
const getGroupIndex = (group: string) => {
const existing = groupIndexMap.get(group)
if (existing !== undefined) return existing
const nextIndex = groupIndexMap.size
groupIndexMap.set(group, nextIndex)
return nextIndex
}
const getNodeGroup = (path: string) => {
const normalized = stripKnowledgePrefix(path)
const parts = normalized.split('/').filter(Boolean)
if (parts.length <= 1) {
return { group: 'root', depth: 0 }
}
return {
group: parts[0],
depth: Math.max(0, parts.length - 2),
}
}
const getNodeColors = (groupIndex: number, depth: number) => {
const base = graphPalette[groupIndex % graphPalette.length]
const light = clampNumber(base.light + depth * 6, 36, 72)
const strokeLight = clampNumber(light - 12, 28, 60)
return {
fill: `hsl(${base.hue} ${base.sat}% ${light}%)`,
stroke: `hsl(${base.hue} ${Math.min(80, base.sat + 8)}% ${strokeLight}%)`,
}
}
const nodes = graphFilePaths.map((path) => {
const degree = degreeMap.get(path) ?? 0
const radius = 6 + Math.min(18, degree * 2)
const { group, depth } = getNodeGroup(path)
const groupIndex = getGroupIndex(group)
const colors = getNodeColors(groupIndex, depth)
return {
id: path,
label: wikiLabel(path) || path,
degree,
radius,
group,
color: colors.fill,
stroke: colors.stroke,
}
})
if (!cancelled) {
setGraphData({ nodes, edges })
setGraphStatus('ready')
}
}
buildGraph().catch((err) => {
if (cancelled) return
console.error('Failed to build graph:', err)
setGraphStatus('error')
setGraphError(err instanceof Error ? err.message : 'Failed to build graph')
})
return () => {
cancelled = true
}
}, [isGraphOpen, knowledgeFilePaths])
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
return (
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
<ChatMessageAttachments attachments={item.attachments} />
</MessageContent>
{item.content && (
<MessageContent>{item.content}</MessageContent>
)}
</Message>
)
}
const { message, files } = parseAttachedFiles(item.content)
return (
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{files.map((filePath, index) => (
<span
key={index}
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full"
>
@{wikiLabel(filePath)}
</span>
))}
</div>
)}
{message}
</MessageContent>
</Message>
)
}
return (
<Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent>
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
</MessageContent>
</Message>
)
}
if (isToolCall(item)) {
const appActionData = getAppActionCardData(item)
if (appActionData) {
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
}
const webSearchData = getWebSearchCardData(item)
if (webSearchData) {
return (
<WebSearchResult
key={item.id}
query={webSearchData.query}
results={webSearchData.results}
status={item.status}
title={webSearchData.title}
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
// Skip rendering if this is a duplicate "already connected" card
if (composioConnectData.hidden) return null
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={handleComposioConnected}
/>
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
<Tool
key={item.id}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
>
<ToolHeader
title={toolTitle}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
<ToolContent>
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)
}
if (isErrorMessage(item)) {
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
</MessageContent>
</Message>
)
}
return null
}
const activeChatTabState = React.useMemo<ChatTabViewState>(() => ({
runId,
conversation,
currentAssistantMessage,
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
}), [
runId,
conversation,
currentAssistantMessage,
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
])
const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
if (tabId === activeChatTabId) return activeChatTabState
return chatViewStateByTab[tabId] ?? emptyChatTabState
}, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
const hasConversation = activeChatTabState.conversation.length > 0 || activeChatTabState.currentAssistantMessage
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => {
const markdownTabs = fileTabs.filter(tab => tab.path.endsWith('.md'))
if (selectedPath?.endsWith('.md')) {
const hasSelectedTab = markdownTabs.some(tab => tab.path === selectedPath)
if (!hasSelectedTab) {
return [...markdownTabs, { id: '__active-markdown-tab__', path: selectedPath }]
}
}
return markdownTabs
}, [fileTabs, selectedPath])
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
if (section === 'knowledge' && !selectedPath && !isGraphOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}
}}>
<div className="flex h-svh w-full overflow-hidden">
{/* Content sidebar with SidebarProvider for collapse functionality */}
<SidebarProvider
style={{
"--sidebar-width": `${DEFAULT_SIDEBAR_WIDTH}px`,
} as React.CSSProperties}
>
<SidebarContentPanel
tree={tree}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
onSelectFile={toggleExpand}
knowledgeActions={knowledgeActions}
onVoiceNoteCreated={handleVoiceNoteCreated}
runs={runs}
currentRunId={runId}
processingRunIds={processingRunIds}
tasksActions={{
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive()
if (selectedPath || isGraphOpen) {
setIsChatSidebarOpen(true)
}
// If already open in a chat tab, switch to it
const existingTab = chatTabs.find(t => t.runId === runIdToLoad)
if (existingTab) {
switchChatTab(existingTab.id)
return
}
// In two-pane mode, keep current knowledge/graph context and just swap chat context.
if (selectedPath || isGraphOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad)
return
}
// Outside two-pane mode, navigate to chat.
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
void navigateToView({ type: 'chat', runId: runIdToLoad })
},
onOpenInNewTab: (targetRunId) => {
openChatInNewTab(targetRunId)
},
onDeleteRun: async (runIdToDelete) => {
try {
await window.ipc.invoke('runs:delete', { runId: runIdToDelete })
// Close any chat tab showing the deleted run
const tabForRun = chatTabs.find(t => t.runId === runIdToDelete)
if (tabForRun) {
if (chatTabs.length > 1) {
closeChatTab(tabForRun.id)
} else {
// Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen) {
handleNewChat()
} else {
void navigateToView({ type: 'chat', runId: null })
}
}
} else if (runId === runIdToDelete) {
if (selectedPath || isGraphOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat()
} else {
void navigateToView({ type: 'chat', runId: null })
}
}
await loadRuns()
} catch (err) {
console.error('Failed to delete run:', err)
}
},
onSelectBackgroundTask: (taskName) => {
void navigateToView({ type: 'task', name: taskName })
},
}}
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
/>
<SidebarInset
className={cn(
"overflow-hidden! min-h-0 min-w-0 transition-[max-width] duration-200 ease-linear",
shouldCollapseLeftPane && "pointer-events-none select-none"
)}
style={shouldCollapseLeftPane ? { maxWidth: 0 } : { maxWidth: '100%' }}
aria-hidden={shouldCollapseLeftPane}
onMouseDownCapture={() => setActiveShortcutPane('left')}
onFocusCapture={() => setActiveShortcutPane('left')}
>
{/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */}
<ContentHeader
onNavigateBack={() => { void navigateBack() }}
onNavigateForward={() => { void navigateForward() }}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
getTabTitle={getFileTabTitle}
getTabId={(t) => t.id}
onSwitchTab={switchFileTab}
onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/>
) : (
<TabBar
tabs={chatTabs}
activeTabId={activeChatTabId}
getTabTitle={getChatTabTitle}
getTabId={(t) => t.id}
isProcessing={isChatTabProcessing}
onSwitchTab={switchChatTab}
onCloseTab={closeChatTab}
/>
)}
{selectedPath && selectedPath.endsWith('.md') && (
<div className="flex items-center gap-1 text-xs text-muted-foreground self-center shrink-0 pl-2">
{isSaving ? (
<>
<LoaderIcon className="h-3 w-3 animate-spin" />
<span>Saving...</span>
</>
) : lastSaved ? (
<>
<CheckIcon className="h-3 w-3 text-green-500" />
<span>Saved</span>
</>
) : null}
</div>
)}
{selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (versionHistoryPath) {
setVersionHistoryPath(null)
setViewingHistoricalVersion(null)
} else {
setVersionHistoryPath(selectedPath)
}
}}
className={cn(
"titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0",
versionHistoryPath && "bg-accent text-foreground"
)}
aria-label="Version history"
>
<HistoryIcon className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !selectedTask && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleNewChatTab}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
aria-label="New chat tab"
>
<SquarePen className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCloseFullScreenChat}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
aria-label="Restore two-pane view"
>
<Minimize2 className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleKnowledgePane}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1 self-center shrink-0"
aria-label={isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
>
{isChatSidebarOpen ? <Maximize2 className="size-5" /> : <Minimize2 className="size-5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
</TooltipContent>
</Tooltip>
)}
</ContentHeader>
{isSuggestedTopicsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<SuggestedTopicsView
onExploreTopic={(title, description) => {
const prompt = `I'd like to explore the topic: ${title}. ${description}`
submitFromPalette(prompt, null)
}}
/>
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView
tree={tree}
onSelectNote={(path) => navigateToFile(path)}
config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG}
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
onSave={(name) => void handleBaseSave(name)}
externalSearch={externalBaseSearch}
onExternalSearchConsumed={() => setExternalBaseSearch(undefined)}
actions={{
rename: knowledgeActions.rename,
remove: knowledgeActions.remove,
copyPath: knowledgeActions.copyPath,
}}
/>
</div>
) : isGraphOpen ? (
<div className="flex-1 min-h-0">
<GraphView
nodes={graphData.nodes}
edges={graphData.edges}
isLoading={false}
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
onSelectNode={(path) => {
navigateToFile(path)
}}
/>
</div>
) : selectedPath ? (
selectedPath.endsWith('.md') ? (
<div className="flex-1 min-h-0 flex flex-row overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{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 (
<div
key={tab.id}
className={cn(
'min-h-0 flex-1 flex-col overflow-hidden',
isActive ? 'flex' : 'hidden'
)}
data-file-tab-panel={tab.id}
aria-hidden={!isActive}
>
<MarkdownEditor
ref={(el) => {
if (el) editorRefsByTabId.current.set(tab.id, el)
else editorRefsByTabId.current.delete(tab.id)
}}
content={tabContent}
notePath={tab.path}
onChange={(markdown) => { 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}
onExport={async (format) => {
const markdown = tabContent
const title = getBaseName(tab.path)
try {
await window.ipc.invoke('export:note', { markdown, format, title })
analytics.noteExported(format)
} catch (err) {
console.error('Export failed:', err)
}
}}
/>
</div>
)
})}
</div>
{versionHistoryPath && (
<VersionHistoryPanel
path={versionHistoryPath}
onClose={() => {
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)
}
}}
/>
)}
</div>
) : (
<div className="flex-1 overflow-auto p-4">
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
{fileContent || 'Loading...'}
</pre>
</div>
)
) : selectedTask ? (
<div className="flex-1 min-h-0 overflow-hidden">
<BackgroundTaskDetail
name={selectedTask.name}
description={selectedTask.description}
schedule={selectedTask.schedule}
enabled={selectedTask.enabled}
status={selectedTask.status}
nextRunAt={selectedTask.nextRunAt}
lastRunAt={selectedTask.lastRunAt}
lastError={selectedTask.lastError}
runCount={selectedTask.runCount}
onToggleEnabled={(enabled) => handleToggleBackgroundTask(selectedTask.name, enabled)}
/>
</div>
) : (
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
{chatTabs.map((tab) => {
const isActive = tab.id === activeChatTabId
const tabState = getChatTabStateForRender(tab.id)
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
const tabConversationContentClassName = tabHasConversation
? "mx-auto w-full max-w-4xl pb-28"
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
return (
<div
key={tab.id}
className={cn(
'min-h-0 h-full flex-col',
isActive
? 'flex'
: 'pointer-events-none invisible absolute inset-0 flex'
)}
data-chat-tab-panel={tab.id}
aria-hidden={!isActive}
>
<Conversation
anchorMessageId={chatViewportAnchorByTab[tab.id]?.messageId}
anchorRequestKey={chatViewportAnchorByTab[tab.id]?.requestKey}
className="relative flex-1"
>
<ConversationContent className={tabConversationContentClassName}>
{!tabHasConversation ? (
<ConversationEmptyState className="h-auto">
<div className="text-2xl font-semibold tracking-tight text-foreground/80 sm:text-3xl md:text-4xl">
What are we working on?
</div>
</ConversationEmptyState>
) : (
<>
{tabState.conversation.map(item => {
const rendered = renderConversationItem(item, tab.id)
if (isToolCall(item)) {
const permRequest = tabState.allPermissionRequests.get(item.id)
if (permRequest) {
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
</React.Fragment>
)
}
}
return rendered
})}
{Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isActive && isProcessing}
/>
))}
{tabState.currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<SmoothStreamingMessage text={tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')} components={streamdownComponents} />
</MessageContent>
</Message>
)}
{isActive && isProcessing && !tabState.currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer>
</MessageContent>
</Message>
)}
</>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
</div>
)
})}
</div>
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
<div className="mx-auto w-full max-w-4xl px-4">
{!hasConversation && (
<Suggestions onSelect={setPresetMessage} className="mb-3 justify-center" />
)}
{chatTabs.map((tab) => {
const isActive = tab.id === activeChatTabId
const tabState = getChatTabStateForRender(tab.id)
return (
<div
key={tab.id}
className={isActive ? 'block' : 'hidden'}
data-chat-input-panel={tab.id}
aria-hidden={!isActive}
>
<ChatInputWithMentions
knowledgeFiles={knowledgeFiles}
recentFiles={recentWikiFiles}
visibleFiles={visibleKnowledgeFiles}
onSubmit={handlePromptSubmit}
onStop={handleStop}
isProcessing={isActive && isProcessing}
isStopping={isActive && isStopping}
isActive={isActive}
presetMessage={isActive ? presetMessage : undefined}
onPresetMessageConsumed={isActive ? () => setPresetMessage(undefined) : undefined}
runId={tabState.runId}
initialDraft={chatDraftsRef.current.get(tab.id)}
onDraftChange={(text) => setChatDraftForTab(tab.id, text)}
isRecording={isActive && isRecording}
recordingText={isActive ? voice.interimText : undefined}
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
onStartRecording={isActive ? handleStartRecording : undefined}
onSubmitRecording={isActive ? handleSubmitRecording : undefined}
onCancelRecording={isActive ? handleCancelRecording : undefined}
voiceAvailable={isActive && voiceAvailable}
ttsAvailable={isActive && ttsAvailable}
ttsEnabled={ttsEnabled}
ttsMode={ttsMode}
onToggleTts={isActive ? handleToggleTts : undefined}
onTtsModeChange={isActive ? handleTtsModeChange : undefined}
/>
</div>
)
})}
</div>
</div>
</div>
</FileCardProvider>
)}
</SidebarInset>
{/* Chat sidebar - shown when viewing files/graph */}
{isRightPaneContext && (
<ChatSidebar
defaultWidth={460}
isOpen={isChatSidebarOpen}
isMaximized={isRightPaneMaximized}
chatTabs={chatTabs}
activeChatTabId={activeChatTabId}
getChatTabTitle={getChatTabTitle}
isChatTabProcessing={isChatTabProcessing}
onSwitchChatTab={switchChatTab}
onCloseChatTab={closeChatTab}
onNewChatTab={handleNewChatTabInSidebar}
onOpenFullScreen={toggleRightPaneMaximize}
conversation={conversation}
currentAssistantMessage={currentAssistantMessage}
chatTabStates={chatViewStateByTab}
viewportAnchors={chatViewportAnchorByTab}
isProcessing={isProcessing}
isStopping={isStopping}
onStop={handleStop}
onSubmit={handlePromptSubmit}
knowledgeFiles={knowledgeFiles}
recentFiles={recentWikiFiles}
visibleFiles={visibleKnowledgeFiles}
runId={runId}
presetMessage={presetMessage}
onPresetMessageConsumed={() => setPresetMessage(undefined)}
getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)}
onDraftChangeForTab={setChatDraftForTab}
pendingAskHumanRequests={pendingAskHumanRequests}
allPermissionRequests={allPermissionRequests}
permissionResponses={permissionResponses}
onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse}
isToolOpenForTab={isToolOpenForTab}
onToolOpenChangeForTab={setToolOpenForTab}
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
onActivate={() => setActiveShortcutPane('right')}
isRecording={isRecording}
recordingText={voice.interimText}
recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'}
onStartRecording={handleStartRecording}
onSubmitRecording={handleSubmitRecording}
onCancelRecording={handleCancelRecording}
voiceAvailable={voiceAvailable}
ttsAvailable={ttsAvailable}
ttsEnabled={ttsEnabled}
ttsMode={ttsMode}
onToggleTts={handleToggleTts}
onTtsModeChange={handleTtsModeChange}
onComposioConnected={handleComposioConnected}
/>
)}
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
<FixedSidebarToggle
onNewChat={handleNewChatTab}
onOpenSearch={() => setIsSearchOpen(true)}
meetingState={meetingTranscription.state}
meetingSummarizing={meetingSummarizing}
meetingAvailable={voiceAvailable}
onToggleMeeting={() => { void handleToggleMeeting() }}
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
/>
</SidebarProvider>
</div>
<CommandPalette
open={isSearchOpen}
onOpenChange={setIsSearchOpen}
onSelectFile={navigateToFile}
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
initialContext={paletteContext}
onChatSubmit={submitFromPalette}
/>
</SidebarSectionProvider>
<Toaster />
<OnboardingModal
open={showOnboarding}
onComplete={handleOnboardingComplete}
/>
<Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Screen recording permission required</DialogTitle>
<DialogDescription>
Rowboat needs <strong>Screen Recording</strong> permission to capture meeting audio from other apps (Zoom, Meet, etc.). This feature won't work without it.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>To enable this:</p>
<ol className="list-decimal list-inside space-y-1.5">
<li>Open <strong>System Settings</strong> → <strong>Privacy & Security</strong> → <strong>Screen Recording</strong></li>
<li>Toggle on <strong>Rowboat</strong></li>
<li>You may need to restart the app after granting permission</li>
</ol>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowMeetingPermissions(false)}>Cancel</Button>
<Button variant="outline" onClick={() => { void handleOpenScreenRecordingSettings() }}>Open System Settings</Button>
<Button onClick={() => { void handleCheckPermissionAndRetry() }} disabled={checkingPermission}>
{checkingPermission ? 'Checking...' : 'Check Again'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
)
}
export default App