feat: live notes — single objective per note replaces multi-track model

Folds the multi-`track:`-array model into one `live:` block per note: a single
persistent objective the live-note agent maintains, plus an optional triggers
object (`cronExpr` / `windows` / `eventMatchCriteria`, each independently
optional). A note is now passive or live — no per-track scopes, no section
ownership contract, no `once` trigger. The agent owns the whole body and makes
patch-style incremental edits per run.

Highlights:
- Schema: `track:` array → single `live:` object (`packages/shared/src/live-note.ts`).
- Runtime: scheduler / event processor / runner under `core/knowledge/live-note/`,
  with split `lastAttemptAt` (every run, drives 5-min backoff) vs `lastRunAt`
  (success only, anchors cycles). `throwOnError` on agent runs surfaces LLM /
  billing failures into `lastRunError`.
- Today.md: regenerated by template v2 (single objective covering overview /
  calendar / emails / what-you-missed / priorities; existing files renamed to
  `Today.md.bkp.<stamp>`).
- Renderer: `LiveNoteSidebar` mounts inside the editor row (no chat overlap,
  auto-closes on note switch); toolbar Radio button becomes a status pill;
  `LiveNotesView` replaces background-agents view.
- Copilot: new `live-note` skill with act-first stance, default folder/cadence
  pickers, and a non-negotiable rule to extend an existing objective rather
  than add a second one. Shared `KNOWLEDGE_NOTE_STYLE_GUIDE` enforces
  terse-and-scannable writing across `doc-collab` and the live-note agent.
- Analytics: `track_block` use-case → `live_note_agent`; trigger
  (`manual` / `cron` / `window` / `event`) becomes the Pass-2 sub-use-case,
  alongside `routing` for Pass 1. Legacy run files with the old value are
  read-mapped via `LegacyStartEvent` so they stay openable in the runs list.

Hard cutover — no back-compat shims for legacy `track:` frontmatter arrays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-05-09 00:26:46 +05:30
parent 0bf7a55611
commit dabca3da19
59 changed files with 3816 additions and 3212 deletions

View file

@ -46,18 +46,17 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
import {
fetchYaml,
listNotesWithTracks,
setNoteTracksActive,
updateTrack,
replaceTrackYaml,
deleteTrack,
} from '@x/core/dist/knowledge/track/fileops.js';
fetchLiveNote,
setLiveNote,
setLiveNoteActive,
deleteLiveNote,
listLiveNotes,
} from '@x/core/dist/knowledge/live-note/fileops.js';
import { browserIpcHandlers } from './browser/ipc.js';
/**
@ -137,14 +136,6 @@ function resolveShellPath(filePath: string): string {
return workspace.resolveWorkspacePath(filePath);
}
function toKnowledgeTrackPath(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized.startsWith('knowledge/')) {
throw new Error('Track note path must be within knowledge/')
}
return normalized.slice('knowledge/'.length)
}
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -385,14 +376,14 @@ export async function startServicesWatcher(): Promise<void> {
});
}
let tracksWatcher: (() => void) | null = null;
export function startTracksWatcher(): void {
if (tracksWatcher) return;
tracksWatcher = trackBus.subscribe((event) => {
let liveNoteAgentWatcher: (() => void) | null = null;
export function startLiveNoteAgentWatcher(): void {
if (liveNoteAgentWatcher) return;
liveNoteAgentWatcher = liveNoteBus.subscribe((event) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('tracks:events', event);
win.webContents.send('live-note-agent:events', event);
}
}
});
@ -813,59 +804,66 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text);
},
// Track handlers
'track:run': async (_event, args) => {
const result = await triggerTrackUpdate(args.id, args.filePath);
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
// Live-note handlers
'live-note:run': async (_event, args) => {
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
return {
success: !result.error,
runId: result.runId,
action: result.action,
summary: result.summary,
contentAfter: result.contentAfter,
error: result.error,
};
},
'track:get': async (_event, args) => {
'live-note:get': async (_event, args) => {
try {
const yaml = await fetchYaml(args.filePath, args.id);
if (yaml === null) return { success: false, error: 'Track not found' };
return { success: true, yaml };
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:update': async (_event, args) => {
'live-note:set': async (_event, args) => {
try {
await updateTrack(args.filePath, args.id, args.updates as Record<string, unknown>);
const yaml = await fetchYaml(args.filePath, args.id);
if (yaml === null) return { success: false, error: 'Track vanished after update' };
return { success: true, yaml };
await setLiveNote(args.filePath, args.live);
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:replaceYaml': async (_event, args) => {
'live-note:setActive': async (_event, args) => {
try {
await replaceTrackYaml(args.filePath, args.id, args.yaml);
const yaml = await fetchYaml(args.filePath, args.id);
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
return { success: true, yaml };
await setLiveNoteActive(args.filePath, args.active);
const live = await fetchLiveNote(args.filePath);
return { success: true, live };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:delete': async (_event, args) => {
'live-note:delete': async (_event, args) => {
try {
await deleteTrack(args.filePath, args.id);
await deleteLiveNote(args.filePath);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:setNoteActive': async (_event, args) => {
'live-note:stop': async (_event, args) => {
try {
const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active);
if (!note) return { success: false, error: 'No tracks found in note' };
return { success: true, note };
const live = await fetchLiveNote(args.filePath);
if (!live?.lastRunId) {
return { success: false, error: 'No active run for this note' };
}
await runsCore.stop(live.lastRunId, false);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:listNotes': async () => {
const notes = await listNotesWithTracks();
'live-note:listNotes': async () => {
const notes = await listLiveNotes();
return { notes };
},
// Billing handler

View file

@ -4,7 +4,7 @@ import {
setupIpcHandlers,
startRunsWatcher,
startServicesWatcher,
startTracksWatcher,
startLiveNoteAgentWatcher,
startWorkspaceWatcher,
stopRunsWatcher,
stopServicesWatcher,
@ -24,8 +24,8 @@ import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { 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 initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.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 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";
@ -328,14 +328,14 @@ app.whenReady().then(async () => {
// start services watcher
startServicesWatcher();
// start tracks watcher
startTracksWatcher();
// start live-note agent event watcher (forwards bus → renderer)
startLiveNoteAgentWatcher();
// start track scheduler (cron/window/once)
initTrackScheduler();
// start live-note scheduler (cron / window)
initLiveNoteScheduler();
// start track event processor (consumes events/pending/, triggers matching tracks)
initTrackEventProcessor();
// start live-note event processor (consumes events/pending/, routes to matching live notes)
initLiveNoteEventProcessor();
// start gmail sync
initGmailSync();

View file

@ -22,7 +22,7 @@ import { getViewerType, isCacheableViewerPath } from '@/lib/file-types';
import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { BackgroundAgentsView } from '@/components/background-agents-view';
import { LiveNotesView } from '@/components/live-notes-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
@ -66,7 +66,7 @@ import { extractConferenceLink } from '@/lib/calendar-event'
import { OnboardingModal } from '@/components/onboarding'
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackSidebar } from '@/components/track-sidebar'
import { LiveNoteSidebar } from '@/components/live-note-sidebar'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
import { VersionHistoryPanel } from '@/components/version-history-panel'
@ -175,7 +175,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const BACKGROUND_AGENTS_TAB_PATH = '__rowboat_background_agents__'
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) =>
@ -305,7 +305,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isBackgroundAgentsTabPath = (path: string) => path === BACKGROUND_AGENTS_TAB_PATH
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => {
@ -351,34 +351,19 @@ const buildSuggestedTopicExplorePrompt = ({
`- Description: ${description}`,
`- Target folder if we set this up: knowledge/${folder}/`,
'',
`Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`,
'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.',
`Please start by telling me that you can set up a live note for "${title}" under knowledge/${folder}/.`,
'Then briefly explain what that live note would track and ask me if you should set it up.',
'Do not create or modify anything yet.',
'Treat a clear confirmation from me as explicit approval to proceed.',
`If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`,
`If I confirm later, load the \`live-note\` skill first, check whether a matching note already exists under knowledge/${folder}/, and extend its existing live objective instead of creating a duplicate.`,
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
'Add a track to the note (a `track:` entry in its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
'Make the new note live (add a `live:` block to its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
].join('\n')
}
const buildBackgroundAgentSetupPrompt = () => [
'Help me set up a background agent.',
'In this flow, a background agent is the same thing as a track on a note (a `track:` entry in the note frontmatter). Do not tell me they are separate concepts.',
'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.',
'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.',
'Start with a short, plain-English explanation of what a background agent is.',
'Do not make the explanation too terse.',
'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.',
'Do not mention triggers, event-based vs schedule-based behavior, tracks, skills, note paths, or other internal implementation details unless I ask.',
'In the first reply, tell me that you will create this in my Tasks folder by default.',
'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.',
'Then ask only what I want it to monitor or update and how often I want it to run.',
'Keep it concise and friendly, but not abrupt.',
'Do not give me a long taxonomy, a big list of options, or a multi-step breakdown unless I ask for more detail.',
'Do not create or modify anything yet.',
'If I confirm later, load the tracks skill, check for a matching note under knowledge/Tasks/ first, and create one there if needed.',
].join('\n')
const buildLiveNoteSetupPrompt = () =>
'I want to set up a Live note / task.'
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
if (!usage) return null
@ -562,7 +547,7 @@ type ViewState =
| { type: 'graph' }
| { type: 'task'; name: string }
| { type: 'suggested-topics' }
| { type: 'background-agents' }
| { type: 'live-notes' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
@ -576,13 +561,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target.
*
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|background-agents>&...
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|live-notes>&...
* file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph
* task: ?type=task&name=daily-brief
* suggested-topics: ?type=suggested-topics
* background-agents: ?type=background-agents
* live-notes: ?type=live-notes
*/
function parseDeepLink(input: string): ViewState | null {
const SCHEME = 'rowboat://'
@ -607,8 +592,8 @@ function parseDeepLink(input: string): ViewState | null {
}
case 'suggested-topics':
return { type: 'suggested-topics' }
case 'background-agents':
return { type: 'background-agents' }
case 'live-notes':
return { type: 'live-notes' }
default:
return null
}
@ -714,12 +699,12 @@ function App() {
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [isBackgroundAgentsOpen, setIsBackgroundAgentsOpen] = useState(false)
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{
path: string | null
graph: boolean
suggestedTopics: boolean
backgroundAgents: boolean
liveNotes: boolean
} | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -730,6 +715,10 @@ function App() {
const [graphError, setGraphError] = useState<string | null>(null)
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false)
// Live-note panel: bound to a single note path. Mounted as a sibling of the
// markdown editor so it shares the layout (no overlap with chat) and
// auto-closes when the active note changes.
const [liveNotePanelPath, setLiveNotePanelPath] = useState<string | null>(null)
const [activeShortcutPane, setActiveShortcutPane] = useState<ShortcutPane>('left')
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac')
const collapsedLeftPaddingPx =
@ -1040,7 +1029,7 @@ function App() {
const getFileTabTitle = useCallback((tab: FileTab) => {
if (isGraphTabPath(tab.path)) return 'Graph View'
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (isBackgroundAgentsTabPath(tab.path)) return 'Background agents'
if (isLiveNotesTabPath(tab.path)) return 'Live notes'
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
@ -2753,7 +2742,7 @@ function App() {
setActiveFileTabId(existingTab.id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setSelectedPath(path)
return
}
@ -2762,7 +2751,7 @@ function App() {
setActiveFileTabId(id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay])
@ -2781,26 +2770,26 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
return
}
if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
return
}
if (isBackgroundAgentsTabPath(tab.path)) {
if (isLiveNotesTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
setIsLiveNotesOpen(true)
return
}
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
@ -2829,7 +2818,7 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
@ -2843,21 +2832,21 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
} else if (isBackgroundAgentsTabPath(newActiveTab.path)) {
setIsLiveNotesOpen(false)
} else if (isLiveNotesTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
setIsLiveNotesOpen(true)
} else {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setSelectedPath(newActiveTab.path)
}
}
@ -2888,12 +2877,12 @@ function App() {
dismissBrowserOverlay()
handleNewChat()
// Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen,
backgroundAgents: isBackgroundAgentsOpen,
liveNotes: isLiveNotesOpen,
})
} else {
setExpandedFrom(null)
@ -2902,8 +2891,8 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen])
setIsLiveNotesOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => {
@ -2946,26 +2935,44 @@ function App() {
setPendingPaletteSubmit(null)
}, [pendingPaletteSubmit])
// Listener for "Edit with Copilot" events from the track sidebar.
// Listener for "Edit with Copilot" events from the live-note panel.
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<{
trackId?: string
filePath?: string
}>
const trackId = ev.detail?.trackId
const filePath = ev.detail?.filePath
if (!trackId || !filePath) return
if (!filePath) return
const displayName = filePath.split('/').pop() ?? filePath
submitFromPalette(
`Let's work on the \`${trackId}\` track in this note. Please load the \`tracks\` skill first, then ask me what I want to change.`,
`Let's tweak the live note objective in this note. Please load the \`live-note\` skill first, then ask me what I want to change.`,
{ path: filePath, displayName },
)
}
window.addEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
window.addEventListener('rowboat:open-copilot-edit-live-note', handler as EventListener)
return () => window.removeEventListener('rowboat:open-copilot-edit-live-note', handler as EventListener)
}, [submitFromPalette])
// Listener for the toolbar "Live note" button — opens the panel for a path.
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<{ filePath?: string }>
const filePath = ev.detail?.filePath
if (!filePath) return
setLiveNotePanelPath(filePath)
}
window.addEventListener('rowboat:open-live-note-panel', handler as EventListener)
return () => window.removeEventListener('rowboat:open-live-note-panel', handler as EventListener)
}, [])
// Auto-close the live-note panel when the active note changes — the panel is
// bound to a specific path, so switching notes invalidates it.
useEffect(() => {
if (liveNotePanelPath && liveNotePanelPath !== selectedPath) {
setLiveNotePanelPath(null)
}
}, [selectedPath, liveNotePanelPath])
// Listener for prompt-block "Run" events
// (dispatched by apps/renderer/src/extensions/prompt-block.tsx)
useEffect(() => {
@ -3017,12 +3024,12 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen,
backgroundAgents: isBackgroundAgentsOpen,
liveNotes: isLiveNotesOpen,
})
}
dismissBrowserOverlay()
@ -3030,27 +3037,27 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay])
setIsLiveNotesOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, dismissBrowserOverlay])
const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) {
if (expandedFrom.graph) {
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
} else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
} else if (expandedFrom.backgroundAgents) {
setIsLiveNotesOpen(false)
} else if (expandedFrom.liveNotes) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
setIsLiveNotesOpen(true)
} else if (expandedFrom.path) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setSelectedPath(expandedFrom.path)
}
setExpandedFrom(null)
@ -3060,12 +3067,12 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isBackgroundAgentsOpen) return { type: 'background-agents' }
if (isLiveNotesOpen) return { type: 'live-notes' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
}, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
}, [selectedBackgroundTask, isLiveNotesOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
@ -3122,14 +3129,14 @@ function App() {
setActiveFileTabId(id)
}, [fileTabs])
const ensureBackgroundAgentsFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isBackgroundAgentsTabPath(tab.path))
const ensureLiveNotesFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isLiveNotesTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: BACKGROUND_AGENTS_TAB_PATH }])
setFileTabs((prev) => [...prev, { id, path: LIVE_NOTES_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
@ -3142,7 +3149,7 @@ function App() {
// visible in the middle pane.
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(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.
@ -3157,7 +3164,7 @@ function App() {
setSelectedPath(null)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
@ -3170,7 +3177,7 @@ function App() {
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
@ -3183,10 +3190,10 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
ensureSuggestedTopicsFileTab()
return
case 'background-agents':
case 'live-notes':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
@ -3194,8 +3201,8 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
ensureBackgroundAgentsFileTab()
setIsLiveNotesOpen(true)
ensureLiveNotesFileTab()
return
case 'chat':
setSelectedPath(null)
@ -3205,7 +3212,7 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
if (view.runId) {
await loadRun(view.runId)
} else {
@ -3213,7 +3220,7 @@ function App() {
}
return
}
}, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
}, [ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
@ -3535,7 +3542,7 @@ function App() {
}, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask && !isBrowserOpen
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -3608,17 +3615,17 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen)
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen)
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen)
const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH
: isSuggestedTopicsOpen
? SUGGESTED_TOPICS_TAB_PATH
: isBackgroundAgentsOpen
? BACKGROUND_AGENTS_TAB_PATH
: isLiveNotesOpen
? LIVE_NOTES_TAB_PATH
: selectedPath
const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath
@ -3673,7 +3680,7 @@ function App() {
}
document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
@ -3698,7 +3705,7 @@ function App() {
}),
},
}))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -3824,14 +3831,14 @@ function App() {
},
openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'graph' })
},
openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -4421,7 +4428,7 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen)
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => {
@ -4438,7 +4445,7 @@ function App() {
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}
}}>
@ -4471,7 +4478,7 @@ function App() {
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) {
setIsChatSidebarOpen(true)
}
@ -4482,7 +4489,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 || isBackgroundAgentsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad)
return
@ -4506,14 +4513,14 @@ function App() {
} else {
// Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) {
handleNewChat()
} else {
void navigateToView({ type: 'chat', runId: null })
}
}
} else if (runId === runIdToDelete) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat()
} else {
@ -4543,8 +4550,8 @@ function App() {
onToggleBrowser={handleToggleBrowser}
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
isBackgroundAgentsOpen={isBackgroundAgentsOpen}
onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })}
isLiveNotesOpen={isLiveNotesOpen}
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
/>
<SidebarInset
className={cn(
@ -4564,7 +4571,7 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && fileTabs.length >= 1 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
@ -4572,7 +4579,7 @@ function App() {
getTabId={(t) => t.id}
onSwitchTab={switchFileTab}
onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/>
) : (
<TabBar
@ -4625,7 +4632,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedTask && !isBrowserOpen && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4640,7 +4647,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !isBrowserOpen && expandedFrom && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBrowserOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4655,7 +4662,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4688,12 +4695,12 @@ function App() {
}}
/>
</div>
) : isBackgroundAgentsOpen ? (
) : isLiveNotesOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BackgroundAgentsView
<LiveNotesView
onOpenNote={(path) => navigateToFile(path)}
onAddNewBackgroundAgent={() => {
submitFromPalette(buildBackgroundAgentSetupPrompt(), null)
onAddNewLiveNote={() => {
submitFromPalette(buildLiveNoteSetupPrompt(), null)
}}
/>
</div>
@ -4812,6 +4819,10 @@ function App() {
)
})}
</div>
<LiveNoteSidebar
filePath={liveNotePanelPath}
onClose={() => setLiveNotePanelPath(null)}
/>
{versionHistoryPath && (
<VersionHistoryPanel
path={versionHistoryPath}
@ -5125,7 +5136,6 @@ function App() {
/>
</SidebarSectionProvider>
<Toaster />
<TrackSidebar />
<OnboardingModal
open={showOnboarding}
onComplete={handleOnboardingComplete}

View file

@ -1,250 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Bot, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
import { toast } from '@/lib/toast'
type BackgroundAgentNote = {
path: string
trackCount: number
createdAt: string | null
lastRunAt: string | null
isActive: boolean
}
type BackgroundAgentsViewProps = {
onOpenNote: (path: string) => void
onAddNewBackgroundAgent: () => void
}
function formatDateLabel(iso: string | null): string {
if (!iso) return '—'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatDateTimeLabel(iso: string | null): string {
if (!iso) return 'Never'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return 'Never'
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function isKnowledgeMarkdownPath(path: string | undefined): boolean {
return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md')
}
export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: BackgroundAgentsViewProps) {
const [notes, setNotes] = useState<BackgroundAgentNote[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set())
const loadNotes = useCallback(async () => {
setLoading(true)
try {
const result = await window.ipc.invoke('track:listNotes', null)
setNotes(result.notes)
setError(null)
} catch (err) {
console.error('Failed to load background agent notes:', err)
setError('Could not load background agents.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadNotes()
}, [loadNotes])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
void loadNotes()
}, 200)
}
const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isKnowledgeMarkdownPath(event.path)) scheduleReload()
break
case 'moved':
if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) {
scheduleReload()
}
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) {
scheduleReload()
}
break
}
})
const cleanupTracks = window.ipc.on('tracks:events', () => {
scheduleReload()
})
return () => {
cleanupWorkspace()
cleanupTracks()
if (timeout) clearTimeout(timeout)
}
}, [loadNotes])
const handleToggleState = useCallback(async (note: BackgroundAgentNote, active: boolean) => {
setUpdatingPaths((prev) => new Set(prev).add(note.path))
try {
const result = await window.ipc.invoke('track:setNoteActive', {
path: note.path,
active,
})
if (!result.success || !result.note) {
throw new Error(result.error ?? 'Failed to update background agent state')
}
const updatedNote = result.note
setNotes((prev) => prev.map((entry) => (
entry.path === note.path ? updatedNote : entry
)))
} catch (err) {
console.error('Failed to update background agent note state:', err)
toast(err instanceof Error ? err.message : 'Failed to update background agent state', 'error')
} finally {
setUpdatingPaths((prev) => {
const next = new Set(prev)
next.delete(note.path)
return next
})
}
}, [])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Bot className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Background agents</h2>
</div>
<Button type="button" size="sm" onClick={onAddNewBackgroundAgent}>
Add new background agent
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Notes that contain tracks. Toggle a note inactive to pause every background agent in it.
</p>
</div>
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Bot className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : notes.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Bot className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
No notes with background agents yet.
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<table className="min-w-full border-collapse">
<thead>
<tr className="border-b border-border/60 bg-muted/30 text-left">
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created date</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th>
</tr>
</thead>
<tbody>
{notes.map((note) => {
const isUpdating = updatingPaths.has(note.path)
return (
<tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="truncate text-left text-sm font-medium text-foreground hover:text-primary"
title={note.path}
>
{wikiLabel(note.path)}
</button>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'}
</span>
</div>
<div className="truncate text-xs text-muted-foreground">
{stripKnowledgePrefix(note.path)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatDateLabel(note.createdAt)}
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatDateTimeLabel(note.lastRunAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{isUpdating ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<span className="size-4 shrink-0" aria-hidden="true" />
)}
<Switch
checked={note.isActive}
onCheckedChange={(checked) => { void handleToggleState(note, checked) }}
disabled={isUpdating}
/>
<span className="min-w-16 text-xs font-medium text-foreground/80">
{note.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View file

@ -43,7 +43,21 @@ interface EditorToolbarProps {
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | void
onExport?: (format: 'md' | 'pdf' | 'docx') => void
onOpenTracks?: () => void
onOpenLiveNote?: () => void
liveState?: LivePillState
}
export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error'
export interface LivePillState {
variant: LivePillVariant
label: string
}
const LIVE_PILL_VARIANT_CLASS: Record<LivePillVariant, string> = {
passive: 'text-muted-foreground hover:bg-accent',
idle: 'text-foreground hover:bg-accent',
running: 'text-foreground bg-primary/10 hover:bg-primary/15 animate-pulse',
error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15',
}
export function EditorToolbar({
@ -51,7 +65,8 @@ export function EditorToolbar({
onSelectionHighlight,
onImageUpload,
onExport,
onOpenTracks,
onOpenLiveNote,
liveState,
}: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
@ -389,17 +404,17 @@ export function EditorToolbar({
</>
)}
{/* Tracks — pushed to far right */}
{onOpenTracks && (
<Button
variant="ghost"
size="icon-sm"
onClick={onOpenTracks}
title="Tracks"
className="ml-auto"
{/* Live Note pill — pushed to far right */}
{onOpenLiveNote && liveState && (
<button
type="button"
onClick={onOpenLiveNote}
title={liveState.variant === 'passive' ? 'Make this note live' : 'Live note'}
className={`ml-auto inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors ${LIVE_PILL_VARIANT_CLASS[liveState.variant]}`}
>
<Radio className="size-4" />
</Button>
<Radio className="size-3.5" />
<span className="truncate max-w-[160px]">{liveState.label}</span>
</button>
)}
</div>
)

View file

@ -46,7 +46,7 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
const commit = useCallback((updated: FieldEntry[]) => {
// Use the latest raw seen as the preserve-source so structured keys
// (like `track:`) survive a round-trip through this UI.
// (like `live:`) survive a round-trip through this UI.
const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current)
lastCommittedRaw.current = newRaw
onRawChange(newRaw)

View file

@ -0,0 +1,682 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import '@/styles/live-note-panel.css'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import {
Radio, Clock, Play, Square, Loader2, Sparkles, CalendarClock, Zap,
Trash2, AlertCircle, ChevronDown, ChevronUp, Plus, X, Save,
} from 'lucide-react'
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
import { formatRelativeTime } from '@/lib/relative-time'
export type OpenLiveNotePanelDetail = {
filePath: string
}
const CRON_PHRASES: Record<string, string> = {
'* * * * *': 'Every minute',
'*/5 * * * *': 'Every 5 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Hourly',
'0 */2 * * *': 'Every 2 hours',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 8 * * *': 'Daily at 8 AM',
'0 9 * * *': 'Daily at 9 AM',
'0 12 * * *': 'Daily at noon',
'0 18 * * *': 'Daily at 6 PM',
'0 9 * * 1-5': 'Weekdays at 9 AM',
'0 17 * * 1-5': 'Weekdays at 5 PM',
}
function describeCron(expr: string): string {
return CRON_PHRASES[expr.trim()] ?? expr
}
function summarizeTriggers(live: LiveNote): { icon: 'timer' | 'calendar' | 'bolt'; text: string } {
const t = live.triggers
if (!t) return { icon: 'bolt', text: 'Manual only' }
const parts: string[] = []
if (t.cronExpr) parts.push(describeCron(t.cronExpr))
if (t.windows && t.windows.length > 0) {
parts.push(t.windows.length === 1
? `${t.windows[0].startTime}${t.windows[0].endTime}`
: `${t.windows.length} windows`)
}
if (t.eventMatchCriteria) parts.push('event-driven')
if (parts.length === 0) return { icon: 'bolt', text: 'Manual only' }
const icon = t.cronExpr ? 'timer' : t.windows?.length ? 'calendar' : 'bolt'
return { icon, text: parts.join(' · ') }
}
function ScheduleIcon({ icon, size = 14 }: { icon: 'timer' | 'calendar' | 'bolt'; size?: number }) {
if (icon === 'timer') return <Clock size={size} />
if (icon === 'calendar') return <CalendarClock size={size} />
return <Zap size={size} />
}
function stripKnowledgePrefix(p: string): string {
return p.replace(/^knowledge\//, '')
}
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/
export interface LiveNoteSidebarProps {
/**
* Note path the panel should bind to. Workspace-relative (`knowledge/Foo.md`)
* or full both forms are accepted; the prefix is stripped internally.
* `null` (or empty) hides the panel entirely.
*/
filePath: string | null
/** Called when the user clicks the close button or hands off to Copilot. */
onClose: () => void
}
export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) {
const [live, setLive] = useState<LiveNote | null>(null)
const [draft, setDraft] = useState<LiveNote | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showAdvanced, setShowAdvanced] = useState(false)
const knowledgeRelPath = useMemo(() => stripKnowledgePrefix(filePath ?? ''), [filePath])
const agentStatus = useLiveNoteAgentStatus()
const runState = agentStatus.get(knowledgeRelPath) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
const refresh = useCallback(async (relPath: string) => {
if (!relPath) { setLive(null); setDraft(null); return }
setLoading(true)
setError(null)
try {
const res = await window.ipc.invoke('live-note:get', { filePath: relPath })
if (!res.success) {
setError(res.error ?? 'Failed to load')
setLive(null)
setDraft(null)
return
}
setLive(res.live ?? null)
setDraft(res.live ? structuredClone(res.live) as LiveNote : null)
setConfirmingDelete(false)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setLive(null)
setDraft(null)
} finally {
setLoading(false)
}
}, [])
// Reset transient panel state and reload data whenever the bound path changes.
useEffect(() => {
setShowAdvanced(false)
setConfirmingDelete(false)
setError(null)
if (knowledgeRelPath) {
void refresh(knowledgeRelPath)
} else {
setLive(null)
setDraft(null)
}
}, [knowledgeRelPath, refresh])
// Re-fetch when a run completes for this file.
useEffect(() => {
if (!knowledgeRelPath) return
const state = agentStatus.get(knowledgeRelPath)
if (state && (state.status === 'done' || state.status === 'error')) {
void refresh(knowledgeRelPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentStatus, knowledgeRelPath])
const isDirty = useMemo(() => {
if (!live || !draft) return false
return JSON.stringify(live) !== JSON.stringify(draft)
}, [live, draft])
const handleSave = useCallback(async () => {
if (!knowledgeRelPath || !draft) return
const parsed = LiveNoteSchema.safeParse(draft)
if (!parsed.success) {
setError(parsed.error.issues.map(i => i.message).join('; '))
return
}
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('live-note:set', { filePath: knowledgeRelPath, live: parsed.data })
if (!res.success) {
setError(res.error ?? 'Save failed')
return
}
setLive(res.live ?? null)
setDraft(res.live ? structuredClone(res.live) as LiveNote : null)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath, draft])
const handleToggleActive = useCallback(async () => {
if (!knowledgeRelPath || !live) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('live-note:setActive', {
filePath: knowledgeRelPath,
active: live.active === false,
})
if (!res.success) {
setError(res.error ?? 'Failed')
return
}
setLive(res.live ?? null)
setDraft(res.live ? structuredClone(res.live) as LiveNote : null)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath, live])
const handleRun = useCallback(async () => {
if (!knowledgeRelPath) return
setError(null)
try {
await window.ipc.invoke('live-note:run', { filePath: knowledgeRelPath })
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [knowledgeRelPath])
const handleStop = useCallback(async () => {
if (!knowledgeRelPath) return
setError(null)
try {
const res = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelPath })
if (!res.success && res.error) setError(res.error)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [knowledgeRelPath])
const handleDelete = useCallback(async () => {
if (!knowledgeRelPath) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('live-note:delete', { filePath: knowledgeRelPath })
if (!res.success) {
setError(res.error ?? 'Delete failed')
return
}
setLive(null)
setDraft(null)
setConfirmingDelete(false)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath])
const handleEditWithCopilot = useCallback(() => {
if (!filePath) return
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-live-note', {
detail: { filePath },
}))
onClose()
}, [filePath, onClose])
const handleMakeLive = useCallback(() => {
// Empty-state CTA: hand off to Copilot for the natural-language flow.
handleEditWithCopilot()
}, [handleEditWithCopilot])
if (!filePath) return null
const noteTitle = filePath
? (filePath.split('/').pop() ?? filePath).replace(/\.md$/, '')
: 'Live note'
const sched = live ? summarizeTriggers(live) : null
const paused = live?.active === false
return (
<aside className="flex w-[420px] max-w-[40vw] shrink-0 flex-col overflow-hidden border-l border-border bg-background">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-3 text-sidebar-foreground">
<Radio className="size-4 shrink-0 text-sidebar-foreground/70" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">Live note</span>
<span className="truncate text-xs text-sidebar-foreground/60">{noteTitle}</span>
</div>
<button
type="button"
className="inline-flex size-7 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={onClose}
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{error && (
<div className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{loading && (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Loading
</div>
)}
{!loading && !live && (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<Radio className="size-8 text-muted-foreground/50" />
<div className="text-sm font-medium text-foreground">This note is passive</div>
<div className="text-xs text-muted-foreground max-w-[260px]">
Make it live to have an agent keep its body up to date describe what you want it to track and how often.
</div>
<Button size="sm" onClick={handleMakeLive} className="mt-2">
<Sparkles className="size-3" />
Make this note live
</Button>
</div>
)}
{!loading && live && draft && sched && (
<div className={`flex flex-1 flex-col overflow-hidden ${paused ? 'opacity-80' : ''}`}>
{/* Status row: schedule summary + active toggle. */}
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-2">
<span className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground">
<ScheduleIcon icon={sched.icon} />
<span className="truncate">
{paused ? `Paused · ${sched.text}` : sched.text}
</span>
</span>
<label className="flex shrink-0 items-center gap-2">
<Switch
checked={!paused}
onCheckedChange={handleToggleActive}
disabled={saving}
/>
<span className="text-xs text-muted-foreground">{paused ? 'Paused' : 'Active'}</span>
</label>
</div>
{/* Persistent error banner — shows lastRunError until the next successful run. */}
{!isRunning && live.lastRunError && (
<div className="mx-3 mt-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertCircle className="size-3.5 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="font-medium">
Last run failed{live.lastAttemptAt ? ` · ${formatRelativeTime(live.lastAttemptAt)}` : ''}
</div>
<div className="break-words text-amber-700/90 dark:text-amber-300/90">{live.lastRunError}</div>
</div>
</div>
)}
{/* Body */}
<div className="flex-1 overflow-auto px-3 py-3 space-y-4">
{/* Objective */}
<Section label="Objective" hint="What this note should keep being.">
<Textarea
value={draft.objective}
onChange={(e) => setDraft({ ...draft, objective: e.target.value })}
rows={6}
spellCheck
placeholder="Keep this note updated with…"
className="font-sans text-sm"
/>
</Section>
{/* Triggers */}
<Section label="Triggers" hint="When the agent fires. Mix freely; absent fields just don't fire.">
<TriggersEditor draft={draft} setDraft={setDraft} />
</Section>
{/* Status */}
{(live.lastRunAt || live.lastRunSummary) && (
<Section label="Last run">
<DetailGrid>
{live.lastRunAt && <DetailRow label="At" value={formatDateTime(live.lastRunAt)} />}
{live.lastRunSummary && <DetailRow label="Summary" value={live.lastRunSummary} />}
</DetailGrid>
</Section>
)}
{/* Advanced (model + provider + danger zone) */}
<div className="border-t border-border pt-3">
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced(s => !s)}
>
{showAdvanced ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
Advanced (model · provider · danger zone)
</button>
{showAdvanced && (
<div className="mt-2 space-y-3">
<LabeledField label="Model">
<Input
value={draft.model ?? ''}
onChange={(e) => setDraft({ ...draft, model: e.target.value || undefined })}
placeholder="(use global default)"
className="font-mono text-xs"
/>
</LabeledField>
<LabeledField label="Provider">
<Input
value={draft.provider ?? ''}
onChange={(e) => setDraft({ ...draft, provider: e.target.value || undefined })}
placeholder="(use global default)"
className="font-mono text-xs"
/>
</LabeledField>
<div className="border-t border-border pt-3">
{confirmingDelete ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm">
<span className="text-destructive">Make this note passive?</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
Make passive
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmingDelete(true)}
>
<Trash2 className="size-3" />
Make passive
</Button>
)}
</div>
</div>
)}
</div>
</div>
{/* Footer — pulsing "Updating…" pill on the left when running */}
<div className="flex shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-3 py-2.5">
{isRunning && (
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground animate-pulse">
<Loader2 className="size-3 animate-spin" />
Updating
</span>
)}
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleEditWithCopilot} disabled={saving || isRunning}>
<Sparkles className="size-3" />
Edit with Copilot
</Button>
{isDirty && !isRunning && (
<Button variant="outline" size="sm" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Save className="size-3" />}
Save
</Button>
)}
{isRunning ? (
<Button
variant="destructive"
size="sm"
onClick={handleStop}
disabled={saving}
>
<Square className="size-3" />
Stop
</Button>
) : (
<Button
size="sm"
onClick={handleRun}
disabled={saving}
>
<Play className="size-3" />
Run now
</Button>
)}
</div>
</div>
</div>
)}
</aside>
)
}
function Section({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<span className="text-xs font-medium text-foreground">{label}</span>
{hint && <span className="text-[10px] text-muted-foreground">{hint}</span>}
</div>
{children}
</div>
)
}
function LabeledField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<span className="text-xs text-muted-foreground">{label}</span>
<div>{children}</div>
</div>
)
}
function TriggersEditor({
draft,
setDraft,
}: {
draft: LiveNote
setDraft: (next: LiveNote) => void
}) {
const triggers: Triggers = draft.triggers ?? {}
const hasCron = typeof triggers.cronExpr === 'string'
const hasWindows = Array.isArray(triggers.windows)
const hasEvent = typeof triggers.eventMatchCriteria === 'string'
const updateTriggers = (next: Partial<Triggers>) => {
const merged: Triggers = { ...triggers, ...next }
// Strip undefined
;(Object.keys(merged) as (keyof Triggers)[]).forEach(key => {
if (merged[key] === undefined) delete merged[key]
})
if (Object.keys(merged).length === 0) {
const { triggers: _omit, ...rest } = draft
setDraft(rest as LiveNote)
} else {
setDraft({ ...draft, triggers: merged })
}
}
return (
<div className="space-y-3">
{/* cronExpr */}
<TriggerRow
present={hasCron}
label="Cron"
onAdd={() => updateTriggers({ cronExpr: '0 * * * *' })}
onRemove={() => updateTriggers({ cronExpr: undefined })}
>
{hasCron && (
<Input
value={triggers.cronExpr ?? ''}
onChange={(e) => updateTriggers({ cronExpr: e.target.value })}
placeholder='"0 * * * *"'
className="font-mono text-xs"
/>
)}
{hasCron && triggers.cronExpr && (
<div className="text-[10px] text-muted-foreground">{describeCron(triggers.cronExpr)}</div>
)}
</TriggerRow>
{/* windows */}
<TriggerRow
present={hasWindows}
label="Windows"
onAdd={() => updateTriggers({ windows: [{ startTime: '09:00', endTime: '12:00' }] })}
onRemove={() => updateTriggers({ windows: undefined })}
>
{triggers.windows && (
<div className="space-y-1.5">
{triggers.windows.map((w, idx) => (
<div key={idx} className="flex items-center gap-1.5">
<Input
value={w.startTime}
onChange={(e) => {
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'}`}
/>
<span className="text-xs text-muted-foreground"></span>
<Input
value={w.endTime}
onChange={(e) => {
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'}`}
/>
<button
type="button"
onClick={() => {
const next = (triggers.windows ?? []).filter((_, i) => i !== idx)
updateTriggers({ windows: next.length === 0 ? undefined : next })
}}
className="ml-1 inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Remove window"
>
<X className="size-3" />
</button>
</div>
))}
<button
type="button"
onClick={() => updateTriggers({
windows: [...(triggers.windows ?? []), { startTime: '13:00', endTime: '15:00' }],
})}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
>
<Plus className="size-3" /> Add window
</button>
</div>
)}
</TriggerRow>
{/* eventMatchCriteria */}
<TriggerRow
present={hasEvent}
label="Events"
onAdd={() => updateTriggers({ eventMatchCriteria: '' })}
onRemove={() => updateTriggers({ eventMatchCriteria: undefined })}
>
{hasEvent && (
<Textarea
value={triggers.eventMatchCriteria ?? ''}
onChange={(e) => updateTriggers({ eventMatchCriteria: e.target.value })}
rows={3}
placeholder="Emails or calendar events about…"
className="text-xs"
/>
)}
</TriggerRow>
</div>
)
}
function TriggerRow({
present,
label,
onAdd,
onRemove,
children,
}: {
present: boolean
label: string
onAdd: () => void
onRemove: () => void
children?: React.ReactNode
}) {
return (
<div className="rounded-md border border-border bg-muted/20 px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{label}</span>
{present ? (
<button
type="button"
onClick={onRemove}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={`Remove ${label}`}
>
<X className="size-3" />
</button>
) : (
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
>
<Plus className="size-3" /> Add
</button>
)}
</div>
{present && children && <div className="mt-2 space-y-1">{children}</div>}
</div>
)
}
function DetailGrid({ children }: { children: React.ReactNode }) {
return (
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 text-xs">
{children}
</dl>
)
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-foreground">{value}</dd>
</>
)
}

View file

@ -0,0 +1,344 @@
import { useCallback, useEffect, useState } from 'react'
import { Radio, Loader2, Square, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
import { toast } from '@/lib/toast'
import { formatRelativeTime } from '@/lib/relative-time'
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
type LiveNoteRow = {
path: string
createdAt: string | null
lastRunAt: string | null
isActive: boolean
objective: string
lastRunError?: string | null
lastAttemptAt?: string | null
}
type LiveNotesViewProps = {
onOpenNote: (path: string) => void
onAddNewLiveNote: () => void
}
function formatDateLabel(iso: string | null): string {
if (!iso) return '—'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatLastRanLabel(iso: string | null): string {
if (!iso) return 'Never'
return formatRelativeTime(iso) || 'Never'
}
function isKnowledgeMarkdownPath(path: string | undefined): boolean {
return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md')
}
export function LiveNotesView({ onOpenNote, onAddNewLiveNote }: LiveNotesViewProps) {
const [notes, setNotes] = useState<LiveNoteRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set())
const [stoppingPaths, setStoppingPaths] = useState<Set<string>>(new Set())
const agentStatus = useLiveNoteAgentStatus()
const loadNotes = useCallback(async () => {
setLoading(true)
try {
const result = await window.ipc.invoke('live-note:listNotes', null)
// listNotes returns the summary fields; we also want lastRunError +
// lastAttemptAt so the rows can render the error/running state. The
// current IPC summary doesn't include them — fetch those per-note in
// parallel so the rows can render fully.
const enriched = await Promise.all(result.notes.map(async (n) => {
const knowledgeRel = n.path.replace(/^knowledge\//, '')
try {
const detail = await window.ipc.invoke('live-note:get', { filePath: knowledgeRel })
if (detail.success && detail.live) {
return {
...n,
lastRunError: detail.live.lastRunError ?? null,
lastAttemptAt: detail.live.lastAttemptAt ?? null,
} satisfies LiveNoteRow
}
} catch {
// fall through
}
return n satisfies LiveNoteRow
}))
setNotes(enriched)
setError(null)
} catch (err) {
console.error('Failed to load live notes:', err)
setError('Could not load live notes.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadNotes()
}, [loadNotes])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
void loadNotes()
}, 200)
}
const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isKnowledgeMarkdownPath(event.path)) scheduleReload()
break
case 'moved':
if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) {
scheduleReload()
}
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) {
scheduleReload()
}
break
}
})
const cleanupAgentEvents = window.ipc.on('live-note-agent:events', () => {
scheduleReload()
})
return () => {
cleanupWorkspace()
cleanupAgentEvents()
if (timeout) clearTimeout(timeout)
}
}, [loadNotes])
const handleToggleState = useCallback(async (note: LiveNoteRow, active: boolean) => {
setUpdatingPaths((prev) => new Set(prev).add(note.path))
try {
const knowledgeRelative = note.path.replace(/^knowledge\//, '')
const result = await window.ipc.invoke('live-note:setActive', {
filePath: knowledgeRelative,
active,
})
if (!result.success || !result.live) {
throw new Error(result.error ?? 'Failed to update live-note state')
}
setNotes((prev) => prev.map((entry) => (
entry.path === note.path
? {
...entry,
isActive: result.live!.active !== false,
lastRunAt: result.live!.lastRunAt ?? entry.lastRunAt,
lastRunError: result.live!.lastRunError ?? null,
lastAttemptAt: result.live!.lastAttemptAt ?? entry.lastAttemptAt,
}
: entry
)))
} catch (err) {
console.error('Failed to update live-note state:', err)
toast(err instanceof Error ? err.message : 'Failed to update live-note state', 'error')
} finally {
setUpdatingPaths((prev) => {
const next = new Set(prev)
next.delete(note.path)
return next
})
}
}, [])
const handleStop = useCallback(async (note: LiveNoteRow) => {
setStoppingPaths((prev) => new Set(prev).add(note.path))
try {
const knowledgeRelative = note.path.replace(/^knowledge\//, '')
const result = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelative })
if (!result.success && result.error) {
toast(result.error, 'error')
}
} catch (err) {
toast(err instanceof Error ? err.message : 'Failed to stop run', 'error')
} finally {
setStoppingPaths((prev) => {
const next = new Set(prev)
next.delete(note.path)
return next
})
}
}, [])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Radio className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Live notes</h2>
</div>
<Button type="button" size="sm" onClick={onAddNewLiveNote}>
New live note
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Notes whose body is kept current by an agent. Toggle a note inactive to pause its agent.
</p>
</div>
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Radio className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : notes.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Radio className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
No live notes yet.
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[50%]" />
<col className="w-[15%]" />
<col className="w-[15%]" />
<col className="w-[20%]" />
</colgroup>
<thead>
<tr className="border-b border-border/60 bg-muted/30 text-left">
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th>
</tr>
</thead>
<tbody>
{notes.map((note) => {
const isUpdating = updatingPaths.has(note.path)
const isStopping = stoppingPaths.has(note.path)
const knowledgeRel = note.path.replace(/^knowledge\//, '')
const runState = agentStatus.get(knowledgeRel)
const isRunning = runState?.status === 'running'
const objectivePreview = note.objective.split('\n')[0].trim()
const hasError = !isRunning && !!note.lastRunError
return (
<tr
key={note.path}
className={`border-b border-border/50 last:border-b-0 transition-colors ${isRunning ? 'bg-primary/5' : 'hover:bg-muted/20'}`}
>
<td className="px-4 py-3 align-top">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-1.5">
{hasError && (
<AlertCircle
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
aria-label="Last run failed"
>
<title>Last run failed: {note.lastRunError}</title>
</AlertCircle>
)}
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="truncate text-left text-sm font-medium text-foreground hover:text-primary"
title={note.path}
>
{wikiLabel(note.path)}
</button>
</div>
<div className="truncate text-xs text-muted-foreground">
{stripKnowledgePrefix(note.path)}
</div>
{objectivePreview && (
<div className="truncate text-xs text-muted-foreground/80" title={note.objective}>
{objectivePreview}
</div>
)}
{hasError && note.lastRunError && (
<div className="truncate text-xs text-amber-600 dark:text-amber-400" title={note.lastRunError}>
{note.lastRunError}
</div>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatDateLabel(note.createdAt)}
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatLastRanLabel(note.lastRunAt)}
</td>
<td className="px-4 py-3">
{isRunning ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground animate-pulse">
<Loader2 className="size-3 animate-spin" />
Updating
</span>
<Button
variant="destructive"
size="sm"
onClick={() => handleStop(note)}
disabled={isStopping}
>
{isStopping ? <Loader2 className="size-3 animate-spin" /> : <Square className="size-3" />}
Stop
</Button>
</div>
) : (
<div className="flex items-center gap-3">
{isUpdating ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<span className="size-4 shrink-0" aria-hidden="true" />
)}
<Switch
checked={note.isActive}
onCheckedChange={(checked) => { void handleToggleState(note, checked) }}
disabled={isUpdating}
/>
<span className="min-w-16 text-xs font-medium text-foreground/80">
{note.isActive ? 'Active' : 'Inactive'}
</span>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View file

@ -290,7 +290,9 @@ function computeWithinBlockOffset(
return 0
}
}
import { EditorToolbar } from './editor-toolbar'
import { EditorToolbar, type LivePillState } from './editor-toolbar'
import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path'
import { formatRelativeTime } from '@/lib/relative-time'
import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
@ -1422,6 +1424,26 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
return createImageUploadHandler(editor, onImageUpload)
}, [editor, onImageUpload])
// Live-note pill state for the toolbar — derived from the on-disk `live:`
// block plus the agent-status bus. The `tick` dependency keeps the relative
// time label fresh as minutes roll over.
const { live: currentLive, isRunning: liveIsRunning, tick: liveTick } = useLiveNoteForPath(notePath)
const livePillStateForCurrentNote: LivePillState = useMemo(() => {
void liveTick // re-run on tick to refresh relative-time label
if (!currentLive) return { variant: 'passive', label: 'Make live' }
if (liveIsRunning) return { variant: 'running', label: 'Updating…' }
if (currentLive.lastRunError) {
const when = currentLive.lastAttemptAt ? formatRelativeTime(currentLive.lastAttemptAt) : ''
return { variant: 'error', label: when ? `Live · failed ${when}` : 'Live · failed' }
}
if (currentLive.active === false) return { variant: 'passive', label: 'Live · paused' }
if (currentLive.lastRunAt) {
const when = formatRelativeTime(currentLive.lastRunAt)
return { variant: 'idle', label: when ? `Live · ${when}` : 'Live' }
}
return { variant: 'idle', label: 'Live · never run' }
}, [currentLive, liveIsRunning, liveTick])
return (
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
<EditorToolbar
@ -1429,11 +1451,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
onExport={onExport}
onOpenTracks={notePath ? () => {
window.dispatchEvent(new CustomEvent('rowboat:open-track-sidebar', {
onOpenLiveNote={notePath ? () => {
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
detail: { filePath: notePath },
}))
} : undefined}
liveState={notePath ? livePillStateForCurrentNote : undefined}
/>
{(frontmatter !== undefined) && onFrontmatterChange && (
<FrontmatterProperties

View file

@ -59,14 +59,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
@ -109,7 +109,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
@ -442,7 +442,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
const providerConfig = {
provider: {
flavor: llmProvider,
@ -452,7 +452,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
model,
knowledgeGraphModel,
meetingNotesModel,
trackBlockModel,
liveNoteAgentModel,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -1195,14 +1195,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
) : showModelInput ? (
<Input
value={activeConfig.trackBlockModel}
onChange={(e) => updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })}
value={activeConfig.liveNoteAgentModel}
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.trackBlockModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { trackBlockModel: value === "__same__" ? "" : value })}
value={activeConfig.liveNoteAgentModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />

View file

@ -268,14 +268,14 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
</div>
) : showModelInput ? (
<Input
value={activeConfig.trackBlockModel}
onChange={(e) => updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })}
value={activeConfig.liveNoteAgentModel}
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.trackBlockModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { trackBlockModel: value === "__same__" ? "" : value })}
value={activeConfig.liveNoteAgentModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />

View file

@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
@ -81,7 +81,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
@ -419,7 +419,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
const providerConfig = {
provider: {
flavor: llmProvider,
@ -429,7 +429,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
model,
knowledgeGraphModel,
meetingNotesModel,
trackBlockModel,
liveNoteAgentModel,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -446,7 +446,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.trackBlockModel, canTest, llmProvider, handleNext])
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {

View file

@ -196,14 +196,14 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
})
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
@ -229,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
@ -303,7 +303,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
models: savedModels,
knowledgeGraphModel: e.knowledgeGraphModel || "",
meetingNotesModel: e.meetingNotesModel || "",
trackBlockModel: e.trackBlockModel || "",
liveNoteAgentModel: e.liveNoteAgentModel || "",
};
}
}
@ -321,7 +321,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
models: activeModels.length > 0 ? activeModels : [""],
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
meetingNotesModel: parsed.meetingNotesModel || "",
trackBlockModel: parsed.trackBlockModel || "",
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
};
}
return next;
@ -396,7 +396,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
models: allModels,
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
trackBlockModel: activeConfig.trackBlockModel.trim() || undefined,
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -430,7 +430,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
models: allModels,
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
trackBlockModel: config.trackBlockModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
})
setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed'))
@ -461,7 +461,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
parsed.models = defModels
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
parsed.trackBlockModel = defConfig.trackBlockModel.trim() || undefined
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
@ -469,7 +469,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
})
setProviderConfigs(prev => ({
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
}))
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
@ -704,14 +704,14 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</div>
) : showModelInput ? (
<Input
value={activeConfig.trackBlockModel}
onChange={(e) => updateConfig(provider, { trackBlockModel: e.target.value })}
value={activeConfig.liveNoteAgentModel}
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.trackBlockModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { trackBlockModel: value === "__same__" ? "" : value })}
value={activeConfig.liveNoteAgentModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />

View file

@ -94,6 +94,7 @@ import { ConnectorsPopover } from "@/components/connectors-popover"
import { HelpPopover } from "@/components/help-popover"
import { SettingsDialog } from "@/components/settings-dialog"
import { toast } from "@/lib/toast"
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
import { useBilling } from "@/hooks/useBilling"
import { ServiceEvent } from "@x/shared/src/service-events.js"
import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription"
@ -214,8 +215,8 @@ type SidebarContentPanelProps = {
onToggleBrowser?: () => void
isSuggestedTopicsOpen?: boolean
onOpenSuggestedTopics?: () => void
isBackgroundAgentsOpen?: boolean
onOpenBackgroundAgents?: () => void
isLiveNotesOpen?: boolean
onOpenLiveNotes?: () => void
} & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -229,25 +230,6 @@ function formatEventTime(ts: string): string {
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
}
function formatRunTime(ts: string): string {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return ""
const now = Date.now()
const diffMs = Math.max(0, now - date.getTime())
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
const diffWeeks = Math.floor(diffDays / 7)
const diffMonths = Math.floor(diffDays / 30)
if (diffMinutes < 1) return "just now"
if (diffMinutes < 60) return `${diffMinutes} m`
if (diffHours < 24) return `${diffHours} h`
if (diffDays < 7) return `${diffDays} d`
if (diffWeeks < 4) return `${diffWeeks} w`
return `${Math.max(1, diffMonths)} m`
}
function SyncStatusBar() {
const { state } = useSidebar()
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
@ -493,8 +475,8 @@ export function SidebarContentPanel({
onToggleBrowser,
isSuggestedTopicsOpen = false,
onOpenSuggestedTopics,
isBackgroundAgentsOpen = false,
onOpenBackgroundAgents,
isLiveNotesOpen = false,
onOpenLiveNotes,
...props
}: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection()
@ -510,7 +492,7 @@ export function SidebarContentPanel({
const isMeetingQuickActionSelected = isMeetingActionActive
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => {
try {
@ -684,19 +666,19 @@ export function SidebarContentPanel({
<span>Suggested Topics</span>
</button>
)}
{onOpenBackgroundAgents && (
{onOpenLiveNotes && (
<button
type="button"
onClick={onOpenBackgroundAgents}
onClick={onOpenLiveNotes}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isBackgroundAgentsQuickActionSelected
isLiveNotesQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Bot className="size-4" />
<span>Background agents</span>
<Radio className="size-4" />
<span>Live notes</span>
</button>
)}
</div>

View file

@ -1,627 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { z } from 'zod'
import '@/styles/track-modal.css'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
Trash2, ChevronDown, ChevronUp, ChevronLeft, X,
} from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { Streamdown } from 'streamdown'
import { TrackSchema, type Trigger } from '@x/shared/dist/track.js'
import { useTrackStatus } from '@/hooks/use-track-status'
export type OpenTrackSidebarDetail = {
filePath: string
selectId?: string
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
const CRON_PHRASES: Record<string, string> = {
'* * * * *': 'Every minute',
'*/5 * * * *': 'Every 5 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Hourly',
'0 */2 * * *': 'Every 2 hours',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 8 * * *': 'Daily at 8 AM',
'0 9 * * *': 'Daily at 9 AM',
'0 12 * * *': 'Daily at noon',
'0 18 * * *': 'Daily at 6 PM',
'0 9 * * 1-5': 'Weekdays at 9 AM',
'0 17 * * 1-5': 'Weekdays at 5 PM',
'0 0 * * 0': 'Sundays at midnight',
'0 0 * * 1': 'Mondays at midnight',
'0 0 1 * *': 'First of each month',
}
function describeCron(expr: string): string {
return CRON_PHRASES[expr.trim()] ?? expr
}
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
function describeTrigger(t: Trigger): ScheduleSummary {
if (t.type === 'once') return { icon: 'target', text: `Once at ${formatDateTime(t.runAt)}` }
if (t.type === 'cron') return { icon: 'timer', text: describeCron(t.expression) }
if (t.type === 'window') return { icon: 'calendar', text: `${t.startTime}${t.endTime}` }
return { icon: 'bolt', text: 'Event-driven' }
}
function summarizeTriggers(triggers: Trigger[] | undefined): ScheduleSummary {
if (!triggers || triggers.length === 0) return { icon: 'bolt', text: 'Manual only' }
const timed = triggers.filter(t => t.type !== 'event')
const events = triggers.filter(t => t.type === 'event')
if (timed.length === 0) {
return { icon: 'bolt', text: events.length > 1 ? `${events.length} event triggers` : 'Event-driven' }
}
const first = describeTrigger(timed[0])
let text = first.text
if (timed.length > 1) text += ` (+${timed.length - 1})`
if (events.length > 0) text += ' · also event-driven'
return { icon: first.icon, text }
}
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
if (icon === 'timer') return <Clock size={size} />
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
return <Zap size={size} />
}
function stripKnowledgePrefix(p: string): string {
return p.replace(/^knowledge\//, '')
}
type Track = z.infer<typeof TrackSchema>
function parseTracksFromFile(content: string): Track[] {
if (!content.startsWith('---')) return []
const close = /\r?\n---\r?\n/.exec(content)
if (!close) return []
const yamlText = content.slice(3, close.index).trim()
if (!yamlText) return []
let fm: unknown
try { fm = parseYaml(yamlText) } catch { return [] }
if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return []
const raw = (fm as Record<string, unknown>).track
if (!Array.isArray(raw)) return []
const tracks: Track[] = []
for (const entry of raw) {
const result = TrackSchema.safeParse(entry)
if (result.success) tracks.push(result.data)
}
return tracks
}
type Tab = 'what' | 'when' | 'event' | 'details'
export function TrackSidebar() {
const [open, setOpen] = useState(false)
const [filePath, setFilePath] = useState<string>('')
const [tracks, setTracks] = useState<Track[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Detail-view state (per-track local UI)
const [activeTab, setActiveTab] = useState<Tab>('what')
const [editingRaw, setEditingRaw] = useState(false)
const [rawDraft, setRawDraft] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [saving, setSaving] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const knowledgeRelPath = useMemo(() => stripKnowledgePrefix(filePath), [filePath])
const allTrackStatus = useTrackStatus()
const refresh = useCallback(async (relPath: string) => {
if (!relPath) { setTracks([]); return }
setLoading(true)
setError(null)
try {
const res = await window.ipc.invoke('workspace:readFile', { path: `knowledge/${relPath}` })
if (res?.data) {
setTracks(parseTracksFromFile(res.data))
} else {
setTracks([])
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setTracks([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<OpenTrackSidebarDetail>
const d = ev.detail
if (!d?.filePath) return
setFilePath(d.filePath)
setSelectedId(d.selectId ?? null)
setActiveTab('what')
setEditingRaw(false)
setRawDraft('')
setShowAdvanced(false)
setConfirmingDelete(false)
setError(null)
setOpen(true)
void refresh(stripKnowledgePrefix(d.filePath))
}
window.addEventListener('rowboat:open-track-sidebar', handler as EventListener)
return () => window.removeEventListener('rowboat:open-track-sidebar', handler as EventListener)
}, [refresh])
// Re-fetch when a run completes for a track in this file.
useEffect(() => {
if (!open || !knowledgeRelPath) return
let stale = false
for (const [, state] of allTrackStatus) {
if (state.status === 'done' || state.status === 'error') {
stale = true
break
}
}
if (stale) void refresh(knowledgeRelPath)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allTrackStatus, open, knowledgeRelPath])
const selected = useMemo(
() => (selectedId ? tracks.find(t => t.id === selectedId) ?? null : null),
[selectedId, tracks],
)
// Seed raw editor draft when entering advanced mode.
useEffect(() => {
if (showAdvanced && selected) {
try {
// Lazy import yaml stringify only when needed; avoid top-level dep cycle.
import('yaml').then(({ stringify }) => {
setRawDraft(stringify(selected).trimEnd())
})
} catch {
setRawDraft('')
}
}
}, [showAdvanced, selected])
useEffect(() => {
if (editingRaw && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length,
)
}
}, [editingRaw])
const runUpdate = useCallback(async (id: string, updates: Record<string, unknown>) => {
if (!knowledgeRelPath) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:update', { id, filePath: knowledgeRelPath, updates })
if (!res?.success && res?.error) setError(res.error)
await refresh(knowledgeRelPath)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath, refresh])
const handleToggleActive = useCallback((id: string, currentlyActive: boolean) => {
void runUpdate(id, { active: !currentlyActive })
}, [runUpdate])
const handleRun = useCallback(async (id: string) => {
if (!knowledgeRelPath) return
try {
await window.ipc.invoke('track:run', { id, filePath: knowledgeRelPath })
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [knowledgeRelPath])
const handleSaveRaw = useCallback(async () => {
if (!knowledgeRelPath || !selectedId) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:replaceYaml', { id: selectedId, filePath: knowledgeRelPath, yaml: rawDraft })
if (res?.success) {
setEditingRaw(false)
await refresh(knowledgeRelPath)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath, selectedId, rawDraft, refresh])
const handleDelete = useCallback(async () => {
if (!knowledgeRelPath || !selectedId) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:delete', { id: selectedId, filePath: knowledgeRelPath })
if (res?.success) {
setSelectedId(null)
setConfirmingDelete(false)
await refresh(knowledgeRelPath)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath, selectedId, refresh])
const handleEditWithCopilot = useCallback(() => {
if (!filePath || !selectedId) return
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
detail: { trackId: selectedId, filePath },
}))
setOpen(false)
}, [filePath, selectedId])
if (!open) return null
const noteTitle = filePath
? (filePath.split('/').pop() ?? filePath).replace(/\.md$/, '')
: 'Tracks'
return (
<aside className="fixed inset-y-0 right-0 z-60 flex w-[min(420px,calc(100vw-2rem))] flex-col overflow-hidden border-l border-border bg-background shadow-2xl">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-3 text-sidebar-foreground">
<Radio className="size-4 shrink-0 text-sidebar-foreground/70" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">Tracks</span>
<span className="truncate text-xs text-sidebar-foreground/60">{noteTitle}</span>
</div>
<button
type="button"
className="inline-flex size-7 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={() => setOpen(false)}
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{error && (
<div className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{!selected && (
<div className="flex-1 overflow-auto">
{loading && (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Loading
</div>
)}
{!loading && tracks.length === 0 && (
<div className="flex flex-col items-center gap-1.5 px-6 py-12 text-center">
<Radio className="size-6 text-muted-foreground/50" />
<div className="text-sm text-muted-foreground">No tracks in this note yet.</div>
<div className="text-xs text-muted-foreground/70">
Ask Copilot &ldquo;track Chicago time hourly&rdquo; to add one.
</div>
</div>
)}
<ul className="divide-y divide-border">
{tracks.map(t => {
const sched = summarizeTriggers(t.triggers)
const runState = allTrackStatus.get(`${t.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
const paused = t.active === false
const instructionPreview = t.instruction.split('\n')[0].trim()
return (
<li key={t.id}>
<button
type="button"
className={`group flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-accent ${paused ? 'opacity-60' : ''}`}
onClick={() => { setSelectedId(t.id); setActiveTab('what') }}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{t.id}</span>
<span className="truncate text-xs text-muted-foreground">
{paused ? 'Paused · ' : ''}{sched.text}
</span>
{instructionPreview && (
<span className="truncate text-xs text-muted-foreground/70">
{instructionPreview}
</span>
)}
</div>
<button
type="button"
className={`inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity hover:bg-background hover:text-foreground ${isRunning ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
onClick={(ev) => { ev.stopPropagation(); void handleRun(t.id) }}
disabled={isRunning}
aria-label={isRunning ? `Running ${t.id}` : `Run ${t.id}`}
title={isRunning ? `Running…` : `Run ${t.id}`}
>
{isRunning ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
</button>
</button>
</li>
)
})}
</ul>
</div>
)}
{selected && (() => {
const triggers: Trigger[] = selected.triggers ?? []
const timedTriggers = triggers.filter((t): t is Exclude<Trigger, { type: 'event' }> => t.type !== 'event')
const eventTriggers = triggers.filter((t): t is Extract<Trigger, { type: 'event' }> => t.type === 'event')
const sched = summarizeTriggers(triggers)
const runState = allTrackStatus.get(`${selected.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
const paused = selected.active === false
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
{ key: 'what', label: 'What', visible: true },
{ key: 'when', label: 'Schedule', visible: timedTriggers.length > 0 },
{ key: 'event', label: 'Events', visible: eventTriggers.length > 0 },
{ key: 'details', label: 'Details', visible: true },
]
const shown = visibleTabs.filter(t => t.visible)
return (
<div className={`flex flex-1 flex-col overflow-hidden ${paused ? 'opacity-80' : ''}`}>
{/* Subheader: back arrow + track id */}
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2">
<button
type="button"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => {
setSelectedId(null)
setShowAdvanced(false)
setEditingRaw(false)
setConfirmingDelete(false)
}}
aria-label="Back to tracks"
>
<ChevronLeft className="size-4" />
</button>
<span className="truncate text-sm font-medium">{selected.id}</span>
</div>
{/* Status row: schedule summary + active toggle */}
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-2">
<span className="truncate text-xs text-muted-foreground">{sched.text}</span>
<label className="flex shrink-0 items-center gap-2">
<Switch
checked={!paused}
onCheckedChange={() => handleToggleActive(selected.id, !paused)}
disabled={saving}
/>
<span className="text-xs text-muted-foreground">{paused ? 'Paused' : 'Active'}</span>
</label>
</div>
{/* Tabs */}
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
{shown.map(tab => (
<button
key={tab.key}
type="button"
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
activeTab === tab.key
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
>
{tab.label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 overflow-auto px-3 py-3">
{activeTab === 'what' && (
<div className="text-sm leading-relaxed">
{selected.instruction ? (
<Streamdown className="prose prose-sm max-w-none dark:prose-invert">
{selected.instruction}
</Streamdown>
) : (
<span className="text-muted-foreground">No instruction set.</span>
)}
</div>
)}
{activeTab === 'when' && timedTriggers.length > 0 && (
<div className="flex flex-col gap-2">
{timedTriggers.map((trig, idx) => {
const tSched = describeTrigger(trig)
return (
<div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<ScheduleIcon icon={tSched.icon} size={14} />
<span>{tSched.text}</span>
</div>
<DetailGrid>
<DetailRow label="Type" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.type}</code>} />
{trig.type === 'cron' && (
<DetailRow label="Expression" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.expression}</code>} />
)}
{trig.type === 'window' && (
<DetailRow label="Window" value={`${trig.startTime} ${trig.endTime}`} />
)}
{trig.type === 'once' && (
<DetailRow label="Runs at" value={formatDateTime(trig.runAt)} />
)}
</DetailGrid>
</div>
)
})}
</div>
)}
{activeTab === 'event' && (
<div className="flex flex-col gap-2">
{eventTriggers.length === 0 ? (
<span className="text-sm text-muted-foreground">No event matching set.</span>
) : eventTriggers.map((trig, idx) => (
<div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
<Streamdown className="prose prose-sm max-w-none dark:prose-invert">
{trig.matchCriteria}
</Streamdown>
</div>
))}
</div>
)}
{activeTab === 'details' && (
<DetailGrid>
<DetailRow label="ID" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.id}</code>} />
<DetailRow label="File" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px] break-all">{filePath}</code>} />
<DetailRow label="Status" value={paused ? 'Paused' : 'Active'} />
{selected.model && <DetailRow label="Model" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.model}</code>} />}
{selected.provider && <DetailRow label="Provider" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.provider}</code>} />}
{selected.lastRunAt && <DetailRow label="Last run" value={formatDateTime(selected.lastRunAt)} />}
{selected.lastRunSummary && <DetailRow label="Summary" value={selected.lastRunSummary} />}
</DetailGrid>
)}
{/* Advanced — raw YAML */}
<div className="mt-6 border-t border-border pt-3">
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const next = !showAdvanced
setShowAdvanced(next)
setEditingRaw(next)
}}
>
{showAdvanced ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
<Code2 className="size-3" />
Advanced (raw YAML)
</button>
{showAdvanced && (
<div className="mt-2 flex flex-col gap-2">
<Textarea
ref={textareaRef}
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
rows={12}
spellCheck={false}
className="font-mono text-xs"
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => { setShowAdvanced(false); setEditingRaw(false) }}
disabled={saving}
>
Cancel
</Button>
<Button size="sm" onClick={handleSaveRaw} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : null}
Save
</Button>
</div>
</div>
)}
</div>
{/* Danger zone — Details tab only */}
{activeTab === 'details' && (
<div className="mt-4 border-t border-border pt-3">
{confirmingDelete ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm">
<span className="text-destructive">Delete this track?</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
Delete
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmingDelete(true)}
>
<Trash2 className="size-3" />
Delete track
</Button>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/20 px-3 py-2.5">
<Button variant="outline" size="sm" onClick={handleEditWithCopilot} disabled={saving}>
<Sparkles className="size-3" />
Edit with Copilot
</Button>
<Button
size="sm"
onClick={() => handleRun(selected.id)}
disabled={isRunning || saving}
>
{isRunning ? <Loader2 className="size-3 animate-spin" /> : <Play className="size-3" />}
{isRunning ? 'Running…' : 'Run now'}
</Button>
</div>
</div>
)
})()}
</aside>
)
}
function DetailGrid({ children }: { children: React.ReactNode }) {
return (
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 text-xs">
{children}
</dl>
)
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-foreground">{value}</dd>
</>
)
}

View file

@ -1,23 +1,23 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { TrackEvent } from '@x/shared/dist/track.js';
import { LiveNoteAgentEvent } from '@x/shared/dist/live-note.js';
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
export type LiveNoteAgentStatus = 'idle' | 'running' | 'done' | 'error';
export interface TrackState {
status: TrackRunStatus;
export interface LiveNoteAgentState {
status: LiveNoteAgentStatus;
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, TrackState>();
// 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, LiveNoteAgentState>();
const listeners = new Set<() => void>();
let subscribed = false;
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
function updateStore(fn: (prev: Map<string, LiveNoteAgentState>) => void) {
store = new Map(store);
fn(store);
for (const listener of listeners) listener();
@ -26,12 +26,12 @@ function updateStore(fn: (prev: Map<string, TrackState>) => void) {
function ensureSubscription() {
if (subscribed) return;
subscribed = true;
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
const key = `${event.trackId}:${event.filePath}`;
window.ipc.on('live-note-agent:events', ((event: z.infer<typeof LiveNoteAgentEvent>) => {
const key = event.filePath;
if (event.type === 'track_run_start') {
if (event.type === 'live_note_agent_start') {
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
} else if (event.type === 'track_run_complete') {
} else if (event.type === 'live_note_agent_complete') {
updateStore(s => s.set(key, {
status: event.error ? 'error' : 'done',
runId: event.runId,
@ -43,7 +43,7 @@ function ensureSubscription() {
updateStore(s => s.delete(key));
}, 5000);
}
}) as (event: z.infer<typeof TrackEvent>) => void);
}) as (event: z.infer<typeof LiveNoteAgentEvent>) => void);
}
function subscribe(onStoreChange: () => void): () => void {
@ -52,21 +52,21 @@ function subscribe(onStoreChange: () => void): () => void {
return () => { listeners.delete(onStoreChange); };
}
function getSnapshot(): Map<string, TrackState> {
function getSnapshot(): Map<string, LiveNoteAgentState> {
return store;
}
/**
* Returns a Map of all track run states, keyed by "trackId:filePath".
* Returns a Map of all live-note agent run states, keyed by `filePath`.
*
* Usage in a track-aware component:
* const trackStatus = useTrackStatus();
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
* Usage in a panel:
* const status = useLiveNoteAgentStatus();
* const state = status.get(filePath) ?? { status: 'idle' };
*
* Usage for a global indicator:
* const trackStatus = useTrackStatus();
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
* const status = useLiveNoteAgentStatus();
* const anyRunning = [...status.values()].some(s => s.status === 'running');
*/
export function useTrackStatus(): Map<string, TrackState> {
export function useLiveNoteAgentStatus(): Map<string, LiveNoteAgentState> {
return useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from 'react'
import type { LiveNote } from '@x/shared/dist/live-note.js'
import { useLiveNoteAgentStatus, type LiveNoteAgentState } from './use-live-note-agent-status'
export interface UseLiveNoteForPathResult {
/** Parsed `live:` block, or null when the note is passive. */
live: LiveNote | null
/** Knowledge-relative path (no leading "knowledge/"). Empty when no path is provided. */
knowledgeRelPath: string
/** Most recent run state from the agent bus. */
agentState: LiveNoteAgentState | null
/** Whether the agent is currently running. Convenience read off agentState. */
isRunning: boolean
/** Loading flag for the initial fetch. */
loading: boolean
/** Force a refetch — useful after a mutation. */
refresh: () => Promise<void>
/** Tick value that increments once a minute so callers can keep relative-time labels fresh. */
tick: number
}
function stripKnowledgePrefix(p: string | null | undefined): string {
if (!p) return ''
return p.replace(/^knowledge\//, '')
}
function isSamePath(a: string, b: string | undefined): boolean {
if (!b) return false
return a === b.replace(/^knowledge\//, '')
}
/**
* Reactive view of a single note's `live:` block.
*
* - Fetches `live-note:get` on mount and whenever the path changes.
* - Subscribes to `live-note-agent:events` (via `useLiveNoteAgentStatus`) to
* surface the running flag in real time.
* - Listens to `workspace:didChange` so external edits to the file trigger a
* refetch.
* - Refetches one extra time when an agent run completes so callers see fresh
* `lastRunAt` / `lastRunSummary` / `lastRunError` values.
* - Ticks every minute so callers using `formatRelativeTime` get a fresh label
* without the underlying data changing.
*
* `notePath` may be either knowledge-relative (`Today.md`) or workspace-rooted
* (`knowledge/Today.md`); the hook normalises internally.
*/
export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult {
const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null)
const [live, setLive] = useState<LiveNote | null>(null)
const [loading, setLoading] = useState(false)
const [tick, setTick] = useState(0)
const agentStatusMap = useLiveNoteAgentStatus()
const agentState = knowledgeRelPath ? agentStatusMap.get(knowledgeRelPath) ?? null : null
const isRunning = agentState?.status === 'running'
const refresh = useCallback(async () => {
if (!knowledgeRelPath) { setLive(null); return }
setLoading(true)
try {
const res = await window.ipc.invoke('live-note:get', { filePath: knowledgeRelPath })
if (res.success) {
setLive(res.live ?? null)
}
} catch {
// Swallow — passive notes / missing files are fine; the next refresh retries.
} finally {
setLoading(false)
}
}, [knowledgeRelPath])
// Initial fetch + on path change.
useEffect(() => {
void refresh()
}, [refresh])
// Refetch when the agent run completes (status flips to done/error) so
// lastRunAt / lastRunError values picked up off disk are fresh.
const agentStatus = agentState?.status
useEffect(() => {
if (agentStatus === 'done' || agentStatus === 'error') {
void refresh()
}
}, [agentStatus, refresh])
// Refetch on external file changes — covers the case where the runner
// patched lastRunSummary on the same file we're viewing.
useEffect(() => {
if (!knowledgeRelPath) return
const fullPath = `knowledge/${knowledgeRelPath}`
const cleanup = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (event.path === fullPath) void refresh()
break
case 'moved':
if (event.from === fullPath || event.to === fullPath) void refresh()
break
case 'bulkChanged':
if (event.paths?.some(p => isSamePath(knowledgeRelPath, p))) void refresh()
break
}
})
return cleanup
}, [knowledgeRelPath, refresh])
// Minute-by-minute tick to keep relative-time labels fresh.
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 60_000)
return () => clearInterval(id)
}, [])
return {
live,
knowledgeRelPath,
agentState,
isRunning,
loading,
refresh,
tick,
}
}

View file

@ -139,12 +139,12 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
* re-emitted by buildFrontmatter (callers must splice them back from the
* original raw if they want to preserve them on save see the helpers below).
*/
const STRUCTURED_KEYS = new Set(['track'])
const STRUCTURED_KEYS = new Set(['live'])
/**
* Extract editable top-level YAML key/value pairs from raw frontmatter.
* Returns a flat record where scalar values are strings and list-of-string
* values are string[]. Structured keys (e.g. `track:`) and any nested-object
* values are string[]. Structured keys (e.g. `live:`) and any nested-object
* shapes are filtered out they are not editable via this surface.
*/
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
@ -189,7 +189,7 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
if (itemMatch) {
const item = itemMatch[1].trim()
// If the list-item line itself contains a `key: value` pair, this is a
// nested-object shape (e.g. `- id: chicago-time` under `track:`). We
// nested-object shape (e.g. `- startTime: "09:00"` under a windows list). We
// can't represent that as a flat string array — drop the whole key.
if (/^\w[\w\s]*\w?:\s*\S/.test(item)) {
delete result[currentKey]
@ -212,7 +212,7 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
/**
* Convert a Record of editable frontmatter fields back to a raw YAML
* frontmatter string. If `preserveRaw` is provided, structured keys (e.g.
* `track:`) are spliced back from the original raw byte-for-byte, so
* `live:`) are spliced back from the original raw byte-for-byte, so
* round-trips through the FrontmatterProperties UI never lose them.
*/
export function buildFrontmatter(
@ -235,7 +235,7 @@ export function buildFrontmatter(
}
}
// Splice preserved structured-key blocks (e.g. track:) back from preserveRaw.
// Splice preserved structured-key blocks (e.g. live:) back from preserveRaw.
const preservedBlocks: string[] = []
if (preserveRaw) {
for (const key of STRUCTURED_KEYS) {

View file

@ -0,0 +1,25 @@
/**
* Compact relative-time formatter "just now", "5 m", "3 h", "2 d", "4 w",
* "5 m" (months). Used by the chat sidebar's run list and the live-note pill.
*
* Returns an empty string for invalid timestamps so callers can fall back to
* a default label.
*/
export function formatRelativeTime(ts: string): string {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return ""
const now = Date.now()
const diffMs = Math.max(0, now - date.getTime())
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
const diffWeeks = Math.floor(diffDays / 7)
const diffMonths = Math.floor(diffDays / 30)
if (diffMinutes < 1) return "just now"
if (diffMinutes < 60) return `${diffMinutes} m`
if (diffHours < 24) return `${diffHours} h`
if (diffDays < 7) return `${diffDays} d`
if (diffWeeks < 4) return `${diffWeeks} w`
return `${Math.max(1, diffMonths)} m`
}