From 23a1544a17e092e3b0991fd840c8d4fd6bb4798b Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 4 Feb 2026 14:43:52 +0530 Subject: [PATCH 01/54] refactor: remove unused MessageSquare icon from sidebar content --- .../components/ai-elements/conversation.tsx | 100 +++++++++++++++++- .../src/components/sidebar-content.tsx | 3 - 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index 35baf6c4..bba0b9e8 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowDownIcon } from "lucide-react"; import type { ComponentProps } from "react"; -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; export type ConversationProps = ComponentProps; @@ -13,11 +13,109 @@ export const Conversation = ({ className, ...props }: ConversationProps) => ( ); +// Threshold in pixels - if user scrolls more than this from bottom, they're considered "engaged" +const SCROLL_ENGAGEMENT_THRESHOLD = 100; + +/** + * Component that preserves scroll position when user has scrolled away from bottom. + * Place this inside a StickToBottom context to prevent unwanted scroll jumps. + */ +export const ScrollPositionPreserver = () => { + const { isAtBottom } = useStickToBottomContext(); + const scrollContainerRef = useRef(null); + const isUserEngagedRef = useRef(false); + const savedScrollTopRef = useRef(null); + const lastContentHeightRef = useRef(0); + + useEffect(() => { + // Find the scroll container (StickToBottom creates a scrollable element) + const findScrollContainer = () => { + // The scroll container is the element with overflow-y-auto/scroll + const containers = document.querySelectorAll('[data-stick-to-bottom-scroll-container]'); + if (containers.length > 0) { + return containers[0] as HTMLElement; + } + // Fallback: find by class pattern from the library + const fallback = document.querySelector('.use-stick-to-bottom-scroll-container'); + return fallback as HTMLElement | null; + }; + + // Try to find it, the library creates it dynamically + const container = findScrollContainer(); + if (container) { + scrollContainerRef.current = container; + } + }, []); + + // Track when user scrolls away from bottom + useEffect(() => { + if (!isAtBottom) { + // User is not at bottom - they've scrolled up + isUserEngagedRef.current = true; + + // Save their current position + if (scrollContainerRef.current) { + savedScrollTopRef.current = scrollContainerRef.current.scrollTop; + lastContentHeightRef.current = scrollContainerRef.current.scrollHeight; + } + } + }, [isAtBottom]); + + // When user reaches bottom again, reset engagement + useEffect(() => { + if (isAtBottom && isUserEngagedRef.current) { + isUserEngagedRef.current = false; + savedScrollTopRef.current = null; + } + }, [isAtBottom]); + + // Use MutationObserver to detect content changes and restore position if needed + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const observer = new MutationObserver(() => { + // If user was engaged (scrolled away) and we have a saved position + if (isUserEngagedRef.current && savedScrollTopRef.current !== null) { + const currentHeight = container.scrollHeight; + const previousHeight = lastContentHeightRef.current; + + // If content height changed significantly and user was scrolled away + if (Math.abs(currentHeight - previousHeight) > 10) { + // Calculate how far from bottom they were + const distanceFromBottom = previousHeight - savedScrollTopRef.current - container.clientHeight; + + // Restore position relative to where they were + // If they were reading something in the middle, keep them there + if (distanceFromBottom > SCROLL_ENGAGEMENT_THRESHOLD) { + // Keep them at the same scroll position (reading older content) + container.scrollTop = savedScrollTopRef.current; + } + + // Update saved values + lastContentHeightRef.current = currentHeight; + } + } + }); + + observer.observe(container, { + childList: true, + subtree: true, + characterData: true, + }); + + return () => observer.disconnect(); + }, []); + + return null; +}; + export type ConversationContentProps = ComponentProps< typeof StickToBottom.Content >; diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index af0d1896..cee59809 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -11,7 +11,6 @@ import { FilePlus, Folder, FolderPlus, - MessageSquare, Mic, Network, Pencil, @@ -689,9 +688,7 @@ function TasksSection({ actions?.onSelectRun(run.id)} - className="gap-2" > - {run.title || '(Untitled chat)'} From d903a8cae3d172658eb0e3b5591b6e5983ed596c Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 4 Feb 2026 15:03:05 +0530 Subject: [PATCH 02/54] feat: add ScrollPositionPreserver component to manage scroll engagement in conversations. --- apps/x/apps/renderer/src/App.tsx | 2 + .../components/ai-elements/conversation.tsx | 220 ++++++++++-------- .../renderer/src/components/chat-sidebar.tsx | 2 + 3 files changed, 131 insertions(+), 93 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 25ab8031..ed5bdc9e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -20,6 +20,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, + ScrollPositionPreserver, } from '@/components/ai-elements/conversation'; import { Message, @@ -1821,6 +1822,7 @@ function App() { ) : (
+ {!hasConversation ? ( diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index bba0b9e8..d9f36353 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -3,115 +3,149 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowDownIcon } from "lucide-react"; -import type { ComponentProps } from "react"; -import { useCallback, useEffect, useRef } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; -export type ConversationProps = ComponentProps; +// Context to share scroll preservation state +interface ScrollPreservationContextValue { + registerScrollContainer: (container: HTMLElement | null) => void; + markUserEngaged: () => void; + resetEngagement: () => void; +} -export const Conversation = ({ className, ...props }: ConversationProps) => ( - -); +const ScrollPreservationContext = createContext(null); -// Threshold in pixels - if user scrolls more than this from bottom, they're considered "engaged" -const SCROLL_ENGAGEMENT_THRESHOLD = 100; +export type ConversationProps = ComponentProps & { + children?: ReactNode; +}; + +export const Conversation = ({ className, children, ...props }: ConversationProps) => { + const [scrollContainer, setScrollContainer] = useState(null); + const isUserEngagedRef = useRef(false); + const savedScrollTopRef = useRef(0); + const lastScrollHeightRef = useRef(0); + + const contextValue: ScrollPreservationContextValue = { + registerScrollContainer: (container) => { + setScrollContainer(container); + }, + markUserEngaged: () => { + // Only save position on first engagement, not on repeated calls + if (!isUserEngagedRef.current && scrollContainer) { + savedScrollTopRef.current = scrollContainer.scrollTop; + lastScrollHeightRef.current = scrollContainer.scrollHeight; + } + isUserEngagedRef.current = true; + }, + resetEngagement: () => { + isUserEngagedRef.current = false; + }, + }; + + // Watch for content changes and restore scroll position if user was engaged + useEffect(() => { + if (!scrollContainer) return; + + let rafId: number | null = null; + + const checkAndRestoreScroll = () => { + if (!isUserEngagedRef.current) return; + + const currentScrollTop = scrollContainer.scrollTop; + const currentScrollHeight = scrollContainer.scrollHeight; + const savedScrollTop = savedScrollTopRef.current; + + // If scroll position jumped significantly (auto-scroll happened) + // and scroll height also changed (content changed), restore position + if ( + Math.abs(currentScrollTop - savedScrollTop) > 50 && + currentScrollHeight !== lastScrollHeightRef.current + ) { + scrollContainer.scrollTop = savedScrollTop; + } + + lastScrollHeightRef.current = currentScrollHeight; + }; + + // Use ResizeObserver to detect content changes + const resizeObserver = new ResizeObserver(() => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(checkAndRestoreScroll); + }); + + resizeObserver.observe(scrollContainer); + + return () => { + resizeObserver.disconnect(); + if (rafId) cancelAnimationFrame(rafId); + }; + }, [scrollContainer]); + + return ( + + + {children} + + + ); +}; /** - * Component that preserves scroll position when user has scrolled away from bottom. - * Place this inside a StickToBottom context to prevent unwanted scroll jumps. + * Component that tracks scroll engagement and preserves position. + * Must be used inside Conversation component. */ export const ScrollPositionPreserver = () => { const { isAtBottom } = useStickToBottomContext(); - const scrollContainerRef = useRef(null); - const isUserEngagedRef = useRef(false); - const savedScrollTopRef = useRef(null); - const lastContentHeightRef = useRef(0); + const preservationContext = useContext(ScrollPreservationContext); + const containerFoundRef = useRef(false); - useEffect(() => { - // Find the scroll container (StickToBottom creates a scrollable element) - const findScrollContainer = () => { - // The scroll container is the element with overflow-y-auto/scroll - const containers = document.querySelectorAll('[data-stick-to-bottom-scroll-container]'); - if (containers.length > 0) { - return containers[0] as HTMLElement; - } - // Fallback: find by class pattern from the library - const fallback = document.querySelector('.use-stick-to-bottom-scroll-container'); - return fallback as HTMLElement | null; - }; + // Find and register scroll container on mount + useLayoutEffect(() => { + if (containerFoundRef.current || !preservationContext) return; - // Try to find it, the library creates it dynamically - const container = findScrollContainer(); - if (container) { - scrollContainerRef.current = container; - } - }, []); - - // Track when user scrolls away from bottom - useEffect(() => { - if (!isAtBottom) { - // User is not at bottom - they've scrolled up - isUserEngagedRef.current = true; - - // Save their current position - if (scrollContainerRef.current) { - savedScrollTopRef.current = scrollContainerRef.current.scrollTop; - lastContentHeightRef.current = scrollContainerRef.current.scrollHeight; - } - } - }, [isAtBottom]); - - // When user reaches bottom again, reset engagement - useEffect(() => { - if (isAtBottom && isUserEngagedRef.current) { - isUserEngagedRef.current = false; - savedScrollTopRef.current = null; - } - }, [isAtBottom]); - - // Use MutationObserver to detect content changes and restore position if needed - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - const observer = new MutationObserver(() => { - // If user was engaged (scrolled away) and we have a saved position - if (isUserEngagedRef.current && savedScrollTopRef.current !== null) { - const currentHeight = container.scrollHeight; - const previousHeight = lastContentHeightRef.current; - - // If content height changed significantly and user was scrolled away - if (Math.abs(currentHeight - previousHeight) > 10) { - // Calculate how far from bottom they were - const distanceFromBottom = previousHeight - savedScrollTopRef.current - container.clientHeight; - - // Restore position relative to where they were - // If they were reading something in the middle, keep them there - if (distanceFromBottom > SCROLL_ENGAGEMENT_THRESHOLD) { - // Keep them at the same scroll position (reading older content) - container.scrollTop = savedScrollTopRef.current; + // Find the scroll container (StickToBottom creates one) + // It's the first parent with overflow-y scroll/auto + const findScrollContainer = (): HTMLElement | null => { + const candidates = document.querySelectorAll('[role="log"]'); + for (const candidate of candidates) { + // The scroll container is a direct child of the role="log" element + const children = candidate.children; + for (const child of children) { + const style = window.getComputedStyle(child); + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { + return child as HTMLElement; } - - // Update saved values - lastContentHeightRef.current = currentHeight; } } - }); + return null; + }; - observer.observe(container, { - childList: true, - subtree: true, - characterData: true, - }); + const container = findScrollContainer(); + if (container) { + preservationContext.registerScrollContainer(container); + containerFoundRef.current = true; + } + }, [preservationContext]); - return () => observer.disconnect(); - }, []); + // Track engagement based on scroll position + useEffect(() => { + if (!preservationContext) return; + + if (!isAtBottom) { + // User is not at bottom - mark as engaged + preservationContext.markUserEngaged(); + } else { + // User is back at bottom - reset + preservationContext.resetEngagement(); + } + }, [isAtBottom, preservationContext]); return null; }; diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 4c97d581..c3ab6396 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -12,6 +12,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, + ScrollPositionPreserver, } from '@/components/ai-elements/conversation' import { Message, @@ -481,6 +482,7 @@ export function ChatSidebar({ {/* Conversation area */}
+ {!hasConversation ? ( From 64e7223cbb5e12f8b88acc081a81b7fa1e0eeeb9 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 4 Feb 2026 15:13:09 +0530 Subject: [PATCH 03/54] fix: made the chat input box same width as the text area --- apps/x/apps/renderer/src/App.tsx | 2 +- apps/x/apps/renderer/src/components/chat-sidebar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index ed5bdc9e..1a679ca0 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1821,7 +1821,7 @@ function App() { ) ) : (
- + {!hasConversation ? ( diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index c3ab6396..b5b32e09 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -481,7 +481,7 @@ export function ChatSidebar({ {/* Conversation area */}
- + {!hasConversation ? ( From 92d324a84edfebb23c009d8ad8d394dfe72fa096 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 4 Feb 2026 18:14:10 +0530 Subject: [PATCH 04/54] feat: implement preferred default models for LLM providers in onboarding modal --- .../apps/renderer/src/components/onboarding-modal.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 1f664f10..2aac98d7 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -157,6 +157,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { loadModels() }, [open]) + // Preferred default models for each provider + const preferredDefaults: Partial> = { + openai: "gpt-5.2", + anthropic: "claude-opus-4-5-20251101", + } + // Initialize default models from catalog useEffect(() => { if (Object.keys(modelsCatalog).length === 0) return @@ -166,7 +172,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { for (const provider of cloudProviders) { const models = modelsCatalog[provider] if (models?.length && !next[provider].model) { - next[provider] = { ...next[provider], model: models[0]?.id || "" } + // Check if preferred default exists in the catalog + const preferredModel = preferredDefaults[provider] + const hasPreferred = preferredModel && models.some(m => m.id === preferredModel) + next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") } } } return next From 53bbd3b76d17f09bd5f23eae6f69290432de48e4 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:36:27 +0530 Subject: [PATCH 05/54] added dark mode --- apps/x/apps/renderer/src/App.css | 27 +++ .../src/components/settings-dialog.tsx | 175 +++++++++++++----- .../renderer/src/contexts/theme-context.tsx | 93 ++++++++++ apps/x/apps/renderer/src/main.tsx | 5 +- 4 files changed, 251 insertions(+), 49 deletions(-) create mode 100644 apps/x/apps/renderer/src/contexts/theme-context.tsx diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 4fd02863..64d2b9a3 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -123,6 +123,9 @@ --sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0)); --sidebar-border: var(--sub-alt-color, oklch(0.922 0 0)); --sidebar-ring: var(--main-color, oklch(0.708 0 0)); + --scrollbar-track: oklch(0.95 0 0); + --scrollbar-thumb: oklch(0.75 0 0); + --scrollbar-thumb-hover: oklch(0.65 0 0); } .dark { @@ -157,15 +160,39 @@ --sidebar-accent-foreground: var(--text-color, oklch(0.985 0 0)); --sidebar-border: var(--sub-alt-color, oklch(1 0 0 / 10%)); --sidebar-ring: var(--main-color, oklch(0.556 0 0)); + --scrollbar-track: oklch(0.2 0 0); + --scrollbar-thumb: oklch(0.4 0 0); + --scrollbar-thumb-hover: oklch(0.5 0 0); } @layer base { * { @apply border-border outline-ring/50; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + scrollbar-width: thin; } + body { @apply bg-background text-foreground; } + + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--scrollbar-track); + } + + ::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); + } } /* Markdown content base styles for Streamdown/MessageResponse */ diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index b60ec736..7d986401 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -1,8 +1,8 @@ "use client" import * as React from "react" -import { useState, useEffect } from "react" -import { Server, Key, Shield } from "lucide-react" +import { useState, useEffect, useCallback } from "react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon } from "lucide-react" import { Dialog, @@ -11,14 +11,15 @@ import { } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" +import { useTheme, type Theme } from "@/contexts/theme-context" -type ConfigTab = "models" | "mcp" | "security" +type ConfigTab = "models" | "mcp" | "security" | "appearance" interface TabConfig { id: ConfigTab label: string icon: React.ElementType - path: string + path?: string description: string } @@ -44,12 +45,82 @@ const tabs: TabConfig[] = [ path: "config/security.json", description: "Configure allowed shell commands", }, + { + id: "appearance", + label: "Appearance", + icon: Palette, + description: "Customize the look and feel", + }, ] interface SettingsDialogProps { children: React.ReactNode } +function ThemeOption({ + label, + icon: Icon, + isSelected, + onClick, +}: { + label: string + icon: React.ElementType + isSelected: boolean + onClick: () => void +}) { + return ( + + ) +} + +function AppearanceSettings() { + const { theme, setTheme } = useTheme() + + return ( +
+
+

Theme

+

+ Select your preferred color scheme +

+
+ setTheme("light")} + /> + setTheme("dark")} + /> + setTheme("system")} + /> +
+
+
+ ) +} + export function SettingsDialog({ children }: SettingsDialogProps) { const [open, setOpen] = useState(false) const [activeTab, setActiveTab] = useState("models") @@ -60,9 +131,20 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [error, setError] = useState(null) const activeTabConfig = tabs.find((t) => t.id === activeTab)! + const isConfigTab = activeTab !== "appearance" - const loadConfig = async (tab: ConfigTab) => { + const formatJson = (jsonString: string): string => { + try { + return JSON.stringify(JSON.parse(jsonString), null, 2) + } catch { + return jsonString + } + } + + const loadConfig = useCallback(async (tab: ConfigTab) => { + if (tab === "appearance") return const tabConfig = tabs.find((t) => t.id === tab)! + if (!tabConfig.path) return setLoading(true) setError(null) try { @@ -72,16 +154,17 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const formattedContent = formatJson(result.data) setContent(formattedContent) setOriginalContent(formattedContent) - } catch (err) { + } catch { setError(`Failed to load ${tabConfig.label} config`) setContent("") setOriginalContent("") } finally { setLoading(false) } - } + }, []) const saveConfig = async () => { + if (!isConfigTab || !activeTabConfig.path) return setSaving(true) setError(null) try { @@ -103,14 +186,6 @@ export function SettingsDialog({ children }: SettingsDialogProps) { } } - const formatJson = (jsonString: string): string => { - try { - return JSON.stringify(JSON.parse(jsonString), null, 2) - } catch { - return jsonString - } - } - const handleFormat = () => { setContent(formatJson(content)) } @@ -118,10 +193,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const hasChanges = content !== originalContent useEffect(() => { - if (open) { + if (open && activeTab !== "appearance") { loadConfig(activeTab) } - }, [open, activeTab]) + }, [open, activeTab, loadConfig]) const handleTabChange = (tab: ConfigTab) => { if (hasChanges) { @@ -173,9 +248,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {

- {/* Editor */} + {/* Content */}
- {loading ? ( + {activeTab === "appearance" ? ( + + ) : loading ? (
Loading...
@@ -190,36 +267,38 @@ export function SettingsDialog({ children }: SettingsDialogProps) { )}
- {/* Footer */} -
-
- {error && ( - {error} - )} - {hasChanges && !error && ( - - Unsaved changes - - )} + {/* Footer - only show for config tabs */} + {isConfigTab && ( +
+
+ {error && ( + {error} + )} + {hasChanges && !error && ( + + Unsaved changes + + )} +
+
+ + +
-
- - -
-
+ )}
diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx new file mode 100644 index 00000000..1149cb42 --- /dev/null +++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx @@ -0,0 +1,93 @@ +"use client" + +import * as React from "react" + +export type Theme = "light" | "dark" | "system" + +type ThemeContextProps = { + theme: Theme + resolvedTheme: "light" | "dark" + setTheme: (theme: Theme) => void +} + +const ThemeContext = React.createContext(null) + +const STORAGE_KEY = "rowboat-theme" + +function getSystemTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light" + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" +} + +export function useTheme() { + const context = React.useContext(ThemeContext) + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider.") + } + return context +} + +export function ThemeProvider({ + defaultTheme = "system", + children, +}: { + defaultTheme?: Theme + children: React.ReactNode +}) { + const [theme, setThemeState] = React.useState(() => { + if (typeof window === "undefined") return defaultTheme + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null + return stored || defaultTheme + }) + + const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => { + if (theme === "system") return getSystemTheme() + return theme + }) + + // Apply theme to document + React.useEffect(() => { + const root = document.documentElement + const resolved = theme === "system" ? getSystemTheme() : theme + + root.classList.remove("light", "dark") + root.classList.add(resolved) + setResolvedTheme(resolved) + }, [theme]) + + // Listen for system theme changes + React.useEffect(() => { + if (theme !== "system") return + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handleChange = () => { + const resolved = getSystemTheme() + document.documentElement.classList.remove("light", "dark") + document.documentElement.classList.add(resolved) + setResolvedTheme(resolved) + } + + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, [theme]) + + const setTheme = React.useCallback((newTheme: Theme) => { + localStorage.setItem(STORAGE_KEY, newTheme) + setThemeState(newTheme) + }, []) + + const contextValue = React.useMemo( + () => ({ + theme, + resolvedTheme, + setTheme, + }), + [theme, resolvedTheme, setTheme] + ) + + return ( + + {children} + + ) +} diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx index 0feeaa41..7ad7ac86 100644 --- a/apps/x/apps/renderer/src/main.tsx +++ b/apps/x/apps/renderer/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { PostHogProvider } from 'posthog-js/react' +import { ThemeProvider } from '@/contexts/theme-context' const options = { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, @@ -12,7 +13,9 @@ const options = { createRoot(document.getElementById('root')!).render( - + + + , ) From f03a00d2af2950e1ff094cc598e1d79c110ef7f0 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:45:51 +0530 Subject: [PATCH 06/54] fix startup flash --- apps/x/apps/main/src/main.ts | 7 +++++++ apps/x/apps/renderer/index.html | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 6ddab7bc..d73ae442 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -64,6 +64,8 @@ function createWindow() { const win = new BrowserWindow({ width: 1280, height: 800, + show: false, // Don't show until ready + backgroundColor: "#252525", // Prevent white flash (matches dark mode) webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -73,6 +75,11 @@ function createWindow() { }, }); + // Show window when content is ready to prevent blank screen + win.once("ready-to-show", () => { + win.show(); + }); + // Open external links in system browser (not sandboxed Electron window) // This handles window.open() and target="_blank" links win.webContents.setWindowOpenHandler(({ url }) => { diff --git a/apps/x/apps/renderer/index.html b/apps/x/apps/renderer/index.html index 1803a850..856065c2 100644 --- a/apps/x/apps/renderer/index.html +++ b/apps/x/apps/renderer/index.html @@ -5,6 +5,22 @@ Rowboat + +
From 4c333f241ff69c2a57734cff974cacff3c5e7f72 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Thu, 5 Feb 2026 00:39:11 +0530 Subject: [PATCH 07/54] feat: enhance markdown editor to preserve blank lines and improve markdown processing --- .../src/components/markdown-editor.tsx | 174 +++++++++++++++++- 1 file changed, 166 insertions(+), 8 deletions(-) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index a65f262a..f7661757 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -1,4 +1,4 @@ -import { useEditor, EditorContent, Extension } from '@tiptap/react' +import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' import { Plugin, PluginKey } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' @@ -10,6 +10,159 @@ import TaskItem from '@tiptap/extension-task-item' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' + +// Zero-width space used as invisible marker for blank lines +const BLANK_LINE_MARKER = '\u200B' + +// Pre-process markdown to preserve blank lines before parsing +function preprocessMarkdown(markdown: string): string { + // Convert sequences of 3+ newlines to paragraphs with zero-width space + // - 2 newlines = normal paragraph break (0 empty paragraphs) + // - 3 newlines = 1 blank line = 1 empty paragraph + // - 4 newlines = 2 blank lines = 2 empty paragraphs + // Formula: emptyParagraphs = totalNewlines - 2 + return markdown.replace(/\n{3,}/g, (match) => { + const totalNewlines = match.length + const emptyParagraphs = totalNewlines - 2 + let result = '\n\n' + for (let i = 0; i < emptyParagraphs; i++) { + result += BLANK_LINE_MARKER + '\n\n' + } + return result + }) +} + +// Post-process to clean up any zero-width spaces in the output +function postprocessMarkdown(markdown: string): string { + // Remove lines that contain only the zero-width space marker + return markdown.split('\n').map(line => { + if (line === BLANK_LINE_MARKER || line.trim() === BLANK_LINE_MARKER) { + return '' + } + // Also remove zero-width spaces from other content + return line.replace(new RegExp(BLANK_LINE_MARKER, 'g'), '') + }).join('\n') +} + +// Custom function to get markdown that preserves empty paragraphs as blank lines +function getMarkdownWithBlankLines(editor: Editor): string { + const json = editor.getJSON() + if (!json.content) return '' + + const blocks: string[] = [] + + // Helper to convert a node to markdown text + const nodeToText = (node: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }): string => { + if (!node.content) return '' + return node.content.map(child => { + if (child.type === 'text') { + let text = child.text || '' + // Apply marks (bold, italic, etc.) + if (child.marks) { + for (const mark of child.marks) { + if (mark.type === 'bold') text = `**${text}**` + else if (mark.type === 'italic') text = `*${text}*` + else if (mark.type === 'code') text = `\`${text}\`` + else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` + } + } + return text + } else if (child.type === 'hardBreak') { + return '\n' + } + return '' + }).join('') + } + + for (const node of json.content) { + if (node.type === 'paragraph') { + const text = nodeToText(node) + // If the paragraph contains only the blank line marker or is empty, it's a blank line + if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) { + // Push empty string to represent blank line - will add extra newline when joining + blocks.push('') + } else { + blocks.push(text) + } + } else if (node.type === 'heading') { + const level = (node.attrs?.level as number) || 1 + const text = nodeToText(node) + blocks.push('#'.repeat(level) + ' ' + text) + } else if (node.type === 'bulletList' || node.type === 'orderedList') { + // Handle lists - all items are part of one block + const listLines: string[] = [] + const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> + listItems.forEach((item, index) => { + const prefix = node.type === 'orderedList' ? `${index + 1}. ` : '- ' + const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> + itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { + const text = nodeToText(para) + if (paraIndex === 0) { + listLines.push(prefix + text) + } else { + listLines.push(' ' + text) + } + }) + }) + blocks.push(listLines.join('\n')) + } else if (node.type === 'taskList') { + const listLines: string[] = [] + const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> + listItems.forEach(item => { + const checked = item.attrs?.checked ? 'x' : ' ' + const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> + itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { + const text = nodeToText(para) + if (paraIndex === 0) { + listLines.push(`- [${checked}] ${text}`) + } else { + listLines.push(' ' + text) + } + }) + }) + blocks.push(listLines.join('\n')) + } else if (node.type === 'codeBlock') { + const lang = (node.attrs?.language as string) || '' + blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') + } else if (node.type === 'blockquote') { + const content = node.content || [] + const quoteLines = content.map(para => '> ' + nodeToText(para)) + blocks.push(quoteLines.join('\n')) + } else if (node.type === 'horizontalRule') { + blocks.push('---') + } else if (node.type === 'wikiLink') { + const path = (node.attrs?.path as string) || '' + blocks.push(`[[${path}]]`) + } else if (node.type === 'image') { + const src = (node.attrs?.src as string) || '' + const alt = (node.attrs?.alt as string) || '' + blocks.push(`![${alt}](${src})`) + } + } + + // Custom join: content blocks get \n\n before them, empty blocks add \n each + // This produces: 1 empty paragraph = 3 newlines (1 blank line on disk) + if (blocks.length === 0) return '' + + let result = '' + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] + const isContent = block !== '' + + if (i === 0) { + result = block + } else if (isContent) { + // Content block: add \n\n before it (standard paragraph break) + result += '\n\n' + block + } else { + // Empty block: just add \n (one extra newline for blank line) + result += '\n' + } + } + + return result +} import { EditorToolbar } from './editor-toolbar' import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' @@ -140,8 +293,9 @@ export function MarkdownEditor({ placeholder, }), Markdown.configure({ - html: false, + html: true, breaks: true, + tightLists: false, transformCopiedText: true, transformPastedText: true, }), @@ -150,8 +304,9 @@ export function MarkdownEditor({ content: '', onUpdate: ({ editor }) => { if (isInternalUpdate.current) return - const storage = editor.storage as unknown as Record string }> - const markdown = storage.markdown?.getMarkdown?.() ?? '' + let markdown = getMarkdownWithBlankLines(editor) + // Post-process to clean up any markers and ensure blank lines are preserved + markdown = postprocessMarkdown(markdown) onChange(markdown) }, editorProps: { @@ -249,11 +404,14 @@ export function MarkdownEditor({ // Update editor content when prop changes (e.g., file selection changes) useEffect(() => { if (editor && content !== undefined) { - const storage = editor.storage as unknown as Record string }> - const currentContent = storage.markdown?.getMarkdown?.() ?? '' - if (currentContent !== content) { + const currentContent = getMarkdownWithBlankLines(editor) + // Normalize for comparison (trim trailing whitespace from lines) + const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() + if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { isInternalUpdate.current = true - editor.commands.setContent(content) + // Pre-process to preserve blank lines + const preprocessed = preprocessMarkdown(content) + editor.commands.setContent(preprocessed) isInternalUpdate.current = false } } From c447a42d070d15f8aef63dafd69da95bc6e92377 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:21:13 +0530 Subject: [PATCH 08/54] feat: add background agents with scheduling support - Add background task scheduling system with cron-based triggers - Add background-task-detail component for viewing agent status - Add agent schedule repo and state management - Update sidebar to show background agents section - Remove old workflow-authoring and workflow-run-ops skills - Add IPC handlers for agent schedule operations Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/ipc.ts | 36 ++ apps/x/apps/main/src/main.ts | 4 + apps/x/apps/renderer/src/App.tsx | 129 +++- .../src/components/background-task-detail.tsx | 175 ++++++ .../src/components/sidebar-content.tsx | 81 ++- apps/x/packages/core/package.json | 3 +- .../packages/core/src/agent-schedule/repo.ts | 43 ++ .../core/src/agent-schedule/runner.ts | 335 +++++++++++ .../core/src/agent-schedule/state-repo.ts | 64 ++ .../skills/background-agents/skill.ts | 555 ++++++++++++++++++ .../src/application/assistant/skills/index.ts | 17 +- .../skills/workflow-authoring/skill.ts | 384 ------------ .../skills/workflow-run-ops/skill.ts | 95 --- .../x/packages/core/src/config/initConfigs.ts | 6 + apps/x/packages/core/src/di/container.ts | 4 + .../shared/src/agent-schedule-state.ts | 17 + apps/x/packages/shared/src/agent-schedule.ts | 44 ++ apps/x/packages/shared/src/index.ts | 2 + apps/x/packages/shared/src/ipc.ts | 28 + apps/x/pnpm-lock.yaml | 22 +- 20 files changed, 1544 insertions(+), 500 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/background-task-detail.tsx create mode 100644 apps/x/packages/core/src/agent-schedule/repo.ts create mode 100644 apps/x/packages/core/src/agent-schedule/runner.ts create mode 100644 apps/x/packages/core/src/agent-schedule/state-repo.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts delete mode 100644 apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts delete mode 100644 apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts create mode 100644 apps/x/packages/shared/src/agent-schedule-state.ts create mode 100644 apps/x/packages/shared/src/agent-schedule.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5a7a7bd9..6efd48df 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -24,6 +24,9 @@ import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; +import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; +import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; +import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -384,5 +387,38 @@ export function setupIpcHandlers() { 'composio:execute-action': async (_event, args) => { return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); }, + // Agent schedule handlers + 'agent-schedule:getConfig': async () => { + const repo = container.resolve('agentScheduleRepo'); + try { + return await repo.getConfig(); + } catch { + // Return empty config if file doesn't exist + return { agents: {} }; + } + }, + 'agent-schedule:getState': async () => { + const repo = container.resolve('agentScheduleStateRepo'); + try { + return await repo.getState(); + } catch { + // Return empty state if file doesn't exist + return { agents: {} }; + } + }, + 'agent-schedule:updateAgent': async (_event, args) => { + const repo = container.resolve('agentScheduleRepo'); + await repo.upsert(args.agentName, args.entry); + // Trigger the runner to pick up the change immediately + triggerAgentScheduleRun(); + return { success: true }; + }, + 'agent-schedule:deleteAgent': async (_event, args) => { + const repo = container.resolve('agentScheduleRepo'); + const stateRepo = container.resolve('agentScheduleStateRepo'); + await repo.delete(args.agentName); + await stateRepo.deleteAgentState(args.agentName); + return { success: true }; + }, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index d73ae442..f074740f 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -10,6 +10,7 @@ import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; +import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; const __filename = fileURLToPath(import.meta.url); @@ -156,6 +157,9 @@ app.whenReady().then(async () => { // start pre-built agent runner initPreBuiltRunner(); + // start background agent runner (scheduled agents) + initAgentRunner(); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1a679ca0..3d01eee3 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -51,6 +51,9 @@ import { Separator } from "@/components/ui/separator" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' +import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' +import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' type DirEntry = z.infer type RunEventType = z.infer @@ -499,6 +502,22 @@ function App() { // Onboarding state const [showOnboarding, setShowOnboarding] = useState(false) + // Background tasks state + type BackgroundTaskItem = { + name: string + description?: string + schedule: z.infer["agents"][string]["schedule"] + enabled: boolean + startingMessage?: string + status?: z.infer["agents"][string]["status"] + nextRunAt?: string | null + lastRunAt?: string | null + lastError?: string | null + runCount?: number + } + const [backgroundTasks, setBackgroundTasks] = useState([]) + const [selectedBackgroundTask, setSelectedBackgroundTask] = useState(null) + // Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures) useEffect(() => { runIdRef.current = runId @@ -528,12 +547,17 @@ function App() { const cleanup = window.ipc.on('workspace:didChange', async (event) => { loadDirectory().then(setTree) - // Reload current file if it was changed externally - if (!selectedPath) return - const changedPath = event.type === 'changed' ? event.path : null const changedPaths = (event.type === 'bulkChanged' ? event.paths : []) ?? [] + // Reload background tasks if agent-schedule.json changed + if (changedPath === 'config/agent-schedule.json' || changedPaths.includes('config/agent-schedule.json')) { + loadBackgroundTasks() + } + + // Reload current file if it was changed externally + if (!selectedPath) return + const isCurrentFileChanged = changedPath === selectedPath || changedPaths.includes(selectedPath) @@ -548,6 +572,7 @@ function App() { } }) return cleanup + // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadDirectory, selectedPath, editorContent]) // Load file content when selected @@ -663,6 +688,63 @@ function App() { 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) => { try { @@ -1169,6 +1251,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setSelectedBackgroundTask(null) }, []) const handleChatInputSubmit = (text: string) => { @@ -1193,6 +1276,8 @@ function App() { // Clear forward history when navigating to a new file setFileHistoryForward([]) setSelectedPath(path) + // Clear background task selection when navigating to a file + setSelectedBackgroundTask(null) }, [selectedPath]) const navigateBack = useCallback(() => { @@ -1686,7 +1771,16 @@ function App() { const conversationContentClassName = hasConversation ? "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" - const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') + const headerTitle = selectedPath + ? selectedPath + : isGraphOpen + ? 'Graph View' + : selectedBackgroundTask + ? `Background Task: ${selectedBackgroundTask}` + : 'Chat' + const selectedTask = selectedBackgroundTask + ? backgroundTasks.find(t => t.name === selectedBackgroundTask) + : null return ( @@ -1716,8 +1810,18 @@ function App() { currentRunId={runId} tasksActions={{ onNewChat: handleNewChat, - onSelectRun: loadRun, + onSelectRun: (runIdToLoad) => { + setSelectedBackgroundTask(null) + loadRun(runIdToLoad) + }, + onSelectBackgroundTask: (taskName) => { + setSelectedBackgroundTask(taskName) + setSelectedPath(null) + setIsGraphOpen(false) + }, }} + backgroundTasks={backgroundTasks} + selectedBackgroundTask={selectedBackgroundTask} /> {/* Header with sidebar triggers */} @@ -1819,6 +1923,21 @@ function App() {
) + ) : selectedTask ? ( +
+ handleToggleBackgroundTask(selectedTask.name, enabled)} + /> +
) : (
diff --git a/apps/x/apps/renderer/src/components/background-task-detail.tsx b/apps/x/apps/renderer/src/components/background-task-detail.tsx new file mode 100644 index 00000000..78d69f2e --- /dev/null +++ b/apps/x/apps/renderer/src/components/background-task-detail.tsx @@ -0,0 +1,175 @@ +import { Bot, Calendar, Clock, AlertCircle, CheckCircle } from "lucide-react" +import { Switch } from "@/components/ui/switch" + +interface BackgroundTaskSchedule { + type: "cron" | "window" | "once" + expression?: string + cron?: string + startTime?: string + endTime?: string + runAt?: string +} + +interface BackgroundTaskDetailProps { + name: string + description?: string + schedule: BackgroundTaskSchedule + enabled: boolean + status?: "scheduled" | "running" | "finished" | "failed" | "triggered" + nextRunAt?: string | null + lastRunAt?: string | null + lastError?: string | null + runCount?: number + onToggleEnabled: (enabled: boolean) => void +} + +function formatScheduleDescription(schedule: BackgroundTaskSchedule): string { + switch (schedule.type) { + case "cron": + return `Runs on cron schedule: ${schedule.expression}` + case "window": + return `Runs once between ${schedule.startTime} and ${schedule.endTime} based on: ${schedule.cron}` + case "once": + return `Runs once at ${schedule.runAt}` + default: + return "Unknown schedule type" + } +} + +function formatDateTime(isoString: string | null | undefined): string { + if (!isoString) return "Never" + try { + const date = new Date(isoString) + return date.toLocaleString() + } catch { + return isoString + } +} + +export function BackgroundTaskDetail({ + name, + description, + schedule, + enabled, + status, + nextRunAt, + lastRunAt, + lastError, + runCount = 0, + onToggleEnabled, +}: BackgroundTaskDetailProps) { + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{name}

+

Background Agent

+
+
+
+ + {/* Content */} +
+ {/* Description */} + {description && ( +
+

Description

+

{description}

+
+ )} + + {/* Schedule */} +
+

Schedule

+
+
+ + {schedule.type} Schedule +
+

+ {formatScheduleDescription(schedule)} +

+
+
+ + {/* Enabled Toggle - hide for completed one-time schedules */} + {status === "triggered" ? ( +
+

Status

+
+
+ +

Completed

+
+

+ This one-time agent has finished running and will not run again. +

+
+
+ ) : ( +
+

Status

+
+
+

{enabled ? "Enabled" : "Disabled"}

+

+ {enabled ? "This agent will run according to its schedule" : "This agent is paused and will not run"} +

+
+ +
+
+ )} + + {/* Run Statistics */} +
+

Run History

+
+
+

{runCount}

+

Total Runs

+
+
+

{formatDateTime(lastRunAt)}

+

Last Run

+
+
+
+ + {/* Next Run */} + {nextRunAt && schedule.type !== "once" && ( +
+

Next Scheduled Run

+
+
+ + {formatDateTime(nextRunAt)} +
+
+
+ )} + + {/* Last Error */} + {lastError && ( +
+

Last Error

+
+
+ +

{lastError}

+
+
+
+ )} +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index cee59809..02422b20 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -3,6 +3,7 @@ import * as React from "react" import { useState } from "react" import { + Bot, ChevronRight, ChevronsDownUp, ChevronsUpDown, @@ -78,9 +79,27 @@ type RunListItem = { agentId: string } +type BackgroundTaskItem = { + name: string + description?: string + schedule: { + type: "cron" | "window" | "once" + expression?: string + cron?: string + startTime?: string + endTime?: string + runAt?: string + } + enabled: boolean + status?: "scheduled" | "running" | "finished" | "failed" | "triggered" + nextRunAt?: string | null + lastRunAt?: string | null +} + type TasksActions = { onNewChat: () => void onSelectRun: (runId: string) => void + onSelectBackgroundTask?: (taskName: string) => void } type SidebarContentPanelProps = { @@ -93,6 +112,8 @@ type SidebarContentPanelProps = { runs?: RunListItem[] currentRunId?: string | null tasksActions?: TasksActions + backgroundTasks?: BackgroundTaskItem[] + selectedBackgroundTask?: string | null } & React.ComponentProps const sectionTitles = { @@ -110,6 +131,8 @@ export function SidebarContentPanel({ runs = [], currentRunId, tasksActions, + backgroundTasks = [], + selectedBackgroundTask, ...props }: SidebarContentPanelProps) { const { activeSection } = useSidebarSection() @@ -137,6 +160,8 @@ export function SidebarContentPanel({ runs={runs} currentRunId={currentRunId} actions={tasksActions} + backgroundTasks={backgroundTasks} + selectedBackgroundTask={selectedBackgroundTask} /> )} @@ -653,15 +678,40 @@ function Tree({ ) } +// Get status indicator color +function getStatusColor(status?: string, enabled?: boolean): string { + // Disabled agents always show gray + if (enabled === false) { + return "bg-gray-400" + } + switch (status) { + case "running": + return "bg-blue-500" + case "finished": + return "bg-green-500" + case "failed": + return "bg-red-500" + case "triggered": + return "bg-gray-400" + case "scheduled": + default: + return "bg-yellow-500" + } +} + // Tasks Section function TasksSection({ runs, currentRunId, actions, + backgroundTasks = [], + selectedBackgroundTask, }: { runs: RunListItem[] currentRunId?: string | null actions?: TasksActions + backgroundTasks?: BackgroundTaskItem[] + selectedBackgroundTask?: string | null }) { return ( @@ -677,9 +727,38 @@ function TasksSection({
- {runs.length > 0 && ( + {/* Background Tasks Section */} + {backgroundTasks.length > 0 && ( <>
+ Background Tasks +
+ + {backgroundTasks.map((task) => ( + + actions?.onSelectBackgroundTask?.(task.name)} + className="gap-2" + > +
+ + +
+ + {task.name} + +
+
+ ))} +
+ + )} + {runs.length > 0 && ( + <> +
Chat history
diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 7fb0bc68..20f12f6b 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -12,9 +12,9 @@ "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", - "@composio/core": "^0.6.0", "@ai-sdk/openai-compatible": "^1.0.27", "@ai-sdk/provider": "^2.0.0", + "@composio/core": "^0.6.0", "@google-cloud/local-auth": "^3.0.1", "@modelcontextprotocol/sdk": "^1.25.1", "@openrouter/ai-sdk-provider": "^1.2.6", @@ -24,6 +24,7 @@ "ai": "^5.0.102", "awilix": "^12.0.5", "chokidar": "^4.0.3", + "cron-parser": "^5.5.0", "glob": "^13.0.0", "google-auth-library": "^10.5.0", "googleapis": "^169.0.0", diff --git a/apps/x/packages/core/src/agent-schedule/repo.ts b/apps/x/packages/core/src/agent-schedule/repo.ts new file mode 100644 index 00000000..f32eb0ae --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/repo.ts @@ -0,0 +1,43 @@ +import { WorkDir } from "../config/config.js"; +import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; +import fs from "fs/promises"; +import path from "path"; +import z from "zod"; + +const DEFAULT_AGENT_SCHEDULES: z.infer["agents"] = {}; + +export interface IAgentScheduleRepo { + ensureConfig(): Promise; + getConfig(): Promise>; + upsert(agentName: string, entry: z.infer): Promise; + delete(agentName: string): Promise; +} + +export class FSAgentScheduleRepo implements IAgentScheduleRepo { + private readonly configPath = path.join(WorkDir, "config", "agent-schedule.json"); + + async ensureConfig(): Promise { + try { + await fs.access(this.configPath); + } catch { + await fs.writeFile(this.configPath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULES }, null, 2)); + } + } + + async getConfig(): Promise> { + const config = await fs.readFile(this.configPath, "utf8"); + return AgentScheduleConfig.parse(JSON.parse(config)); + } + + async upsert(agentName: string, entry: z.infer): Promise { + const conf = await this.getConfig(); + conf.agents[agentName] = entry; + await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); + } + + async delete(agentName: string): Promise { + const conf = await this.getConfig(); + delete conf.agents[agentName]; + await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); + } +} diff --git a/apps/x/packages/core/src/agent-schedule/runner.ts b/apps/x/packages/core/src/agent-schedule/runner.ts new file mode 100644 index 00000000..4eab6081 --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/runner.ts @@ -0,0 +1,335 @@ +import { CronExpressionParser } from "cron-parser"; +import container from "../di/container.js"; +import { IAgentScheduleRepo } from "./repo.js"; +import { IAgentScheduleStateRepo } from "./state-repo.js"; +import { IRunsRepo } from "../runs/repo.js"; +import { IAgentRuntime } from "../agents/runtime.js"; +import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; +import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; +import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; +import { MessageEvent } from "@x/shared/dist/runs.js"; +import z from "zod"; + +const DEFAULT_STARTING_MESSAGE = "go"; + +const POLL_INTERVAL_MS = 60 * 1000; // 1 minute +const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Convert a Date to local ISO 8601 string (without Z suffix). + * Example: "2024-02-05T08:30:00" + */ +function toLocalISOString(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +// --- Wake Signal for Immediate Run Trigger --- +let wakeResolve: (() => void) | null = null; + +export function triggerRun(): void { + if (wakeResolve) { + console.log("[AgentRunner] Triggered - waking up immediately"); + wakeResolve(); + wakeResolve = null; + } +} + +function interruptibleSleep(ms: number): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timeout); + resolve(); + }; + }); +} + +/** + * Calculate the next run time for a schedule. + * Returns ISO datetime string or null if schedule shouldn't run again. + */ +function calculateNextRunAt( + schedule: z.infer["schedule"] +): string | null { + const now = new Date(); + + switch (schedule.type) { + case "cron": { + try { + const interval = CronExpressionParser.parse(schedule.expression, { + currentDate: now, + }); + return toLocalISOString(interval.next().toDate()); + } catch (error) { + console.error("[AgentRunner] Invalid cron expression:", schedule.expression, error); + return null; + } + } + case "window": { + try { + // Parse base cron to get the next occurrence date + const interval = CronExpressionParser.parse(schedule.cron, { + currentDate: now, + }); + const nextDate = interval.next().toDate(); + + // Parse start and end times + const [startHour, startMin] = schedule.startTime.split(":").map(Number); + const [endHour, endMin] = schedule.endTime.split(":").map(Number); + + // Pick a random time within the window + const startMinutes = startHour * 60 + startMin; + const endMinutes = endHour * 60 + endMin; + const randomMinutes = startMinutes + Math.floor(Math.random() * (endMinutes - startMinutes)); + + nextDate.setHours(Math.floor(randomMinutes / 60), randomMinutes % 60, 0, 0); + return toLocalISOString(nextDate); + } catch (error) { + console.error("[AgentRunner] Invalid window schedule:", error); + return null; + } + } + case "once": { + // Once schedules don't have a "next" run - they're done after first run + return null; + } + } +} + +/** + * Check if an agent should run now based on its schedule and state. + */ +function shouldRunNow( + entry: z.infer, + state: z.infer | null +): boolean { + // Don't run if disabled + if (entry.enabled === false) { + return false; + } + + // Don't run if already running + if (state?.status === "running") { + return false; + } + + // Don't run once-schedules that are already triggered + if (entry.schedule.type === "once" && state?.status === "triggered") { + return false; + } + + const now = new Date(); + + // For once-schedules without state, check if runAt time has passed + if (entry.schedule.type === "once") { + const runAt = new Date(entry.schedule.runAt); + return now >= runAt; + } + + // For cron and window schedules, check nextRunAt + if (!state?.nextRunAt) { + // No nextRunAt set - needs to be initialized, so run now + return true; + } + + const nextRunAt = new Date(state.nextRunAt); + return now >= nextRunAt; +} + +/** + * Run a single agent. + */ +async function runAgent( + agentName: string, + entry: z.infer, + stateRepo: IAgentScheduleStateRepo, + runsRepo: IRunsRepo, + agentRuntime: IAgentRuntime, + idGenerator: IMonotonicallyIncreasingIdGenerator +): Promise { + console.log(`[AgentRunner] Starting agent: ${agentName}`); + + const startedAt = toLocalISOString(new Date()); + + // Update state to running with startedAt timestamp + await stateRepo.updateAgentState(agentName, { + status: "running", + startedAt: startedAt, + }); + + try { + // Create a new run + const run = await runsRepo.create({ agentId: agentName }); + console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`); + + // Add the starting message as a user message + const startingMessage = entry.startingMessage ?? DEFAULT_STARTING_MESSAGE; + const messageEvent: z.infer = { + runId: run.id, + type: "message", + messageId: await idGenerator.next(), + message: { + role: "user", + content: startingMessage, + }, + subflow: [], + }; + await runsRepo.appendEvents(run.id, [messageEvent]); + console.log(`[AgentRunner] Sent starting message to agent ${agentName}: "${startingMessage}"`); + + // Trigger the run + await agentRuntime.trigger(run.id); + + // Calculate next run time + const nextRunAt = calculateNextRunAt(entry.schedule); + + // Update state to finished (clear startedAt) + const currentState = await stateRepo.getAgentState(agentName); + await stateRepo.updateAgentState(agentName, { + status: entry.schedule.type === "once" ? "triggered" : "finished", + startedAt: null, + lastRunAt: toLocalISOString(new Date()), + nextRunAt: nextRunAt, + lastError: null, + runCount: (currentState?.runCount ?? 0) + 1, + }); + + console.log(`[AgentRunner] Finished agent: ${agentName}`); + } catch (error) { + console.error(`[AgentRunner] Error running agent ${agentName}:`, error); + + // Calculate next run time even on failure (for retry) + const nextRunAt = calculateNextRunAt(entry.schedule); + + // Update state to failed (clear startedAt) + const currentState = await stateRepo.getAgentState(agentName); + await stateRepo.updateAgentState(agentName, { + status: "failed", + startedAt: null, + lastRunAt: toLocalISOString(new Date()), + nextRunAt: nextRunAt, + lastError: error instanceof Error ? error.message : String(error), + runCount: (currentState?.runCount ?? 0) + 1, + }); + } +} + +/** + * Check for timed-out agents and mark them as failed. + */ +async function checkForTimeouts( + state: z.infer, + config: z.infer, + stateRepo: IAgentScheduleStateRepo +): Promise { + const now = new Date(); + + for (const [agentName, agentState] of Object.entries(state.agents)) { + if (agentState.status === "running" && agentState.startedAt) { + const startedAt = new Date(agentState.startedAt); + const elapsed = now.getTime() - startedAt.getTime(); + + if (elapsed > TIMEOUT_MS) { + console.log(`[AgentRunner] Agent ${agentName} timed out after ${Math.round(elapsed / 1000 / 60)} minutes`); + + // Get schedule entry for calculating next run + const entry = config.agents[agentName]; + const nextRunAt = entry ? calculateNextRunAt(entry.schedule) : null; + + await stateRepo.updateAgentState(agentName, { + status: "failed", + startedAt: null, + lastRunAt: toLocalISOString(now), + nextRunAt: nextRunAt, + lastError: `Timed out after ${Math.round(elapsed / 1000 / 60)} minutes`, + runCount: (agentState.runCount ?? 0) + 1, + }); + } + } + } +} + +/** + * Main polling loop. + */ +async function pollAndRun(): Promise { + const scheduleRepo = container.resolve("agentScheduleRepo"); + const stateRepo = container.resolve("agentScheduleStateRepo"); + const runsRepo = container.resolve("runsRepo"); + const agentRuntime = container.resolve("agentRuntime"); + const idGenerator = container.resolve("idGenerator"); + + // Load config and state + let config: z.infer; + let state: z.infer; + + try { + config = await scheduleRepo.getConfig(); + state = await stateRepo.getState(); + } catch (error) { + console.error("[AgentRunner] Error loading config/state:", error); + return; + } + + // Check for timed-out agents first + await checkForTimeouts(state, config, stateRepo); + + // Reload state after timeout checks (state may have changed) + try { + state = await stateRepo.getState(); + } catch (error) { + console.error("[AgentRunner] Error reloading state:", error); + return; + } + + // Check each agent + for (const [agentName, entry] of Object.entries(config.agents)) { + const agentState = state.agents[agentName] ?? null; + + // Initialize state if needed (set nextRunAt for new agents) + if (!agentState && entry.schedule.type !== "once") { + const nextRunAt = calculateNextRunAt(entry.schedule); + if (nextRunAt) { + await stateRepo.updateAgentState(agentName, { + status: "scheduled", + startedAt: null, + lastRunAt: null, + nextRunAt: nextRunAt, + lastError: null, + runCount: 0, + }); + console.log(`[AgentRunner] Initialized state for ${agentName}, next run at ${nextRunAt}`); + } + continue; // Don't run immediately on first initialization + } + + if (shouldRunNow(entry, agentState)) { + // Run agent (don't await - let it run in background) + runAgent(agentName, entry, stateRepo, runsRepo, agentRuntime, idGenerator).catch((error) => { + console.error(`[AgentRunner] Unhandled error in runAgent for ${agentName}:`, error); + }); + } + } +} + +/** + * Initialize the background agent runner service. + * Polls every minute to check for agents that need to run. + */ +export async function init(): Promise { + console.log("[AgentRunner] Starting background agent runner service"); + + while (true) { + try { + await pollAndRun(); + } catch (error) { + console.error("[AgentRunner] Error in main loop:", error); + } + + await interruptibleSleep(POLL_INTERVAL_MS); + } +} diff --git a/apps/x/packages/core/src/agent-schedule/state-repo.ts b/apps/x/packages/core/src/agent-schedule/state-repo.ts new file mode 100644 index 00000000..38c8f034 --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/state-repo.ts @@ -0,0 +1,64 @@ +import { WorkDir } from "../config/config.js"; +import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; +import fs from "fs/promises"; +import path from "path"; +import z from "zod"; + +const DEFAULT_AGENT_SCHEDULE_STATE: z.infer["agents"] = {}; + +export interface IAgentScheduleStateRepo { + ensureState(): Promise; + getState(): Promise>; + getAgentState(agentName: string): Promise | null>; + updateAgentState(agentName: string, entry: Partial>): Promise; + setAgentState(agentName: string, entry: z.infer): Promise; + deleteAgentState(agentName: string): Promise; +} + +export class FSAgentScheduleStateRepo implements IAgentScheduleStateRepo { + private readonly statePath = path.join(WorkDir, "config", "agent-schedule-state.json"); + + async ensureState(): Promise { + try { + await fs.access(this.statePath); + } catch { + await fs.writeFile(this.statePath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULE_STATE }, null, 2)); + } + } + + async getState(): Promise> { + const state = await fs.readFile(this.statePath, "utf8"); + return AgentScheduleState.parse(JSON.parse(state)); + } + + async getAgentState(agentName: string): Promise | null> { + const state = await this.getState(); + return state.agents[agentName] ?? null; + } + + async updateAgentState(agentName: string, entry: Partial>): Promise { + const state = await this.getState(); + const existing = state.agents[agentName] ?? { + status: "scheduled" as const, + startedAt: null, + lastRunAt: null, + nextRunAt: null, + lastError: null, + runCount: 0, + }; + state.agents[agentName] = { ...existing, ...entry }; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } + + async setAgentState(agentName: string, entry: z.infer): Promise { + const state = await this.getState(); + state.agents[agentName] = entry; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } + + async deleteAgentState(agentName: string): Promise { + const state = await this.getState(); + delete state.agents[agentName]; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } +} diff --git a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts new file mode 100644 index 00000000..7ac1b89e --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts @@ -0,0 +1,555 @@ +export const skill = String.raw` +# Background Agents + +Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace. + +## Core Concepts + +**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. + +- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter +- Agents configure a model, tools (in frontmatter), and instructions (in the body) +- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents** +- **"Workflows" are just agents that orchestrate other agents** by having them as tools +- **Background agents run on schedules** defined in ` + "`~/.rowboat/config/agent-schedule.json`" + ` + +## How multi-agent workflows work + +1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + ` +2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below) +3. The orchestrator calls other agents as tools when needed +4. Data flows through tool call parameters and responses + +## Scheduling Background Agents + +Background agents run automatically based on schedules defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `. + +### Schedule Configuration File + +` + "```json" + ` +{ + "agents": { + "agent_name": { + "schedule": { ... }, + "enabled": true + } + } +} +` + "```" + ` + +### Schedule Types + +**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat). + +**1. Cron Schedule** - Runs at exact times defined by cron expression +` + "```json" + ` +{ + "schedule": { + "type": "cron", + "expression": "0 8 * * *" + }, + "enabled": true +} +` + "```" + ` + +Common cron expressions: +- ` + "`*/5 * * * *`" + ` - Every 5 minutes +- ` + "`0 8 * * *`" + ` - Every day at 8am +- ` + "`0 9 * * 1`" + ` - Every Monday at 9am +- ` + "`0 0 1 * *`" + ` - First day of every month at midnight + +**2. Window Schedule** - Runs once during a time window +` + "```json" + ` +{ + "schedule": { + "type": "window", + "cron": "0 0 * * *", + "startTime": "08:00", + "endTime": "10:00" + }, + "enabled": true +} +` + "```" + ` + +The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am"). + +**3. Once Schedule** - Runs exactly once at a specific time +` + "```json" + ` +{ + "schedule": { + "type": "once", + "runAt": "2024-02-05T10:30:00" + }, + "enabled": true +} +` + "```" + ` + +Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix). + +### Starting Message + +You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `. + +` + "```json" + ` +{ + "schedule": { "type": "cron", "expression": "0 8 * * *" }, + "enabled": true, + "startingMessage": "Please summarize my emails from the last 24 hours" +} +` + "```" + ` + +### Description + +You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI. + +` + "```json" + ` +{ + "schedule": { "type": "cron", "expression": "0 8 * * *" }, + "enabled": true, + "description": "Summarizes emails and calendar events every morning" +} +` + "```" + ` + +### Complete Schedule Example + +` + "```json" + ` +{ + "agents": { + "daily_digest": { + "schedule": { + "type": "cron", + "expression": "0 8 * * *" + }, + "enabled": true, + "description": "Daily email and calendar summary", + "startingMessage": "Summarize my emails and calendar for today" + }, + "morning_briefing": { + "schedule": { + "type": "window", + "cron": "0 0 * * *", + "startTime": "07:00", + "endTime": "09:00" + }, + "enabled": true, + "description": "Morning news and updates briefing" + }, + "one_time_setup": { + "schedule": { + "type": "once", + "runAt": "2024-12-01T12:00:00" + }, + "enabled": true, + "description": "One-time data migration task" + } + } +} +` + "```" + ` + +### Schedule State (Read-Only) + +**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner. + +The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `: +- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) +- ` + "`lastRunAt`" + `: When the agent last ran +- ` + "`nextRunAt`" + `: When the agent will run next +- ` + "`lastError`" + `: Error message if the last run failed +- ` + "`runCount`" + `: Total number of runs + +When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `. + +## Agent File Format + +Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. + +### Basic Structure +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + tool_key: + type: builtin + name: tool_name +--- +# Instructions + +Your detailed instructions go here in Markdown format. +` + "```" + ` + +### Frontmatter Fields +- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5') +- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json +- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions + +### Instructions (Body) +The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting. + +### Naming Rules +- Agent filename determines the agent name (without .md extension) +- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent" +- Use lowercase with underscores for multi-word names +- No spaces or special characters in names +- **The agent name in agent-schedule.json must match the filename** (without .md) + +### Agent Format Example +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string + description: Search query + required: + - query +--- +# Web Search Agent + +You are a web search agent. When asked a question: + +1. Use the search tool to find relevant information +2. Summarize the results clearly +3. Cite your sources + +Be concise and accurate. +` + "```" + ` + +## Tool Types & Schemas + +Tools in agents must follow one of three types. Each has specific required fields. + +### 1. Builtin Tools +Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: builtin + name: tool_name +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "builtin" +- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile") + +**Example:** +` + "```yaml" + ` +bash: + type: builtin + name: executeCommand +` + "```" + ` + +**Available builtin tools:** +- ` + "`executeCommand`" + ` - Execute shell commands +- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations +- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations +- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management +- ` + "`analyzeAgent`" + ` - Analyze agent structure +- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management +- ` + "`loadSkill`" + ` - Load skill guidance + +### 2. MCP Tools +Tools from external MCP servers (APIs, databases, web scraping, etc.) + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: mcp + name: tool_name_from_server + description: What the tool does + mcpServerName: server_name_from_config + inputSchema: + type: object + properties: + param: + type: string + description: Parameter description + required: + - param +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "mcp" +- ` + "`name`" + `: Exact tool name from MCP server +- ` + "`description`" + `: What the tool does (helps agent understand when to use it) +- ` + "`mcpServerName`" + `: Server name from config/mcp.json +- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters + +**Example:** +` + "```yaml" + ` +search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string + description: Search query + required: + - query +` + "```" + ` + +**Important:** +- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server +- Copy the schema exactly—don't modify property types or structure +- Only include ` + "`required`" + ` array if parameters are mandatory + +### 3. Agent Tools (for chaining agents) +Reference other agents as tools to build multi-agent workflows + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: agent + name: target_agent_name +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "agent" +- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory) + +**Example:** +` + "```yaml" + ` +summariser: + type: agent + name: summariser_agent +` + "```" + ` + +**How it works:** +- Use ` + "`type: agent`" + ` to call other agents as tools +- The target agent will be invoked with the parameters you pass +- Results are returned as tool output +- This is how you build multi-agent workflows +- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `) + +## Complete Multi-Agent Workflow Example + +**Email digest workflow** - This is all done through agents calling other agents: + +**1. Task-specific agent** (` + "`agents/email_reader.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + read_file: + type: builtin + name: workspace-readFile + list_dir: + type: builtin + name: workspace-readdir +--- +# Email Reader Agent + +Read emails from the gmail_sync folder and extract key information. +Look for unread or recent emails and summarize the sender, subject, and key points. +Don't ask for human input. +` + "```" + ` + +**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + email_reader: + type: agent + name: email_reader + write_file: + type: builtin + name: workspace-writeFile +--- +# Daily Summary Agent + +1. Use the email_reader tool to get email summaries +2. Create a consolidated daily digest +3. Save the digest to ~/Desktop/daily_digest.md + +Don't ask for human input. +` + "```" + ` + +Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions. + +**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + daily_summary: + type: agent + name: daily_summary + search: + type: mcp + name: search + mcpServerName: exa + description: Search the web for news + inputSchema: + type: object + properties: + query: + type: string + description: Search query +--- +# Morning Briefing Workflow + +Create a morning briefing: + +1. Get email digest using daily_summary +2. Search for relevant news using the search tool +3. Compile a comprehensive morning briefing + +Execute these steps in sequence. Don't ask for human input. +` + "```" + ` + +**4. Schedule the workflow** in ` + "`~/.rowboat/config/agent-schedule.json`" + `: +` + "```json" + ` +{ + "agents": { + "morning_briefing": { + "schedule": { + "type": "cron", + "expression": "0 7 * * *" + }, + "enabled": true, + "startingMessage": "Create my morning briefing for today" + } + } +} +` + "```" + ` + +This schedules the morning briefing workflow to run every day at 7am local time. + +## Naming and organization rules +- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter +- Agent filename (without .md) becomes the agent name +- When referencing an agent as a tool, use its filename without extension +- When scheduling an agent, use its filename without extension in agent-schedule.json +- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users + +## Best practices for background agents +1. **Single responsibility**: Each agent should do one specific thing well +2. **Clear delegation**: Agent instructions should explicitly say when to call other agents +3. **Autonomous operation**: Add "Don't ask for human input" for background agents +4. **Data passing**: Make it clear what data to extract and pass between agents +5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") +6. **Orchestration**: Create a top-level agent that coordinates the workflow +7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks +8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene +9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations +10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md" + +## Validation & Best Practices + +### CRITICAL: Schema Compliance +- Agent files MUST be valid Markdown with YAML frontmatter +- Agent filename (without .md) becomes the agent name +- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent") +- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema +- Agent tools MUST reference existing agent files +- Invalid agents will fail to load and prevent workflow execution + +### File Creation/Update Process +1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter +2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + ` +3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent +4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `) +5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + ` + +### Common Validation Errors to Avoid + +❌ **WRONG - Missing frontmatter delimiters:** +` + "```markdown" + ` +model: gpt-5.1 +# My Agent +Instructions here +` + "```" + ` + +❌ **WRONG - Invalid YAML indentation:** +` + "```markdown" + ` +--- +tools: +bash: + type: builtin +--- +` + "```" + ` +(bash should be indented under tools) + +❌ **WRONG - Invalid tool type:** +` + "```yaml" + ` +tools: + tool1: + type: custom + name: something +` + "```" + ` +(type must be builtin, mcp, or agent) + +❌ **WRONG - Unquoted strings containing colons:** +` + "```yaml" + ` +tools: + search: + description: Number of results (default: 8) +` + "```" + ` +(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `) + +❌ **WRONG - MCP tool missing required fields:** +` + "```yaml" + ` +tools: + search: + type: mcp + name: firecrawl_search +` + "```" + ` +(Missing: description, mcpServerName, inputSchema) + +✅ **CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +--- +# Simple Agent + +Do simple tasks as instructed. +` + "```" + ` + +✅ **CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string +--- +# Search Agent + +Use the search tool to find information on the web. +` + "```" + ` + +## Capabilities checklist +1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing +2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes +3. Validate YAML frontmatter syntax before creating/updating agents +4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update +5. When creating multi-agent workflows, create an orchestrator agent +6. Add other agents as tools with ` + "`type: agent`" + ` for chaining +7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations +8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) +9. Confirm work done and outline next steps once changes are complete +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 6ef19e8d..0d167a52 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -8,9 +8,8 @@ import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; import slackSkill from "./slack/skill.js"; -import workflowAuthoringSkill from "./workflow-authoring/skill.js"; +import backgroundAgentsSkill from "./background-agents/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; -import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; @@ -66,10 +65,10 @@ const definitions: SkillDefinition[] = [ content: slackSkill, }, { - id: "workflow-authoring", - title: "Workflow Authoring", - summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.", - content: workflowAuthoringSkill, + id: "background-agents", + title: "Background Agents", + summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.", + content: backgroundAgentsSkill, }, { id: "builtin-tools", @@ -89,12 +88,6 @@ const definitions: SkillDefinition[] = [ summary: "Following the confirmation process before removing workflows or agents and their dependencies.", content: deletionGuardrailsSkill, }, - { - id: "workflow-run-ops", - title: "Workflow Run Operations", - summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.", - content: workflowRunOpsSkill, - }, ]; const skillEntries = definitions.map((definition) => ({ diff --git a/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts b/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts deleted file mode 100644 index bcd50258..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts +++ /dev/null @@ -1,384 +0,0 @@ -export const skill = String.raw` -# Agent and Workflow Authoring - -Load this skill whenever a user wants to inspect, create, or update agents inside the Rowboat workspace. - -## Core Concepts - -**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. - -- **All definitions live in \`agents/*.md\`** - Markdown files with YAML frontmatter -- Agents configure a model, tools (in frontmatter), and instructions (in the body) -- Tools can be: builtin (like \`executeCommand\`), MCP integrations, or **other agents** -- **"Workflows" are just agents that orchestrate other agents** by having them as tools - -## How multi-agent workflows work - -1. **Create an orchestrator agent** that has other agents in its \`tools\` -2. **Run the orchestrator**: \`rowboatx --agent orchestrator_name\` -3. The orchestrator calls other agents as tools when needed -4. Data flows through tool call parameters and responses - -## Agent File Format - -Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. - -### Basic Structure -\`\`\`markdown ---- -model: gpt-5.1 -tools: - tool_key: - type: builtin - name: tool_name ---- -# Instructions - -Your detailed instructions go here in Markdown format. -\`\`\` - -### Frontmatter Fields -- \`model\`: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5') -- \`provider\`: (OPTIONAL) Provider alias from models.json -- \`tools\`: (OPTIONAL) Object containing tool definitions - -### Instructions (Body) -The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting. - -### Naming Rules -- Agent filename determines the agent name (without .md extension) -- Example: \`summariser_agent.md\` creates an agent named "summariser_agent" -- Use lowercase with underscores for multi-word names -- No spaces or special characters in names - -### Agent Format Example -\`\`\`markdown ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query ---- -# Web Search Agent - -You are a web search agent. When asked a question: - -1. Use the search tool to find relevant information -2. Summarize the results clearly -3. Cite your sources - -Be concise and accurate. -\`\`\` - -## Tool Types & Schemas - -Tools in agents must follow one of three types. Each has specific required fields. - -### 1. Builtin Tools -Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: builtin - name: tool_name -\`\`\` - -**Required fields:** -- \`type\`: Must be "builtin" -- \`name\`: Builtin tool name (e.g., "executeCommand", "workspace-readFile") - -**Example:** -\`\`\`yaml -bash: - type: builtin - name: executeCommand -\`\`\` - -**Available builtin tools:** -- \`executeCommand\` - Execute shell commands -- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-remove\` - File operations -- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\` - Directory operations -- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management -- \`analyzeAgent\` - Analyze agent structure -- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\` - MCP management -- \`loadSkill\` - Load skill guidance - -### 2. MCP Tools -Tools from external MCP servers (APIs, databases, web scraping, etc.) - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: mcp - name: tool_name_from_server - description: What the tool does - mcpServerName: server_name_from_config - inputSchema: - type: object - properties: - param: - type: string - description: Parameter description - required: - - param -\`\`\` - -**Required fields:** -- \`type\`: Must be "mcp" -- \`name\`: Exact tool name from MCP server -- \`description\`: What the tool does (helps agent understand when to use it) -- \`mcpServerName\`: Server name from config/mcp.json -- \`inputSchema\`: Full JSON Schema object for tool parameters - -**Example:** -\`\`\`yaml -search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query -\`\`\` - -**Important:** -- Use \`listMcpTools\` to get the exact inputSchema from the server -- Copy the schema exactly—don't modify property types or structure -- Only include \`required\` array if parameters are mandatory - -### 3. Agent Tools (for chaining agents) -Reference other agents as tools to build multi-agent workflows - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: agent - name: target_agent_name -\`\`\` - -**Required fields:** -- \`type\`: Must be "agent" -- \`name\`: Name of the target agent (must exist in agents/ directory) - -**Example:** -\`\`\`yaml -summariser: - type: agent - name: summariser_agent -\`\`\` - -**How it works:** -- Use \`type: agent\` to call other agents as tools -- The target agent will be invoked with the parameters you pass -- Results are returned as tool output -- This is how you build multi-agent workflows -- The referenced agent file must exist (e.g., \`agents/summariser_agent.md\`) - -## Complete Multi-Agent Workflow Example - -**Podcast creation workflow** - This is all done through agents calling other agents: - -**1. Task-specific agent** (\`agents/summariser_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - bash: - type: builtin - name: executeCommand ---- -# Summariser Agent - -Download and summarise an arxiv paper. Use curl to fetch the PDF. -Output just the GIST in two lines. Don't ask for human input. -\`\`\` - -**2. Agent that delegates to other agents** (\`agents/summarise-a-few.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - summariser: - type: agent - name: summariser_agent ---- -# Summarise Multiple Papers - -Pick 2 interesting papers and summarise each using the summariser tool. -Pass the paper URL to the tool. Don't ask for human input. -\`\`\` - -**3. Orchestrator agent** (\`agents/podcast_workflow.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - bash: - type: builtin - name: executeCommand - summarise_papers: - type: agent - name: summarise-a-few - text_to_speech: - type: mcp - name: text_to_speech - mcpServerName: elevenLabs - description: Generate audio from text - inputSchema: - type: object - properties: - text: - type: string - description: Text to convert to speech ---- -# Podcast Workflow - -Create a podcast from arXiv papers: - -1. Fetch arXiv papers about agents using bash -2. Pick papers and summarise them using summarise_papers -3. Create a podcast transcript -4. Generate audio using text_to_speech - -Execute these steps in sequence. -\`\`\` - -**To run this workflow**: \`rowboatx --agent podcast_workflow\` - -## Naming and organization rules -- **All agents live in \`agents/*.md\`** - Markdown files with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- When referencing an agent as a tool, use its filename without extension -- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users - -## Best practices for multi-agent design -1. **Single responsibility**: Each agent should do one specific thing well -2. **Clear delegation**: Agent instructions should explicitly say when to call other agents -3. **Autonomous operation**: Add "Don't ask for human input" for autonomous workflows -4. **Data passing**: Make it clear what data to extract and pass between agents -5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") -6. **Orchestration**: Create a top-level agent that coordinates the workflow - -## Validation & Best Practices - -### CRITICAL: Schema Compliance -- Agent files MUST be valid Markdown with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- Tools in frontmatter MUST have valid \`type\` ("builtin", "mcp", or "agent") -- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema -- Agent tools MUST reference existing agent files -- Invalid agents will fail to load and prevent workflow execution - -### File Creation/Update Process -1. When creating an agent, use \`workspace-writeFile\` with valid Markdown + YAML frontmatter -2. When updating an agent, read it first with \`workspace-readFile\`, modify, then use \`workspace-writeFile\` -3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent -4. **Quote strings containing colons** (e.g., \`description: "Default: 8"\` not \`description: Default: 8\`) -5. Test agent loading after creation/update by using \`analyzeAgent\` - -### Common Validation Errors to Avoid - -❌ **WRONG - Missing frontmatter delimiters:** -\`\`\`markdown -model: gpt-5.1 -# My Agent -Instructions here -\`\`\` - -❌ **WRONG - Invalid YAML indentation:** -\`\`\`markdown ---- -tools: -bash: - type: builtin ---- -\`\`\` -(bash should be indented under tools) - -❌ **WRONG - Invalid tool type:** -\`\`\`yaml -tools: - tool1: - type: custom - name: something -\`\`\` -(type must be builtin, mcp, or agent) - -❌ **WRONG - Unquoted strings containing colons:** -\`\`\`yaml -tools: - search: - description: Number of results (default: 8) -\`\`\` -(Strings with colons must be quoted: \`description: "Number of results (default: 8)"\`) - -❌ **WRONG - MCP tool missing required fields:** -\`\`\`yaml -tools: - search: - type: mcp - name: firecrawl_search -\`\`\` -(Missing: description, mcpServerName, inputSchema) - -✅ **CORRECT - Minimal valid agent** (\`agents/simple_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 ---- -# Simple Agent - -Do simple tasks as instructed. -\`\`\` - -✅ **CORRECT - Agent with MCP tool** (\`agents/search_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string ---- -# Search Agent - -Use the search tool to find information on the web. -\`\`\` - -## Capabilities checklist -1. Explore \`agents/\` directory to understand existing agents before editing -2. Read existing agents with \`workspace-readFile\` before making changes -3. Validate YAML frontmatter syntax before creating/updating agents -4. Use \`analyzeAgent\` to verify agent structure after creation/update -5. When creating multi-agent workflows, create an orchestrator agent -6. Add other agents as tools with \`type: agent\` for chaining -7. Use \`listMcpServers\` and \`listMcpTools\` when adding MCP integrations -8. Confirm work done and outline next steps once changes are complete -`; - -export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts b/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts deleted file mode 100644 index 25f62267..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts +++ /dev/null @@ -1,95 +0,0 @@ -export const skill = String.raw` -# Agent Run Operations - -Package of repeatable commands for running agents, inspecting agent run history under ~/.rowboat/runs, and managing cron schedules. Load this skill whenever a user asks about running agents, execution history, paused runs, or scheduling. - -## When to use -- User wants to run an agent (including multi-agent workflows) -- User wants to list or filter agent runs (all runs, by agent, time range, or paused for input) -- User wants to inspect cron jobs or change agent schedules -- User asks how to set up monitoring for waiting runs - -## Running Agents - -**To run any agent**: -\`\`\`bash -rowboatx --agent -\`\`\` - -**With input**: -\`\`\`bash -rowboatx --agent --input "your input here" -\`\`\` - -**Non-interactive** (for automation/cron): -\`\`\`bash -rowboatx --agent --input "input" --no-interactive -\`\`\` - -**Note**: Multi-agent workflows are just agents that have other agents in their tools. Run the orchestrator agent to trigger the whole workflow. - -## Run monitoring examples -Operate from ~/.rowboat (Rowboat tools already set this as the working directory). Use executeCommand with the sample Bash snippets below, modifying placeholders as needed. - -Each run file name starts with a timestamp like '2025-11-12T08-02-41Z'. You can use this to filter for date/time ranges. - -Each line of the run file contains a running log with the first line containing information about the agent run. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","agent":"agent_name","interactive":true,"ts":"2025-11-12T08:02:41.168Z"}' - -If a run is waiting for human input the last line will contain 'paused_for_human_input'. See examples below. - -1. **List all runs** - - ls ~/.rowboat/runs - - -2. **Filter by agent** - - grep -rl '"agent":""' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r - - Replace with the desired agent name. - -3. **Filter by time window** - To the previous commands add the below through unix pipe - - awk -F'/' '$NF >= "2025-11-12T08-03" && $NF <= "2025-11-12T08-10"' - - Use the correct timestamps. - -4. **Show runs waiting for human input** - - awk 'FNR==1{if (NR>1) print fn, last; fn=FILENAME} {last=$0} END{print fn, last}' ~/.rowboat/runs/*.jsonl | grep 'pause-for-human-input' | awk '{print $1}' - - Prints the files whose last line equals 'pause-for-human-input'. - -## Cron management examples - -For scheduling agents to run automatically at specific times. - -1. **View current cron schedule** - \`\`\`bash - crontab -l 2>/dev/null || echo 'No crontab entries configured.' - \`\`\` - -2. **Schedule an agent to run periodically** - \`\`\`bash - (crontab -l 2>/dev/null; echo '0 10 * * * cd /path/to/cli && rowboatx --agent --input "input" --no-interactive >> ~/.rowboat/logs/.log 2>&1') | crontab - - \`\`\` - - Example (runs daily at 10 AM): - \`\`\`bash - (crontab -l 2>/dev/null; echo '0 10 * * * cd ~/rowboat-V2/apps/cli && rowboatx --agent podcast_workflow --no-interactive >> ~/.rowboat/logs/podcast.log 2>&1') | crontab - - \`\`\` - -3. **Unschedule/remove an agent** - \`\`\`bash - crontab -l | grep -v '' | crontab - - \`\`\` - -## Common cron schedule patterns -- \`0 10 * * *\` - Daily at 10 AM -- \`0 */6 * * *\` - Every 6 hours -- \`0 9 * * 1\` - Every Monday at 9 AM -- \`*/30 * * * *\` - Every 30 minutes -`; - -export default skill; diff --git a/apps/x/packages/core/src/config/initConfigs.ts b/apps/x/packages/core/src/config/initConfigs.ts index 1c447e37..adfb8b24 100644 --- a/apps/x/packages/core/src/config/initConfigs.ts +++ b/apps/x/packages/core/src/config/initConfigs.ts @@ -1,6 +1,8 @@ import container from "../di/container.js"; import type { IModelConfigRepo } from "../models/repo.js"; import type { IMcpConfigRepo } from "../mcp/repo.js"; +import type { IAgentScheduleRepo } from "../agent-schedule/repo.js"; +import type { IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { ensureSecurityConfig } from "./security.js"; /** @@ -11,10 +13,14 @@ export async function initConfigs(): Promise { // Resolve repos and explicitly call their ensureConfig methods const modelConfigRepo = container.resolve("modelConfigRepo"); const mcpConfigRepo = container.resolve("mcpConfigRepo"); + const agentScheduleRepo = container.resolve("agentScheduleRepo"); + const agentScheduleStateRepo = container.resolve("agentScheduleStateRepo"); await Promise.all([ modelConfigRepo.ensureConfig(), mcpConfigRepo.ensureConfig(), + agentScheduleRepo.ensureConfig(), + agentScheduleStateRepo.ensureState(), ensureSecurityConfig(), ]); } diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 2b3fd2d7..d02ca7e6 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -12,6 +12,8 @@ import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js"; import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js"; import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js"; +import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; +import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -33,6 +35,8 @@ container.register({ oauthRepo: asClass(FSOAuthRepo).singleton(), clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(), granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(), + agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(), + agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(), }); export default container; \ No newline at end of file diff --git a/apps/x/packages/shared/src/agent-schedule-state.ts b/apps/x/packages/shared/src/agent-schedule-state.ts new file mode 100644 index 00000000..09e9037c --- /dev/null +++ b/apps/x/packages/shared/src/agent-schedule-state.ts @@ -0,0 +1,17 @@ +import z from "zod"; + +// "triggered" is terminal state for once-schedules (will not run again) +export const AgentScheduleStatus = z.enum(["scheduled", "running", "finished", "failed", "triggered"]); + +export const AgentScheduleStateEntry = z.object({ + status: AgentScheduleStatus, + startedAt: z.string().nullable(), // When current run started (for timeout detection) + lastRunAt: z.string().nullable(), // ISO 8601 local datetime + nextRunAt: z.string().nullable(), // ISO 8601 local datetime + lastError: z.string().nullable(), + runCount: z.number().default(0), +}); + +export const AgentScheduleState = z.object({ + agents: z.record(z.string(), AgentScheduleStateEntry), +}); diff --git a/apps/x/packages/shared/src/agent-schedule.ts b/apps/x/packages/shared/src/agent-schedule.ts new file mode 100644 index 00000000..62184083 --- /dev/null +++ b/apps/x/packages/shared/src/agent-schedule.ts @@ -0,0 +1,44 @@ +import z from "zod"; + +// Cron schedule - runs at exact times defined by cron expression. +// Examples: +// - Every 5 minutes: "*/5 * * * *" +// - Everyday at 8am: "0 8 * * *" +// - Every Monday at 9am: "0 9 * * 1" +export const CronSchedule = z.object({ + type: z.literal("cron"), + expression: z.string(), +}); + +// Window schedule - runs once during a time window. +// The agent will run once at a random time within the specified window. +// Examples: +// - Daily between 8am and 10am: cron="0 0 * * *", startTime="08:00", endTime="10:00" +// - Weekly on Monday between 9am-12pm: cron="0 0 * * 1", startTime="09:00", endTime="12:00" +export const WindowSchedule = z.object({ + type: z.literal("window"), + cron: z.string(), // Base frequency cron expression + startTime: z.string(), // "HH:MM" format + endTime: z.string(), // "HH:MM" format +}); + +// Once schedule - runs exactly once at a specific time, then never again. +// Examples: +// - Run once at specific datetime: runAt="2024-02-05T10:30:00" +export const OnceSchedule = z.object({ + type: z.literal("once"), + runAt: z.string(), // ISO 8601 datetime (local time, e.g., "2024-02-05T10:30:00") +}); + +export const ScheduleDefinition = z.union([CronSchedule, WindowSchedule, OnceSchedule]); + +export const AgentScheduleEntry = z.object({ + schedule: ScheduleDefinition, + enabled: z.boolean().optional().default(true), + startingMessage: z.string().optional(), // Message sent to agent when run starts (defaults to "go") + description: z.string().optional(), // Brief description of what the agent does (for UI display) +}); + +export const AgentScheduleConfig = z.object({ + agents: z.record(z.string(), AgentScheduleEntry), +}); diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 3bca8969..5d54883f 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -4,4 +4,6 @@ export * as ipc from './ipc.js'; export * as models from './models.js'; export * as workspace from './workspace.js'; export * as mcp from './mcp.js'; +export * as agentSchedule from './agent-schedule.js'; +export * as agentScheduleState from './agent-schedule-state.js'; export { PrefixLogger }; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 2835a90b..767de9a0 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -3,6 +3,8 @@ import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, Work import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; import { LlmModelConfig } from './models.js'; +import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; +import { AgentScheduleState } from './agent-schedule-state.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -353,6 +355,32 @@ const ipcSchemas = { }), res: z.null(), }, + // Agent schedule channels + 'agent-schedule:getConfig': { + req: z.null(), + res: AgentScheduleConfig, + }, + 'agent-schedule:getState': { + req: z.null(), + res: AgentScheduleState, + }, + 'agent-schedule:updateAgent': { + req: z.object({ + agentName: z.string(), + entry: AgentScheduleEntry, + }), + res: z.object({ + success: z.literal(true), + }), + }, + 'agent-schedule:deleteAgent': { + req: z.object({ + agentName: z.string(), + }), + res: z.object({ + success: z.literal(true), + }), + }, } as const; // ============================================================================ diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 5995b0ea..2775c44c 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + cron-parser: + specifier: ^5.5.0 + version: 5.5.0 glob: specifier: ^13.0.0 version: 13.0.0 @@ -3452,6 +3455,10 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -4248,6 +4255,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -4256,12 +4264,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4898,6 +4906,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + macos-alias@0.2.12: resolution: {integrity: sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw==} os: [darwin] @@ -10620,6 +10632,10 @@ snapshots: crelt@1.0.6: {} + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + cross-dirname@0.1.0: {} cross-spawn@6.0.6: @@ -12345,6 +12361,8 @@ snapshots: dependencies: react: 19.2.3 + luxon@3.7.2: {} + macos-alias@0.2.12: dependencies: nan: 2.24.0 From 89e5a7f8bc4182adbd65ecccac6dc242ca2b4cb0 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:57:40 +0530 Subject: [PATCH 09/54] added presentation card and changed chat hello wording --- apps/x/apps/renderer/src/App.tsx | 9 ++------- .../renderer/src/components/ai-elements/suggestions.tsx | 8 +++++++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 3d01eee3..a0fe4e0e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1945,13 +1945,8 @@ function App() { {!hasConversation ? ( -
- Rowboat -
-
- - L - to open chat from anywhere +
+ What are we working on?
) : ( diff --git a/apps/x/apps/renderer/src/components/ai-elements/suggestions.tsx b/apps/x/apps/renderer/src/components/ai-elements/suggestions.tsx index 94eed585..7e4d2f57 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/suggestions.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/suggestions.tsx @@ -1,4 +1,4 @@ -import { Mail, Calendar, FolderOpen, FileText } from 'lucide-react' +import { Mail, Calendar, FolderOpen, FileText, Presentation } from 'lucide-react' import { cn } from '@/lib/utils' export interface Suggestion { @@ -33,6 +33,12 @@ const defaultSuggestions: Suggestion[] = [ prompt: 'Help me organize [folder or files]', icon: , }, + { + id: 'create-presentation', + label: 'Create a presentation', + prompt: 'Create a pdf presentation on [topic]', + icon: , + }, ] interface SuggestionsProps { From fc1e1b628ea39ce4b8c92200a6eec06b0931bd5d Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:08:04 +0530 Subject: [PATCH 10/54] use builtin tools for writing file --- .../skills/create-presentations/skill.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts index 594c0f7b..332bbab5 100644 --- a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts @@ -7,20 +7,22 @@ Activate when the user wants to create presentations, slide decks, or pitch deck ## Workflow -1. Check ~/.rowboat/knowledge/ for relevant context about the company, product, team, etc. +1. Use workspace-readFile to check knowledge/ for relevant context about the company, product, team, etc. 2. Ensure Playwright is installed: 'npm install playwright && npx playwright install chromium' -3. Create an HTML file (e.g., /tmp/presentation.html) with slides (1280x720px each) -4. Create a Node.js script to convert HTML to PDF: +3. Use workspace-getRoot to get the workspace root path. +4. Use workspace-writeFile to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each). +5. Use workspace-writeFile to create a Node.js conversion script at tmp/convert.js (workspace-relative): ~~~javascript -// save as /tmp/convert.js +// save as tmp/convert.js via workspace-writeFile const { chromium } = require('playwright'); const path = require('path'); (async () => { const browser = await chromium.launch(); const page = await browser.newPage(); - await page.goto('file:///tmp/presentation.html', { waitUntil: 'networkidle' }); + // Use the workspace root path from workspace-getRoot + await page.goto('file:///tmp/presentation.html', { waitUntil: 'networkidle' }); await page.pdf({ path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'), width: '1280px', @@ -32,10 +34,13 @@ const path = require('path'); })(); ~~~ -5. Run it: 'node /tmp/convert.js' -6. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" +Replace with the actual absolute path returned by workspace-getRoot. + +6. Run it: 'node /tmp/convert.js' +7. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" Do NOT show HTML code to the user. Do NOT explain how to export. Just create the PDF and deliver it. +Use workspace-writeFile and workspace-readFile for ALL file operations. Do NOT use executeCommand to write or read files. ## PDF Export Rules From 53f66c44c08a286cb5b788406636567e9e66b0cd Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:12:55 +0530 Subject: [PATCH 11/54] remove footers as they can collide --- .../application/assistant/skills/create-presentations/skill.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts index 332bbab5..97f801c8 100644 --- a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts @@ -50,6 +50,7 @@ Use workspace-writeFile and workspace-readFile for ALL file operations. Do NOT u 2. **No box-shadow** - Use borders instead: \`border: 1px solid #e5e7eb\` 3. **Bullets via CSS only** - Use \`li::before\` pseudo-elements, not separate DOM elements 4. **Content must fit** - Slides are 1280x720px with 60px padding. Safe area is 1160x600px. Use \`overflow: hidden\`. +5. **No footers or headers** - Never add fixed/absolute-positioned footer or header elements to slides. They overlap with content in PDF rendering. If you need a slide number or title, include it as part of the normal content flow. ## Required CSS From 1cddc705df178c4da8da1be816aaaf37338319aa Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:19:22 +0530 Subject: [PATCH 12/54] collab on doc does a readfile everytime --- .../src/application/assistant/skills/doc-collab/skill.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts b/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts index ba17f545..159ce638 100644 --- a/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts @@ -13,6 +13,10 @@ You are an expert document assistant helping the user create, edit, and refine d **Strictly follow their choice for the entire session.** Don't switch modes without asking. +## CRITICAL: Re-read Before Every Response + +**Before every response, you MUST use workspace-readFile to re-read the current document.** The user may have edited the file manually outside of this conversation. Always work with the latest version of the file, never rely on a cached or previous version. + ## Core Principles **Be concise and direct:** @@ -90,6 +94,8 @@ workspace-createFile({ ### Step 2: Understand the Request +**IMPORTANT: Never make unsolicited edits.** If the user hasn't specified what they want to do with the document, ask them: "What would you like to change?" Do NOT proactively improve, restructure, or suggest edits unless the user has explicitly asked for changes. + **Types of requests:** 1. **Direct edits** - "Change the title to X", "Add a bullet point about Y", "Remove the pricing section" @@ -104,6 +110,9 @@ workspace-createFile({ 4. **Research-backed additions** - "Add context about [Person]", "Include what we discussed with [Company]" → Search knowledge base first, then add relevant context +5. **No clear request** - User just says "let's work on X" with no specific ask + → Read the document, then ask: "What would you like to change?" + ### Step 3: Execute Changes **For edits, use workspace-editFile:** From c4c68d76be1f43de21370faf098c511b858432c8 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:03:47 +0530 Subject: [PATCH 13/54] refactor onboarding: remove welcome screen, streamline model setup, tighten spacing Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/renderer/public/logo-only.png | Bin 0 -> 22928 bytes .../src/components/onboarding-modal.tsx | 211 +++++++----------- 2 files changed, 79 insertions(+), 132 deletions(-) create mode 100644 apps/x/apps/renderer/public/logo-only.png diff --git a/apps/x/apps/renderer/public/logo-only.png b/apps/x/apps/renderer/public/logo-only.png new file mode 100644 index 0000000000000000000000000000000000000000..e2fd6386b98cb11d2ba86fa6114c7e0f09bb65fb GIT binary patch literal 22928 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 literal 0 HcmV?d00001 diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 2aac98d7..ad0a7da5 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react" +import { Loader2, Mic, Mail, CheckCircle2, MessageSquare } from "lucide-react" import { Dialog, @@ -38,7 +38,7 @@ interface OnboardingModalProps { onComplete: () => void } -type Step = 0 | 1 | 2 | 3 +type Step = 0 | 1 | 2 type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" @@ -68,8 +68,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle", }) - const [savingLlmConfig, setSavingLlmConfig] = useState(false) - // OAuth provider states const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) @@ -268,7 +266,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { }, [startSlackConnect]) const handleNext = () => { - if (currentStep < 3) { + if (currentStep < 2) { setCurrentStep((prev) => (prev + 1) as Step) } } @@ -277,24 +275,27 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onComplete() } - const handleTestConnection = useCallback(async () => { + const handleTestAndSaveLlmConfig = useCallback(async () => { if (!canTest) return setTestState({ status: "testing" }) try { const apiKey = activeConfig.apiKey.trim() || undefined const baseURL = activeConfig.baseURL.trim() || undefined const model = activeConfig.model.trim() - const result = await window.ipc.invoke("models:test", { + const providerConfig = { provider: { flavor: llmProvider, apiKey, baseURL, }, model, - }) + } + const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { setTestState({ status: "success" }) - toast.success("Connection successful") + // Save and continue + await window.ipc.invoke("models:saveConfig", providerConfig) + handleNext() } else { setTestState({ status: "error", error: result.error }) toast.error(result.error || "Connection test failed") @@ -304,31 +305,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { setTestState({ status: "error", error: "Connection test failed" }) toast.error("Connection test failed") } - }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider]) - - const handleSaveLlmConfig = useCallback(async () => { - if (testState.status !== "success") return - setSavingLlmConfig(true) - try { - const apiKey = activeConfig.apiKey.trim() || undefined - const baseURL = activeConfig.baseURL.trim() || undefined - const model = activeConfig.model.trim() - await window.ipc.invoke("models:saveConfig", { - provider: { - flavor: llmProvider, - apiKey, - baseURL, - }, - model, - }) - setSavingLlmConfig(false) - handleNext() - } catch (error) { - console.error("Failed to save LLM config:", error) - toast.error("Failed to save LLM settings") - setSavingLlmConfig(false) - } - }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, handleNext, llmProvider, testState.status]) + }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider, handleNext]) // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { @@ -468,7 +445,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step indicator component const StepIndicator = () => (
- {[0, 1, 2, 3].map((step) => ( + {[0, 1, 2].map((step) => (
) - // Step 0: Welcome - const WelcomeStep = () => ( -
-
- -
- - Your AI coworker, with memory - - Rowboat connects to your email, calendar, and meetings to help you stay on top of your work. - - -
-
-
1
-

Syncs with your email, calendar, and meetings

-
-
-
2
-

Remembers the people and context from your conversations

-
-
-
3
-

Helps you follow up and never miss what matters

-
-
- -
- ) - - // Step 1: LLM Setup + // Step 0: LLM Setup const LlmSetupStep = () => { - const providerOptions: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ + const [showMoreProviders, setShowMoreProviders] = useState(false) + + const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ { id: "openai", name: "OpenAI", description: "Use your OpenAI API key" }, { id: "anthropic", name: "Anthropic", description: "Use your Anthropic API key" }, - { id: "google", name: "Google", description: "Use your Google AI Studio key" }, + { id: "google", name: "Gemini", description: "Use your Google AI Studio key" }, + { id: "ollama", name: "Ollama (Local)", description: "Run a local model via Ollama" }, + ] + + const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ { id: "openrouter", name: "OpenRouter", description: "Access multiple models with one key" }, { id: "aigateway", name: "AI Gateway (Vercel)", description: "Use Vercel's AI Gateway" }, - { id: "ollama", name: "Ollama (Local)", description: "Run a local model via Ollama" }, { id: "openai-compatible", name: "OpenAI-Compatible", description: "Local or hosted OpenAI-compatible API" }, ] + const isMoreProvider = moreProviders.some(p => p.id === llmProvider) + const modelsForProvider = modelsCatalog[llmProvider] || [] const showModelInput = isLocalProvider || modelsForProvider.length === 0 + const renderProviderCard = (provider: { id: LlmProviderFlavor; name: string; description: string }) => ( + + ) + return (
- +
+ Rowboat + Your AI coworker, with memory +
+ Choose your model - - Select your provider and model to power Rowboat’s AI. - -
+
Provider
- {providerOptions.map((provider) => ( - - ))} + {primaryProviders.map(renderProviderCard)}
+ {(showMoreProviders || isMoreProvider) ? ( +
+ {moreProviders.map(renderProviderCard)} +
+ ) : ( + + )}
@@ -750,48 +717,36 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { )}
-
+ {testState.status === "error" && ( +
+ {testState.error || "Connection test failed"} +
+ )} + +
- {testState.status === "success" && ( - Connected - )} - {testState.status === "error" && ( - - {testState.error || "Test failed"} - - )} -
- -
-
) } - // Step 2: Connect Accounts + // Step 1: Connect Accounts const AccountConnectionStep = () => (
Connect Your Accounts - Connect your accounts to start syncing your data. You can always add more later. + Connect your accounts to start syncing your data locally. You can always add more later. @@ -821,13 +776,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')}
- {/* Team Communication Section */} -
-
- Team Communication -
- {renderSlackRow()} -
)}
@@ -843,7 +791,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) - // Step 3: Completion + // Step 2: Completion const CompletionStep = () => { const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected @@ -856,7 +804,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { You're All Set! {hasConnections ? ( - <>Your workspace will populate over the next ~30 minutes as we sync your data. + <>Give me 30 minutes to build your context graph.
I can still help with other things on your computer. ) : ( <>You can connect your accounts anytime from the sidebar to start syncing data. )} @@ -926,10 +874,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onEscapeKeyDown={(e) => e.preventDefault()} > - {currentStep === 0 && } - {currentStep === 1 && } - {currentStep === 2 && } - {currentStep === 3 && } + {currentStep === 0 && } + {currentStep === 1 && } + {currentStep === 2 && } From c3409c4cffde3af2d6998e4bb7149189698ad8f3 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:27:52 +0530 Subject: [PATCH 14/54] fix eslint + ts errs in renderer --- .../renderer/src/components/onboarding-modal.tsx | 13 +++++++++---- .../renderer/src/components/settings-dialog.tsx | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index ad0a7da5..b98fccf3 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,7 +2,8 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2, MessageSquare } from "lucide-react" +import { Loader2, Mic, Mail, CheckCircle2 } from "lucide-react" +// import { MessageSquare } from "lucide-react" import { Dialog, @@ -81,7 +82,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Composio/Slack state const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) const [slackConnected, setSlackConnected] = useState(false) - const [slackLoading, setSlackLoading] = useState(true) + // const [slackLoading, setSlackLoading] = useState(true) const [slackConnecting, setSlackConnecting] = useState(false) const updateProviderConfig = useCallback( @@ -212,14 +213,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Load Slack connection status const refreshSlackStatus = useCallback(async () => { try { - setSlackLoading(true) + // setSlackLoading(true) const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) setSlackConnected(result.isConnected) } catch (error) { console.error('Failed to load Slack status:', error) setSlackConnected(false) } finally { - setSlackLoading(false) + // setSlackLoading(false) } }, []) @@ -241,6 +242,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { }, []) // Connect to Slack via Composio (checks if configured first) + /* const handleConnectSlack = useCallback(async () => { // Check if Composio is configured const configResult = await window.ipc.invoke('composio:is-configured', null) @@ -250,6 +252,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } await startSlackConnect() }, [startSlackConnect]) + */ // Handle Composio API key submission const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { @@ -538,6 +541,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) // Render Slack row + /* const renderSlackRow = () => (
@@ -580,6 +584,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) + */ // Step 0: LLM Setup const LlmSetupStep = () => { diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 7d986401..26c5452f 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" -import { useTheme, type Theme } from "@/contexts/theme-context" +import { useTheme } from "@/contexts/theme-context" type ConfigTab = "models" | "mcp" | "security" | "appearance" From dbdfcffa8d15ca0d2e97edbdcaaae959613c53e1 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:58:11 +0530 Subject: [PATCH 15/54] disable x-publish workflow --- .github/workflows/x-publish.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/x-publish.yml b/.github/workflows/x-publish.yml index aac99cb9..4f58b1df 100644 --- a/.github/workflows/x-publish.yml +++ b/.github/workflows/x-publish.yml @@ -1,9 +1,6 @@ name: Publish to npm -on: - push: - branches: - - main +on: workflow_dispatch permissions: id-token: write # Required for OIDC From e1637d596549fe82ce3a4e1c8ed610b70be30807 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:58:21 +0530 Subject: [PATCH 16/54] add win32 + linux electron builds --- .github/workflows/electron-build.yml | 162 ++++++++++++++++++++--- apps/x/apps/main/bundle.mjs | 2 +- apps/x/apps/main/forge.config.cjs | 27 ++-- apps/x/apps/main/package.json | 14 +- apps/x/apps/main/src/main.ts | 7 +- apps/x/pnpm-lock.yaml | 185 +++++++++++++++++++++++++++ 6 files changed, 354 insertions(+), 43 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index f7c25752..4bd774c2 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -8,7 +8,7 @@ permissions: contents: write # Required to upload release assets jobs: - build: + build-macos: runs-on: macos-latest steps: @@ -75,7 +75,7 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" # Add keychain to search list - security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain # Verify certificate was imported security find-identity -v "$KEYCHAIN_PATH" @@ -87,31 +87,16 @@ jobs: run: pnpm install --frozen-lockfile working-directory: apps/x - - name: Build and publish to S3 + - name: Build electron app env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} - run: npm run publish - working-directory: apps/x/apps/main - - - name: Upload workflow artifacts - uses: actions/upload-artifact@v4 - with: - name: distributables - path: apps/x/apps/main/out/make/* - retention-days: 30 - - - name: Attach files to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: apps/x/apps/main/out/make/* - env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx electron-forge publish --arch=arm64,x64 --platform=darwin + working-directory: apps/x/apps/main - name: Cleanup keychain if: always() @@ -120,3 +105,140 @@ jobs: if [ -f "$KEYCHAIN_PATH" ]; then security delete-keychain "$KEYCHAIN_PATH" || true fi + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: distributables + path: apps/x/apps/main/out/make/* + retention-days: 30 + + build-linux: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + cache-dependency-path: 'apps/x/pnpm-lock.yaml' + + - name: Extract version from tag + id: version + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Extracted version: ${VERSION}" + + - name: Update package.json versions + run: | + node -e " + const fs = require('fs'); + const version = '${{ steps.version.outputs.version }}'; + + // Update apps/x/package.json + const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); + rootPackage.version = version; + fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); + + // Update apps/x/apps/main/package.json + const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); + mainPackage.version = version; + fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); + + console.log('Updated version to:', version); + " + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: apps/x + + - name: Build electron app + env: + VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} + VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx electron-forge publish --arch=x64,arm64 --platform=linux + working-directory: apps/x/apps/main + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: distributables-linux + path: apps/x/apps/main/out/make/* + retention-days: 30 + + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + cache-dependency-path: 'apps/x/pnpm-lock.yaml' + + - name: Extract version from tag + id: version + shell: bash + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Extracted version: ${VERSION}" + + - name: Update package.json versions + shell: bash + run: | + node -e " + const fs = require('fs'); + const version = '${{ steps.version.outputs.version }}'; + + // Update apps/x/package.json + const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); + rootPackage.version = version; + fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); + + // Update apps/x/apps/main/package.json + const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); + mainPackage.version = version; + fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); + + console.log('Updated version to:', version); + " + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: apps/x + + - name: Build electron app + env: + VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} + VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx electron-forge publish --arch=x64 --platform=win32 + working-directory: apps/x/apps/main + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: distributables-windows + path: apps/x/apps/main/out/make/* + retention-days: 30 diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 2444e356..125c4575 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -22,7 +22,7 @@ await esbuild.build({ platform: 'node', target: 'node20', outfile: './.package/dist/main.cjs', - external: ['electron'], // Provided by Electron runtime + external: ['electron', 'electron-squirrel-startup'], // Provided by Electron runtime // Use CommonJS format - many dependencies use require() which doesn't work // well with esbuild's ESM shim. CJS handles dynamic requires natively. format: 'cjs', diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 3a2b340f..7e410e97 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -19,9 +19,6 @@ module.exports = { appleIdPassword: process.env.APPLE_PASSWORD, teamId: process.env.APPLE_TEAM_ID }, - // NOTE: Electron Forge ignores packagerConfig.dir and always packages from the - // config file's directory. We use packageAfterCopy hook instead to customize output. - // dir: path.join(__dirname, '.package'), // Not supported by Forge // Since we bundle everything with esbuild, we don't need node_modules at all. // These settings prevent Forge's dependency walker (flora-colossus) from trying // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces. @@ -43,23 +40,27 @@ module.exports = { }) }, { - name: '@electron-forge/maker-zip', - platforms: ['darwin'], - // ZIP is used by Squirrel.Mac for auto-updates + name: '@electron-forge/maker-squirrel', config: (arch) => ({ - // Path must match S3 publisher's folder structure: releases/darwin/{arch} - macUpdateManifestBaseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/darwin/${arch}` + authors: 'Rowboat', + description: 'AI coworker with memory', + name: `Rowboat-win32-${arch}`, }) + }, + { + name: '@electron-forge/maker-zip', + platform: ["darwin", "win32", "linux"], } ], publishers: [ { - name: '@electron-forge/publisher-s3', + name: '@electron-forge/publisher-github', config: { - bucket: 'rowboat-desktop-app-releases', - region: 'us-east-1', - public: true, - folder: 'releases' // Creates structure: releases/darwin/{arch}/files (separate builds for arm64 and x64) + repository: { + owner: 'rowboatlabs', + name: 'rowboat' + }, + prerelease: true } } ], diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 676f7269..836fad96 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -6,9 +6,8 @@ "scripts": { "start": "electron .", "build": "rm -rf dist && tsc && node bundle.mjs", - "package": "electron-forge package --arch=arm64,x64 --platform=darwin", - "make": "electron-forge make --arch=arm64,x64 --platform=darwin", - "publish": "electron-forge publish --arch=arm64,x64 --platform=darwin" + "package": "electron-forge package", + "make": "electron-forge make" }, "dependencies": { "@x/core": "workspace:*", @@ -18,14 +17,15 @@ "zod": "^4.2.1" }, "devDependencies": { - "@types/node": "^25.0.3", - "electron": "^39.2.7", - "esbuild": "^0.24.2", "@electron-forge/cli": "^7.10.2", "@electron-forge/maker-deb": "^7.10.2", "@electron-forge/maker-dmg": "^7.10.2", "@electron-forge/maker-squirrel": "^7.10.2", "@electron-forge/maker-zip": "^7.10.2", - "@electron-forge/publisher-s3": "^7.10.2" + "@electron-forge/publisher-github": "^7.11.1", + "@electron-forge/publisher-s3": "^7.10.2", + "@types/node": "^25.0.3", + "electron": "^39.2.7", + "esbuild": "^0.24.2" } } \ No newline at end of file diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index f074740f..e35a7c98 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -16,6 +16,9 @@ import { initConfigs } from "@x/core/dist/config/initConfigs.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// run this as early in the main process as possible +if (require('electron-squirrel-startup')) app.quit(); + // Path resolution differs between development and production: const preloadPath = app.isPackaged ? path.join(__dirname, "../preload/dist/preload.js") @@ -115,8 +118,8 @@ app.whenReady().then(async () => { if (app.isPackaged) { updateElectronApp({ updateSource: { - type: UpdateSourceType.StaticStorage, - baseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/${process.platform}/${process.arch}`, + type: UpdateSourceType.ElectronPublicUpdateService, + repo: "rowboatlabs/rowboat", }, notifyUser: true, // Shows native dialog when update is available }); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 2775c44c..bd05ec61 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@electron-forge/maker-zip': specifier: ^7.10.2 version: 7.11.1 + '@electron-forge/publisher-github': + specifier: ^7.11.1 + version: 7.11.1 '@electron-forge/publisher-s3': specifier: ^7.10.2 version: 7.11.1 @@ -734,6 +737,10 @@ packages: resolution: {integrity: sha512-rXE9oMFGMtdQrixnumWYH5TTGsp99iPHZb3jI74YWq518ctCh6DlIgWlhf6ok2X0+lhWovcIb45KJucUFAQ13w==} engines: {node: '>= 16.4.0'} + '@electron-forge/publisher-github@7.11.1': + resolution: {integrity: sha512-3S7DS1NZRrYvf59eqH0F2ke9oLD5FQqW5+t6kY1EuEo6I8HF+u6dOkGnvzhRh+uvKkjy4ynV3j735PlqBbClGQ==} + engines: {node: '>= 16.4.0'} + '@electron-forge/publisher-s3@7.11.1': resolution: {integrity: sha512-80XQnCC6SvzX96Y2uW0nsm7cLuN3S8W1OeS+DdEb8bITR+o017PFOjfs2634DYsTYdx2+TFtpadVhUI04ATdtQ==} engines: {node: '>= 16.4.0'} @@ -1370,6 +1377,70 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + + '@octokit/core@5.2.2': + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.1': + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@12.11.0': + resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==} + + '@octokit/openapi-types@24.2.0': + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + + '@octokit/plugin-paginate-rest@11.4.4-cjs.2': + resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-request-log@4.0.1': + resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1': + resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5 + + '@octokit/plugin-retry@6.1.0': + resolution: {integrity: sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + + '@octokit/rest@20.1.2': + resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} + engines: {node: '>= 18'} + + '@octokit/types@13.10.0': + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + + '@octokit/types@6.41.0': + resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} + '@openrouter/ai-sdk-provider@1.5.4': resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} engines: {node: '>=18'} @@ -3163,6 +3234,9 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -3186,6 +3260,9 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -3703,6 +3780,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -6443,6 +6523,9 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -7591,6 +7674,24 @@ snapshots: - bluebird - supports-color + '@electron-forge/publisher-github@7.11.1': + dependencies: + '@electron-forge/publisher-base': 7.11.1 + '@electron-forge/shared-types': 7.11.1 + '@octokit/core': 5.2.2 + '@octokit/plugin-retry': 6.1.0(@octokit/core@5.2.2) + '@octokit/request-error': 5.1.1 + '@octokit/rest': 20.1.2 + '@octokit/types': 6.41.0 + chalk: 4.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + log-symbols: 4.1.0 + mime-types: 2.1.35 + transitivePeerDependencies: + - bluebird + - supports-color + '@electron-forge/publisher-s3@7.11.1': dependencies: '@aws-sdk/client-s3': 3.971.0 @@ -8286,6 +8387,82 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@octokit/auth-token@4.0.0': {} + + '@octokit/core@5.2.2': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + + '@octokit/endpoint@9.0.6': + dependencies: + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@7.1.1': + dependencies: + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/openapi-types@12.11.0': {} + + '@octokit/openapi-types@24.2.0': {} + + '@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + + '@octokit/plugin-retry@6.1.0(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + bottleneck: 2.19.5 + + '@octokit/request-error@5.1.1': + dependencies: + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request@8.4.1': + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/rest@20.1.2': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/plugin-paginate-rest': 11.4.4-cjs.2(@octokit/core@5.2.2) + '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + + '@octokit/types@13.10.0': + dependencies: + '@octokit/openapi-types': 24.2.0 + + '@octokit/types@6.41.0': + dependencies: + '@octokit/openapi-types': 12.11.0 + '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1)': dependencies: '@openrouter/sdk': 0.1.27 @@ -10318,6 +10495,8 @@ snapshots: baseline-browser-mapping@2.9.11: {} + before-after-hook@2.2.3: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -10351,6 +10530,8 @@ snapshots: boolean@3.2.0: optional: true + bottleneck@2.19.5: {} + bowser@2.13.1: {} bplist-creator@0.0.8: @@ -10902,6 +11083,8 @@ snapshots: depd@2.0.0: {} + deprecation@2.3.1: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -14293,6 +14476,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-user-agent@6.0.1: {} + universalify@0.1.2: {} universalify@2.0.1: {} From 680f1eef7b91a1ba46ba0b23265df2280f570f4d Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 6 Feb 2026 06:58:12 +0530 Subject: [PATCH 17/54] fix electron build asset names --- apps/x/apps/main/forge.config.cjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 7e410e97..72b6d086 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -36,15 +36,16 @@ module.exports = { name: '@electron-forge/maker-dmg', config: (arch) => ({ format: 'ULFO', - name: `Rowboat-${arch}`, // Architecture-specific name to avoid conflicts + name: `Rowboat-darwin-${arch}`, // Architecture-specific name to avoid conflicts }) }, { name: '@electron-forge/maker-squirrel', config: (arch) => ({ - authors: 'Rowboat', + authors: 'rowboatlabs', description: 'AI coworker with memory', name: `Rowboat-win32-${arch}`, + setupExe: `Rowboat-win32-${arch}`, }) }, { From 269787a013a76203f14dcf6b47e5badff8d76cd9 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:09:31 +0530 Subject: [PATCH 18/54] fix electron-squirrel-startup --- apps/x/apps/main/package.json | 1 + apps/x/apps/main/src/main.ts | 3 ++- apps/x/pnpm-lock.yaml | 10 +++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 836fad96..a3ef9e56 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -24,6 +24,7 @@ "@electron-forge/maker-zip": "^7.10.2", "@electron-forge/publisher-github": "^7.11.1", "@electron-forge/publisher-s3": "^7.10.2", + "@types/electron-squirrel-startup": "^1.0.2", "@types/node": "^25.0.3", "electron": "^39.2.7", "esbuild": "^0.24.2" diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index e35a7c98..6bf82325 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -12,12 +12,13 @@ import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js" import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; +import started from "electron-squirrel-startup"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // run this as early in the main process as possible -if (require('electron-squirrel-startup')) app.quit(); +if (started) app.quit(); // Path resolution differs between development and production: const preloadPath = app.isPackaged diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index bd05ec61..129e92e2 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@electron-forge/publisher-s3': specifier: ^7.10.2 version: 7.11.1 + '@types/electron-squirrel-startup': + specifier: ^1.0.2 + version: 1.0.2 '@types/node': specifier: ^25.0.3 version: 25.0.3 @@ -2849,6 +2852,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/electron-squirrel-startup@1.0.2': + resolution: {integrity: sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -6325,7 +6331,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp@0.9.4: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} @@ -10050,6 +10056,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/electron-squirrel-startup@1.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 From 116ebce184b9ad3b7617f9f1020ce751cad28f79 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:12:58 +0530 Subject: [PATCH 19/54] fix target names --- apps/x/apps/main/forge.config.cjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 72b6d086..f828d130 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -3,6 +3,7 @@ // Forge loads configs with require(), which fails on ESM files const path = require('path'); +const pkg = require('./package.json'); module.exports = { packagerConfig: { @@ -36,7 +37,7 @@ module.exports = { name: '@electron-forge/maker-dmg', config: (arch) => ({ format: 'ULFO', - name: `Rowboat-darwin-${arch}`, // Architecture-specific name to avoid conflicts + name: `Rowboat-darwin-${arch}-${pkg.version}`, // Architecture-specific name to avoid conflicts }) }, { @@ -45,7 +46,7 @@ module.exports = { authors: 'rowboatlabs', description: 'AI coworker with memory', name: `Rowboat-win32-${arch}`, - setupExe: `Rowboat-win32-${arch}`, + setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, }) }, { From 6425dbcf285837d35074f262feddd2c6d29548fc Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:28:55 +0530 Subject: [PATCH 20/54] fix electron-squirrel-startup --- apps/x/apps/main/bundle.mjs | 2 +- apps/x/apps/main/package.json | 1 + apps/x/pnpm-lock.yaml | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 125c4575..2444e356 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -22,7 +22,7 @@ await esbuild.build({ platform: 'node', target: 'node20', outfile: './.package/dist/main.cjs', - external: ['electron', 'electron-squirrel-startup'], // Provided by Electron runtime + external: ['electron'], // Provided by Electron runtime // Use CommonJS format - many dependencies use require() which doesn't work // well with esbuild's ESM shim. CJS handles dynamic requires natively. format: 'cjs', diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index a3ef9e56..38e31b88 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,6 +13,7 @@ "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", + "electron-squirrel-startup": "^1.0.1", "update-electron-app": "^3.1.2", "zod": "^4.2.1" }, diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 129e92e2..2b155fd5 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + electron-squirrel-startup: + specifier: ^1.0.1 + version: 1.0.1 update-electron-app: specifier: ^3.1.2 version: 3.1.2 @@ -3859,6 +3862,9 @@ packages: engines: {node: '>= 16'} hasBin: true + electron-squirrel-startup@1.0.1: + resolution: {integrity: sha512-sTfFIHGku+7PsHLJ7v0dRcZNkALrV+YEozINTW8X1nM//e5O3L+rfYuvSW00lmGHnYmUjARZulD8F2V8ISI9RA==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -11198,6 +11204,12 @@ snapshots: - supports-color optional: true + electron-squirrel-startup@1.0.1: + dependencies: + debug: 2.6.9 + transitivePeerDependencies: + - supports-color + electron-to-chromium@1.5.267: {} electron-winstaller@5.4.0: From eefc6a9700128e715b448c3a91bcc45a9180a7d1 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Thu, 5 Feb 2026 16:05:10 +0530 Subject: [PATCH 21/54] feat: add syncing update for graph building on the UI --- apps/x/apps/main/src/ipc.ts | 21 + apps/x/apps/main/src/main.ts | 7 +- .../src/components/sidebar-content.tsx | 125 +++++- .../core/src/knowledge/build_graph.ts | 367 +++++++++++++++--- .../core/src/knowledge/granola/sync.ts | 235 +++++++---- .../core/src/knowledge/sync_calendar.ts | 144 ++++++- .../core/src/knowledge/sync_fireflies.ts | 67 +++- .../packages/core/src/knowledge/sync_gmail.ts | 87 ++++- .../packages/core/src/services/service_bus.ts | 24 ++ .../core/src/services/service_logger.ts | 108 ++++++ apps/x/packages/shared/src/index.ts | 1 + apps/x/packages/shared/src/ipc.ts | 5 + apps/x/packages/shared/src/service-events.ts | 65 ++++ 13 files changed, 1093 insertions(+), 163 deletions(-) create mode 100644 apps/x/packages/core/src/services/service_bus.ts create mode 100644 apps/x/packages/core/src/services/service_logger.ts create mode 100644 apps/x/packages/shared/src/service-events.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 6efd48df..2244c9ee 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -12,10 +12,12 @@ import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; import * as runsCore from '@x/core/dist/runs/runs.js'; import { bus } from '@x/core/dist/runs/bus.js'; +import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; import z from 'zod'; import { RunEvent } from '@x/shared/dist/runs.js'; +import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; import { testModelConnection } from '@x/core/dist/models/models.js'; @@ -218,6 +220,15 @@ function emitRunEvent(event: z.infer): void { } } +function emitServiceEvent(event: z.infer): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('services:events', event); + } + } +} + export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { @@ -237,6 +248,16 @@ export async function startRunsWatcher(): Promise { }); } +let servicesWatcher: (() => void) | null = null; +export async function startServicesWatcher(): Promise { + if (servicesWatcher) { + return; + } + servicesWatcher = await serviceBus.subscribe(async (event) => { + emitServiceEvent(event); + }); +} + // ============================================================================ // Handler Implementations // ============================================================================ diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 6bf82325..a77a8f7b 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, protocol, net, shell } from "electron"; import path from "node:path"; -import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; +import { setupIpcHandlers, startRunsWatcher, startServicesWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; @@ -143,6 +143,9 @@ app.whenReady().then(async () => { // start runs watcher startRunsWatcher(); + // start services watcher + startServicesWatcher(); + // start gmail sync initGmailSync(); @@ -180,4 +183,4 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { // Clean up watcher on app quit stopWorkspaceWatcher(); -}); \ No newline at end of file +}); diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 02422b20..4be433d2 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { Bot, ChevronRight, @@ -15,6 +15,7 @@ import { Mic, Network, Pencil, + LoaderIcon, Square, SquarePen, Trash2, @@ -28,6 +29,7 @@ import { import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, @@ -36,6 +38,7 @@ import { SidebarMenuItem, SidebarMenuSub, SidebarRail, + useSidebar, } from "@/components/ui/sidebar" import { Tooltip, @@ -52,6 +55,8 @@ import { import { Input } from "@/components/ui/input" import { useSidebarSection } from "@/contexts/sidebar-context" import { toast } from "@/lib/toast" +import { ServiceEvent } from "@x/shared/src/service-events.js" +import z from "zod" interface TreeNode { path: string @@ -96,6 +101,11 @@ type BackgroundTaskItem = { lastRunAt?: string | null } +type ServiceEventType = z.infer + +const MAX_SYNC_EVENTS = 30 +const RUN_STALE_MS = 2 * 60 * 60 * 1000 + type TasksActions = { onNewChat: () => void onSelectRun: (runId: string) => void @@ -121,6 +131,117 @@ const sectionTitles = { tasks: "Chats", } +function formatEventTime(ts: string): string { + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return "" + return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) +} + +function SyncStatusBar() { + const { state, isMobile } = useSidebar() + const [events, setEvents] = useState([]) + const [activeRuns, setActiveRuns] = useState>(new Set()) + const [isExpanded, setIsExpanded] = useState(false) + const runTimeoutsRef = useRef>>(new Map()) + + useEffect(() => { + const cleanup = window.ipc.on('services:events', (event) => { + const nextEvent = event as ServiceEventType + setEvents((prev) => [nextEvent, ...prev].slice(0, MAX_SYNC_EVENTS)) + if (nextEvent.type === 'run_start') { + setActiveRuns((prev) => { + const next = new Set(prev) + next.add(nextEvent.runId) + return next + }) + const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) + if (existingTimeout) { + clearTimeout(existingTimeout) + } + const timeout = setTimeout(() => { + setActiveRuns((prev) => { + if (!prev.has(nextEvent.runId)) return prev + const next = new Set(prev) + next.delete(nextEvent.runId) + return next + }) + runTimeoutsRef.current.delete(nextEvent.runId) + }, RUN_STALE_MS) + runTimeoutsRef.current.set(nextEvent.runId, timeout) + } else if (nextEvent.type === 'run_complete') { + setActiveRuns((prev) => { + const next = new Set(prev) + next.delete(nextEvent.runId) + return next + }) + const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) + if (existingTimeout) { + clearTimeout(existingTimeout) + runTimeoutsRef.current.delete(nextEvent.runId) + } + } + }) + return cleanup + }, []) + + useEffect(() => { + return () => { + runTimeoutsRef.current.forEach((timeout) => clearTimeout(timeout)) + runTimeoutsRef.current.clear() + } + }, []) + + const isSyncing = activeRuns.size > 0 + const isCollapsed = state === "collapsed" + + return ( + <> + {!isMobile && isCollapsed && isSyncing && ( +
+ +
+ )} + + + {isExpanded && ( +
+ {events.length === 0 ? ( +
No recent activity.
+ ) : ( + events.map((event, idx) => ( +
+ + {formatEventTime(event.ts)} + + {event.message} +
+ )) + )} +
+ )} +
+ + ) +} + export function SidebarContentPanel({ tree, selectedPath, @@ -165,6 +286,7 @@ export function SidebarContentPanel({ /> )} + ) @@ -779,4 +901,3 @@ function TasksSection({ ) } - diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index 1c954bdc..2346005e 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -4,6 +4,7 @@ import { WorkDir } from '../config/config.js'; import { autoConfigureStrictnessIfNeeded } from '../config/strictness_analyzer.js'; import { createRun, createMessage } from '../runs/runs.js'; import { bus } from '../runs/bus.js'; +import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { loadState, saveState, @@ -32,6 +33,23 @@ const SOURCE_FOLDERS = [ // Voice memos are now created directly in knowledge/Voice Memos// const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos'); +const MAX_EVENT_ITEMS = 50; + +function limitEventItems(items: string[], max: number = MAX_EVENT_ITEMS): { items: string[]; truncated: boolean } { + if (items.length <= max) { + return { items, truncated: false }; + } + return { items: items.slice(0, max), truncated: true }; +} + +function extractPathFromToolInput(input: string): string | null { + try { + const parsed = JSON.parse(input) as { path?: string }; + return typeof parsed.path === 'string' ? parsed.path : null; + } catch { + return null; + } +} /** * Get unprocessed voice memo files from knowledge/Voice Memos/ @@ -148,7 +166,11 @@ async function waitForRunCompletion(runId: string): Promise { /** * Run note creation agent on a batch of files to extract entities and create/update notes */ -async function createNotesFromBatch(files: { path: string; content: string }[], batchNumber: number, knowledgeIndex: string): Promise { +async function createNotesFromBatch( + files: { path: string; content: string }[], + batchNumber: number, + knowledgeIndex: string +): Promise<{ runId: string; notesCreated: Set; notesModified: Set }> { // Ensure notes output directory exists if (!fs.existsSync(NOTES_OUTPUT_DIR)) { fs.mkdirSync(NOTES_OUTPUT_DIR, { recursive: true }); @@ -182,18 +204,155 @@ async function createNotesFromBatch(files: { path: string; content: string }[], message += `\n\n---\n\n`; }); + const notesCreated = new Set(); + const notesModified = new Set(); + + const unsubscribe = await bus.subscribe(run.id, async (event) => { + if (event.type !== "tool-invocation") { + return; + } + if (event.toolName !== "workspace-writeFile" && event.toolName !== "workspace-edit") { + return; + } + const toolPath = extractPathFromToolInput(event.input); + if (!toolPath) { + return; + } + if (event.toolName === "workspace-writeFile") { + notesCreated.add(toolPath); + } else if (event.toolName === "workspace-edit") { + notesModified.add(toolPath); + } + }); + await createMessage(run.id, message); // Wait for the run to complete await waitForRunCompletion(run.id); + unsubscribe(); - return run.id; + return { runId: run.id, notesCreated, notesModified }; } /** * Build the knowledge graph from all content files in the specified source directory * Only processes new or changed files based on state tracking */ +type BatchResult = { + processedFiles: string[]; + notesCreated: Set; + notesModified: Set; + hadError: boolean; +}; + +async function buildGraphWithFiles( + sourceDir: string, + filesToProcess: string[], + state: GraphState, + run?: ServiceRunContext +): Promise { + console.log(`[buildGraph] Starting build for directory: ${sourceDir}`); + + if (filesToProcess.length === 0) { + console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`); + return { processedFiles: [], notesCreated: new Set(), notesModified: new Set(), hadError: false }; + } + + console.log(`[buildGraph] Found ${filesToProcess.length} new/changed files to process in ${path.basename(sourceDir)}`); + + // Read file contents + const contentFiles = await readFileContents(filesToProcess); + + if (contentFiles.length === 0) { + console.log(`No files could be read from ${sourceDir}`); + return { processedFiles: [], notesCreated: new Set(), notesModified: new Set(), hadError: false }; + } + + const BATCH_SIZE = 10; // Reduced from 25 to 10 files per agent run for faster processing + const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE); + + console.log(`Processing ${contentFiles.length} files in ${totalBatches} batches (${BATCH_SIZE} files per batch)...`); + + const processedFiles: string[] = []; + const notesCreated = new Set(); + const notesModified = new Set(); + let hadError = false; + + // Process files in batches + for (let i = 0; i < contentFiles.length; i += BATCH_SIZE) { + const batch = contentFiles.slice(i, i + BATCH_SIZE); + const batchNumber = Math.floor(i / BATCH_SIZE) + 1; + + try { + // Build fresh index before each batch to include notes from previous batches + console.log(`Building knowledge index for batch ${batchNumber}...`); + const indexStartTime = Date.now(); + const index = buildKnowledgeIndex(); + const indexForPrompt = formatIndexForPrompt(index); + const indexDuration = ((Date.now() - indexStartTime) / 1000).toFixed(2); + console.log(`Index built in ${indexDuration}s: ${index.people.length} people, ${index.organizations.length} orgs, ${index.projects.length} projects, ${index.topics.length} topics, ${index.other.length} other`); + + console.log(`Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`); + if (run) { + await serviceLogger.log({ + type: 'progress', + service: run.service, + runId: run.runId, + level: 'info', + message: `Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)`, + step: 'batch', + current: batchNumber, + total: totalBatches, + details: { filesInBatch: batch.length }, + }); + } + const agentStartTime = Date.now(); + const batchResult = await createNotesFromBatch(batch, batchNumber, indexForPrompt); + const agentDuration = ((Date.now() - agentStartTime) / 1000).toFixed(2); + console.log(`Batch ${batchNumber}/${totalBatches} complete in ${agentDuration}s`); + + for (const note of batchResult.notesCreated) { + notesCreated.add(note); + } + for (const note of batchResult.notesModified) { + notesModified.add(note); + } + + // Mark files in this batch as processed + for (const file of batch) { + markFileAsProcessed(file.path, state); + processedFiles.push(file.path); + } + + // Save state after each successful batch + // This ensures partial progress is saved even if later batches fail + saveState(state); + } catch (error) { + hadError = true; + console.error(`Error processing batch ${batchNumber}:`, error); + if (run) { + await serviceLogger.log({ + type: 'error', + service: run.service, + runId: run.runId, + level: 'error', + message: `Error processing batch ${batchNumber}`, + error: error instanceof Error ? error.message : String(error), + context: { batchNumber }, + }); + } + // Continue with next batch (without saving state for failed batch) + } + } + + // Update state with last build time and save + state.lastBuildTime = new Date().toISOString(); + saveState(state); + + console.log(`Knowledge graph build complete. Processed ${processedFiles.length} files.`); + return { processedFiles, notesCreated, notesModified, hadError }; +} + export async function buildGraph(sourceDir: string): Promise { console.log(`[buildGraph] Starting build for directory: ${sourceDir}`); @@ -210,62 +369,7 @@ export async function buildGraph(sourceDir: string): Promise { return; } - console.log(`[buildGraph] Found ${filesToProcess.length} new/changed files to process in ${path.basename(sourceDir)}`); - - // Read file contents - const contentFiles = await readFileContents(filesToProcess); - - if (contentFiles.length === 0) { - console.log(`No files could be read from ${sourceDir}`); - return; - } - - const BATCH_SIZE = 10; // Reduced from 25 to 10 files per agent run for faster processing - const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE); - - console.log(`Processing ${contentFiles.length} files in ${totalBatches} batches (${BATCH_SIZE} files per batch)...`); - - // Process files in batches - const processedFiles: string[] = []; - for (let i = 0; i < contentFiles.length; i += BATCH_SIZE) { - const batch = contentFiles.slice(i, i + BATCH_SIZE); - const batchNumber = Math.floor(i / BATCH_SIZE) + 1; - - try { - // Build fresh index before each batch to include notes from previous batches - console.log(`Building knowledge index for batch ${batchNumber}...`); - const indexStartTime = Date.now(); - const index = buildKnowledgeIndex(); - const indexForPrompt = formatIndexForPrompt(index); - const indexDuration = ((Date.now() - indexStartTime) / 1000).toFixed(2); - console.log(`Index built in ${indexDuration}s: ${index.people.length} people, ${index.organizations.length} orgs, ${index.projects.length} projects, ${index.topics.length} topics, ${index.other.length} other`); - - console.log(`Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`); - const agentStartTime = Date.now(); - await createNotesFromBatch(batch, batchNumber, indexForPrompt); - const agentDuration = ((Date.now() - agentStartTime) / 1000).toFixed(2); - console.log(`Batch ${batchNumber}/${totalBatches} complete in ${agentDuration}s`); - - // Mark files in this batch as processed - for (const file of batch) { - markFileAsProcessed(file.path, state); - processedFiles.push(file.path); - } - - // Save state after each successful batch - // This ensures partial progress is saved even if later batches fail - saveState(state); - } catch (error) { - console.error(`Error processing batch ${batchNumber}:`, error); - // Continue with next batch (without saving state for failed batch) - } - } - - // Update state with last build time and save - state.lastBuildTime = new Date().toISOString(); - saveState(state); - - console.log(`Knowledge graph build complete. Processed ${processedFiles.length} files.`); + await buildGraphWithFiles(sourceDir, filesToProcess, state); } /** @@ -287,10 +391,39 @@ async function processVoiceMemosForKnowledge(): Promise { console.log(`[GraphBuilder] Processing ${unprocessedFiles.length} voice memo transcripts for entity extraction...`); console.log(`[GraphBuilder] Files to process: ${unprocessedFiles.map(f => path.basename(f)).join(', ')}`); + const run = await serviceLogger.startRun({ + service: 'voice_memo', + message: `Processing ${unprocessedFiles.length} voice memo${unprocessedFiles.length === 1 ? '' : 's'}`, + trigger: 'timer', + }); + + const relativeVoiceMemos = unprocessedFiles.map(filePath => path.relative(WorkDir, filePath)); + const limitedVoiceMemos = limitEventItems(relativeVoiceMemos); + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${unprocessedFiles.length} new voice memo${unprocessedFiles.length === 1 ? '' : 's'}`, + counts: { voiceMemos: unprocessedFiles.length }, + items: limitedVoiceMemos.items, + truncated: limitedVoiceMemos.truncated, + }); + // Read the files const contentFiles = await readFileContents(unprocessedFiles); if (contentFiles.length === 0) { + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: 'info', + message: 'No voice memos could be read', + durationMs: Date.now() - run.startedAt, + outcome: 'error', + summary: { processedFiles: 0 }, + }); return false; } @@ -298,6 +431,10 @@ async function processVoiceMemosForKnowledge(): Promise { const BATCH_SIZE = 10; const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE); + const notesCreated = new Set(); + const notesModified = new Set(); + let hadError = false; + for (let i = 0; i < contentFiles.length; i += BATCH_SIZE) { const batch = contentFiles.slice(i, i + BATCH_SIZE); const batchNumber = Math.floor(i / BATCH_SIZE) + 1; @@ -309,9 +446,27 @@ async function processVoiceMemosForKnowledge(): Promise { const indexForPrompt = formatIndexForPrompt(index); console.log(`[GraphBuilder] Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`); - await createNotesFromBatch(batch, batchNumber, indexForPrompt); + await serviceLogger.log({ + type: 'progress', + service: run.service, + runId: run.runId, + level: 'info', + message: `Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)`, + step: 'batch', + current: batchNumber, + total: totalBatches, + details: { filesInBatch: batch.length }, + }); + const batchResult = await createNotesFromBatch(batch, batchNumber, indexForPrompt); console.log(`[GraphBuilder] Batch ${batchNumber}/${totalBatches} complete`); + for (const note of batchResult.notesCreated) { + notesCreated.add(note); + } + for (const note of batchResult.notesModified) { + notesModified.add(note); + } + // Mark files as processed for (const file of batch) { markFileAsProcessed(file.path, state); @@ -320,7 +475,17 @@ async function processVoiceMemosForKnowledge(): Promise { // Save state after each batch saveState(state); } catch (error) { + hadError = true; console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error); + await serviceLogger.log({ + type: 'error', + service: run.service, + runId: run.runId, + level: 'error', + message: `Error processing voice memo batch ${batchNumber}`, + error: error instanceof Error ? error.message : String(error), + context: { batchNumber }, + }); } } @@ -328,6 +493,21 @@ async function processVoiceMemosForKnowledge(): Promise { state.lastBuildTime = new Date().toISOString(); saveState(state); + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: hadError ? 'error' : 'info', + message: `Voice memos processed: ${contentFiles.length} files, ${notesCreated.size} created, ${notesModified.size} updated`, + durationMs: Date.now() - run.startedAt, + outcome: hadError ? 'error' : 'ok', + summary: { + processedFiles: contentFiles.length, + notesCreated: notesCreated.size, + notesModified: notesModified.size, + }, + }); + return true; } @@ -352,6 +532,11 @@ async function processAllSources(): Promise { console.error('[GraphBuilder] Error processing voice memos:', error); } + const state = loadState(); + const folderChanges: { folder: string; sourceDir: string; files: string[] }[] = []; + const countsByFolder: Record = {}; + const allFiles: string[] = []; + for (const folder of SOURCE_FOLDERS) { const sourceDir = path.join(WorkDir, folder); @@ -362,14 +547,13 @@ async function processAllSources(): Promise { } try { - // Quick check if there are any files to process before doing the full build - const state = loadState(); const filesToProcess = getFilesToProcess(sourceDir, state); if (filesToProcess.length > 0) { console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`); - await buildGraph(sourceDir); - anyFilesProcessed = true; + folderChanges.push({ folder, sourceDir, files: filesToProcess }); + countsByFolder[folder] = filesToProcess.length; + allFiles.push(...filesToProcess); } } catch (error) { console.error(`[GraphBuilder] Error processing ${folder}:`, error); @@ -377,6 +561,63 @@ async function processAllSources(): Promise { } } + if (allFiles.length > 0) { + const run = await serviceLogger.startRun({ + service: 'graph', + message: 'Syncing knowledge graph', + trigger: 'timer', + config: { sources: SOURCE_FOLDERS }, + }); + + const relativeFiles = allFiles.map(filePath => path.relative(WorkDir, filePath)); + const limitedFiles = limitEventItems(relativeFiles); + const foldersList = Object.keys(countsByFolder).join(', '); + const folderMessage = foldersList ? ` across ${foldersList}` : ''; + + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${allFiles.length} changed file${allFiles.length === 1 ? '' : 's'}${folderMessage}`, + counts: countsByFolder, + items: limitedFiles.items, + truncated: limitedFiles.truncated, + }); + + const notesCreated = new Set(); + const notesModified = new Set(); + const processedFiles: string[] = []; + let hadError = false; + + for (const entry of folderChanges) { + const result = await buildGraphWithFiles(entry.sourceDir, entry.files, state, run); + result.processedFiles.forEach(file => processedFiles.push(file)); + result.notesCreated.forEach(note => notesCreated.add(note)); + result.notesModified.forEach(note => notesModified.add(note)); + if (result.hadError) { + hadError = true; + } + } + + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: hadError ? 'error' : 'info', + message: `Graph sync complete: ${processedFiles.length} files, ${notesCreated.size} created, ${notesModified.size} updated`, + durationMs: Date.now() - run.startedAt, + outcome: hadError ? 'error' : 'ok', + summary: { + processedFiles: processedFiles.length, + notesCreated: notesCreated.size, + notesModified: notesModified.size, + }, + }); + + anyFilesProcessed = true; + } + if (!anyFilesProcessed) { console.log('[GraphBuilder] No new content to process'); } else { diff --git a/apps/x/packages/core/src/knowledge/granola/sync.ts b/apps/x/packages/core/src/knowledge/granola/sync.ts index 6c736085..5e303e8d 100644 --- a/apps/x/packages/core/src/knowledge/granola/sync.ts +++ b/apps/x/packages/core/src/knowledge/granola/sync.ts @@ -4,6 +4,7 @@ import { homedir } from 'os'; import { WorkDir } from '../../config/config.js'; import container from '../../di/container.js'; import { IGranolaConfigRepo } from './repo.js'; +import { serviceLogger } from '../../services/service_logger.js'; import { GetDocumentsResponse, SyncState, @@ -22,6 +23,14 @@ const API_DELAY_MS = 1000; // 1 second delay between API calls const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit const MAX_RETRIES = 3; // Maximum retries for rate-limited requests const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync +const MAX_EVENT_ITEMS = 50; + +function limitEventItems(items: string[], max: number = MAX_EVENT_ITEMS): { items: string[]; truncated: boolean } { + if (items.length <= max) { + return { items, truncated: false }; + } + return { items: items.slice(0, max), truncated: true }; +} // --- Wake Signal for Immediate Sync Trigger --- let wakeResolve: (() => void) | null = null; @@ -325,102 +334,169 @@ function documentToMarkdown(doc: Document): string { async function syncNotes(): Promise { console.log('[Granola] Starting sync...'); - - // Check if enabled - const granolaRepo = container.resolve('granolaConfigRepo'); - const config = await granolaRepo.getConfig(); - if (!config.enabled) { - console.log('[Granola] Sync disabled in config'); - return; - } - - // Extract access token - const accessToken = extractAccessToken(); - if (!accessToken) { - console.log('[Granola] No access token available'); - return; - } - - // Ensure sync directory exists - ensureDir(SYNC_DIR); - - // Load state - const state = loadState(); - - let newCount = 0; - let updatedCount = 0; - let offset = 0; - let hasMore = true; - // Fetch documents with pagination - while (hasMore) { - // Delay before API call (except first) - if (offset > 0) { - await sleep(API_DELAY_MS); + let runId: string | null = null; + let runStartedAt = 0; + const ensureRun = async () => { + if (!runId) { + const run = await serviceLogger.startRun({ + service: 'granola', + message: 'Syncing Granola notes', + trigger: 'timer', + }); + runId = run.runId; + runStartedAt = run.startedAt; + } + }; + + try { + // Check if enabled + const granolaRepo = container.resolve('granolaConfigRepo'); + const config = await granolaRepo.getConfig(); + if (!config.enabled) { + console.log('[Granola] Sync disabled in config'); + return; } - const docsResponse = await getDocuments(accessToken, MAX_BATCH_SIZE, offset); - if (!docsResponse) { - console.log('[Granola] Failed to fetch documents'); - break; + // Extract access token + const accessToken = extractAccessToken(); + if (!accessToken) { + console.log('[Granola] No access token available'); + return; } - if (docsResponse.docs.length === 0) { - console.log('[Granola] No more documents to fetch'); - hasMore = false; - break; - } + // Ensure sync directory exists + ensureDir(SYNC_DIR); - // Process each document - for (const doc of docsResponse.docs) { - const docUpdatedAt = doc.updated_at || doc.created_at; - const lastSyncedAt = state.syncedDocs[doc.id]; + // Load state + const state = loadState(); - // Check if needs sync (new or updated) - const needsSync = !lastSyncedAt || lastSyncedAt !== docUpdatedAt; + let newCount = 0; + let updatedCount = 0; + let offset = 0; + let hasMore = true; + const changedTitles: string[] = []; - if (!needsSync) { - continue; + // Fetch documents with pagination + while (hasMore) { + // Delay before API call (except first) + if (offset > 0) { + await sleep(API_DELAY_MS); } - // Convert to markdown and save - const markdown = documentToMarkdown(doc); - const docTitle = doc.title || 'Untitled'; - const filename = `${doc.id}_${cleanFilename(docTitle)}.md`; - const filePath = path.join(SYNC_DIR, filename); - - fs.writeFileSync(filePath, markdown); - - if (lastSyncedAt) { - console.log(`[Granola] Updated: ${filename}`); - updatedCount++; - } else { - console.log(`[Granola] Saved: ${filename}`); - newCount++; + const docsResponse = await getDocuments(accessToken, MAX_BATCH_SIZE, offset); + if (!docsResponse) { + console.log('[Granola] Failed to fetch documents'); + break; } - // Update state - state.syncedDocs[doc.id] = docUpdatedAt; + if (docsResponse.docs.length === 0) { + console.log('[Granola] No more documents to fetch'); + hasMore = false; + break; + } + + // Process each document + for (const doc of docsResponse.docs) { + const docUpdatedAt = doc.updated_at || doc.created_at; + const lastSyncedAt = state.syncedDocs[doc.id]; + + // Check if needs sync (new or updated) + const needsSync = !lastSyncedAt || lastSyncedAt !== docUpdatedAt; + + if (!needsSync) { + continue; + } + + await ensureRun(); + const docTitle = doc.title || 'Untitled'; + changedTitles.push(docTitle); + + // Convert to markdown and save + const markdown = documentToMarkdown(doc); + const filename = `${doc.id}_${cleanFilename(docTitle)}.md`; + const filePath = path.join(SYNC_DIR, filename); + + fs.writeFileSync(filePath, markdown); + + if (lastSyncedAt) { + console.log(`[Granola] Updated: ${filename}`); + updatedCount++; + } else { + console.log(`[Granola] Saved: ${filename}`); + newCount++; + } + + // Update state + state.syncedDocs[doc.id] = docUpdatedAt; + } + + // Move to next page + offset += docsResponse.docs.length; + + // Stop if we got fewer docs than requested (last page) + if (docsResponse.docs.length < MAX_BATCH_SIZE) { + hasMore = false; + } } - // Move to next page - offset += docsResponse.docs.length; + // Save state + state.lastSyncDate = new Date().toISOString(); + saveState(state); - // Stop if we got fewer docs than requested (last page) - if (docsResponse.docs.length < MAX_BATCH_SIZE) { - hasMore = false; + console.log(`[Granola] Sync complete: ${newCount} new, ${updatedCount} updated`); + + if (runId) { + const totalChanges = newCount + updatedCount; + const limitedTitles = limitEventItems(changedTitles); + await serviceLogger.log({ + type: 'changes_identified', + service: 'granola', + runId, + level: 'info', + message: `Granola updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + counts: { newNotes: newCount, updatedNotes: updatedCount }, + items: limitedTitles.items, + truncated: limitedTitles.truncated, + }); + await serviceLogger.log({ + type: 'run_complete', + service: 'granola', + runId, + level: 'info', + message: `Granola sync complete: ${newCount} new, ${updatedCount} updated`, + durationMs: Date.now() - runStartedAt, + outcome: 'ok', + summary: { newNotes: newCount, updatedNotes: updatedCount }, + }); } - } - // Save state - state.lastSyncDate = new Date().toISOString(); - saveState(state); - - console.log(`[Granola] Sync complete: ${newCount} new, ${updatedCount} updated`); - - // Build knowledge graph if there were changes - if (newCount > 0 || updatedCount > 0) { - // Graph building is now handled by the independent graph builder service + // Build knowledge graph if there were changes + if (newCount > 0 || updatedCount > 0) { + // Graph building is now handled by the independent graph builder service + } + } catch (error) { + console.error('[Granola] Error in sync:', error); + if (runId) { + await serviceLogger.log({ + type: 'error', + service: 'granola', + runId, + level: 'error', + message: 'Granola sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: 'granola', + runId, + level: 'error', + message: 'Granola sync failed', + durationMs: Date.now() - runStartedAt, + outcome: 'error', + }); + } + throw error; } } @@ -443,4 +519,3 @@ export async function init(): Promise { await interruptibleSleep(SYNC_INTERVAL_MS); } } - diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index 46ec2e1e..36c83016 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -5,6 +5,7 @@ import { OAuth2Client } from 'google-auth-library'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; +import { serviceLogger } from '../services/service_logger.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); @@ -14,6 +15,14 @@ const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', 'https://www.googleapis.com/auth/drive.readonly' ]; +const MAX_EVENT_ITEMS = 50; + +function limitEventItems(items: string[], max: number = MAX_EVENT_ITEMS): { items: string[]; truncated: boolean } { + if (items.length <= max) { + return { items, truncated: false }; + } + return { items: items.slice(0, max), truncated: true }; +} const nhm = new NodeHtmlMarkdown(); @@ -49,10 +58,11 @@ function cleanFilename(name: string): string { // --- Sync Logic --- -function cleanUpOldFiles(currentEventIds: Set, syncDir: string) { - if (!fs.existsSync(syncDir)) return; +function cleanUpOldFiles(currentEventIds: Set, syncDir: string): string[] { + if (!fs.existsSync(syncDir)) return []; const files = fs.readdirSync(syncDir); + const deleted: string[] = []; for (const filename of files) { if (filename === 'sync_state.json') continue; @@ -79,36 +89,49 @@ function cleanUpOldFiles(currentEventIds: Set, syncDir: string) { try { fs.unlinkSync(path.join(syncDir, filename)); console.log(`Removed old/out-of-window file: ${filename}`); + deleted.push(filename); } catch (e) { console.error(`Error deleting file ${filename}:`, e); } } } + return deleted; } -async function saveEvent(event: cal.Schema$Event, syncDir: string): Promise { +async function saveEvent(event: cal.Schema$Event, syncDir: string): Promise<{ changed: boolean; isNew: boolean; title: string }> { const eventId = event.id; - if (!eventId) return false; + if (!eventId) return { changed: false, isNew: false, title: 'Unknown' }; const filePath = path.join(syncDir, `${eventId}.json`); + const content = JSON.stringify(event, null, 2); + const exists = fs.existsSync(filePath); try { - fs.writeFileSync(filePath, JSON.stringify(event, null, 2)); - return true; + if (exists) { + const existing = fs.readFileSync(filePath, 'utf-8'); + if (existing === content) { + return { changed: false, isNew: false, title: event.summary || eventId }; + } + } + + fs.writeFileSync(filePath, content); + return { changed: true, isNew: !exists, title: event.summary || eventId }; } catch (e) { console.error(`Error saving event ${eventId}:`, e); - return false; + return { changed: false, isNew: false, title: event.summary || eventId }; } } -async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, syncDir: string) { - if (!event.attachments || event.attachments.length === 0) return; +async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, syncDir: string): Promise { + if (!event.attachments || event.attachments.length === 0) return 0; const eventId = event.id; const eventTitle = event.summary || 'Untitled'; const eventDate = event.start?.dateTime || event.start?.date || 'Unknown'; const organizer = event.organizer?.email || 'Unknown'; + let savedCount = 0; + for (const att of event.attachments) { // We only care about Google Docs if (att.mimeType === 'application/vnd.google-apps.document') { @@ -145,12 +168,14 @@ async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, s ].join('\n'); fs.writeFileSync(filePath, frontmatter + md); + savedCount++; console.log(`Synced Note: ${att.title} for event ${eventTitle}`); } catch (e) { console.error(`Failed to download note ${att.title}:`, e); } } } + return savedCount; } async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackDays: number) { @@ -167,6 +192,26 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD const calendar = google.calendar({ version: 'v3', auth }); const drive = google.drive({ version: 'v3', auth }); + let runId: string | null = null; + let runStartedAt = 0; + let newCount = 0; + let updatedCount = 0; + let deletedCount = 0; + let attachmentCount = 0; + const changedTitles: string[] = []; + + const ensureRun = async () => { + if (!runId) { + const run = await serviceLogger.startRun({ + service: 'calendar', + message: 'Syncing calendar', + trigger: 'timer', + }); + runId = run.runId; + runStartedAt = run.startedAt; + } + }; + try { const res = await calendar.events.list({ calendarId: 'primary', @@ -185,17 +230,90 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD console.log(`Found ${events.length} events.`); for (const event of events) { if (event.id) { - await saveEvent(event, syncDir); - await processAttachments(drive, event, syncDir); + const result = await saveEvent(event, syncDir); + const attachmentsSaved = await processAttachments(drive, event, syncDir); currentEventIds.add(event.id); + + if (result.changed) { + await ensureRun(); + changedTitles.push(result.title); + if (result.isNew) { + newCount++; + } else { + updatedCount++; + } + } + + if (attachmentsSaved > 0) { + await ensureRun(); + attachmentCount += attachmentsSaved; + } } } } - cleanUpOldFiles(currentEventIds, syncDir); + const deletedFiles = cleanUpOldFiles(currentEventIds, syncDir); + if (deletedFiles.length > 0) { + await ensureRun(); + deletedCount = deletedFiles.length; + } + + if (runId) { + const totalChanges = newCount + updatedCount + deletedCount + attachmentCount; + const limitedTitles = limitEventItems(changedTitles); + await serviceLogger.log({ + type: 'changes_identified', + service: 'calendar', + runId, + level: 'info', + message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + counts: { + newEvents: newCount, + updatedEvents: updatedCount, + deletedFiles: deletedCount, + attachments: attachmentCount, + }, + items: limitedTitles.items, + truncated: limitedTitles.truncated, + }); + await serviceLogger.log({ + type: 'run_complete', + service: 'calendar', + runId, + level: 'info', + message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + durationMs: Date.now() - runStartedAt, + outcome: 'ok', + summary: { + newEvents: newCount, + updatedEvents: updatedCount, + deletedFiles: deletedCount, + attachments: attachmentCount, + }, + }); + } } catch (error) { console.error("An error occurred during calendar sync:", error); + if (runId) { + await serviceLogger.log({ + type: 'error', + service: 'calendar', + runId, + level: 'error', + message: 'Calendar sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: 'calendar', + runId, + level: 'error', + message: 'Calendar sync failed', + durationMs: Date.now() - runStartedAt, + outcome: 'error', + }); + } // If 401, clear tokens to force re-auth next run const e = error as { response?: { status?: number } }; if (e.response?.status === 401) { @@ -256,4 +374,4 @@ export async function init() { console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); await interruptibleSleep(SYNC_INTERVAL_MS); } -} \ No newline at end of file +} diff --git a/apps/x/packages/core/src/knowledge/sync_fireflies.ts b/apps/x/packages/core/src/knowledge/sync_fireflies.ts index e65529f6..093ee044 100644 --- a/apps/x/packages/core/src/knowledge/sync_fireflies.ts +++ b/apps/x/packages/core/src/knowledge/sync_fireflies.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; import { FirefliesClientFactory } from './fireflies-client-factory.js'; +import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'fireflies_transcripts'); @@ -11,6 +12,14 @@ const LOOKBACK_DAYS = 30; // Last 1 month const API_DELAY_MS = 2000; // 2 second delay between API calls const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit const MAX_RETRIES = 3; // Maximum retries for rate-limited requests +const MAX_EVENT_ITEMS = 50; + +function limitEventItems(items: string[], max: number = MAX_EVENT_ITEMS): { items: string[]; truncated: boolean } { + if (items.length <= max) { + return { items, truncated: false }; + } + return { items: items.slice(0, max), truncated: true }; +} // --- Wake Signal for Immediate Sync Trigger --- let wakeResolve: (() => void) | null = null; @@ -414,6 +423,8 @@ async function syncMeetings() { console.log(`[Fireflies] Fetching meetings from ${fromDateStr} to ${toDateStr}...`); + let run: ServiceRunContext | null = null; + try { // Step 1: Get list of transcripts with rate limiting const transcriptsResult = await callWithRateLimit( @@ -456,6 +467,31 @@ async function syncMeetings() { } console.log(`[Fireflies] Found ${meetings.length} transcripts`); + + const newMeetings = meetings.filter(m => m.id && !syncedIds.has(m.id)); + if (newMeetings.length === 0) { + console.log('[Fireflies] No new transcripts to sync'); + saveState(toDateStr, Array.from(syncedIds), new Date().toISOString()); + return; + } + + run = await serviceLogger.startRun({ + service: 'fireflies', + message: 'Syncing Fireflies transcripts', + trigger: 'timer', + }); + const meetingTitles = newMeetings.map(m => m.title || m.id); + const limitedTitles = limitEventItems(meetingTitles); + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${newMeetings.length} new transcript${newMeetings.length === 1 ? '' : 's'}`, + counts: { transcripts: newMeetings.length }, + items: limitedTitles.items, + truncated: limitedTitles.truncated, + }); // Step 2: Fetch and save each transcript let newCount = 0; @@ -559,9 +595,39 @@ async function syncMeetings() { // Save state with updated timestamp saveState(toDateStr, Array.from(syncedIds), new Date().toISOString()); + + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: 'info', + message: `Fireflies sync complete: ${newCount} transcript${newCount === 1 ? '' : 's'}`, + durationMs: Date.now() - run.startedAt, + outcome: newCount > 0 ? 'ok' : 'error', + summary: { transcripts: newCount }, + }); } catch (error) { console.error('[Fireflies] Error during sync:', error); + if (run) { + await serviceLogger.log({ + type: 'error', + service: run.service, + runId: run.runId, + level: 'error', + message: 'Fireflies sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: 'error', + message: 'Fireflies sync failed', + durationMs: Date.now() - run.startedAt, + outcome: 'error', + }); + } // Check if it's an auth error const errorMessage = error instanceof Error ? error.message : String(error); @@ -600,4 +666,3 @@ export async function init() { await interruptibleSleep(SYNC_INTERVAL_MS); } } - diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index d1782a96..000c5f10 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -5,11 +5,20 @@ import { NodeHtmlMarkdown } from 'node-html-markdown' import { OAuth2Client } from 'google-auth-library'; import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; +import { serviceLogger } from '../services/service_logger.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; +const MAX_EVENT_ITEMS = 50; + +function limitEventItems(items: string[], max: number = MAX_EVENT_ITEMS): { items: string[]; truncated: boolean } { + if (items.length <= max) { + return { items, truncated: false }; + } + return { items: items.slice(0, max), truncated: true }; +} const nhm = new NodeHtmlMarkdown(); @@ -200,6 +209,7 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str const profile = await gmail.users.getProfile({ userId: 'me' }); const currentHistoryId = profile.data.historyId!; + const threadIds: string[] = []; let pageToken: string | undefined; do { const res = await gmail.users.threads.list({ @@ -211,13 +221,52 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str const threads = res.data.threads; if (threads) { for (const thread of threads) { - await processThread(auth, thread.id!, syncDir, attachmentsDir); + if (thread.id) { + threadIds.push(thread.id); + } } } pageToken = res.data.nextPageToken ?? undefined; } while (pageToken); + if (threadIds.length === 0) { + saveState(currentHistoryId, stateFile); + console.log("Full sync complete. No threads found."); + return; + } + + const run = await serviceLogger.startRun({ + service: 'gmail', + message: 'Syncing Gmail', + trigger: 'timer', + }); + const limitedThreads = limitEventItems(threadIds); + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${threadIds.length} thread${threadIds.length === 1 ? '' : 's'} to sync`, + counts: { threads: threadIds.length }, + items: limitedThreads.items, + truncated: limitedThreads.truncated, + }); + + for (const threadId of threadIds) { + await processThread(auth, threadId, syncDir, attachmentsDir); + } + saveState(currentHistoryId, stateFile); + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: 'info', + message: `Gmail sync complete: ${threadIds.length} thread${threadIds.length === 1 ? '' : 's'}`, + durationMs: Date.now() - run.startedAt, + outcome: 'ok', + summary: { threads: threadIds.length }, + }); console.log("Full sync complete."); } @@ -253,12 +302,46 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: } } - for (const tid of threadIds) { + if (threadIds.size === 0) { + const profile = await gmail.users.getProfile({ userId: 'me' }); + saveState(profile.data.historyId!, stateFile); + return; + } + + const run = await serviceLogger.startRun({ + service: 'gmail', + message: 'Syncing Gmail', + trigger: 'timer', + }); + const threadIdList = Array.from(threadIds); + const limitedThreads = limitEventItems(threadIdList); + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${threadIdList.length} new thread${threadIdList.length === 1 ? '' : 's'}`, + counts: { threads: threadIdList.length }, + items: limitedThreads.items, + truncated: limitedThreads.truncated, + }); + + for (const tid of threadIdList) { await processThread(auth, tid, syncDir, attachmentsDir); } const profile = await gmail.users.getProfile({ userId: 'me' }); saveState(profile.data.historyId!, stateFile); + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: 'info', + message: `Gmail sync complete: ${threadIdList.length} thread${threadIdList.length === 1 ? '' : 's'}`, + durationMs: Date.now() - run.startedAt, + outcome: 'ok', + summary: { threads: threadIdList.length }, + }); } catch (error: unknown) { const e = error as { response?: { status?: number } }; diff --git a/apps/x/packages/core/src/services/service_bus.ts b/apps/x/packages/core/src/services/service_bus.ts new file mode 100644 index 00000000..66b7c662 --- /dev/null +++ b/apps/x/packages/core/src/services/service_bus.ts @@ -0,0 +1,24 @@ +import type { ServiceEventType } from "@x/shared/dist/service-events.js"; + +type ServiceEventHandler = (event: ServiceEventType) => Promise | void; + +export class ServiceBus { + private subscribers: ServiceEventHandler[] = []; + + async publish(event: ServiceEventType): Promise { + const pending = this.subscribers.map(async (handler) => handler(event)); + await Promise.all(pending); + } + + async subscribe(handler: ServiceEventHandler): Promise<() => void> { + this.subscribers.push(handler); + return () => { + const idx = this.subscribers.indexOf(handler); + if (idx >= 0) { + this.subscribers.splice(idx, 1); + } + }; + } +} + +export const serviceBus = new ServiceBus(); diff --git a/apps/x/packages/core/src/services/service_logger.ts b/apps/x/packages/core/src/services/service_logger.ts new file mode 100644 index 00000000..50cac7e8 --- /dev/null +++ b/apps/x/packages/core/src/services/service_logger.ts @@ -0,0 +1,108 @@ +import fs from "fs"; +import fsp from "fs/promises"; +import path from "path"; +import { WorkDir } from "../config/config.js"; +import { IdGen } from "../application/lib/id-gen.js"; +import type { ServiceEventType } from "@x/shared/dist/service-events.js"; +import { serviceBus } from "./service_bus.js"; + +type ServiceNameType = ServiceEventType["service"]; +type DistributiveOmit = T extends any ? Omit : never; +type ServiceEventInput = DistributiveOmit; + +const LOG_DIR = path.join(WorkDir, "logs"); +const LOG_FILE = path.join(LOG_DIR, "services.jsonl"); +const MAX_LOG_BYTES = 10 * 1024 * 1024; + +export type ServiceRunContext = { + runId: string; + service: ServiceNameType; + startedAt: number; +}; + +function safeTimestampForFile(ts: string): string { + return ts.replace(/[:.]/g, "-"); +} + +export class ServiceLogger { + private idGen = new IdGen(); + private stream: fs.WriteStream | null = null; + private currentSize = 0; + private initialized = false; + private writeQueue: Promise = Promise.resolve(); + + private async ensureReady(): Promise { + if (this.initialized) return; + await fsp.mkdir(LOG_DIR, { recursive: true }); + try { + const stats = await fsp.stat(LOG_FILE); + this.currentSize = stats.size; + } catch { + this.currentSize = 0; + } + this.stream = fs.createWriteStream(LOG_FILE, { flags: "a", encoding: "utf8" }); + this.initialized = true; + } + + private async rotateIfNeeded(nextBytes: number): Promise { + if (this.currentSize + nextBytes <= MAX_LOG_BYTES) return; + if (this.stream) { + this.stream.close(); + this.stream = null; + } + const ts = safeTimestampForFile(new Date().toISOString()); + const rotatedPath = path.join(LOG_DIR, `services.${ts}.jsonl`); + try { + await fsp.rename(LOG_FILE, rotatedPath); + } catch { + // Ignore if file doesn't exist or rename fails + } + this.currentSize = 0; + this.stream = fs.createWriteStream(LOG_FILE, { flags: "a", encoding: "utf8" }); + } + + async log(event: ServiceEventInput): Promise { + const payload = { + ...event, + ts: new Date().toISOString(), + } as ServiceEventType; + const line = JSON.stringify(payload) + "\n"; + const bytes = Buffer.byteLength(line, "utf8"); + + this.writeQueue = this.writeQueue.then(async () => { + await this.ensureReady(); + await this.rotateIfNeeded(bytes); + this.stream?.write(line); + this.currentSize += bytes; + try { + await serviceBus.publish(payload); + } catch { + // Ignore publish errors to avoid blocking log writes + } + }); + + return this.writeQueue; + } + + async startRun(opts: { + service: ServiceNameType; + message: string; + trigger?: "timer" | "manual" | "startup"; + config?: Record; + }): Promise { + const runId = `${opts.service}_${await this.idGen.next()}`; + const startedAt = Date.now(); + await this.log({ + type: "run_start", + service: opts.service, + runId, + level: "info", + message: opts.message, + trigger: opts.trigger, + config: opts.config, + }); + return { runId, service: opts.service, startedAt }; + } +} + +export const serviceLogger = new ServiceLogger(); diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 5d54883f..4f10fc82 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -6,4 +6,5 @@ export * as workspace from './workspace.js'; export * as mcp from './mcp.js'; export * as agentSchedule from './agent-schedule.js'; export * as agentScheduleState from './agent-schedule-state.js'; +export * as serviceEvents from './service-events.js'; export { PrefixLogger }; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 767de9a0..67b29a28 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -5,6 +5,7 @@ import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolP import { LlmModelConfig } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; +import { ServiceEvent } from './service-events.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -176,6 +177,10 @@ const ipcSchemas = { req: z.null(), res: z.null(), }, + 'services:events': { + req: ServiceEvent, + res: z.null(), + }, 'models:list': { req: z.null(), res: z.object({ diff --git a/apps/x/packages/shared/src/service-events.ts b/apps/x/packages/shared/src/service-events.ts new file mode 100644 index 00000000..d214472c --- /dev/null +++ b/apps/x/packages/shared/src/service-events.ts @@ -0,0 +1,65 @@ +import z from 'zod'; + +export const ServiceName = z.enum([ + 'graph', + 'gmail', + 'calendar', + 'fireflies', + 'granola', + 'voice_memo', +]); + +const ServiceEventBase = z.object({ + service: ServiceName, + runId: z.string(), + ts: z.iso.datetime(), + level: z.enum(['info', 'warn', 'error']), + message: z.string(), +}); + +export const ServiceRunStartEvent = ServiceEventBase.extend({ + type: z.literal('run_start'), + trigger: z.enum(['timer', 'manual', 'startup']).optional(), + config: z.record(z.string(), z.unknown()).optional(), +}); + +export const ServiceChangesIdentifiedEvent = ServiceEventBase.extend({ + type: z.literal('changes_identified'), + counts: z.record(z.string(), z.number()).optional(), + items: z.array(z.string()).optional(), + truncated: z.boolean().optional(), +}); + +export const ServiceProgressEvent = ServiceEventBase.extend({ + type: z.literal('progress'), + step: z.string().optional(), + current: z.number().optional(), + total: z.number().optional(), + details: z.record(z.string(), z.unknown()).optional(), +}); + +export const ServiceRunCompleteEvent = ServiceEventBase.extend({ + type: z.literal('run_complete'), + durationMs: z.number(), + outcome: z.enum(['ok', 'idle', 'skipped', 'error']), + summary: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + items: z.array(z.string()).optional(), + truncated: z.boolean().optional(), +}); + +export const ServiceErrorEvent = ServiceEventBase.extend({ + type: z.literal('error'), + error: z.string(), + context: z.record(z.string(), z.unknown()).optional(), +}); + +export const ServiceEvent = z.union([ + ServiceRunStartEvent, + ServiceChangesIdentifiedEvent, + ServiceProgressEvent, + ServiceRunCompleteEvent, + ServiceErrorEvent, +]); + +export type ServiceNameType = z.infer; +export type ServiceEventType = z.infer; From cbe34aec8e11b18e2d925a0d0b894113f25cc2c6 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Fri, 6 Feb 2026 14:51:15 +0530 Subject: [PATCH 22/54] sidebar remake --- apps/x/apps/renderer/src/App.tsx | 8 -- .../src/components/sidebar-content.tsx | 60 ++++++++++-- .../renderer/src/components/sidebar-icon.tsx | 94 ------------------- 3 files changed, 51 insertions(+), 111 deletions(-) delete mode 100644 apps/x/apps/renderer/src/components/sidebar-icon.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a0fe4e0e..56d953e3 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -13,7 +13,6 @@ import { ChatInputBar } from './components/chat-button'; import { ChatSidebar } from './components/chat-sidebar'; import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { useDebounce } from './hooks/use-debounce'; -import { SidebarIcon } from '@/components/sidebar-icon'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarSectionProvider, type ActiveSection } from '@/contexts/sidebar-context'; import { @@ -1786,16 +1785,9 @@ function App() {
- {/* Icon sidebar - always visible, fixed position */} - - - {/* Spacer for the fixed icon sidebar */} -
- {/* Content sidebar with SidebarProvider for collapse functionality */} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 4be433d2..4f7e65bc 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -12,10 +12,13 @@ import { FilePlus, Folder, FolderPlus, + HelpCircle, Mic, Network, Pencil, + Plug, LoaderIcon, + Settings, Square, SquarePen, Trash2, @@ -53,7 +56,11 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu" import { Input } from "@/components/ui/input" -import { useSidebarSection } from "@/contexts/sidebar-context" +import { cn } from "@/lib/utils" +import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context" +import { ConnectorsPopover } from "@/components/connectors-popover" +import { HelpPopover } from "@/components/help-popover" +import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" import { ServiceEvent } from "@x/shared/src/service-events.js" import z from "zod" @@ -126,10 +133,10 @@ type SidebarContentPanelProps = { selectedBackgroundTask?: string | null } & React.ComponentProps -const sectionTitles = { - knowledge: "Knowledge", - tasks: "Chats", -} +const sectionTabs: { id: ActiveSection; label: string }[] = [ + { id: "tasks", label: "Chat" }, + { id: "knowledge", label: "Knowledge" }, +] function formatEventTime(ts: string): string { const date = new Date(ts) @@ -199,7 +206,7 @@ function SyncStatusBar() { {!isMobile && isCollapsed && isSyncing && (
@@ -256,13 +263,28 @@ export function SidebarContentPanel({ selectedBackgroundTask, ...props }: SidebarContentPanelProps) { - const { activeSection } = useSidebarSection() + const { activeSection, setActiveSection } = useSidebarSection() return ( -
- {sectionTitles[activeSection]} +
+
+ {sectionTabs.map((tab) => ( + + ))} +
@@ -286,6 +308,26 @@ export function SidebarContentPanel({ /> )} + {/* Bottom actions */} +
+
+ + + + + + + + + +
+
diff --git a/apps/x/apps/renderer/src/components/sidebar-icon.tsx b/apps/x/apps/renderer/src/components/sidebar-icon.tsx deleted file mode 100644 index 54aa0622..00000000 --- a/apps/x/apps/renderer/src/components/sidebar-icon.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client" - -import * as React from "react" -import { - Brain, - HelpCircle, - MessageSquare, - Plug, - Settings, -} from "lucide-react" - -import { cn } from "@/lib/utils" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context" -import { ConnectorsPopover } from "@/components/connectors-popover" -import { HelpPopover } from "@/components/help-popover" -import { SettingsDialog } from "@/components/settings-dialog" - -type NavItem = { - id: ActiveSection - title: string - icon: React.ElementType -} - -const navItems: NavItem[] = [ - { id: "tasks", title: "Chats", icon: MessageSquare }, - { id: "knowledge", title: "Knowledge", icon: Brain }, -] - -export function SidebarIcon() { - const { activeSection, setActiveSection } = useSidebarSection() - - return ( -
- {/* Main navigation */} - - - {/* Secondary navigation (bottom) */} - -
- ) -} From 840ae4b9cbdf3f1009b451d7cfa6a3ed555f1287 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Fri, 6 Feb 2026 19:02:22 +0530 Subject: [PATCH 23/54] titlebar refactor --- apps/x/apps/main/src/main.ts | 2 + apps/x/apps/renderer/src/App.css | 9 +++ apps/x/apps/renderer/src/App.tsx | 60 +++++++++++++++---- .../renderer/src/components/chat-sidebar.tsx | 2 +- .../src/components/sidebar-content.tsx | 7 ++- 5 files changed, 67 insertions(+), 13 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index a77a8f7b..d2ad14e1 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -71,6 +71,8 @@ function createWindow() { height: 800, show: false, // Don't show until ready backgroundColor: "#252525", // Prevent white flash (matches dark mode) + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 }, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 64d2b9a3..991236ea 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -255,6 +255,15 @@ } } +/* Titlebar drag regions for frameless window */ +.titlebar-drag-region { + -webkit-app-region: drag; +} + +.titlebar-no-drag { + -webkit-app-region: no-drag; +} + .graph-view { background-color: var(--background); user-select: none; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 56d953e3..5023da09 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen, Square } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, SquarePen, Square } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; @@ -43,7 +43,7 @@ import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/ import { SidebarInset, SidebarProvider, - SidebarTrigger, + useSidebar, } from "@/components/ui/sidebar" import { TooltipProvider } from "@/components/ui/tooltip" import { Separator } from "@/components/ui/separator" @@ -443,6 +443,46 @@ function ChatInputWithMentions({ ) } +/** Traffic light placeholders + toggle button, fixed next to macOS traffic lights */ +function FixedSidebarToggle() { + const { toggleSidebar } = useSidebar() + return ( +
+ {/* Placeholder dots that show through when traffic lights are hidden (window unfocused) */} +
+
+
+
+
+ {/* Sidebar toggle */} + +
+ ) +} + +/** Main content header that adjusts padding based on sidebar state */ +function ContentHeader({ children }: { children: React.ReactNode }) { + const { state } = useSidebar() + const isCollapsed = state === "collapsed" + return ( +
+ {children} +
+ ) +} + function App() { // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) @@ -1816,10 +1856,8 @@ function App() { selectedBackgroundTask={selectedBackgroundTask} /> - {/* Header with sidebar triggers */} -
- - + {/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */} + {headerTitle} @@ -1848,7 +1886,7 @@ function App() { setIsChatSidebarOpen(true) } }} - className="text-foreground gap-1.5" + className="titlebar-no-drag text-foreground gap-1.5" > New Chat @@ -1859,7 +1897,7 @@ function App() { variant="ghost" size="sm" onClick={() => setIsGraphOpen(false)} - className="text-foreground" + className="titlebar-no-drag text-foreground" > Close Graph @@ -1871,14 +1909,14 @@ function App() { variant="ghost" size="icon" onClick={() => setIsChatSidebarOpen(!isChatSidebarOpen)} - className="size-7 -mr-1" + className="titlebar-no-drag size-7 -mr-1" > Toggle Chat Sidebar )} -
+ {isGraphOpen ? (
@@ -2056,6 +2094,8 @@ function App() { onAskHumanResponse={handleAskHumanResponse} /> )} + {/* Rendered last so its no-drag region paints over the sidebar drag region */} + {/* Floating chat input - shown when viewing files/graph and chat sidebar is closed */} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index b5b32e09..876c7830 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -458,7 +458,7 @@ export function ChatSidebar({ {showContent && ( <> {/* Header - minimal, expand and new chat buttons */} -
+
{onOpenFullScreen && ( diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 4f7e65bc..bc6421b1 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -267,9 +267,12 @@ export function SidebarContentPanel({ return ( - + + {/* Top spacer to clear the traffic lights + fixed toggle row */} +
+ {/* Tab switcher - centered below the traffic lights row */}
-
+
{sectionTabs.map((tab) => ( )} {(selectedPath || isGraphOpen) && ( - <> - - - + )} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 876c7830..41ed6e71 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -458,7 +458,7 @@ export function ChatSidebar({ {showContent && ( <> {/* Header - minimal, expand and new chat buttons */} -
+
{onOpenFullScreen && ( From ce30c056047fe7ef93100f18e9de72dde6f79cf8 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Fri, 6 Feb 2026 20:19:23 +0530 Subject: [PATCH 25/54] refactor: remove unused button for new chat in the App component --- apps/x/apps/renderer/src/App.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4a33e2e5..112b0c6f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, SquarePen, Square } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; @@ -1875,22 +1875,6 @@ function App() { ) : null}
)} - {!isGraphOpen && ( - - )} {!selectedPath && isGraphOpen && ( - - -
From 35ca1b69c03b36aff7b27b6cd0c6602e5699f7a7 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:06:07 +0530 Subject: [PATCH 28/54] build .deb and .rpm --- apps/x/apps/main/forge.config.cjs | 24 ++++++++++++++++++- apps/x/apps/main/package.json | 8 +++++-- apps/x/pnpm-lock.yaml | 38 ++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index f828d130..57f733f2 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -7,7 +7,6 @@ const pkg = require('./package.json'); module.exports = { packagerConfig: { - name: 'Rowboat', executableName: 'rowboat', icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', @@ -49,6 +48,29 @@ module.exports = { setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, }) }, + { + name: '@electron-forge/maker-deb', + config: (arch) => ({ + options: { + name: `Rowboat-linux`, + bin: "rowboat", + description: 'AI coworker with memory', + maintainer: 'rowboatlabs', + homepage: 'https://rowboatlabs.com' + } + }) + }, + { + name: '@electron-forge/maker-rpm', + config: { + options: { + name: `Rowboat-linux`, + bin: "rowboat", + description: 'AI coworker with memory', + homepage: 'https://rowboatlabs.com' + } + } + }, { name: '@electron-forge/maker-zip', platform: ["darwin", "win32", "linux"], diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 38e31b88..9419d4e7 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -1,8 +1,11 @@ { - "name": "Rowboat", + "name": "rowboat", + "productName": "Rowboat", + "description": "AI coworker with memory", "type": "module", "version": "0.1.0", "main": ".package/dist/main.cjs", + "license": "Apache-2.0", "scripts": { "start": "electron .", "build": "rm -rf dist && tsc && node bundle.mjs", @@ -19,8 +22,9 @@ }, "devDependencies": { "@electron-forge/cli": "^7.10.2", - "@electron-forge/maker-deb": "^7.10.2", + "@electron-forge/maker-deb": "^7.11.1", "@electron-forge/maker-dmg": "^7.10.2", + "@electron-forge/maker-rpm": "^7.11.1", "@electron-forge/maker-squirrel": "^7.10.2", "@electron-forge/maker-zip": "^7.10.2", "@electron-forge/publisher-github": "^7.11.1", diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 2b155fd5..d9746616 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -64,11 +64,14 @@ importers: specifier: ^7.10.2 version: 7.11.1(encoding@0.1.13)(esbuild@0.24.2) '@electron-forge/maker-deb': - specifier: ^7.10.2 + specifier: ^7.11.1 version: 7.11.1 '@electron-forge/maker-dmg': specifier: ^7.10.2 version: 7.11.1 + '@electron-forge/maker-rpm': + specifier: ^7.11.1 + version: 7.11.1 '@electron-forge/maker-squirrel': specifier: ^7.10.2 version: 7.11.1 @@ -727,6 +730,10 @@ packages: resolution: {integrity: sha512-7zs5/Ewz1PcOl4N1102stFgBiFGWxU18+UPFUSd/fgf9MErBl4HBWuVNMIHyeJ/56rdfkcmTxTqE+9TBEYrZcg==} engines: {node: '>= 16.4.0'} + '@electron-forge/maker-rpm@7.11.1': + resolution: {integrity: sha512-iEfJPRQQyaTqk2EbUfZgulChNWvxGXeYUH0xBX/r5cj1pL4vcJXt3jLMQBVn3mk/0Ytv9UWRs8R/XuNWX6sf2w==} + engines: {node: '>= 16.4.0'} + '@electron-forge/maker-squirrel@7.11.1': resolution: {integrity: sha512-oSg7fgad6l+X0DjtRkSpMzB0AjzyDO4mb2gzM4kTodkP1ADeiMi08bxy0ZeCESqLm5+fG72cAPmEr3BAPvI1yw==} engines: {node: '>= 16.4.0'} @@ -3862,6 +3869,12 @@ packages: engines: {node: '>= 16'} hasBin: true + electron-installer-redhat@3.4.0: + resolution: {integrity: sha512-gEISr3U32Sgtj+fjxUAlSDo3wyGGq6OBx7rF5UdpIgbnpUvMN4W5uYb0ThpnAZ42VEJh/3aODQXHbFS4f5J3Iw==} + engines: {node: '>= 10.0.0'} + os: [darwin, linux] + hasBin: true + electron-squirrel-startup@1.0.1: resolution: {integrity: sha512-sTfFIHGku+7PsHLJ7v0dRcZNkALrV+YEozINTW8X1nM//e5O3L+rfYuvSW00lmGHnYmUjARZulD8F2V8ISI9RA==} @@ -7650,6 +7663,16 @@ snapshots: - bluebird - supports-color + '@electron-forge/maker-rpm@7.11.1': + dependencies: + '@electron-forge/maker-base': 7.11.1 + '@electron-forge/shared-types': 7.11.1 + optionalDependencies: + electron-installer-redhat: 3.4.0 + transitivePeerDependencies: + - bluebird + - supports-color + '@electron-forge/maker-squirrel@7.11.1': dependencies: '@electron-forge/maker-base': 7.11.1 @@ -11204,6 +11227,19 @@ snapshots: - supports-color optional: true + electron-installer-redhat@3.4.0: + dependencies: + '@malept/cross-spawn-promise': 1.1.1 + debug: 4.4.3 + electron-installer-common: 0.10.4 + fs-extra: 9.1.0 + lodash: 4.17.21 + word-wrap: 1.2.5 + yargs: 16.2.0 + transitivePeerDependencies: + - supports-color + optional: true + electron-squirrel-startup@1.0.1: dependencies: debug: 2.6.9 From 0de9589a7dba6ffc39faa2d060e33828023c6f96 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:53:43 +0530 Subject: [PATCH 29/54] feat: add interactive file path cards in chat UI Render filepath code blocks as rich, clickable cards with three variants: knowledge files (navigate to editor), audio files (inline play/pause), and system files (open externally). Adds shell:openPath and shell:readFileBase64 IPC channels, FileCardProvider context, and Streamdown pre override. Co-Authored-By: Claude Opus 4.6 --- apps/x/apps/main/src/ipc.ts | 36 ++- apps/x/apps/renderer/src/App.tsx | 11 +- .../components/ai-elements/file-path-card.tsx | 231 ++++++++++++++++++ .../ai-elements/markdown-code-override.tsx | 27 ++ .../src/components/ai-elements/message.tsx | 2 +- .../renderer/src/components/chat-sidebar.tsx | 12 +- .../src/contexts/file-card-context.tsx | 27 ++ .../src/application/assistant/instructions.ts | 21 +- apps/x/packages/shared/src/ipc.ts | 9 + 9 files changed, 369 insertions(+), 7 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx create mode 100644 apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx create mode 100644 apps/x/apps/renderer/src/contexts/file-card-context.tsx diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 90377e62..7b42a98e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,5 +1,7 @@ -import { ipcMain, BrowserWindow } from 'electron'; +import { ipcMain, BrowserWindow, shell } from 'electron'; import { ipc } from '@x/shared'; +import path from 'node:path'; +import os from 'node:os'; import { connectProvider, disconnectProvider, @@ -455,5 +457,37 @@ export function setupIpcHandlers() { await stateRepo.deleteAgentState(args.agentName); return { success: true }; }, + // Shell integration handlers + 'shell:openPath': async (_event, args) => { + let filePath = args.path; + if (filePath.startsWith('~')) { + filePath = path.join(os.homedir(), filePath.slice(1)); + } + const error = await shell.openPath(filePath); + return { error: error || undefined }; + }, + 'shell:readFileBase64': async (_event, args) => { + let filePath = args.path; + if (filePath.startsWith('~')) { + filePath = path.join(os.homedir(), filePath.slice(1)); + } + const stat = await fs.stat(filePath); + if (stat.size > 10 * 1024 * 1024) { + throw new Error('File too large (>10MB)'); + } + const buffer = await fs.readFile(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mimeMap: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', '.ico': 'image/x-icon', + '.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', + '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.aac': 'audio/aac', + '.pdf': 'application/pdf', '.json': 'application/json', + '.txt': 'text/plain', '.md': 'text/markdown', + }; + const mimeType = mimeMap[ext] || 'application/octet-stream'; + return { data: buffer.toString('base64'), mimeType, size: stat.size }; + }, }); } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 112b0c6f..d64d9947 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -50,6 +50,8 @@ import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { FileCardProvider } from '@/contexts/file-card-context' +import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' @@ -108,6 +110,8 @@ const toToolState = (status: ToolCall['status']): ToolState => { } } +const streamdownComponents = { pre: MarkdownPreOverride } + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -1766,7 +1770,7 @@ function App() { return ( - {item.content} + {item.content} ) @@ -1948,6 +1952,7 @@ function App() { />
) : ( + { setSelectedPath(path); setIsGraphOpen(false) }}>
@@ -2004,7 +2009,7 @@ function App() { {currentAssistantMessage && ( - {currentAssistantMessage} + {currentAssistantMessage} )} @@ -2042,6 +2047,7 @@ function App() {
+ )}
@@ -2071,6 +2077,7 @@ function App() { permissionResponses={permissionResponses} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} + onOpenKnowledgeFile={(path) => { setSelectedPath(path); setIsGraphOpen(false) }} /> )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} diff --git a/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx new file mode 100644 index 00000000..c178c4d0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { BookOpen, FileIcon, FileText, Image, Music, Pause, Play, Video } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useFileCard } from '@/contexts/file-card-context' +import { useSidebarSection } from '@/contexts/sidebar-context' +import { wikiLabel } from '@/lib/wiki-links' + +const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.m4a', '.ogg', '.flac', '.aac']) +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']) +const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm']) +const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.rtf', '.csv']) + +function getExtension(filePath: string): string { + const dot = filePath.lastIndexOf('.') + return dot >= 0 ? filePath.slice(dot).toLowerCase() : '' +} + +function getFileNameWithoutExt(filePath: string): string { + const name = filePath.split('/').pop() || filePath + const dot = name.lastIndexOf('.') + return dot > 0 ? name.slice(0, dot) : name +} + +function getFileCategory(ext: string): { label: string; icon: typeof FileIcon } { + if (AUDIO_EXTENSIONS.has(ext)) return { label: 'Audio', icon: Music } + if (IMAGE_EXTENSIONS.has(ext)) return { label: 'Image', icon: Image } + if (VIDEO_EXTENSIONS.has(ext)) return { label: 'Video', icon: Video } + if (DOCUMENT_EXTENSIONS.has(ext)) return { label: 'Document', icon: FileText } + if (ext === '.md') return { label: 'Markdown', icon: FileText } + return { label: 'File', icon: FileIcon } +} + +function getExtLabel(ext: string): string { + return ext ? ext.slice(1).toUpperCase() : '' +} + +// Shared card shell used by all variants +function CardShell({ + icon, + title, + subtitle, + onClick, + action, +}: { + icon: React.ReactNode + title: string + subtitle: string + onClick?: () => void + action?: React.ReactNode +}) { + return ( +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined} + className="flex items-center gap-3 rounded-xl border border-border bg-card p-3 pr-4 text-left transition-colors hover:bg-accent/50 cursor-pointer w-full my-2" + > +
+ {icon} +
+
+
{title}
+
{subtitle}
+
+ {action} +
+ ) +} + +// --- Knowledge File Card --- + +function KnowledgeFileCard({ filePath }: { filePath: string }) { + const { onOpenKnowledgeFile } = useFileCard() + const { setActiveSection } = useSidebarSection() + const label = wikiLabel(filePath) + const ext = getExtension(filePath) + const extLabel = getExtLabel(ext) + + return ( + } + title={label} + subtitle={extLabel ? `Knowledge \u00b7 ${extLabel}` : 'Knowledge'} + onClick={() => { setActiveSection('knowledge'); onOpenKnowledgeFile(filePath) }} + action={ + + } + /> + ) +} + +// --- Audio File Card --- + +function AudioFileCard({ filePath }: { filePath: string }) { + const [isPlaying, setIsPlaying] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const audioRef = useRef(null) + const ext = getExtension(filePath) + const extLabel = getExtLabel(ext) + + const handlePlayPause = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation() + if (isPlaying && audioRef.current) { + audioRef.current.pause() + setIsPlaying(false) + return + } + + if (!audioRef.current) { + setIsLoading(true) + try { + const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath }) + const dataUrl = `data:${result.mimeType};base64,${result.data}` + const audio = new Audio(dataUrl) + audio.addEventListener('ended', () => setIsPlaying(false)) + audioRef.current = audio + } catch (err) { + console.error('Failed to load audio:', err) + setIsLoading(false) + return + } + setIsLoading(false) + } + + audioRef.current.play() + setIsPlaying(true) + }, [filePath, isPlaying]) + + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + } + }, []) + + const handleOpen = async () => { + await window.ipc.invoke('shell:openPath', { path: filePath }) + } + + return ( + + {isPlaying + ? + : + } + + } + title={getFileNameWithoutExt(filePath)} + subtitle={`Audio \u00b7 ${extLabel}`} + onClick={handleOpen} + action={ + + } + /> + ) +} + +// --- System File Card --- + +function SystemFileCard({ filePath }: { filePath: string }) { + const ext = getExtension(filePath) + const isImage = IMAGE_EXTENSIONS.has(ext) + const [thumbnail, setThumbnail] = useState(null) + const { label: categoryLabel, icon: CategoryIcon } = getFileCategory(ext) + const extLabel = getExtLabel(ext) + + useEffect(() => { + if (!isImage) return + let cancelled = false + window.ipc.invoke('shell:readFileBase64', { path: filePath }) + .then((result) => { + if (!cancelled) { + setThumbnail(`data:${result.mimeType};base64,${result.data}`) + } + }) + .catch(() => {/* ignore thumbnail failures */}) + return () => { cancelled = true } + }, [filePath, isImage]) + + const handleOpen = async () => { + await window.ipc.invoke('shell:openPath', { path: filePath }) + } + + return ( + + : + } + title={getFileNameWithoutExt(filePath)} + subtitle={extLabel ? `${categoryLabel} \u00b7 ${extLabel}` : categoryLabel} + onClick={handleOpen} + action={ + + } + /> + ) +} + +// --- Main FilePathCard --- + +export function FilePathCard({ filePath }: { filePath: string }) { + const trimmed = filePath.trim() + + if (trimmed.startsWith('knowledge/')) { + return + } + + const ext = getExtension(trimmed) + if (AUDIO_EXTENSIONS.has(ext)) { + return + } + + return +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx new file mode 100644 index 00000000..c1470326 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx @@ -0,0 +1,27 @@ +import { isValidElement, type JSX } from 'react' +import { FilePathCard } from './file-path-card' + +export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { + const { children, ...rest } = props + + // Check if the child is a with className "language-filepath" + if (isValidElement(children)) { + const childProps = children.props as { className?: string; children?: unknown } + if ( + typeof childProps.className === 'string' && + childProps.className.includes('language-filepath') + ) { + // Extract the text content from the code element + const text = typeof childProps.children === 'string' + ? childProps.children.trim() + : '' + if (text) { + return + } + } + } + + // Passthrough for all other code blocks - return children directly + // so Streamdown's own rendering (syntax highlighting, etc.) is preserved + return
{children}
+} diff --git a/apps/x/apps/renderer/src/components/ai-elements/message.tsx b/apps/x/apps/renderer/src/components/ai-elements/message.tsx index 635d455c..ec3acfc1 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/message.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/message.tsx @@ -50,7 +50,7 @@ export const MessageContent = ({ className={cn( "is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm", "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground", - "group-[.is-assistant]:text-foreground", + "group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground", className )} {...props} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 41ed6e71..48b89b4d 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -33,6 +33,8 @@ import { getMentionHighlightSegments } from '@/lib/mention-highlights' import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js' import z from 'zod' import React from 'react' +import { FileCardProvider } from '@/contexts/file-card-context' +import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' interface ChatMessage { id: string @@ -103,6 +105,8 @@ const normalizeToolOutput = (output: ToolCall['result'] | undefined, status: Too return output } +const streamdownComponents = { pre: MarkdownPreOverride } + const MIN_WIDTH = 300 const MAX_WIDTH = 700 const DEFAULT_WIDTH = 400 @@ -131,6 +135,7 @@ interface ChatSidebarProps { permissionResponses?: Map onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void + onOpenKnowledgeFile?: (path: string) => void } export function ChatSidebar({ @@ -156,6 +161,7 @@ export function ChatSidebar({ permissionResponses = new Map(), onPermissionResponse, onAskHumanResponse, + onOpenKnowledgeFile, }: ChatSidebarProps) { const [width, setWidth] = useState(defaultWidth) const [isResizing, setIsResizing] = useState(false) @@ -391,7 +397,7 @@ export function ChatSidebar({ {item.role === 'assistant' ? ( - {item.content} + {item.content} ) : ( item.content )} @@ -480,6 +486,7 @@ export function ChatSidebar({ {/* Conversation area */} + {})}>
@@ -538,7 +545,7 @@ export function ChatSidebar({ {currentAssistantMessage && ( - {currentAssistantMessage} + {currentAssistantMessage} )} @@ -650,6 +657,7 @@ export function ChatSidebar({ )}
+ )}
diff --git a/apps/x/apps/renderer/src/contexts/file-card-context.tsx b/apps/x/apps/renderer/src/contexts/file-card-context.tsx new file mode 100644 index 00000000..08910b12 --- /dev/null +++ b/apps/x/apps/renderer/src/contexts/file-card-context.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, type ReactNode } from 'react' + +interface FileCardContextType { + onOpenKnowledgeFile: (path: string) => void +} + +const FileCardContext = createContext(null) + +export function useFileCard() { + const ctx = useContext(FileCardContext) + if (!ctx) throw new Error('useFileCard must be used within FileCardProvider') + return ctx +} + +export function FileCardProvider({ + onOpenKnowledgeFile, + children, +}: { + onOpenKnowledgeFile: (path: string) => void + children: ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 57f3a446..1104df90 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -179,4 +179,23 @@ When a user asks for ANY task that might require external capabilities (web sear **Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`. -Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do.`; +Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do. + +## File Path References + +When you reference a file path in your response (whether a knowledge base file or a file on the user's system), ALWAYS wrap it in a filepath code block: + +\`\`\`filepath +knowledge/People/Sarah Chen.md +\`\`\` + +\`\`\`filepath +~/Desktop/report.pdf +\`\`\` + +This renders as an interactive card in the UI. Use this format for: +- Knowledge base file paths (knowledge/...) +- Files on the user's machine (~/Desktop/..., /Users/..., etc.) +- Audio files, images, documents, or any file reference + +Never output raw file paths in plain text when they could be wrapped in a filepath block.`; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 67b29a28..175c409e 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -386,6 +386,15 @@ const ipcSchemas = { success: z.literal(true), }), }, + // Shell integration channels + 'shell:openPath': { + req: z.object({ path: z.string() }), + res: z.object({ error: z.string().optional() }), + }, + 'shell:readFileBase64': { + req: z.object({ path: z.string() }), + res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), + }, } as const; // ============================================================================ From 4151c296bd8934010a43e54b59db94eb8946aee2 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:11:13 +0530 Subject: [PATCH 30/54] feat: add parseFile builtin tool for PDF, Excel, CSV, Word extraction Co-Authored-By: Claude Opus 4.6 --- apps/x/apps/main/package.json | 4 + apps/x/packages/core/package.json | 8 +- .../src/application/assistant/instructions.ts | 1 + .../assistant/skills/organize-files/skill.ts | 5 + .../core/src/application/lib/builtin-tools.ts | 114 ++++++ apps/x/pnpm-lock.yaml | 382 ++++++++++++++++++ 6 files changed, 513 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 9419d4e7..c777a237 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -17,7 +17,11 @@ "@x/shared": "workspace:*", "chokidar": "^4.0.3", "electron-squirrel-startup": "^1.0.1", + "mammoth": "^1.11.0", + "papaparse": "^5.5.3", + "pdf-parse": "^2.4.5", "update-electron-app": "^3.1.2", + "xlsx": "^0.18.5", "zod": "^4.2.1" }, "devDependencies": { diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 20f12f6b..743354cc 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -28,14 +28,20 @@ "glob": "^13.0.0", "google-auth-library": "^10.5.0", "googleapis": "^169.0.0", + "mammoth": "^1.11.0", "node-html-markdown": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4", "openid-client": "^6.8.1", + "papaparse": "^5.5.3", + "pdf-parse": "^2.4.5", "react": "^19.2.3", + "xlsx": "^0.18.5", "yaml": "^2.8.2", "zod": "^4.2.1" }, "devDependencies": { - "@types/node": "^25.0.3" + "@types/node": "^25.0.3", + "@types/papaparse": "^5.5.2", + "@types/pdf-parse": "^1.1.5" } } diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 1104df90..0b0eebc6 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -158,6 +158,7 @@ When a user asks for ANY task that might require external capabilities (web sear - \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations - \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search - \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management +- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute paths or workspace-relative paths — no need to copy files into the workspace first. - \`analyzeAgent\` - Agent analysis - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`loadSkill\` - Skill loading diff --git a/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts b/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts index bfdf8cf2..d0e1ef41 100644 --- a/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts @@ -72,6 +72,11 @@ grep -r "search term" ~/Documents --include="*.txt" --include="*.md" find ~/Downloads -name "*.pdf" -exec basename {} \; \`\`\` +**Extracting content from documents:** +When users want to read or summarize a document's contents (PDF, Excel, CSV, Word .docx), use the \`parseFile\` builtin tool. It extracts text from binary formats so you can answer questions about them. +- Accepts absolute paths (e.g., \`~/Downloads/report.pdf\`) or workspace-relative paths — no need to copy files first. +- Supported formats: \`.pdf\`, \`.xlsx\`, \`.xls\`, \`.csv\`, \`.docx\` + ## Organizing Files **Create destination folder:** diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 9411eb41..b9b8d635 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1,5 +1,6 @@ import { z, ZodType } from "zod"; import * as path from "path"; +import * as fs from "fs/promises"; import { execSync } from "child_process"; import { glob } from "glob"; import { executeCommand, executeCommandAbortable } from "./command-executor.js"; @@ -15,6 +16,11 @@ import { composioAccountsRepo } from "../../composio/repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js"; import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js"; import type { ToolContext } from "./exec-tool.js"; +// Parser libraries are loaded dynamically inside parseFile.execute() +// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. +// Import paths are computed so esbuild cannot statically resolve them. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _importDynamic = new Function('mod', 'return import(mod)') as (mod: string) => Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars const BuiltinToolsSchema = z.record(z.string(), z.object({ @@ -690,6 +696,114 @@ export const BuiltinTools: z.infer = { }, }, + 'parseFile': { + description: 'Parse and extract text content from files (PDF, Excel, CSV, Word .docx). Auto-detects format from file extension.', + inputSchema: z.object({ + path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'), + }), + execute: async ({ path: filePath }: { path: string }) => { + try { + const fileName = path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + const supportedExts = ['.pdf', '.xlsx', '.xls', '.csv', '.docx']; + + if (!supportedExts.includes(ext)) { + return { + success: false, + error: `Unsupported file format '${ext}'. Supported formats: ${supportedExts.join(', ')}`, + }; + } + + // Read file as buffer — support both absolute and workspace-relative paths + let buffer: Buffer; + if (path.isAbsolute(filePath)) { + buffer = await fs.readFile(filePath); + } else { + const result = await workspace.readFile(filePath, 'base64'); + buffer = Buffer.from(result.data, 'base64'); + } + + if (ext === '.pdf') { + const { PDFParse } = await _importDynamic("pdf-parse"); + const parser = new PDFParse({ data: new Uint8Array(buffer) }); + try { + const textResult = await parser.getText(); + const infoResult = await parser.getInfo(); + return { + success: true, + fileName, + format: 'pdf', + content: textResult.text, + metadata: { + pages: textResult.total, + title: infoResult.info?.Title || undefined, + author: infoResult.info?.Author || undefined, + }, + }; + } finally { + await parser.destroy(); + } + } + + if (ext === '.xlsx' || ext === '.xls') { + const XLSX = await _importDynamic("xlsx"); + const workbook = XLSX.read(buffer, { type: 'buffer' }); + const sheets: Record = {}; + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName]; + sheets[sheetName] = XLSX.utils.sheet_to_csv(sheet); + } + return { + success: true, + fileName, + format: ext === '.xlsx' ? 'xlsx' : 'xls', + content: Object.values(sheets).join('\n\n'), + metadata: { + sheetNames: workbook.SheetNames, + sheetCount: workbook.SheetNames.length, + }, + sheets, + }; + } + + if (ext === '.csv') { + const Papa = (await _importDynamic("papaparse")).default; + const text = buffer.toString('utf8'); + const parsed = Papa.parse(text, { header: true, skipEmptyLines: true }); + return { + success: true, + fileName, + format: 'csv', + content: text, + metadata: { + rowCount: parsed.data.length, + headers: parsed.meta.fields || [], + }, + data: parsed.data, + }; + } + + if (ext === '.docx') { + const mammoth = (await _importDynamic("mammoth")).default; + const docResult = await mammoth.extractRawText({ buffer }); + return { + success: true, + fileName, + format: 'docx', + content: docResult.value, + }; + } + + return { success: false, error: 'Unexpected error' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, + analyzeAgent: { description: 'Read and analyze an agent file to understand its structure, tools, and configuration', inputSchema: z.object({ diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index d9746616..7afa3ff3 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -53,9 +53,21 @@ importers: electron-squirrel-startup: specifier: ^1.0.1 version: 1.0.1 + mammoth: + specifier: ^1.11.0 + version: 1.11.0 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 + pdf-parse: + specifier: ^2.4.5 + version: 2.4.5 update-electron-app: specifier: ^3.1.2 version: 3.1.2 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zod: specifier: ^4.2.1 version: 4.2.1 @@ -344,6 +356,9 @@ importers: googleapis: specifier: ^169.0.0 version: 169.0.0 + mammoth: + specifier: ^1.11.0 + version: 1.11.0 node-html-markdown: specifier: ^2.0.0 version: 2.0.0 @@ -353,9 +368,18 @@ importers: openid-client: specifier: ^6.8.1 version: 6.8.1 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 + pdf-parse: + specifier: ^2.4.5 + version: 2.4.5 react: specifier: ^19.2.3 version: 19.2.3 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -366,6 +390,12 @@ importers: '@types/node': specifier: ^25.0.3 version: 25.0.3 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 + '@types/pdf-parse': + specifier: ^1.1.5 + version: 1.1.5 packages/shared: dependencies: @@ -1369,6 +1399,70 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/canvas-android-arm64@0.1.80': + resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.80': + resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.80': + resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.80': + resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2934,6 +3028,12 @@ packages: '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + + '@types/pdf-parse@1.1.5': + resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3119,6 +3219,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3198,6 +3302,9 @@ packages: os: [darwin] hasBin: true + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3262,6 +3369,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -3358,6 +3468,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3452,6 +3566,10 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@0.5.3: resolution: {integrity: sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==} @@ -3535,6 +3653,9 @@ packages: core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -3545,6 +3666,11 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3819,6 +3945,9 @@ packages: dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -3841,6 +3970,9 @@ packages: ds-store@0.1.6: resolution: {integrity: sha512-kY21M6Lz+76OS3bnCzjdsJSF7LBpLYGCVfavW8TgQD2XkcqIZ86W0y9qUDZu6fp7SIZzqosMDW2zi7zVFfv4hw==} + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -4213,6 +4345,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + framer-motion@12.23.26: resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} peerDependencies: @@ -4590,6 +4726,9 @@ packages: engines: {node: '>=6.9.0'} hasBin: true + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -4731,6 +4870,9 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -4813,6 +4955,9 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + junk@3.1.0: resolution: {integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==} engines: {node: '>=8'} @@ -4847,6 +4992,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -4980,6 +5128,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -5026,6 +5177,11 @@ packages: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + mammoth@1.11.0: + resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==} + engines: {node: '>=12.0.0'} + hasBin: true + map-age-cleaner@0.1.3: resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} engines: {node: '>=6'} @@ -5523,6 +5679,9 @@ packages: openid-client@6.8.1: resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5590,6 +5749,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5665,6 +5827,15 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-parse@2.4.5: + resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} + engines: {node: '>=20.16.0 <21 || >=22.3.0'} + hasBin: true + + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + pe-library@1.0.1: resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} engines: {node: '>=14', npm: '>=7'} @@ -5735,6 +5906,9 @@ packages: resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -5929,6 +6103,9 @@ packages: resolution: {integrity: sha512-eFIBOPW7FGjzBuk3hdXEuNSiTZS/xEMlH49HxMyzb0hyPfu4EhVjT2DH32K1hSSmVq4sebAWnZuuY5auISUTGA==} engines: {node: '>=4'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -6095,6 +6272,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -6145,6 +6325,9 @@ packages: server-destroy@1.0.1: resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -6245,9 +6428,16 @@ packages: spdx-license-ids@3.0.22: resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + ssri@9.0.1: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -6276,6 +6466,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -6504,6 +6697,9 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -6761,10 +6957,18 @@ packages: engines: {node: '>= 8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -6780,6 +6984,15 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -8400,6 +8613,49 @@ snapshots: - hono - supports-color + '@napi-rs/canvas-android-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + optional: true + + '@napi-rs/canvas@0.1.80': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.80 + '@napi-rs/canvas-darwin-arm64': 0.1.80 + '@napi-rs/canvas-darwin-x64': 0.1.80 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.80 + '@napi-rs/canvas-linux-arm64-musl': 0.1.80 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-musl': 0.1.80 + '@napi-rs/canvas-win32-x64-msvc': 0.1.80 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10164,6 +10420,14 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 25.0.3 + + '@types/pdf-parse@1.1.5': + dependencies: + '@types/node': 25.0.3 + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -10402,6 +10666,8 @@ snapshots: acorn@8.15.0: {} + adler-32@1.3.1: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -10487,6 +10753,10 @@ snapshots: repeat-string: 1.6.1 optional: true + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -10546,6 +10816,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.4.7: {} + bluebird@3.7.2: {} body-parser@2.2.1: @@ -10679,6 +10951,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10774,6 +11051,8 @@ snapshots: - '@types/react' - '@types/react-dom' + codepage@1.15.0: {} + color-convert@0.5.3: optional: true @@ -10835,6 +11114,8 @@ snapshots: core-js@3.47.0: {} + core-util-is@1.0.3: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -10848,6 +11129,8 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@1.2.2: {} + crelt@1.0.6: {} cron-parser@5.5.0: @@ -11137,6 +11420,8 @@ snapshots: dfa@1.2.0: {} + dingbat-to-unicode@1.0.1: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -11171,6 +11456,10 @@ snapshots: tn1150: 0.1.0 optional: true + duck@0.1.12: + dependencies: + underscore: 1.13.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -11693,6 +11982,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion-dom: 12.23.23 @@ -12237,6 +12528,8 @@ snapshots: image-size@0.7.5: optional: true + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -12340,6 +12633,8 @@ snapshots: dependencies: is-docker: 2.2.1 + isarray@1.0.0: {} + isbinaryfile@4.0.10: {} isexe@2.0.0: {} @@ -12418,6 +12713,13 @@ snapshots: jsonpointer@5.0.1: optional: true + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + junk@3.1.0: {} jwa@2.0.1: @@ -12458,6 +12760,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -12576,6 +12882,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.7 + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -12633,6 +12945,19 @@ snapshots: - bluebird - supports-color + mammoth@1.11.0: + dependencies: + '@xmldom/xmldom': 0.8.11 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.7 + xmlbuilder: 10.1.1 + map-age-cleaner@0.1.3: dependencies: p-defer: 1.0.0 @@ -13346,6 +13671,8 @@ snapshots: jose: 6.1.3 oauth4webapi: 3.8.3 + option@0.2.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -13409,6 +13736,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13481,6 +13810,15 @@ snapshots: pathe@2.0.3: {} + pdf-parse@2.4.5: + dependencies: + '@napi-rs/canvas': 0.1.80 + pdfjs-dist: 5.4.296 + + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.80 + pe-library@1.0.1: {} pend@1.2.0: {} @@ -13550,6 +13888,8 @@ snapshots: proc-log@2.0.1: {} + process-nextick-args@2.0.1: {} + progress@2.0.3: {} promise-inflight@1.0.1: {} @@ -13793,6 +14133,16 @@ snapshots: normalize-package-data: 2.5.0 path-type: 2.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -14025,6 +14375,8 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -14085,6 +14437,8 @@ snapshots: server-destroy@1.0.1: {} + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} shebang-command@1.2.0: @@ -14198,9 +14552,15 @@ snapshots: spdx-license-ids@3.0.22: {} + sprintf-js@1.0.3: {} + sprintf-js@1.1.3: optional: true + ssf@0.11.2: + dependencies: + frac: 1.1.2 + ssri@9.0.1: dependencies: minipass: 3.3.6 @@ -14258,6 +14618,10 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -14467,6 +14831,8 @@ snapshots: ufo@1.6.1: {} + underscore@1.13.7: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -14731,8 +15097,12 @@ snapshots: dependencies: isexe: 2.0.0 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -14753,6 +15123,18 @@ snapshots: wrappy@1.0.2: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + + xmlbuilder@10.1.1: {} + xmlbuilder@15.1.1: {} xtend@4.0.2: From 950c862e02a29b9ed87841897d2134e91fa2e2c5 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:58:20 +0530 Subject: [PATCH 31/54] Added a new LLMParse tool that sends files directly to the user configured LLM as a multimodal attachment and asks it to return markdown. This complements the existing parseFile tool which extracts text locally using libraries. --- .../src/application/assistant/instructions.ts | 3 +- .../assistant/skills/organize-files/skill.ts | 4 + .../core/src/application/lib/builtin-tools.ts | 91 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 0b0eebc6..e0eb0205 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -158,7 +158,8 @@ When a user asks for ANY task that might require external capabilities (web sear - \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations - \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search - \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management -- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute paths or workspace-relative paths — no need to copy files into the workspace first. +- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute paths or workspace-relative paths — no need to copy files into the workspace first. Best for well-structured digital documents. +- \`LLMParse\` - Send a file to the configured LLM as a multimodal attachment to extract content as markdown. Use this instead of \`parseFile\` for scanned PDFs, images with text, complex layouts, presentations, or any format where local parsing falls short. Supports documents and images. - \`analyzeAgent\` - Agent analysis - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`loadSkill\` - Skill loading diff --git a/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts b/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts index d0e1ef41..aecf976f 100644 --- a/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts @@ -77,6 +77,10 @@ When users want to read or summarize a document's contents (PDF, Excel, CSV, Wor - Accepts absolute paths (e.g., \`~/Downloads/report.pdf\`) or workspace-relative paths — no need to copy files first. - Supported formats: \`.pdf\`, \`.xlsx\`, \`.xls\`, \`.csv\`, \`.docx\` +For scanned PDFs, images with text, complex layouts, or presentations where local parsing falls short, use the \`LLMParse\` builtin tool instead. It sends the file to the configured LLM as a multimodal attachment and returns well-structured markdown. +- Supports everything \`parseFile\` does plus images (\`.png\`, \`.jpg\`, \`.gif\`, \`.webp\`, \`.svg\`, \`.bmp\`, \`.tiff\`), PowerPoint (\`.pptx\`), HTML, and plain text. +- Also accepts an optional \`prompt\` parameter for custom extraction instructions. + ## Organizing Files **Create destination folder:** diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index b9b8d635..19fbc4e5 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -16,6 +16,9 @@ import { composioAccountsRepo } from "../../composio/repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js"; import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js"; import type { ToolContext } from "./exec-tool.js"; +import { generateText } from "ai"; +import { createProvider } from "../../models/models.js"; +import { IModelConfigRepo } from "../../models/repo.js"; // Parser libraries are loaded dynamically inside parseFile.execute() // to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. // Import paths are computed so esbuild cannot statically resolve them. @@ -258,6 +261,26 @@ const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => { return allSlug; }; +const LLMPARSE_MIME_TYPES: Record = { + '.pdf': 'application/pdf', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.doc': 'application/msword', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xls': 'application/vnd.ms-excel', + '.csv': 'text/csv', + '.txt': 'text/plain', + '.html': 'text/html', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', +}; + export const BuiltinTools: z.infer = { loadSkill: { description: "Load a Rowboat skill definition into context by fetching its guidance string", @@ -804,6 +827,74 @@ export const BuiltinTools: z.infer = { }, }, + 'LLMParse': { + description: 'Send a file to the configured LLM as a multimodal attachment and ask it to extract content as markdown. Best for scanned PDFs, images with text, complex layouts, or any format where local parsing falls short. Supports documents (PDF, Word, Excel, PowerPoint, CSV, TXT, HTML) and images (PNG, JPG, GIF, WebP, SVG, BMP, TIFF).', + inputSchema: z.object({ + path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'), + prompt: z.string().optional().describe('Custom instruction for the LLM (defaults to "Convert this file to well-structured markdown.")'), + }), + execute: async ({ path: filePath, prompt }: { path: string; prompt?: string }) => { + try { + const fileName = path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mimeType = LLMPARSE_MIME_TYPES[ext]; + + if (!mimeType) { + return { + success: false, + error: `Unsupported file format '${ext}'. Supported formats: ${Object.keys(LLMPARSE_MIME_TYPES).join(', ')}`, + }; + } + + // Read file as buffer — support both absolute and workspace-relative paths + let buffer: Buffer; + if (path.isAbsolute(filePath)) { + buffer = await fs.readFile(filePath); + } else { + const result = await workspace.readFile(filePath, 'base64'); + buffer = Buffer.from(result.data, 'base64'); + } + + const base64 = buffer.toString('base64'); + + // Resolve model config from DI container + const modelConfigRepo = container.resolve('modelConfigRepo'); + const modelConfig = await modelConfigRepo.getConfig(); + const provider = createProvider(modelConfig.provider); + const model = provider.languageModel(modelConfig.model); + + const userPrompt = prompt || 'Convert this file to well-structured markdown.'; + + const response = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: userPrompt }, + { type: 'file', data: base64, mediaType: mimeType }, + ], + }, + ], + }); + + return { + success: true, + fileName, + format: ext.slice(1), + mimeType, + content: response.text, + usage: response.usage, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, + analyzeAgent: { description: 'Read and analyze an agent file to understand its structure, tools, and configuration', inputSchema: z.object({ From 2bb27e477fdf26e91812ff09c07e72127c74b975 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 9 Feb 2026 14:20:55 +0530 Subject: [PATCH 32/54] feat: enhance SyncStatusBar with popover for service logs and increase event limit - Introduced a Popover component to display sync activity and logs. - Increased the maximum sync events from 30 to 1000. - Added a mapping for service labels to improve user feedback on active services. - Refactored state management to track active services and log events more effectively. --- .../src/components/sidebar-content.tsx | 179 +++++++++++++----- 1 file changed, 133 insertions(+), 46 deletions(-) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index ddacc605..edf370ad 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -43,6 +43,11 @@ import { SidebarRail, useSidebar, } from "@/components/ui/sidebar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" import { Tooltip, TooltipContent, @@ -110,9 +115,18 @@ type BackgroundTaskItem = { type ServiceEventType = z.infer -const MAX_SYNC_EVENTS = 30 +const MAX_SYNC_EVENTS = 1000 const RUN_STALE_MS = 2 * 60 * 60 * 1000 +const SERVICE_LABELS: Record = { + gmail: "Syncing Gmail", + calendar: "Syncing Calendar", + fireflies: "Syncing Fireflies", + granola: "Syncing Granola", + graph: "Updating knowledge", + voice_memo: "Processing voice memo", +} + type TasksActions = { onNewChat: () => void onSelectRun: (runId: string) => void @@ -146,29 +160,28 @@ function formatEventTime(ts: string): string { function SyncStatusBar() { const { state, isMobile } = useSidebar() - const [events, setEvents] = useState([]) - const [activeRuns, setActiveRuns] = useState>(new Set()) - const [isExpanded, setIsExpanded] = useState(false) + const [activeServices, setActiveServices] = useState>(new Map()) + const [popoverOpen, setPopoverOpen] = useState(false) + const [logEvents, setLogEvents] = useState([]) + const [logLoading, setLogLoading] = useState(false) const runTimeoutsRef = useRef>>(new Map()) + // Track active runs from real-time events useEffect(() => { const cleanup = window.ipc.on('services:events', (event) => { const nextEvent = event as ServiceEventType - setEvents((prev) => [nextEvent, ...prev].slice(0, MAX_SYNC_EVENTS)) if (nextEvent.type === 'run_start') { - setActiveRuns((prev) => { - const next = new Set(prev) - next.add(nextEvent.runId) + setActiveServices((prev) => { + const next = new Map(prev) + next.set(nextEvent.runId, nextEvent.service) return next }) const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) - if (existingTimeout) { - clearTimeout(existingTimeout) - } + if (existingTimeout) clearTimeout(existingTimeout) const timeout = setTimeout(() => { - setActiveRuns((prev) => { + setActiveServices((prev) => { if (!prev.has(nextEvent.runId)) return prev - const next = new Set(prev) + const next = new Map(prev) next.delete(nextEvent.runId) return next }) @@ -176,8 +189,8 @@ function SyncStatusBar() { }, RUN_STALE_MS) runTimeoutsRef.current.set(nextEvent.runId, timeout) } else if (nextEvent.type === 'run_complete') { - setActiveRuns((prev) => { - const next = new Set(prev) + setActiveServices((prev) => { + const next = new Map(prev) next.delete(nextEvent.runId) return next }) @@ -198,9 +211,48 @@ function SyncStatusBar() { } }, []) - const isSyncing = activeRuns.size > 0 + // Load logs from JSONL file when popover opens + useEffect(() => { + if (!popoverOpen) return + let cancelled = false + async function loadLogs() { + setLogLoading(true) + try { + const result = await window.ipc.invoke('workspace:readFile', { + path: 'logs/services.jsonl', + encoding: 'utf8', + }) + if (cancelled) return + const lines = result.data.trim().split('\n').filter(Boolean) + const parsed: ServiceEventType[] = [] + for (const line of lines) { + try { + parsed.push(JSON.parse(line)) + } catch { + // skip malformed lines + } + } + // Newest first, limit to 1000 + setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS)) + } catch { + if (!cancelled) setLogEvents([]) + } finally { + if (!cancelled) setLogLoading(false) + } + } + loadLogs() + return () => { cancelled = true } + }, [popoverOpen]) + + const isSyncing = activeServices.size > 0 const isCollapsed = state === "collapsed" + // Build status label from active services + const activeServiceNames = [...new Set(activeServices.values())] + const statusLabel = isSyncing + ? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ") + : "All caught up" + return ( <> {!isMobile && isCollapsed && isSyncing && ( @@ -213,37 +265,72 @@ function SyncStatusBar() {
)} - - {isExpanded && ( -
- {events.length === 0 ? ( -
No recent activity.
- ) : ( - events.map((event, idx) => ( -
- - {formatEventTime(event.ts)} - - {event.message} + + + + + +
+

Sync Activity

+

+ {isSyncing ? statusLabel : "All services up to date"} +

+
+
+ {logLoading ? ( +
+
- )) - )} -
- )} + ) : logEvents.length === 0 ? ( +
+ No recent activity. +
+ ) : ( +
+ {logEvents.map((event, idx) => ( +
+ + {formatEventTime(event.ts)} + + + + {SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service} + + + {event.message} +
+ ))} +
+ )} +
+ + ) From 399bd9a6e8e938f8a82f35f56fd6fb0fd0e939f7 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:29:26 +0530 Subject: [PATCH 33/54] voice note requires dgm --- .../renderer/src/components/sidebar-content.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index edf370ad..0d8d9aea 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -460,12 +460,25 @@ async function transcribeWithDeepgram(audioBlob: Blob): Promise { // Voice Note Recording Button function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) { const [isRecording, setIsRecording] = React.useState(false) + const [hasDeepgramKey, setHasDeepgramKey] = React.useState(false) const mediaRecorderRef = React.useRef(null) const chunksRef = React.useRef([]) const notePathRef = React.useRef(null) const timestampRef = React.useRef(null) const relativePathRef = React.useRef(null) + React.useEffect(() => { + window.ipc.invoke('workspace:readFile', { + path: 'config/deepgram.json', + encoding: 'utf8', + }).then((result: { data: string }) => { + const { apiKey } = JSON.parse(result.data) as { apiKey: string } + setHasDeepgramKey(!!apiKey) + }).catch(() => { + setHasDeepgramKey(false) + }) + }, []) + const startRecording = async () => { try { // Generate timestamp and paths immediately @@ -627,6 +640,8 @@ ${transcript} setIsRecording(false) } + if (!hasDeepgramKey) return null + return ( From f535c6191b6c5f4a0ddb58ed2d718ea620bbda2e Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:32:01 +0530 Subject: [PATCH 34/54] remove logo --- apps/x/apps/renderer/src/components/onboarding-modal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index b98fccf3..6a1290dc 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -630,7 +630,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return (
- Rowboat Your AI coworker, with memory
From 2efc80a7e2f5c0f39a06ff6080043b5d7b36e785 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 9 Feb 2026 16:05:07 +0530 Subject: [PATCH 35/54] fix: chat sidebar buttons and sidebar collapse behaviour --- apps/x/apps/renderer/src/App.tsx | 46 +++++++++++++++++-- .../renderer/src/components/chat-sidebar.tsx | 20 ++++---- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d64d9947..152b1ffd 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; @@ -497,6 +497,7 @@ function App() { const [expandedPaths, setExpandedPaths] = useState>(new Set()) const [recentWikiFiles, setRecentWikiFiles] = useState([]) const [isGraphOpen, setIsGraphOpen] = useState(false) + const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [], @@ -1303,9 +1304,28 @@ function App() { } const handleOpenFullScreenChat = useCallback(() => { + // Remember where we came from so the close button can return + if (selectedPath || isGraphOpen) { + setExpandedFrom({ path: selectedPath, graph: isGraphOpen }) + } + // Copy sidebar input text to full-screen input (keep sidebar message intact for return) + if (message.trim()) { + setPresetMessage(message) + } setSelectedPath(null) setIsGraphOpen(false) - }, []) + }, [selectedPath, isGraphOpen, message]) + + const handleCloseFullScreenChat = useCallback(() => { + if (expandedFrom) { + if (expandedFrom.graph) { + setIsGraphOpen(true) + } else if (expandedFrom.path) { + setSelectedPath(expandedFrom.path) + } + setExpandedFrom(null) + } + }, [expandedFrom]) // File navigation with history tracking const navigateToFile = useCallback((path: string | null) => { @@ -1320,6 +1340,7 @@ function App() { setSelectedPath(path) // Clear background task selection when navigating to a file setSelectedBackgroundTask(null) + setExpandedFrom(null) }, [selectedPath]) const navigateBack = useCallback(() => { @@ -1394,17 +1415,22 @@ function App() { } }, []) - // Keyboard shortcut: Ctrl+L to open main chat view + // 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() - handleOpenFullScreenChat() + if (isFullScreenChat && expandedFrom) { + handleCloseFullScreenChat() + } else { + handleOpenFullScreenChat() + } } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [handleOpenFullScreenChat]) + }, [handleOpenFullScreenChat, handleCloseFullScreenChat, isFullScreenChat, expandedFrom]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -1889,6 +1915,16 @@ function App() { Close Graph )} + {!selectedPath && !isGraphOpen && expandedFrom && ( + + )} {(selectedPath || isGraphOpen) && ( + + New chat + {onOpenFullScreen && ( - Full screen chat )} - - - - - New chat - {/* Conversation area */} From 464f257271c70920895214cf30339c638004ffad Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 9 Feb 2026 21:38:04 +0530 Subject: [PATCH 36/54] feat: implement model settings UI in settings dialog --- .../src/components/settings-dialog.tsx | 369 +++++++++++++++++- 1 file changed, 354 insertions(+), 15 deletions(-) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 26c5452f..a48c280a 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2 } from "lucide-react" import { Dialog, @@ -10,8 +10,17 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { cn } from "@/lib/utils" import { useTheme } from "@/contexts/theme-context" +import { toast } from "sonner" type ConfigTab = "models" | "mcp" | "security" | "appearance" @@ -57,6 +66,8 @@ interface SettingsDialogProps { children: React.ReactNode } +// --- Theme option for Appearance tab --- + function ThemeOption({ label, icon: Icon, @@ -121,6 +132,333 @@ function AppearanceSettings() { ) } +// --- Model Settings UI --- + +type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" + +interface LlmModelOption { + id: string + name?: string + release_date?: string +} + +const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ + { id: "openai", name: "OpenAI", description: "GPT models" }, + { id: "anthropic", name: "Anthropic", description: "Claude models" }, + { id: "google", name: "Gemini", description: "Google AI Studio" }, + { id: "ollama", name: "Ollama (Local)", description: "Run models locally" }, +] + +const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ + { id: "openrouter", name: "OpenRouter", description: "Multiple models, one key" }, + { id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway" }, + { id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API" }, +] + +const preferredDefaults: Partial> = { + openai: "gpt-5.2", + anthropic: "claude-opus-4-5-20251101", +} + +const defaultBaseURLs: Partial> = { + ollama: "http://localhost:11434", + "openai-compatible": "http://localhost:1234/v1", +} + +function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { + const [provider, setProvider] = useState("openai") + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "" }, + anthropic: { apiKey: "", baseURL: "", model: "" }, + google: { apiKey: "", baseURL: "", model: "" }, + openrouter: { apiKey: "", baseURL: "", model: "" }, + aigateway: { apiKey: "", baseURL: "", model: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, + }) + const [modelsCatalog, setModelsCatalog] = useState>({}) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(null) + const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" }) + const [configLoading, setConfigLoading] = useState(true) + const [showMoreProviders, setShowMoreProviders] = useState(false) + + const activeConfig = providerConfigs[provider] + const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" + const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway" + const requiresBaseURL = provider === "ollama" || provider === "openai-compatible" + const isLocalProvider = provider === "ollama" || provider === "openai-compatible" + const modelsForProvider = modelsCatalog[provider] || [] + const showModelInput = isLocalProvider || modelsForProvider.length === 0 + const isMoreProvider = moreProviders.some(p => p.id === provider) + + const canTest = + activeConfig.model.trim().length > 0 && + (!requiresApiKey || activeConfig.apiKey.trim().length > 0) && + (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) + + const updateConfig = useCallback( + (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { + setProviderConfigs(prev => ({ + ...prev, + [prov]: { ...prev[prov], ...updates }, + })) + setTestState({ status: "idle" }) + }, + [] + ) + + // Load current config from file + useEffect(() => { + if (!dialogOpen) return + + async function loadCurrentConfig() { + try { + setConfigLoading(true) + const result = await window.ipc.invoke("workspace:readFile", { + path: "config/models.json", + }) + const parsed = JSON.parse(result.data) + if (parsed?.provider?.flavor && parsed?.model) { + const flavor = parsed.provider.flavor as LlmProviderFlavor + setProvider(flavor) + setProviderConfigs(prev => ({ + ...prev, + [flavor]: { + apiKey: parsed.provider.apiKey || "", + baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), + model: parsed.model, + }, + })) + } + } catch { + // No existing config or parse error - use defaults + } finally { + setConfigLoading(false) + } + } + + loadCurrentConfig() + }, [dialogOpen]) + + // Load models catalog + useEffect(() => { + if (!dialogOpen) return + + async function loadModels() { + try { + setModelsLoading(true) + setModelsError(null) + const result = await window.ipc.invoke("models:list", null) + const catalog: Record = {} + for (const p of result.providers || []) { + catalog[p.id] = p.models || [] + } + setModelsCatalog(catalog) + } catch { + setModelsError("Failed to load models list") + setModelsCatalog({}) + } finally { + setModelsLoading(false) + } + } + + loadModels() + }, [dialogOpen]) + + // Set default models from catalog when catalog loads + useEffect(() => { + if (Object.keys(modelsCatalog).length === 0) return + setProviderConfigs(prev => { + const next = { ...prev } + const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] + for (const prov of cloudProviders) { + const models = modelsCatalog[prov] + if (models?.length && !next[prov].model) { + const preferred = preferredDefaults[prov] + const hasPreferred = preferred && models.some(m => m.id === preferred) + next[prov] = { ...next[prov], model: hasPreferred ? preferred : (models[0]?.id || "") } + } + } + return next + }) + }, [modelsCatalog]) + + const handleTestAndSave = useCallback(async () => { + if (!canTest) return + setTestState({ status: "testing" }) + try { + const providerConfig = { + provider: { + flavor: provider, + apiKey: activeConfig.apiKey.trim() || undefined, + baseURL: activeConfig.baseURL.trim() || undefined, + }, + model: activeConfig.model.trim(), + } + const result = await window.ipc.invoke("models:test", providerConfig) + if (result.success) { + await window.ipc.invoke("models:saveConfig", providerConfig) + setTestState({ status: "success" }) + toast.success("Model configuration saved") + } else { + setTestState({ status: "error", error: result.error }) + toast.error(result.error || "Connection test failed") + } + } catch { + setTestState({ status: "error", error: "Connection test failed" }) + toast.error("Connection test failed") + } + }, [canTest, provider, activeConfig]) + + const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => ( + + ) + + if (configLoading) { + return ( +
+ + Loading... +
+ ) + } + + return ( +
+ {/* Provider selection */} +
+ Provider +
+ {primaryProviders.map(renderProviderCard)} +
+ {(showMoreProviders || isMoreProvider) ? ( +
+ {moreProviders.map(renderProviderCard)} +
+ ) : ( + + )} +
+ + {/* Model selection */} +
+ Model + {modelsLoading ? ( +
+ + Loading models... +
+ ) : showModelInput ? ( + updateConfig(provider, { model: e.target.value })} + placeholder="Enter model ID" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ + {/* API Key */} + {requiresApiKey && ( +
+ API Key + updateConfig(provider, { apiKey: e.target.value })} + placeholder="Paste your API key" + /> +
+ )} + + {/* Base URL */} + {showBaseURL && ( +
+ Base URL + updateConfig(provider, { baseURL: e.target.value })} + placeholder={ + provider === "ollama" + ? "http://localhost:11434" + : provider === "openai-compatible" + ? "http://localhost:1234/v1" + : "https://ai-gateway.vercel.sh/v1" + } + /> +
+ )} + + {/* Test status */} + {testState.status === "error" && ( +
+ {testState.error || "Connection test failed"} +
+ )} + {testState.status === "success" && ( +
+ + Connected and saved +
+ )} + + {/* Test & Save button */} + +
+ ) +} + +// --- Main Settings Dialog --- + export function SettingsDialog({ children }: SettingsDialogProps) { const [open, setOpen] = useState(false) const [activeTab, setActiveTab] = useState("models") @@ -131,7 +469,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [error, setError] = useState(null) const activeTabConfig = tabs.find((t) => t.id === activeTab)! - const isConfigTab = activeTab !== "appearance" + const isJsonTab = activeTab === "mcp" || activeTab === "security" const formatJson = (jsonString: string): string => { try { @@ -142,7 +480,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { } const loadConfig = useCallback(async (tab: ConfigTab) => { - if (tab === "appearance") return + if (tab === "appearance" || tab === "models") return const tabConfig = tabs.find((t) => t.id === tab)! if (!tabConfig.path) return setLoading(true) @@ -164,11 +502,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) { }, []) const saveConfig = async () => { - if (!isConfigTab || !activeTabConfig.path) return + if (!isJsonTab || !activeTabConfig.path) return setSaving(true) setError(null) try { - // Validate JSON before saving JSON.parse(content) await window.ipc.invoke("workspace:writeFile", { path: activeTabConfig.path, @@ -193,13 +530,13 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const hasChanges = content !== originalContent useEffect(() => { - if (open && activeTab !== "appearance") { + if (open && isJsonTab) { loadConfig(activeTab) } - }, [open, activeTab, loadConfig]) + }, [open, activeTab, isJsonTab, loadConfig]) const handleTabChange = (tab: ConfigTab) => { - if (hasChanges) { + if (isJsonTab && hasChanges) { if (!confirm("You have unsaved changes. Discard them?")) { return } @@ -211,9 +548,9 @@ export function SettingsDialog({ children }: SettingsDialogProps) { {children} -
+
{/* Sidebar */}
@@ -239,7 +576,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Main content */} -
+
{/* Header */}

{activeTabConfig.label}

@@ -249,8 +586,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Content */} -
- {activeTab === "appearance" ? ( +
+ {activeTab === "models" ? ( + + ) : activeTab === "appearance" ? ( ) : loading ? (
@@ -267,8 +606,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) { )}
- {/* Footer - only show for config tabs */} - {isConfigTab && ( + {/* Footer - only show for JSON config tabs */} + {isJsonTab && (
{error && ( From 0dfa48285f289ec9f55f62d27aa50c5f35fffffd Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 9 Feb 2026 21:56:51 +0530 Subject: [PATCH 37/54] style: update prompt input box to be less rounded and have no shadow --- apps/x/apps/renderer/src/App.tsx | 2 +- apps/x/apps/renderer/src/components/chat-button.tsx | 2 +- apps/x/apps/renderer/src/components/chat-sidebar.tsx | 2 +- apps/x/apps/renderer/src/components/ui/input-group.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 152b1ffd..150cd2ce 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -360,7 +360,7 @@ function ChatInputInner({ }, [controller]) return ( -
+
-
+
)} -
+
{mentionHighlights.hasHighlights && (
) { data-slot="input-group" role="group" className={cn( - "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none", + "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-sm border shadow-none transition-[color,box-shadow] outline-none", "h-9 min-w-0 has-[>textarea]:h-auto", // Variants based on alignment. From 2426d9f1717958c8cef02613bee53af547b470ae Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 9 Feb 2026 22:08:21 +0530 Subject: [PATCH 38/54] style: popover placement --- apps/x/apps/renderer/src/components/connectors-popover.tsx | 2 +- apps/x/apps/renderer/src/components/help-popover.tsx | 2 +- apps/x/apps/renderer/src/components/sidebar-content.tsx | 2 +- apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx | 4 ++-- apps/x/apps/renderer/src/components/ui/hover-card.tsx | 4 ++-- apps/x/apps/renderer/src/components/ui/popover.tsx | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 7033a2ac..607bee4b 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -447,7 +447,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
diff --git a/apps/x/apps/renderer/src/components/help-popover.tsx b/apps/x/apps/renderer/src/components/help-popover.tsx index 6f23c577..fdabacc0 100644 --- a/apps/x/apps/renderer/src/components/help-popover.tsx +++ b/apps/x/apps/renderer/src/components/help-popover.tsx @@ -49,7 +49,7 @@ export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 0d8d9aea..52b3c481 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -285,7 +285,7 @@ function SyncStatusBar() {
diff --git a/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx b/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx index eaed9baf..847d4f22 100644 --- a/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx +++ b/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx @@ -31,7 +31,7 @@ function DropdownMenuTrigger({ function DropdownMenuContent({ className, - sideOffset = 4, + sideOffset = 8, ...props }: React.ComponentProps) { return ( @@ -40,7 +40,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg", className )} {...props} diff --git a/apps/x/apps/renderer/src/components/ui/hover-card.tsx b/apps/x/apps/renderer/src/components/ui/hover-card.tsx index e4204e0d..a8f21e17 100644 --- a/apps/x/apps/renderer/src/components/ui/hover-card.tsx +++ b/apps/x/apps/renderer/src/components/ui/hover-card.tsx @@ -20,7 +20,7 @@ function HoverCardTrigger({ function HoverCardContent({ className, align = "center", - sideOffset = 4, + sideOffset = 8, ...props }: React.ComponentProps) { return ( @@ -30,7 +30,7 @@ function HoverCardContent({ align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-lg outline-hidden", className )} {...props} diff --git a/apps/x/apps/renderer/src/components/ui/popover.tsx b/apps/x/apps/renderer/src/components/ui/popover.tsx index 6d51b6ce..27f1af3c 100644 --- a/apps/x/apps/renderer/src/components/ui/popover.tsx +++ b/apps/x/apps/renderer/src/components/ui/popover.tsx @@ -18,7 +18,7 @@ function PopoverTrigger({ function PopoverContent({ className, align = "center", - sideOffset = 4, + sideOffset = 8, ...props }: React.ComponentProps) { return ( @@ -28,7 +28,7 @@ function PopoverContent({ align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-lg outline-hidden", className )} {...props} From 4046ba9d72cdb6e96c19e74ea867e989fa8a3729 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 9 Feb 2026 22:17:48 +0530 Subject: [PATCH 39/54] style: update dropdown menu component for consistent spacing and class adjustments --- .../renderer/src/components/ui/dropdown-menu.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx b/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx index 847d4f22..5f710b3f 100644 --- a/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx +++ b/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx @@ -40,7 +40,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg", className )} {...props} @@ -72,7 +72,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} @@ -90,7 +90,7 @@ function DropdownMenuCheckboxItem({ Date: Mon, 9 Feb 2026 23:24:37 +0530 Subject: [PATCH 40/54] feat: enhance navigation in the app with back/forward buttons and view state management --- apps/x/apps/renderer/src/App.tsx | 387 +++++++++++++----- .../src/components/editor-toolbar.tsx | 58 +-- .../src/components/markdown-editor.tsx | 12 - 3 files changed, 305 insertions(+), 152 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 150cd2ce..2ae02677 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; @@ -446,8 +446,33 @@ function ChatInputWithMentions({ ) } -/** Traffic light placeholders + toggle button, fixed next to macOS traffic lights */ -function FixedSidebarToggle() { +/** 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 } + +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 +} + +/** Traffic light placeholders + toggle button + back/forward nav, fixed next to macOS traffic lights */ +function FixedSidebarToggle({ + onNavigateBack, + onNavigateForward, + canNavigateBack, + canNavigateForward, +}: { + onNavigateBack: () => void + onNavigateForward: () => void + canNavigateBack: boolean + canNavigateForward: boolean +}) { const { toggleSidebar } = useSidebar() return (
@@ -466,6 +491,25 @@ function FixedSidebarToggle() { > + {/* Back / Forward navigation */} + +
) } @@ -489,8 +533,6 @@ function ContentHeader({ children }: { children: React.ReactNode }) { function App() { // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) - const [fileHistoryBack, setFileHistoryBack] = useState([]) - const [fileHistoryForward, setFileHistoryForward] = useState([]) const [fileContent, setFileContent] = useState('') const [editorContent, setEditorContent] = useState('') const [tree, setTree] = useState([]) @@ -506,6 +548,16 @@ function App() { const [graphError, setGraphError] = useState(null) const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true) + // Keep the latest selected path in a ref (avoids stale async updates when switching rapidly) + const selectedPathRef = useRef(null) + const editorPathRef = useRef(null) + const fileLoadRequestIdRef = useRef(0) + const initialContentByPathRef = useRef>(new Map()) + + // 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) + // Auto-save state const [isSaving, setIsSaving] = useState(false) const [lastSaved, setLastSaved] = useState(null) @@ -521,6 +573,7 @@ function App() { const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) const runIdRef = useRef(null) + const loadRunRequestIdRef = useRef(0) const [isProcessing, setIsProcessing] = useState(false) const [isStopping, setIsStopping] = useState(false) const [stopClickedAt, setStopClickedAt] = useState(null) @@ -561,11 +614,28 @@ function App() { const [backgroundTasks, setBackgroundTasks] = useState([]) const [selectedBackgroundTask, setSelectedBackgroundTask] = useState(null) + // Keep selectedPathRef in sync for async guards + useEffect(() => { + selectedPathRef.current = selectedPath + if (!selectedPath) { + editorPathRef.current = null + } + }, [selectedPath]) + // Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures) useEffect(() => { runIdRef.current = runId }, [runId]) + const handleEditorChange = useCallback((markdown: string) => { + const nextSelectedPath = selectedPathRef.current + // 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 + } + setEditorContent(markdown) + }, []) + // Load directory tree const loadDirectory = useCallback(async () => { try { @@ -600,16 +670,21 @@ function App() { // Reload current file if it was changed externally if (!selectedPath) return + const pathToReload = selectedPath const isCurrentFileChanged = - changedPath === selectedPath || changedPaths.includes(selectedPath) + changedPath === pathToReload || changedPaths.includes(pathToReload) if (isCurrentFileChanged) { // Only reload if no unsaved edits - if (editorContent === initialContentRef.current) { - const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath }) + const baseline = initialContentByPathRef.current.get(pathToReload) ?? initialContentRef.current + if (editorContent === baseline) { + const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload }) + if (selectedPathRef.current !== pathToReload) return setFileContent(result.data) setEditorContent(result.data) + editorPathRef.current = pathToReload + initialContentByPathRef.current.set(pathToReload, result.data) initialContentRef.current = result.data } } @@ -627,13 +702,20 @@ function App() { setLastSaved(null) return } - (async () => { + const requestId = (fileLoadRequestIdRef.current += 1) + const pathToLoad = selectedPath + let cancelled = false + ;(async () => { try { - const stat = await window.ipc.invoke('workspace:stat', { path: selectedPath }) + const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) + if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return if (stat.kind === 'file') { - const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath }) + const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return setFileContent(result.data) setEditorContent(result.data) + editorPathRef.current = pathToLoad + initialContentByPathRef.current.set(pathToLoad, result.data) initialContentRef.current = result.data setLastSaved(null) } else { @@ -643,11 +725,16 @@ function App() { } } catch (err) { console.error('Failed to load file:', err) - setFileContent('') - setEditorContent('') - initialContentRef.current = '' + if (!cancelled && fileLoadRequestIdRef.current === requestId && selectedPathRef.current === pathToLoad) { + setFileContent('') + setEditorContent('') + initialContentRef.current = '' + } } })() + return () => { + cancelled = true + } }, [selectedPath]) // Track recently opened markdown files for wiki links @@ -662,28 +749,42 @@ function App() { // Auto-save when content changes useEffect(() => { - if (!selectedPath || !selectedPath.endsWith('.md')) return - if (debouncedContent === initialContentRef.current) return + 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 () => { - setIsSaving(true) - let pathToSave = selectedPath + const wasActiveAtStart = selectedPathRef.current === pathAtStart + if (wasActiveAtStart) setIsSaving(true) + let pathToSave = pathAtStart try { - if (!renameInProgressRef.current && selectedPath.startsWith('knowledge/')) { + // Only rename the currently active file (avoids renaming/jumping while user switches rapidly) + if ( + wasActiveAtStart && + selectedPathRef.current === pathAtStart && + !renameInProgressRef.current && + pathAtStart.startsWith('knowledge/') + ) { const headingTitle = getHeadingTitle(debouncedContent) const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null - const currentBase = getBaseName(selectedPath) + const currentBase = getBaseName(pathAtStart) if (desiredName && desiredName !== currentBase) { - const parentDir = selectedPath.split('/').slice(0, -1).join('/') + const parentDir = pathAtStart.split('/').slice(0, -1).join('/') const targetPath = `${parentDir}/${desiredName}.md` - if (targetPath !== selectedPath) { + if (targetPath !== pathAtStart) { const exists = await window.ipc.invoke('workspace:exists', { path: targetPath }) if (!exists.exists) { renameInProgressRef.current = true - await window.ipc.invoke('workspace:rename', { from: selectedPath, to: targetPath }) + await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath }) pathToSave = targetPath - setSelectedPath(targetPath) + editorPathRef.current = targetPath + initialContentByPathRef.current.delete(pathAtStart) + if (selectedPathRef.current === pathAtStart) { + setSelectedPath(targetPath) + } } } } @@ -693,17 +794,24 @@ function App() { data: debouncedContent, opts: { encoding: 'utf8' } }) - initialContentRef.current = debouncedContent - setLastSaved(new Date()) + initialContentByPathRef.current.set(pathToSave, debouncedContent) + + // Only update "current file" UI state if we're still on this file + if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) { + initialContentRef.current = debouncedContent + setLastSaved(new Date()) + } } catch (err) { console.error('Failed to save file:', err) } finally { renameInProgressRef.current = false - setIsSaving(false) + if (wasActiveAtStart && (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave)) { + setIsSaving(false) + } } } saveFile() - }, [debouncedContent, selectedPath]) + }, [debouncedContent]) // Load runs list (all pages) const loadRuns = useCallback(async () => { @@ -790,8 +898,10 @@ function App() { // 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[] = [] @@ -875,6 +985,7 @@ function App() { } } } + if (loadRunRequestIdRef.current !== requestId) return // Track permission requests and responses from history const allPermissionRequests = new Map>() @@ -893,6 +1004,7 @@ function App() { respondedAskHumanIds.add(event.toolCallId) } } + if (loadRunRequestIdRef.current !== requestId) return // Separate pending vs responded permission requests const pendingPerms = new Map>() @@ -908,6 +1020,7 @@ function App() { pendingAsks.set(id, req) } } + if (loadRunRequestIdRef.current !== requestId) return // Set the conversation and runId setConversation(items) @@ -1283,6 +1396,8 @@ function App() { }, [runId]) const handleNewChat = useCallback(() => { + // Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in) + loadRunRequestIdRef.current += 1 setConversation([]) setCurrentAssistantMessage('') setCurrentReasoning('') @@ -1327,54 +1442,139 @@ function App() { } }, [expandedFrom]) - // File navigation with history tracking - const navigateToFile = useCallback((path: string | null) => { - if (path === selectedPath) return + const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => { + historyRef.current = next + setViewHistory(next) + }, []) - // Push current path to back history (if we have one) - if (selectedPath) { - setFileHistoryBack(prev => [...prev, selectedPath]) + const currentViewState = React.useMemo(() => { + if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } + if (selectedPath) return { type: 'file', path: selectedPath } + if (isGraphOpen) return { type: 'graph' } + return { type: 'chat', runId } + }, [selectedBackgroundTask, 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 applyViewState = useCallback(async (view: ViewState) => { + switch (view.type) { + case 'file': + setSelectedBackgroundTask(null) + setIsGraphOpen(false) + setExpandedFrom(null) + setSelectedPath(view.path) + return + case 'graph': + setSelectedBackgroundTask(null) + setSelectedPath(null) + setExpandedFrom(null) + setIsGraphOpen(true) + return + case 'task': + setSelectedPath(null) + setIsGraphOpen(false) + setExpandedFrom(null) + setSelectedBackgroundTask(view.name) + return + case 'chat': + setSelectedPath(null) + setIsGraphOpen(false) + setExpandedFrom(null) + setSelectedBackgroundTask(null) + if (view.runId) { + await loadRun(view.runId) + } else { + handleNewChat() + } + return } - // Clear forward history when navigating to a new file - setFileHistoryForward([]) - setSelectedPath(path) - // Clear background task selection when navigating to a file - setSelectedBackgroundTask(null) - setExpandedFrom(null) - }, [selectedPath]) + }, [handleNewChat, loadRun]) - const navigateBack = useCallback(() => { - if (fileHistoryBack.length === 0) return + const navigateToView = useCallback(async (nextView: ViewState) => { + const current = currentViewState + if (viewStatesEqual(current, nextView)) return - const newBack = [...fileHistoryBack] - const previousPath = newBack.pop()! + const nextHistory = { + back: appendUnique(historyRef.current.back, current), + forward: [] as ViewState[], + } + setHistory(nextHistory) + await applyViewState(nextView) + }, [appendUnique, applyViewState, currentViewState, setHistory]) - // Push current path to forward history - if (selectedPath) { - setFileHistoryForward(prev => [...prev, selectedPath]) + 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 } - setFileHistoryBack(newBack) - setSelectedPath(previousPath) - }, [fileHistoryBack, selectedPath]) + 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(() => { - if (fileHistoryForward.length === 0) return + const navigateForward = useCallback(async () => { + const { back, forward } = historyRef.current + if (forward.length === 0) return - const newForward = [...fileHistoryForward] - const nextPath = newForward.pop()! - - // Push current path to back history - if (selectedPath) { - setFileHistoryBack(prev => [...prev, selectedPath]) + let i = forward.length - 1 + while (i >= 0 && viewStatesEqual(forward[i], currentViewState)) i -= 1 + if (i < 0) { + setHistory({ back, forward: [] }) + return } - setFileHistoryForward(newForward) - setSelectedPath(nextPath) - }, [fileHistoryForward, selectedPath]) + 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 = fileHistoryBack.length > 0 - const canNavigateForward = fileHistoryForward.length > 0 + 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 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 => { @@ -1424,18 +1624,17 @@ function App() { if (isFullScreenChat && expandedFrom) { handleCloseFullScreenChat() } else { - handleOpenFullScreenChat() + navigateToFullScreenChat() } } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [handleOpenFullScreenChat, handleCloseFullScreenChat, isFullScreenChat, expandedFrom]) + }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { navigateToFile(path) - setIsGraphOpen(false) return } @@ -1451,10 +1650,12 @@ function App() { // Handle sidebar section changes - switch to chat view for tasks const handleSectionChange = useCallback((section: ActiveSection) => { if (section === 'tasks') { - setSelectedPath(null) - setIsGraphOpen(false) + if (selectedBackgroundTask) return + if (selectedPath || isGraphOpen) { + void navigateToView({ type: 'chat', runId }) + } } - }, []) + }, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath]) // Knowledge quick actions const knowledgeFiles = React.useMemo(() => { @@ -1542,8 +1743,7 @@ function App() { data: `# ${name}\n\n`, opts: { encoding: 'utf8' } }) - setIsGraphOpen(false) - setSelectedPath(fullPath) + navigateToFile(fullPath) } catch (err) { console.error('Failed to create note:', err) throw err @@ -1561,8 +1761,7 @@ function App() { } }, openGraph: () => { - setSelectedPath(null) - setIsGraphOpen(true) + void navigateToView({ type: 'graph' }) }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), @@ -1593,7 +1792,7 @@ function App() { const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path navigator.clipboard.writeText(fullPath) }, - }), [tree, selectedPath, workspaceRoot, collectDirPaths]) + }), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView]) // Handler for when a voice note is created/updated const handleVoiceNoteCreated = useCallback(async (notePath: string) => { @@ -1614,9 +1813,8 @@ function App() { }) // Select the file to show it in the editor - setIsGraphOpen(false) - setSelectedPath(notePath) - }, [loadDirectory]) + navigateToFile(notePath) + }, [loadDirectory, navigateToFile]) const ensureWikiFile = useCallback(async (wikiPath: string) => { const resolvedPath = toKnowledgePath(wikiPath) @@ -1870,15 +2068,14 @@ function App() { runs={runs} currentRunId={runId} tasksActions={{ - onNewChat: handleNewChat, + onNewChat: () => { + void navigateToView({ type: 'chat', runId: null }) + }, onSelectRun: (runIdToLoad) => { - setSelectedBackgroundTask(null) - loadRun(runIdToLoad) + void navigateToView({ type: 'chat', runId: runIdToLoad }) }, onSelectBackgroundTask: (taskName) => { - setSelectedBackgroundTask(taskName) - setSelectedPath(null) - setIsGraphOpen(false) + void navigateToView({ type: 'task', name: taskName }) }, }} backgroundTasks={backgroundTasks} @@ -1909,7 +2106,7 @@ function App() {
) : ( - { setSelectedPath(path); setIsGraphOpen(false) }}> + { navigateToFile(path) }}>
@@ -2093,7 +2285,7 @@ function App() { defaultWidth={400} isOpen={isChatSidebarOpen} onNewChat={handleNewChat} - onOpenFullScreen={handleOpenFullScreenChat} + onOpenFullScreen={navigateToFullScreenChat} conversation={conversation} currentAssistantMessage={currentAssistantMessage} currentReasoning={currentReasoning} @@ -2113,11 +2305,16 @@ function App() { permissionResponses={permissionResponses} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} - onOpenKnowledgeFile={(path) => { setSelectedPath(path); setIsGraphOpen(false) }} + onOpenKnowledgeFile={(path) => { navigateToFile(path) }} /> )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} - + { void navigateBack() }} + onNavigateForward={() => { void navigateForward() }} + canNavigateBack={canNavigateBack} + canNavigateForward={canNavigateForward} + /> {/* Floating chat input - shown when viewing files/graph and chat sidebar is closed */} diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index 95853e5c..bf258633 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -22,8 +22,6 @@ import { MinusIcon, LinkIcon, CodeSquareIcon, - ChevronLeftIcon, - ChevronRightIcon, ExternalLinkIcon, Trash2Icon, ImageIcon, @@ -33,20 +31,12 @@ interface EditorToolbarProps { editor: Editor | null onSelectionHighlight?: (range: { from: number; to: number } | null) => void onImageUpload?: (file: File) => Promise | void - onNavigateBack?: () => void - onNavigateForward?: () => void - canNavigateBack?: boolean - canNavigateForward?: boolean } export function EditorToolbar({ editor, onSelectionHighlight, onImageUpload, - onNavigateBack, - onNavigateForward, - canNavigateBack, - canNavigateForward, }: EditorToolbarProps) { const [linkUrl, setLinkUrl] = useState('') const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) @@ -117,35 +107,13 @@ export function EditorToolbar({ return (
- {/* Back / Forward Navigation */} - - - -
- {/* Text formatting */}