mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-28 19:05:31 +02:00
feat: background tasks
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) <noreply@anthropic.com>
This commit is contained in:
parent
13fa80c687
commit
b01af12148
45 changed files with 4025 additions and 594 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<LanguageModelUsage> | 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 (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
||||
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}
|
||||
/>
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
|
|
@ -4571,7 +4610,7 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && fileTabs.length >= 1 ? (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? (
|
||||
<TabBar
|
||||
tabs={fileTabs}
|
||||
activeTabId={activeFileTabId ?? ''}
|
||||
|
|
@ -4579,7 +4618,7 @@ function App() {
|
|||
getTabId={(t) => 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)))}
|
||||
/>
|
||||
) : (
|
||||
<TabBar
|
||||
|
|
@ -4632,7 +4671,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedTask && !isBrowserOpen && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4647,7 +4686,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBrowserOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4662,7 +4701,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4704,6 +4743,17 @@ function App() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isBgTasksOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BgTasksView
|
||||
onCreateWithCopilot={(description) => {
|
||||
submitFromPalette(buildBgTaskSetupPrompt(description), null)
|
||||
}}
|
||||
onEditWithCopilot={(slug) => {
|
||||
submitFromPalette(buildBgTaskEditPrompt(slug), null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
|
|
|
|||
1689
apps/x/apps/renderer/src/components/bg-tasks-view.tsx
Normal file
1689
apps/x/apps/renderer/src/components/bg-tasks-view.tsx
Normal file
File diff suppressed because it is too large
Load diff
78
apps/x/apps/renderer/src/components/compact-conversation.tsx
Normal file
78
apps/x/apps/renderer/src/components/compact-conversation.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useState } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import {
|
||||
type ConversationItem,
|
||||
type ToolCall,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
getToolDisplayName,
|
||||
toToolState,
|
||||
normalizeToolOutput,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { Tool, ToolHeader, ToolContent, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
|
||||
/**
|
||||
* Compact rendering of a run's conversation log — used by the live-note panel's
|
||||
* "Last run" tab and the bg-task sidebar's "Runs history" drill-down. Keep this
|
||||
* the single source of truth so the two surfaces stay visually aligned.
|
||||
*
|
||||
* - User messages: right-aligned secondary bubble, plain text.
|
||||
* - Assistant messages: full-width markdown.
|
||||
* - Tool calls: collapsible `Tool` row with tabbed input/output.
|
||||
* - Errors: destructive-tinted banner.
|
||||
*/
|
||||
export function CompactConversation({ items }: { items: ConversationItem[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{items.map((item) => {
|
||||
if (isErrorMessage(item)) {
|
||||
return (
|
||||
<div key={item.id} className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{item.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isToolCall(item)) return <CompactToolRow key={item.id} tool={item} />
|
||||
if (isChatMessage(item)) {
|
||||
const isUser = item.role === 'user'
|
||||
return (
|
||||
<div key={item.id} className={isUser ? 'flex justify-end' : ''}>
|
||||
<div className={isUser
|
||||
? 'max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-xs text-foreground whitespace-pre-wrap break-words'
|
||||
: 'w-full text-xs text-foreground'}>
|
||||
{isUser ? (
|
||||
item.content
|
||||
) : (
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1.5 [&_ul]:my-1.5 [&_ol]:my-1.5 [&_pre]:my-2 [&_pre]:text-[11px] [&_code]:text-[11px]">
|
||||
{item.content}
|
||||
</Streamdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactToolRow({ tool }: { tool: ToolCall }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const title = getToolDisplayName(tool)
|
||||
const state = toToolState(tool.status)
|
||||
const errorText = tool.status === 'error' && typeof tool.result === 'string' ? tool.result : undefined
|
||||
return (
|
||||
<Tool open={open} onOpenChange={setOpen} className="mb-0 text-xs">
|
||||
<ToolHeader title={title} type={`tool-${tool.name}` as `tool-${string}`} state={state} />
|
||||
<ToolContent>
|
||||
<ToolTabbedContent
|
||||
input={tool.input}
|
||||
output={normalizeToolOutput(tool.result, tool.status) ?? undefined}
|
||||
errorText={errorText}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,13 +15,8 @@ import type { Run } from '@x/shared/dist/runs.js'
|
|||
import type z from 'zod'
|
||||
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import {
|
||||
type ConversationItem, type ToolCall,
|
||||
isChatMessage, isToolCall, isErrorMessage,
|
||||
getToolDisplayName, toToolState, normalizeToolOutput,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { Tool, ToolHeader, ToolContent, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
|
||||
export type OpenLiveNotePanelDetail = {
|
||||
filePath: string
|
||||
|
|
@ -767,60 +762,6 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
)
|
||||
}
|
||||
|
||||
function CompactConversation({ items }: { items: ConversationItem[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{items.map((item) => {
|
||||
if (isErrorMessage(item)) {
|
||||
return (
|
||||
<div key={item.id} className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{item.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isToolCall(item)) return <CompactToolRow key={item.id} tool={item} />
|
||||
if (isChatMessage(item)) {
|
||||
const isUser = item.role === 'user'
|
||||
return (
|
||||
<div key={item.id} className={isUser ? 'flex justify-end' : ''}>
|
||||
<div className={isUser
|
||||
? 'max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-xs text-foreground whitespace-pre-wrap break-words'
|
||||
: 'w-full text-xs text-foreground'}>
|
||||
{isUser ? (
|
||||
item.content
|
||||
) : (
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1.5 [&_ul]:my-1.5 [&_ol]:my-1.5 [&_pre]:my-2 [&_pre]:text-[11px] [&_code]:text-[11px]">
|
||||
{item.content}
|
||||
</Streamdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactToolRow({ tool }: { tool: ToolCall }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const title = getToolDisplayName(tool)
|
||||
const state = toToolState(tool.status)
|
||||
const errorText = tool.status === 'error' && typeof tool.result === 'string' ? tool.result : undefined
|
||||
return (
|
||||
<Tool open={open} onOpenChange={setOpen} className="mb-0 text-xs">
|
||||
<ToolHeader title={title} type={`tool-${tool.name}` as `tool-${string}`} state={state} />
|
||||
<ToolContent>
|
||||
<ToolTabbedContent
|
||||
input={tool.input}
|
||||
output={normalizeToolOutput(tool.result, tool.status) ?? undefined}
|
||||
errorText={errorText}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
}
|
||||
|
||||
function TriggersEditor({
|
||||
draft,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
Table2,
|
||||
Plug,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
LoaderIcon,
|
||||
Settings,
|
||||
Square,
|
||||
|
|
@ -217,6 +218,8 @@ type SidebarContentPanelProps = {
|
|||
onOpenSuggestedTopics?: () => void
|
||||
isLiveNotesOpen?: boolean
|
||||
onOpenLiveNotes?: () => void
|
||||
isBgTasksOpen?: boolean
|
||||
onOpenBgTasks?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||
|
|
@ -477,6 +480,8 @@ export function SidebarContentPanel({
|
|||
onOpenSuggestedTopics,
|
||||
isLiveNotesOpen = false,
|
||||
onOpenLiveNotes,
|
||||
isBgTasksOpen = false,
|
||||
onOpenBgTasks,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
|
@ -493,6 +498,7 @@ export function SidebarContentPanel({
|
|||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
|
||||
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
|
||||
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
|
||||
|
||||
const handleRowboatLogin = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -666,6 +672,21 @@ export function SidebarContentPanel({
|
|||
<span>Suggested Topics</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenBgTasks && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBgTasks}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isBgTasksQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<ListChecks className="size-4" />
|
||||
<span>Background tasks</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenLiveNotes && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
72
apps/x/apps/renderer/src/hooks/use-bg-task-agent-status.ts
Normal file
72
apps/x/apps/renderer/src/hooks/use-bg-task-agent-status.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import z from 'zod';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { BackgroundTaskAgentEvent } from '@x/shared/dist/background-task.js';
|
||||
|
||||
export type BackgroundTaskAgentStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
export interface BackgroundTaskAgentState {
|
||||
status: BackgroundTaskAgentStatus;
|
||||
runId?: string;
|
||||
summary?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Module-level store — shared across all hook consumers, subscribed once.
|
||||
// We replace the Map on every mutation so useSyncExternalStore detects the change.
|
||||
let store = new Map<string, BackgroundTaskAgentState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let subscribed = false;
|
||||
|
||||
function updateStore(fn: (prev: Map<string, BackgroundTaskAgentState>) => void) {
|
||||
store = new Map(store);
|
||||
fn(store);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function ensureSubscription() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
window.ipc.on('bg-task-agent:events', ((event: z.infer<typeof BackgroundTaskAgentEvent>) => {
|
||||
const key = event.slug;
|
||||
|
||||
if (event.type === 'background_task_agent_start') {
|
||||
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
|
||||
} else if (event.type === 'background_task_agent_complete') {
|
||||
updateStore(s => s.set(key, {
|
||||
status: event.error ? 'error' : 'done',
|
||||
runId: event.runId,
|
||||
summary: event.summary ?? null,
|
||||
error: event.error ?? null,
|
||||
}));
|
||||
// Auto-clear after 5 seconds
|
||||
setTimeout(() => {
|
||||
updateStore(s => s.delete(key));
|
||||
}, 5000);
|
||||
}
|
||||
}) as (event: z.infer<typeof BackgroundTaskAgentEvent>) => void);
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
ensureSubscription();
|
||||
listeners.add(onStoreChange);
|
||||
return () => { listeners.delete(onStoreChange); };
|
||||
}
|
||||
|
||||
function getSnapshot(): Map<string, BackgroundTaskAgentState> {
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map of all bg-task agent run states, keyed by `slug`.
|
||||
*
|
||||
* Usage in the detail view:
|
||||
* const status = useBackgroundTaskAgentStatus();
|
||||
* const state = status.get(slug) ?? { status: 'idle' };
|
||||
*
|
||||
* Usage for a global indicator:
|
||||
* const status = useBackgroundTaskAgentStatus();
|
||||
* const anyRunning = [...status.values()].some(s => s.status === 'running');
|
||||
*/
|
||||
export function useBackgroundTaskAgentStatus(): Map<string, BackgroundTaskAgentState> {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue