mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-28 19:05:31 +02:00
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:
parent
0bf7a55611
commit
dabca3da19
59 changed files with 3816 additions and 3212 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
682
apps/x/apps/renderer/src/components/live-note-sidebar.tsx
Normal file
682
apps/x/apps/renderer/src/components/live-note-sidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
344
apps/x/apps/renderer/src/components/live-notes-view.tsx
Normal file
344
apps/x/apps/renderer/src/components/live-notes-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 “track Chicago time hourly” 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
124
apps/x/apps/renderer/src/hooks/use-live-note-for-path.ts
Normal file
124
apps/x/apps/renderer/src/hooks/use-live-note-for-path.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
25
apps/x/apps/renderer/src/lib/relative-time.ts
Normal file
25
apps/x/apps/renderer/src/lib/relative-time.ts
Normal 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`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue