From b01af12148432b90ce50e895a7229c2840c85963 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 12 May 2026 17:43:25 +0530 Subject: [PATCH] feat: background tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Background Tasks — recurring background agents the user can set up to either keep a digest current (daily email summary, top HN stories, weather brief) or perform a recurring action (draft a reply, post to Slack, call an API). Each task is a persistent set of instructions plus optional triggers (schedule, time-of-day window, or matching incoming Gmail / calendar event). The agent reads the verbs in the instructions on every run and picks the right mode automatically. User-facing surfaces: - New "Background tasks" entry in the sidebar, with a table listing every task, its schedule, last run, and an active toggle. - A detail page per task with a max-width reader showing the task's current output and a control sidebar for editing instructions, triggers, and reviewing run history. - "New task" can open in a free-form box where the user describes what they want and Copilot sets it up end-to-end, or in a structured form for manual setup. - "Edit with Copilot" hand-off from the detail view, pre-seeded with the task's context. Under the hood: - The event pipeline that previously powered live-notes is now a generic consumer registry. Live-notes and background tasks both subscribe; incoming events are routed to candidates from both concurrently. - Schedule helpers and the agent-message trigger block are factored out of live-notes into shared modules. Both features use the same building blocks now. - Copilot's proactive routing is reframed: anything recurring (cadence words, watch / monitor verbs, action verbs, event-conditional asks) now flows to background tasks. Live-notes load only on explicit mention. - A small reliability fix for the run-creation fallback chain: an empty-string model/provider passed by an LLM tool call now correctly falls through to the default instead of being persisted as a real value. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/apps/main/src/ipc.ts | 90 + apps/x/apps/main/src/main.ts | 20 +- apps/x/apps/renderer/src/App.tsx | 134 +- .../renderer/src/components/bg-tasks-view.tsx | 1689 +++++++++++++++++ .../src/components/compact-conversation.tsx | 78 + .../src/components/live-note-sidebar.tsx | 61 +- .../src/components/sidebar-content.tsx | 21 + .../src/hooks/use-bg-task-agent-status.ts | 72 + .../core/src/agents/build-trigger-block.ts | 104 + apps/x/packages/core/src/agents/runtime.ts | 5 + apps/x/packages/core/src/agents/utils.ts | 2 +- .../x/packages/core/src/analytics/use_case.ts | 2 +- .../src/application/assistant/instructions.ts | 8 +- .../assistant/skills/background-task/skill.ts | 138 ++ .../src/application/assistant/skills/index.ts | 9 +- .../core/src/application/lib/builtin-tools.ts | 90 + .../core/src/background-tasks/agent.ts | 84 + .../packages/core/src/background-tasks/bus.ts | 23 + .../src/background-tasks/event-consumer.ts | 63 + .../core/src/background-tasks/fileops.ts | 255 +++ .../core/src/background-tasks/runner.ts | 193 ++ .../core/src/background-tasks/scheduler.ts | 93 + apps/x/packages/core/src/events/consumer.ts | 41 + apps/x/packages/core/src/events/init.ts | 32 + apps/x/packages/core/src/events/processor.ts | 170 ++ apps/x/packages/core/src/events/producer.ts | 31 + apps/x/packages/core/src/events/routing.ts | 116 ++ .../src/knowledge/live-note/event-consumer.ts | 79 + .../core/src/knowledge/live-note/events.ts | 204 -- .../core/src/knowledge/live-note/routing.ts | 111 -- .../core/src/knowledge/live-note/runner.ts | 60 +- .../src/knowledge/live-note/schedule-utils.ts | 96 +- .../core/src/knowledge/live-note/scheduler.ts | 2 +- .../core/src/knowledge/sync_calendar.ts | 2 +- .../packages/core/src/knowledge/sync_gmail.ts | 2 +- apps/x/packages/core/src/models/defaults.ts | 10 + apps/x/packages/core/src/runs/repo.ts | 13 +- apps/x/packages/core/src/runs/runs.ts | 7 +- apps/x/packages/core/src/schedule/utils.ts | 92 + apps/x/packages/shared/src/background-task.ts | 111 ++ apps/x/packages/shared/src/events.ts | 76 + apps/x/packages/shared/src/index.ts | 2 + apps/x/packages/shared/src/ipc.ts | 99 + apps/x/packages/shared/src/live-note.ts | 27 +- apps/x/packages/shared/src/runs.ts | 2 + 45 files changed, 4025 insertions(+), 594 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/bg-tasks-view.tsx create mode 100644 apps/x/apps/renderer/src/components/compact-conversation.tsx create mode 100644 apps/x/apps/renderer/src/hooks/use-bg-task-agent-status.ts create mode 100644 apps/x/packages/core/src/agents/build-trigger-block.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/background-task/skill.ts create mode 100644 apps/x/packages/core/src/background-tasks/agent.ts create mode 100644 apps/x/packages/core/src/background-tasks/bus.ts create mode 100644 apps/x/packages/core/src/background-tasks/event-consumer.ts create mode 100644 apps/x/packages/core/src/background-tasks/fileops.ts create mode 100644 apps/x/packages/core/src/background-tasks/runner.ts create mode 100644 apps/x/packages/core/src/background-tasks/scheduler.ts create mode 100644 apps/x/packages/core/src/events/consumer.ts create mode 100644 apps/x/packages/core/src/events/init.ts create mode 100644 apps/x/packages/core/src/events/processor.ts create mode 100644 apps/x/packages/core/src/events/producer.ts create mode 100644 apps/x/packages/core/src/events/routing.ts create mode 100644 apps/x/packages/core/src/knowledge/live-note/event-consumer.ts delete mode 100644 apps/x/packages/core/src/knowledge/live-note/events.ts delete mode 100644 apps/x/packages/core/src/knowledge/live-note/routing.ts create mode 100644 apps/x/packages/core/src/schedule/utils.ts create mode 100644 apps/x/packages/shared/src/background-task.ts create mode 100644 apps/x/packages/shared/src/events.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a667b512..f07a1542 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -57,6 +57,16 @@ import { deleteLiveNote, listLiveNotes, } from '@x/core/dist/knowledge/live-note/fileops.js'; +import { runBackgroundTask } from '@x/core/dist/background-tasks/runner.js'; +import { backgroundTaskBus } from '@x/core/dist/background-tasks/bus.js'; +import { + fetchTask, + patchTask, + createTask, + deleteTask, + listTasks, + readRunIds as readTaskRunIds, +} from '@x/core/dist/background-tasks/fileops.js'; import { browserIpcHandlers } from './browser/ipc.js'; /** @@ -389,6 +399,19 @@ export function startLiveNoteAgentWatcher(): void { }); } +let backgroundTaskAgentWatcher: (() => void) | null = null; +export function startBackgroundTaskAgentWatcher(): void { + if (backgroundTaskAgentWatcher) return; + backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('bg-task-agent:events', event); + } + } + }); +} + export function stopRunsWatcher(): void { if (runsWatcher) { runsWatcher(); @@ -866,6 +889,73 @@ export function setupIpcHandlers() { const notes = await listLiveNotes(); return { notes }; }, + // Bg-task handlers + 'bg-task:run': async (_event, args) => { + const result = await runBackgroundTask(args.slug, 'manual', args.context); + return { + success: !result.error, + runId: result.runId, + summary: result.summary, + error: result.error, + }; + }, + 'bg-task:get': async (_event, args) => { + try { + const task = await fetchTask(args.slug); + return { success: true, task }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:patch': async (_event, args) => { + try { + const task = await patchTask(args.slug, args.partial); + return { success: true, task }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:create': async (_event, args) => { + try { + const { slug } = await createTask({ + name: args.name, + instructions: args.instructions, + ...(args.triggers ? { triggers: args.triggers } : {}), + ...(args.model ? { model: args.model } : {}), + ...(args.provider ? { provider: args.provider } : {}), + }); + return { success: true, slug }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:delete': async (_event, args) => { + try { + await deleteTask(args.slug); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:stop': async (_event, args) => { + try { + const task = await fetchTask(args.slug); + if (!task?.lastRunId) { + return { success: false, error: 'No active run for this task' }; + } + await runsCore.stop(task.lastRunId, false); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:list': async (_event, args) => { + return listTasks(args); + }, + 'bg-task:listRunIds': async (_event, args) => { + const runIds = await readTaskRunIds(args.slug, args.limit); + return { runIds }; + }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index accc971e..8bb93db5 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -5,6 +5,7 @@ import { startRunsWatcher, startServicesWatcher, startLiveNoteAgentWatcher, + startBackgroundTaskAgentWatcher, startWorkspaceWatcher, stopRunsWatcher, stopServicesWatcher, @@ -25,7 +26,10 @@ import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js"; import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js"; -import { init as initLiveNoteEventProcessor } from "@x/core/dist/knowledge/live-note/events.js"; +import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js"; +import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js"; +import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js"; +import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; @@ -331,11 +335,21 @@ app.whenReady().then(async () => { // start live-note agent event watcher (forwards bus → renderer) startLiveNoteAgentWatcher(); + // start bg-task agent event watcher (forwards bus → renderer) + startBackgroundTaskAgentWatcher(); + // start live-note scheduler (cron / window) initLiveNoteScheduler(); - // start live-note event processor (consumes events/pending/, routes to matching live notes) - initLiveNoteEventProcessor(); + // start bg-task scheduler (cron / window) + initBackgroundTaskScheduler(); + + // register event consumers and start the shared event processor + // (consumes $WorkDir/events/pending/, routes events to all consumers + // concurrently for Pass-1, then fires each consumer's candidates in parallel) + registerConsumer(liveNoteEventConsumer); + registerConsumer(backgroundTaskEventConsumer); + initEventProcessor(); // start gmail sync initGmailSync(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 5ec8476b..b3297ccd 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -23,6 +23,7 @@ import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { LiveNotesView } from '@/components/live-notes-view'; +import { BgTasksView } from '@/components/bg-tasks-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -176,6 +177,7 @@ const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' +const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -306,6 +308,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH +const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -365,6 +368,12 @@ const buildSuggestedTopicExplorePrompt = ({ const buildLiveNoteSetupPrompt = () => 'I want to set up a Live note / task.' +const buildBgTaskSetupPrompt = (description: string) => + `Create a background task for me. Here's what I want it to do:\n\n${description}` + +const buildBgTaskEditPrompt = (slug: string) => + `Let's tweak the background task \`${slug}\`. Please load the \`background-task\` skill first, read the task's current \`bg-tasks/${slug}/task.yaml\`, then ask me what I want to change.` + const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') @@ -700,6 +709,7 @@ function App() { const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false) + const [isBgTasksOpen, setIsBgTasksOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null graph: boolean @@ -1030,6 +1040,7 @@ function App() { if (isGraphTabPath(tab.path)) return 'Graph View' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' if (isLiveNotesTabPath(tab.path)) return 'Live notes' + if (isBgTasksTabPath(tab.path)) return 'Background tasks' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -2742,7 +2753,7 @@ function App() { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setSelectedPath(path) return } @@ -2751,7 +2762,7 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2770,14 +2781,14 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) return } if (isLiveNotesTabPath(tab.path)) { @@ -2789,7 +2800,7 @@ function App() { } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setSelectedPath(tab.path) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) @@ -2818,7 +2829,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2832,12 +2843,12 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) } else if (isLiveNotesTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -2846,7 +2857,7 @@ function App() { } else { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2877,7 +2888,7 @@ function App() { dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -2891,8 +2902,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) - }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen]) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -3024,7 +3035,7 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -3037,19 +3048,19 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, dismissBrowserOverlay]) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) } else if (expandedFrom.liveNotes) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) @@ -3057,7 +3068,7 @@ function App() { } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -3072,7 +3083,7 @@ function App() { if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isLiveNotesOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3140,6 +3151,30 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureBgTasksFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isBgTasksTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: BG_TASKS_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + + const openBgTasksView = useCallback(() => { + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setSelectedBackgroundTask(null) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setIsBgTasksOpen(true) + ensureBgTasksFileTab() + }, [ensureBgTasksFileTab]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -3149,7 +3184,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -3164,7 +3199,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3177,7 +3212,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3190,7 +3225,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) ensureSuggestedTopicsFileTab() return case 'live-notes': @@ -3212,7 +3247,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false) + setIsLiveNotesOpen(false); setIsBgTasksOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -3542,7 +3577,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3615,17 +3650,19 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen ? SUGGESTED_TOPICS_TAB_PATH : isLiveNotesOpen ? LIVE_NOTES_TAB_PATH + : isBgTasksOpen + ? BG_TASKS_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -3680,7 +3717,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3705,7 +3742,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3831,14 +3868,14 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4428,7 +4465,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4445,7 +4482,7 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -4478,7 +4515,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4489,7 +4526,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4513,14 +4550,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4552,6 +4589,8 @@ function App() { onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} isLiveNotesOpen={isLiveNotesOpen} onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} + isBgTasksOpen={isBgTasksOpen} + onOpenBgTasks={openBgTasksView} /> - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && ( + + {triggers.cronExpr && ( +
{describeCron(triggers.cronExpr)}
+ )} + + ) : ( + + )} + + +
+ Windows +
+
+ {hasWindows && triggers.windows ? ( +
+ {triggers.windows.map((w, idx) => ( +
+ { + const next = [...(triggers.windows ?? [])] + next[idx] = { ...next[idx], startTime: e.target.value } + updateTriggers({ windows: next }) + }} + placeholder="09:00" + className={`h-7 w-20 font-mono text-xs ${HH_MM.test(w.startTime) ? '' : 'border-destructive'}`} + /> + + { + const next = [...(triggers.windows ?? [])] + next[idx] = { ...next[idx], endTime: e.target.value } + updateTriggers({ windows: next }) + }} + placeholder="12:00" + className={`h-7 w-20 font-mono text-xs ${HH_MM.test(w.endTime) ? '' : 'border-destructive'}`} + /> + +
+ ))} + +
+ ) : ( + + )} +
+ +
+ Events +
+
+ {hasEvent ? ( + editingEvents ? ( +
+