feat: tracks — frontmatter directives, sidebar UI, multi-trigger

Recasts the old "track blocks" as "tracks" — directives stored in a
note's frontmatter rather than inline YAML fences and HTML-comment
target regions. The motivation is UX: the inline anatomy made notes
feel like config, leaked into the editing surface, and competed with
the writing flow. Frontmatter is invisible to the body editor, so
moving directives there reclaims the body as just markdown the user
wrote.

The runtime agent now edits the note body freely via standard
workspace tools rather than rewriting a constrained target region.
Each track's instruction names an H2 section to own; the agent
finds or creates that section, updates only its content, and
self-heals position on subsequent runs.

Triggers are now a unified array per track. cron / window / once /
event in any combination, including multi-trigger setups (the
flagship example: a priorities track that rebuilds at three
day-windows and reacts to incoming gmail / calendar events).
window is forgiving — fires once per day anywhere inside its
band — so users opening the app late in the morning still get the
morning run.

The chip-in-editor is gone. Tracks are managed from a right-side
sidebar opened by a Radio-icon button at the top-right of the
editor toolbar. Cmd+K is no longer a Copilot entry point — search-
only — pending a more intuitive invocation surface later.

Today.md ships as the flagship demo of what tracks can do, with a
versioned migration system so future template updates roll out
cleanly to existing users (existing body preserved, old version
backed up).

Copilot is tuned to listen for any signal that the user wants
something dynamic — not just the literal word "track". Strong
phrasings get acted on directly; one-off questions about decaying
information are answered first and then offered as a track. New or
edited tracks run once by default so the user immediately sees
content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-05-07 18:00:20 +05:30
parent 4709e6eb89
commit db6757514c
36 changed files with 2043 additions and 2275 deletions

View file

@ -54,9 +54,9 @@ import {
fetchYaml,
listNotesWithTracks,
setNoteTracksActive,
updateTrackBlock,
replaceTrackBlockYaml,
deleteTrackBlock,
updateTrack,
replaceTrackYaml,
deleteTrack,
} from '@x/core/dist/knowledge/track/fileops.js';
import { browserIpcHandlers } from './browser/ipc.js';
@ -815,12 +815,12 @@ export function setupIpcHandlers() {
},
// Track handlers
'track:run': async (_event, args) => {
const result = await triggerTrackUpdate(args.trackId, args.filePath);
const result = await triggerTrackUpdate(args.id, args.filePath);
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
},
'track:get': async (_event, args) => {
try {
const yaml = await fetchYaml(args.filePath, args.trackId);
const yaml = await fetchYaml(args.filePath, args.id);
if (yaml === null) return { success: false, error: 'Track not found' };
return { success: true, yaml };
} catch (err) {
@ -829,8 +829,8 @@ export function setupIpcHandlers() {
},
'track:update': async (_event, args) => {
try {
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
const yaml = await fetchYaml(args.filePath, args.trackId);
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 };
} catch (err) {
@ -839,8 +839,8 @@ export function setupIpcHandlers() {
},
'track:replaceYaml': async (_event, args) => {
try {
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
const yaml = await fetchYaml(args.filePath, args.trackId);
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 };
} catch (err) {
@ -849,7 +849,7 @@ export function setupIpcHandlers() {
},
'track:delete': async (_event, args) => {
try {
await deleteTrackBlock(args.filePath, args.trackId);
await deleteTrack(args.filePath, args.id);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
@ -858,7 +858,7 @@ export function setupIpcHandlers() {
'track:setNoteActive': async (_event, args) => {
try {
const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active);
if (!note) return { success: false, error: 'No track blocks found in note' };
if (!note) return { success: false, error: 'No tracks found in note' };
return { success: true, note };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };

View file

@ -59,8 +59,8 @@ import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event'
import { OnboardingModal } from '@/components/onboarding'
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackModal } from '@/components/track-modal'
import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackSidebar } from '@/components/track-sidebar'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
import { VersionHistoryPanel } from '@/components/version-history-panel'
@ -351,20 +351,20 @@ const buildSuggestedTopicExplorePrompt = ({
'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 no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
'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.',
'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 note-based track block. Do not tell me they are separate concepts.',
'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, track blocks, skills, note paths, or other internal implementation details unless I ask.',
'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.',
@ -874,7 +874,6 @@ function App() {
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
// queued across the new-chat-tab state flush before submit fires.
const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map())
const [paletteContext, setPaletteContext] = useState<CommandPaletteContext | null>(null)
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
const handleSubmitRecording = useCallback(() => {
@ -2933,8 +2932,7 @@ function App() {
setPendingPaletteSubmit(null)
}, [pendingPaletteSubmit])
// Listener for track-block "Edit with Copilot" events
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
// Listener for "Edit with Copilot" events from the track sidebar.
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<{
@ -3539,16 +3537,11 @@ function App() {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
// Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode).
// If an editor tab is currently active, capture cursor context so Chat mode shows the
// note + line as a removable chip.
// Keyboard shortcut: Cmd+K / Ctrl+K opens the search palette (search-only).
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
const activeId = activeFileTabIdRef.current
const handle = activeId ? editorRefsByTabId.current.get(activeId) : null
setPaletteContext(handle?.getCursorContext() ?? null)
setIsSearchOpen(true)
}
}
@ -5090,12 +5083,10 @@ function App() {
onOpenChange={setIsSearchOpen}
onSelectFile={navigateToFile}
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
initialContext={paletteContext}
onChatSubmit={submitFromPalette}
/>
</SidebarSectionProvider>
<Toaster />
<TrackModal />
<TrackSidebar />
<OnboardingModal
open={showOnboarding}
onComplete={handleOnboardingComplete}

View file

@ -154,7 +154,7 @@ export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: Ba
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Notes that contain track blocks. Toggle a note inactive to pause every background agent in it.
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">

View file

@ -29,6 +29,7 @@ import {
FileTextIcon,
FileIcon,
FileTypeIcon,
Radio,
} from 'lucide-react'
import {
DropdownMenu,
@ -42,6 +43,7 @@ interface EditorToolbarProps {
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | void
onExport?: (format: 'md' | 'pdf' | 'docx') => void
onOpenTracks?: () => void
}
export function EditorToolbar({
@ -49,6 +51,7 @@ export function EditorToolbar({
onSelectionHighlight,
onImageUpload,
onExport,
onOpenTracks,
}: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
@ -385,6 +388,19 @@ export function EditorToolbar({
</DropdownMenu>
</>
)}
{/* Tracks — pushed to far right */}
{onOpenTracks && (
<Button
variant="ghost"
size="icon-sm"
onClick={onOpenTracks}
title="Tracks"
className="ml-auto"
>
<Radio className="size-4" />
</Button>
)}
</div>
)
}

View file

@ -15,12 +15,12 @@ function fieldsFromRaw(raw: string | null): FieldEntry[] {
return Object.entries(record).map(([key, value]) => ({ key, value }))
}
function fieldsToRaw(fields: FieldEntry[]): string | null {
function fieldsToRaw(fields: FieldEntry[], preserveRaw: string | null): string | null {
const record: Record<string, string | string[]> = {}
for (const { key, value } of fields) {
if (key.trim()) record[key.trim()] = value
}
return buildFrontmatter(record)
return buildFrontmatter(record, preserveRaw)
}
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
@ -45,10 +45,12 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
}, [editingNewKey])
const commit = useCallback((updated: FieldEntry[]) => {
const newRaw = fieldsToRaw(updated)
// Use the latest raw seen as the preserve-source so structured keys
// (like `track:`) survive a round-trip through this UI.
const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current)
lastCommittedRaw.current = newRaw
onRawChange(newRaw)
}, [onRawChange])
}, [onRawChange, raw])
// For scalar fields: update local state immediately, commit on blur
const updateLocalValue = useCallback((index: number, newValue: string) => {

View file

@ -11,9 +11,7 @@ import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { TrackBlockExtension } from '@/extensions/track-block'
import { PromptBlockExtension } from '@/extensions/prompt-block'
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { IframeBlockExtension } from '@/extensions/iframe-block'
@ -48,36 +46,6 @@ function preprocessMarkdown(markdown: string): string {
})
}
// Convert track-target open/close HTML comment markers into placeholder divs
// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom
// nodes. Content *between* the markers is left untouched — tiptap-markdown
// parses it naturally as whatever it is (paragraphs, lists, custom-block
// fences, etc.), all rendered live by the existing extension set.
//
// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag
// line until a blank line terminates it, and markdown inline rules (bold,
// italics, links) don't apply inside the block. Without surrounding blank
// lines, the line right after our placeholder div gets absorbed as HTML and
// its markdown is not parsed.
//
// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n`
// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks
// on save; a `\n?` regex on reload would only consume one of those two
// newlines, so every cycle would add a net newline on each side of every
// marker — causing tracks running on an open note to steadily inflate the
// file with blank lines around target regions.
function preprocessTrackTargets(md: string): string {
return md
.replace(
/\n*<!--track-target:([^\s>]+)-->\n*/g,
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
)
.replace(
/\n*<!--\/track-target:([^\s>]+)-->\n*/g,
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
)
}
// Post-process to clean up any zero-width spaces in the output
function postprocessMarkdown(markdown: string): string {
// Remove lines that contain only the zero-width space marker
@ -189,12 +157,6 @@ function blockToMarkdown(node: JsonNode): string {
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'promptBlock':
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
case 'trackBlock':
return '```track\n' + (node.attrs?.data as string || '') + '\n```'
case 'trackTargetOpen':
return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'trackTargetClose':
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock':
@ -697,10 +659,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}),
ImageUploadPlaceholderExtension,
TaskBlockExtension,
TrackBlockExtension.configure({ notePath }),
PromptBlockExtension.configure({ notePath }),
TrackTargetOpenExtension,
TrackTargetCloseExtension,
ImageBlockExtension,
EmbedBlockExtension,
IframeBlockExtension,
@ -1100,9 +1059,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
isInternalUpdate.current = true
// Pre-process to preserve blank lines, then wrap track-target comment
// regions into placeholder divs so TrackTargetExtension can pick them up.
const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
const preprocessed = preprocessMarkdown(content)
// Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
isInternalUpdate.current = false
@ -1472,6 +1429,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
onExport={onExport}
onOpenTracks={notePath ? () => {
window.dispatchEvent(new CustomEvent('rowboat:open-track-sidebar', {
detail: { filePath: notePath },
}))
} : undefined}
/>
{(frontmatter !== undefined) && onFrontmatterChange && (
<FrontmatterProperties

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import posthog from 'posthog-js'
import * as analytics from '@/lib/analytics'
import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react'
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
import {
CommandDialog,
CommandInput,
@ -22,13 +22,14 @@ interface SearchResult {
}
type SearchType = 'knowledge' | 'chat'
type Mode = 'chat' | 'search'
function activeTabToTypes(section: ActiveSection): SearchType[] {
if (section === 'knowledge') return ['knowledge']
return ['chat'] // "tasks" tab maps to chat
return ['chat']
}
// Retained for any remaining programmatic Copilot entry points (background-agent
// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot.
export type CommandPaletteContext = {
path: string
lineNumber: number
@ -43,12 +44,8 @@ export type CommandPaletteMention = {
interface CommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
// Search mode
onSelectFile: (path: string) => void
onSelectRun: (runId: string) => void
// Chat mode
initialContext?: CommandPaletteContext | null
onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void
}
export function CommandPalette({
@ -56,14 +53,8 @@ export function CommandPalette({
onOpenChange,
onSelectFile,
onSelectRun,
initialContext,
onChatSubmit,
}: CommandPaletteProps) {
const { activeSection } = useSidebarSection()
const [mode, setMode] = useState<Mode>('chat')
const [chatInput, setChatInput] = useState('')
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
const chatInputRef = useRef<HTMLInputElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
@ -74,45 +65,23 @@ export function CommandPalette({
)
const debouncedQuery = useDebounce(query, 250)
// On open: always reset to Chat mode (per spec — no mode persistence), sync context chip
// and reset search filters.
// Sync filters and clear query when the dialog opens.
useEffect(() => {
if (open) {
setMode('chat')
setChatInput('')
setContextChip(initialContext ?? null)
setQuery('')
setActiveTypes(new Set(activeTabToTypes(activeSection)))
}
}, [open, activeSection, initialContext])
}, [open, activeSection])
// Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't
// swallow it. Only fires while the dialog is open.
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
e.preventDefault()
e.stopPropagation()
setMode(prev => (prev === 'chat' ? 'search' : 'chat'))
}
document.addEventListener('keydown', handler, true)
return () => document.removeEventListener('keydown', handler, true)
searchInputRef.current?.focus()
}, [open])
// Refocus the appropriate input on mode change so the user can start typing immediately.
useEffect(() => {
if (!open) return
const target = mode === 'chat' ? chatInputRef : searchInputRef
target.current?.focus()
}, [open, mode])
const toggleType = useCallback((type: SearchType) => {
setActiveTypes(new Set([type]))
}, [])
// Search query effect (only meaningful while in search mode, but the debounce keeps running
// harmlessly otherwise — empty query skips the IPC call below).
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([])
@ -133,25 +102,19 @@ export function CommandPalette({
})
.catch((err) => {
console.error('Search failed:', err)
if (!cancelled) {
setResults([])
}
if (!cancelled) setResults([])
})
.finally(() => {
if (!cancelled) {
setIsSearching(false)
}
if (!cancelled) setIsSearching(false)
})
return () => { cancelled = true }
}, [debouncedQuery, activeTypes])
// Reset transient state on close.
useEffect(() => {
if (!open) {
setQuery('')
setResults([])
setChatInput('')
}
}, [open])
@ -164,20 +127,6 @@ export function CommandPalette({
}
}, [onOpenChange, onSelectFile, onSelectRun])
const submitChat = useCallback(() => {
const text = chatInput.trim()
if (!text && !contextChip) return
const mention: CommandPaletteMention | null = contextChip
? {
path: contextChip.path,
displayName: deriveDisplayName(contextChip.path),
lineNumber: contextChip.lineNumber,
}
: null
onChatSubmit(text, mention)
onOpenChange(false)
}, [chatInput, contextChip, onChatSubmit, onOpenChange])
const knowledgeResults = results.filter(r => r.type === 'knowledge')
const chatResults = results.filter(r => r.type === 'chat')
@ -185,178 +134,77 @@ export function CommandPalette({
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
title="Search"
description="Search across knowledge and chats"
showCloseButton={false}
className="top-[20%] translate-y-0"
>
{/* Mode strip */}
<CommandInput
ref={searchInputRef}
placeholder="Search..."
value={query}
onValueChange={setQuery}
/>
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
<ModeButton
active={mode === 'chat'}
onClick={() => setMode('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chat"
/>
<ModeButton
active={mode === 'search'}
onClick={() => setMode('search')}
<FilterToggle
active={activeTypes.has('knowledge')}
onClick={() => toggleType('knowledge')}
icon={<FileTextIcon className="size-3" />}
label="Search"
label="Knowledge"
/>
<FilterToggle
active={activeTypes.has('chat')}
onClick={() => toggleType('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chats"
/>
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span>
</div>
{mode === 'chat' ? (
<div className="flex flex-col">
<input
ref={chatInputRef}
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => {
// cmdk's Command component intercepts Enter for item selection — stop it
// before bubbling so we control the chat submit ourselves.
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault()
e.stopPropagation()
submitChat()
}
}}
placeholder="Ask copilot anything…"
autoFocus
className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
{contextChip && (
<div className="flex items-center gap-2 px-3 pb-3">
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs">
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="font-medium">{deriveDisplayName(contextChip.path)}</span>
<span className="text-muted-foreground">· Line {contextChip.lineNumber}</span>
<button
type="button"
onClick={() => setContextChip(null)}
aria-label="Remove context"
className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<XIcon className="size-3" />
</button>
</span>
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
</div>
)}
{!contextChip && (
<div className="flex items-center px-3 pb-3">
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
</div>
)}
</div>
) : (
<>
<CommandInput
ref={searchInputRef}
placeholder="Search..."
value={query}
onValueChange={setQuery}
/>
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
<FilterToggle
active={activeTypes.has('knowledge')}
onClick={() => toggleType('knowledge')}
icon={<FileTextIcon className="size-3" />}
label="Knowledge"
/>
<FilterToggle
active={activeTypes.has('chat')}
onClick={() => toggleType('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chats"
/>
</div>
<CommandList>
{!query.trim() && (
<CommandEmpty>Type to search...</CommandEmpty>
)}
{query.trim() && !isSearching && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{knowledgeResults.length > 0 && (
<CommandGroup heading="Knowledge">
{knowledgeResults.map((result) => (
<CommandItem
key={`knowledge-${result.path}`}
value={`knowledge-${result.title}-${result.path}`}
onSelect={() => handleSelect(result)}
>
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{result.title}</span>
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{chatResults.length > 0 && (
<CommandGroup heading="Chats">
{chatResults.map((result) => (
<CommandItem
key={`chat-${result.path}`}
value={`chat-${result.title}-${result.path}`}
onSelect={() => handleSelect(result)}
>
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{result.title}</span>
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</>
)}
<CommandList>
{!query.trim() && (
<CommandEmpty>Type to search...</CommandEmpty>
)}
{query.trim() && !isSearching && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{knowledgeResults.length > 0 && (
<CommandGroup heading="Knowledge">
{knowledgeResults.map((result) => (
<CommandItem
key={`knowledge-${result.path}`}
value={`knowledge-${result.title}-${result.path}`}
onSelect={() => handleSelect(result)}
>
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{result.title}</span>
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{chatResults.length > 0 && (
<CommandGroup heading="Chats">
{chatResults.map((result) => (
<CommandItem
key={`chat-${result.path}`}
value={`chat-${result.title}-${result.path}`}
onSelect={() => handleSelect(result)}
>
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{result.title}</span>
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
)
}
// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette.
export const SearchDialog = CommandPalette
function deriveDisplayName(path: string): string {
const base = path.split('/').pop() ?? path
return base.replace(/\.md$/, '')
}
function ModeButton({
active,
onClick,
icon,
label,
}: {
active: boolean
onClick: () => void
icon: React.ReactNode
label: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)}
>
{icon}
{label}
</button>
)
}
function FilterToggle({
active,
onClick,
@ -370,17 +218,19 @@ function FilterToggle({
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
)}
>
{icon}
{label}
<span>{label}</span>
</button>
)
}
// Back-compat export: thin alias to CommandPalette.
export const SearchDialog = CommandPalette

View file

@ -1,530 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { z } from 'zod'
import '@/styles/track-modal.css'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
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,
} from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { Streamdown } from 'streamdown'
import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js'
import { useTrackStatus } from '@/hooks/use-track-status'
import type { OpenTrackModalDetail } from '@/extensions/track-block'
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,
})
}
// ---------------------------------------------------------------------------
// Schedule helpers
// ---------------------------------------------------------------------------
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 summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary {
if (!schedule) return { icon: 'bolt', text: 'Manual only' }
if (schedule.type === 'once') {
return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` }
}
if (schedule.type === 'cron') {
return { icon: 'timer', text: describeCron(schedule.expression) }
}
if (schedule.type === 'window') {
return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}${schedule.endTime}` }
}
return { icon: 'calendar', text: 'Scheduled' }
}
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} />
}
// ---------------------------------------------------------------------------
// Modal
// ---------------------------------------------------------------------------
type Tab = 'what' | 'when' | 'event' | 'details'
export function TrackModal() {
const [open, setOpen] = useState(false)
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
const [yaml, setYaml] = useState<string>('')
const [loading, setLoading] = useState(false)
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 [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Listen for the open event and seed modal state.
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<OpenTrackModalDetail>
const d = ev.detail
if (!d?.trackId || !d?.filePath) return
setDetail(d)
setYaml(d.initialYaml ?? '')
setActiveTab('what')
setEditingRaw(false)
setRawDraft('')
setShowAdvanced(false)
setConfirmingDelete(false)
setError(null)
setOpen(true)
void fetchFresh(d)
}
window.addEventListener('rowboat:open-track-modal', handler as EventListener)
return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => {
try {
setLoading(true)
const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) })
if (res?.success && res.yaml) {
setYaml(res.yaml)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [])
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
if (!yaml) return null
try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null }
}, [yaml])
const trackId = track?.trackId ?? detail?.trackId ?? ''
const instruction = track?.instruction ?? ''
const active = track?.active ?? true
const schedule = track?.schedule
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
const lastRunAt = track?.lastRunAt ?? ''
const lastRunId = track?.lastRunId ?? ''
const lastRunSummary = track?.lastRunSummary ?? ''
const model = track?.model ?? ''
const provider = track?.provider ?? ''
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
const triggerType: 'scheduled' | 'event' | 'manual' =
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : ''
const allTrackStatus = useTrackStatus()
const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
useEffect(() => {
if (editingRaw && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length,
)
}
}, [editingRaw])
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
{ key: 'what', label: 'What to track', visible: true },
{ key: 'when', label: 'When to run', visible: !!schedule },
{ key: 'event', label: 'Event matching', visible: !!eventMatchCriteria },
{ key: 'details', label: 'Details', visible: true },
]
const shown = visibleTabs.filter(t => t.visible)
useEffect(() => {
if (!shown.some(t => t.key === activeTab)) setActiveTab('what')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule, eventMatchCriteria])
// -------------------------------------------------------------------------
// IPC-backed mutations
// -------------------------------------------------------------------------
const runUpdate = useCallback(async (updates: Record<string, unknown>) => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:update', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
updates,
})
if (res?.success && res.yaml) {
setYaml(res.yaml)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail])
const handleToggleActive = useCallback(() => {
void runUpdate({ active: !active })
}, [active, runUpdate])
const handleRun = useCallback(async () => {
if (!detail || isRunning) return
try {
await window.ipc.invoke('track:run', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
})
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [detail, isRunning])
const handleSaveRaw = useCallback(async () => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:replaceYaml', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
yaml: rawDraft,
})
if (res?.success && res.yaml) {
setYaml(res.yaml)
setEditingRaw(false)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail, rawDraft])
const handleDelete = useCallback(async () => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:delete', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
})
if (res?.success) {
// Tell the editor to remove the node so Tiptap's next save doesn't
// re-create the track block on disk.
try { detail.onDeleted() } catch { /* editor may have unmounted */ }
setOpen(false)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail])
const handleEditWithCopilot = useCallback(() => {
if (!detail) return
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
detail: {
trackId: detail.trackId,
filePath: detail.filePath,
},
}))
setOpen(false)
}, [detail])
if (!detail) return null
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
data-trigger={triggerType}
data-active={active ? 'true' : 'false'}
>
<div className="track-modal-header">
<div className="track-modal-header-left">
<div className="track-modal-icon-wrap">
<Radio size={16} />
</div>
<div className="track-modal-title-col">
<DialogHeader className="space-y-0">
<DialogTitle className="track-modal-title">
{trackId || 'Track'}
</DialogTitle>
<DialogDescription className="track-modal-subtitle">
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
{scheduleSummary.text}
{eventMatchCriteria && triggerType === 'scheduled' && (
<span className="track-modal-subtitle-sep">· also event-driven</span>
)}
</DialogDescription>
</DialogHeader>
</div>
</div>
<div className="track-modal-header-actions">
<label className="track-modal-toggle">
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
</label>
</div>
</div>
{/* Tabs */}
<div className="track-modal-tabs">
{shown.map(tab => (
<button
key={tab.key}
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
>
{tab.label}
</button>
))}
</div>
{/* Body */}
<div className="track-modal-body">
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest</div>}
{activeTab === 'what' && (
<div className="track-modal-prose">
{instruction
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
: <span className="track-modal-empty">No instruction set.</span>}
</div>
)}
{activeTab === 'when' && schedule && (
<div className="track-modal-when">
<div className="track-modal-when-headline">
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
<span>{scheduleSummary.text}</span>
</div>
<dl className="track-modal-dl">
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
{schedule.type === 'cron' && (
<>
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
</>
)}
{schedule.type === 'window' && (
<>
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
<dt>Window</dt><dd>{schedule.startTime} {schedule.endTime}</dd>
</>
)}
{schedule.type === 'once' && (
<>
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
</>
)}
</dl>
</div>
)}
{activeTab === 'event' && (
<div className="track-modal-prose">
{eventMatchCriteria
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
: <span className="track-modal-empty">No event matching set.</span>}
</div>
)}
{activeTab === 'details' && (
<div className="track-modal-details">
<dl className="track-modal-dl">
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
{model && (<>
<dt>Model</dt><dd><code>{model}</code></dd>
</>)}
{provider && (<>
<dt>Provider</dt><dd><code>{provider}</code></dd>
</>)}
{lastRunAt && (<>
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
</>)}
{lastRunId && (<>
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
</>)}
{lastRunSummary && (<>
<dt>Summary</dt><dd>{lastRunSummary}</dd>
</>)}
</dl>
</div>
)}
{/* Advanced (raw YAML) — all tabs */}
<div className="track-modal-advanced">
<button
className="track-modal-advanced-toggle"
onClick={() => {
const next = !showAdvanced
setShowAdvanced(next)
if (next) {
setRawDraft(yaml)
setEditingRaw(true)
} else {
setEditingRaw(false)
}
}}
>
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
<Code2 size={12} />
Advanced (raw YAML)
</button>
{showAdvanced && (
<div className="track-modal-raw-editor">
<Textarea
ref={textareaRef}
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
rows={12}
spellCheck={false}
className="track-modal-textarea"
/>
<div className="track-modal-raw-actions">
<Button
variant="outline"
size="sm"
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
disabled={saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSaveRaw}
disabled={saving || rawDraft.trim() === yaml.trim()}
>
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
Save
</Button>
</div>
</div>
)}
</div>
{/* Danger zone — on Details tab only */}
{activeTab === 'details' && (
<div className="track-modal-danger-zone">
{confirmingDelete ? (
<div className="track-modal-confirm">
<span>Delete this track and its generated content?</span>
<div className="track-modal-confirm-actions">
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
Yes, delete
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="track-modal-delete-btn"
onClick={() => setConfirmingDelete(true)}
>
<Trash2 size={12} />
Delete track block
</Button>
)}
</div>
)}
</div>
{error && (
<div className="track-modal-error">{error}</div>
)}
<DialogFooter className="track-modal-footer">
<Button
variant="outline"
size="sm"
onClick={handleEditWithCopilot}
disabled={saving}
>
<Sparkles size={12} />
Edit with Copilot
</Button>
<Button
size="sm"
onClick={handleRun}
disabled={isRunning || saving}
className="track-modal-run-btn"
>
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
{isRunning ? 'Running…' : 'Run now'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function stripKnowledgePrefix(p: string): string {
return p.replace(/^knowledge\//, '')
}

View file

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

View file

@ -1,199 +0,0 @@
import { z } from 'zod'
import { useMemo, type ComponentType } from 'react'
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { Radio, Loader2, type LucideProps } from 'lucide-react'
import * as LucideIcons from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
import { useTrackStatus } from '@/hooks/use-track-status'
function resolveIcon(iconName: string): ComponentType<LucideProps> | null {
const key = iconName
.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join('')
const component = (LucideIcons as Record<string, unknown>)[key]
if (component != null) return component as ComponentType<LucideProps>
return null
}
function TrackIcon({ icon, size }: { icon?: string; size: number }) {
if (icon) {
const Icon = resolveIcon(icon)
if (Icon) return <Icon size={size} />
}
return <Radio size={size} />
}
function truncate(text: string, maxLen: number): string {
const clean = text.replace(/\s+/g, ' ').trim()
if (clean.length <= maxLen) return clean
return clean.slice(0, maxLen).trimEnd() + '…'
}
// Detail shape for the open-track-modal window event. Defined here so the
// consumer (TrackModal) can import it without a circular dependency.
export type OpenTrackModalDetail = {
trackId: string
/** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */
filePath: string
/** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */
initialYaml: string
/** Invoked after a successful IPC delete so the editor can remove the node. */
onDeleted: () => void
}
// ---------------------------------------------------------------------------
// Chip (display-only)
// ---------------------------------------------------------------------------
function TrackBlockView({ node, deleteNode, extension }: {
node: { attrs: Record<string, unknown> }
deleteNode: () => void
updateAttributes: (attrs: Record<string, unknown>) => void
extension: { options: { notePath?: string } }
}) {
const raw = node.attrs.data as string
const cleaned = raw.replace(/[\u200B-\u200D\uFEFF]/g, "");
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
try {
return TrackBlockSchema.parse(parseYaml(cleaned))
} catch(error) { console.error('error', error); return null }
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
const trackId = track?.trackId ?? ''
const instruction = track?.instruction ?? ''
const active = track?.active ?? true
const schedule = track?.schedule
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
const notePath = extension.options.notePath
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
const triggerType: 'scheduled' | 'event' | 'manual' =
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
const allTrackStatus = useTrackStatus()
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation()
if (!trackId || !notePath) return
const detail: OpenTrackModalDetail = {
trackId,
filePath: notePath,
initialYaml: raw,
onDeleted: () => deleteNode(),
}
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
'rowboat:open-track-modal',
{ detail },
))
}
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpen(e as unknown as React.MouseEvent)
}
}
return (
<NodeViewWrapper
className="track-block-chip-wrapper"
data-type="track-block"
data-trigger={triggerType}
data-active={active ? 'true' : 'false'}
data-trackid={trackId}
>
<button
type="button"
className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`}
onClick={handleOpen}
onKeyDown={handleKey}
onMouseDown={(e) => e.stopPropagation()}
title={instruction ? `${trackId}: ${instruction}` : trackId}
>
<span className="track-block-chip-icon">
{isRunning
? <Loader2 size={24} className="animate-spin" />
: <TrackIcon icon={track?.icon} size={24} />}
</span>
<span className="track-block-chip-id">{trackId || 'track'}</span>
{instruction && <span className="track-block-chip-sep">·</span>}
{instruction && (
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
)}
{!active && <span className="track-block-chip-paused-label">paused</span>}
</button>
</NodeViewWrapper>
)
}
// ---------------------------------------------------------------------------
// Tiptap extension — unchanged schema, parseHTML, serialize
// ---------------------------------------------------------------------------
export const TrackBlockExtension = Node.create({
name: 'trackBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addOptions() {
return {
notePath: undefined as string | undefined,
}
},
addAttributes() {
return {
data: {
default: '',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-track')) {
return { data: code.textContent || '' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TrackBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```track\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -1,90 +0,0 @@
import { mergeAttributes, Node } from '@tiptap/react'
/**
* Track target markers two Tiptap atom nodes that represent the open and
* close HTML comment markers bracketing a track's output region on disk:
*
* <!--track-target:ID--> TrackTargetOpenExtension
* content in between regular Tiptap nodes (paragraphs, lists,
* custom blocks, whatever tiptap-markdown parses)
* <!--/track-target:ID--> TrackTargetCloseExtension
*
* The markers are *semantic boundaries*, not a UI container. Content between
* them is real, editable document content fully rendered by the existing
* extension set and freely editable by the user. The backend's updateContent()
* in fileops.ts still locates the region on disk by these comment markers.
*
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
* regex replace, converting each comment into a placeholder div that these
* extensions' parseHTML rules pick up. No content capture.
*
* Save path: both Tiptap's built-in markdown serializer
* (`addStorage().markdown.serialize`) AND the app's custom serializer
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
* back out they must stay in sync.
*/
type MarkerVariant = 'open' | 'close'
function buildMarkerExtension(variant: MarkerVariant) {
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
const commentFor = (id: string) =>
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
return Node.create({
name,
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
trackId: { default: '' },
}
},
parseHTML() {
return [
{
tag: `div[data-type="${htmlType}"]`,
getAttrs(el) {
if (!(el instanceof HTMLElement)) return false
return { trackId: el.getAttribute('data-track-id') ?? '' }
},
},
]
},
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': htmlType,
'data-track-id': (node.attrs.trackId as string) ?? '',
}),
]
},
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
node: { attrs: { trackId: string } },
) {
state.write(commentFor(node.attrs.trackId ?? ''))
state.closeBlock(node)
},
parse: {
// handled via preprocessTrackTargets → parseHTML
},
},
}
},
})
}
export const TrackTargetOpenExtension = buildMarkerExtension('open')
export const TrackTargetCloseExtension = buildMarkerExtension('close')

View file

@ -1,6 +1,6 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { TrackEvent } from '@x/shared/dist/track-block.js';
import { TrackEvent } from '@x/shared/dist/track.js';
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
@ -59,7 +59,7 @@ function getSnapshot(): Map<string, TrackState> {
/**
* Returns a Map of all track run states, keyed by "trackId:filePath".
*
* Usage in a track block component:
* Usage in a track-aware component:
* const trackStatus = useTrackStatus();
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
*

View file

@ -133,9 +133,19 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
}
/**
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
* Returns a flat record where scalar values are strings and list values are string[].
* Skips `---` delimiters and blank lines.
* Keys that hold structured (nested object/array-of-object) data and must NOT
* be mangled by the flat-string FrontmatterProperties UI. These pass through
* unchanged on a round-trip never exposed as editable fields, never
* 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'])
/**
* 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
* shapes are filtered out they are not editable via this surface.
*/
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {}
@ -143,10 +153,12 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
const lines = raw.split('\n')
let currentKey: string | null = null
let pendingNested = false
for (const line of lines) {
if (line === '---' || line.trim() === '') {
currentKey = null
pendingNested = false
continue
}
@ -155,39 +167,61 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
if (topMatch) {
const key = topMatch[1]
const value = topMatch[2].trim()
pendingNested = false
if (STRUCTURED_KEYS.has(key)) {
currentKey = null
continue
}
if (value) {
result[key] = value
currentKey = null
} else {
// List will follow
currentKey = key
result[key] = []
}
continue
}
// List item under current key
if (currentKey) {
const itemMatch = line.match(/^\s+-\s+(.+)$/)
if (itemMatch) {
const arr = result[currentKey]
if (Array.isArray(arr)) {
arr.push(itemMatch[1].trim())
}
if (!currentKey) continue
// List item under current key.
const itemMatch = line.match(/^\s+-\s+(.*)$/)
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
// 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]
currentKey = null
pendingNested = true
continue
}
const arr = result[currentKey]
if (Array.isArray(arr)) arr.push(item)
continue
}
// Indented continuation of a nested object — keep dropping its parent.
if (pendingNested && /^\s/.test(line)) continue
}
return result
}
/**
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
* Returns null if no non-empty fields remain.
* 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
* round-trips through the FrontmatterProperties UI never lose them.
*/
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
export function buildFrontmatter(
fields: Record<string, string | string[]>,
preserveRaw: string | null = null,
): string | null {
const lines: string[] = []
for (const [key, value] of Object.entries(fields)) {
if (STRUCTURED_KEYS.has(key)) continue
if (Array.isArray(value)) {
if (value.length === 0) continue
lines.push(`${key}:`)
@ -200,8 +234,55 @@ export function buildFrontmatter(fields: Record<string, string | string[]>): str
lines.push(`${key}: ${trimmed}`)
}
}
if (lines.length === 0) return null
return `---\n${lines.join('\n')}\n---`
// Splice preserved structured-key blocks (e.g. track:) back from preserveRaw.
const preservedBlocks: string[] = []
if (preserveRaw) {
for (const key of STRUCTURED_KEYS) {
const block = extractTopLevelBlock(preserveRaw, key)
if (block) preservedBlocks.push(block)
}
}
if (lines.length === 0 && preservedBlocks.length === 0) return null
const allLines = [...lines, ...preservedBlocks.flatMap(b => b.split('\n'))]
return `---\n${allLines.join('\n')}\n---`
}
/**
* Return the byte-for-byte line block for a top-level key in raw frontmatter,
* including its nested children (any indented lines that follow), or null if
* the key is absent. Used to round-trip structured keys safely.
*/
function extractTopLevelBlock(raw: string, key: string): string | null {
const lines = raw.split('\n')
let start = -1
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line === '---') continue
const m = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
if (m && m[1] === key) {
start = i
break
}
}
if (start === -1) return null
let end = start
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i]
if (line === '---') break
if (/^\s/.test(line)) {
end = i
continue
}
if (line.trim() === '') {
// blank line — end of this top-level block
break
}
// another top-level key — stop
break
}
return lines.slice(start, end + 1).join('\n')
}
/** Map known tag values → category for legacy flat-list frontmatter. */

View file

@ -656,159 +656,11 @@
color: color-mix(in srgb, var(--foreground) 38%, transparent);
}
/* =============================================================
Track Block inline chip (display-only)
The chip just opens a modal (TrackModal). All mutations live in the
modal and go through IPC, so the editor never writes track state.
(Track inline chip and target-marker styles removed tracks now
live entirely in the note's frontmatter and are managed via the
right-side track sidebar.)
============================================================= */
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
--track-accent: #64748b; /* default: manual/slate */
margin: 8px 0;
display: block;
}
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
.tiptap-editor .ProseMirror .track-block-chip {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 24px 16px;
font-family: inherit;
font-size: 16px;
line-height: 1.3;
color: var(--foreground);
background: color-mix(in srgb, var(--muted) 40%, transparent);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s ease;
user-select: none;
}
.tiptap-editor .ProseMirror .track-block-chip:hover {
background: color-mix(in srgb, var(--muted) 70%, transparent);
}
.tiptap-editor .ProseMirror .track-block-chip:active {
transform: translateY(0.5px);
}
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
outline: 2px solid var(--track-accent);
outline-offset: 2px;
}
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
opacity: 0.65;
}
.tiptap-editor .ProseMirror .track-block-chip-running {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
animation: track-chip-pulse 2s ease-in-out infinite;
}
@keyframes track-chip-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
}
.tiptap-editor .ProseMirror .track-block-chip-icon {
flex-shrink: 0;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="up-next"] .track-block-chip-icon { color: #3b82f6; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="calendar"] .track-block-chip-icon { color: #22c55e; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="emails"] .track-block-chip-icon { color: #f59e0b; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="what-you-missed"] .track-block-chip-icon { color: #3b82f6; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="priorities"] .track-block-chip-icon { color: #ef4444; }
.tiptap-editor .ProseMirror .track-block-chip-id {
font-weight: 600;
color: color-mix(in srgb, var(--foreground) 75%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-sep {
color: color-mix(in srgb, var(--foreground) 25%, transparent);
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-instruction {
color: color-mix(in srgb, var(--foreground) 55%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: color-mix(in srgb, var(--foreground) 10%, transparent);
padding: 1px 6px;
border-radius: 999px;
}
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
outline: 2px solid var(--track-accent);
outline-offset: 2px;
}
/* =============================================================
Track target markers thin visual bookends around a track's
output region. The content BETWEEN these markers is normal,
editable document content (rendered by the existing extensions).
============================================================= */
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
position: relative;
height: 1px;
margin: 14px 0 6px 0;
background: color-mix(in srgb, var(--foreground) 15%, transparent);
pointer-events: none;
}
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
content: 'track: ' attr(data-track-id);
position: absolute;
top: -8px;
left: 8px;
padding: 0 6px;
background: var(--background, #fff);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
text-transform: none;
white-space: nowrap;
pointer-events: auto;
}
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
height: 1px;
margin: 6px 0 14px 0;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
pointer-events: none;
}
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
outline-offset: 1px;
pointer-events: auto;
}
/* Shared block styles (image, embed, chart, table) */
.tiptap-editor .ProseMirror .image-block-wrapper,
.tiptap-editor .ProseMirror .embed-block-wrapper,

View file

@ -1,5 +1,7 @@
/* =============================================================
Track Modal dialog overlay for track block details / edits
Track sidebar styles. Filename is legacy (predates the modal
sidebar refactor); the .track-modal-* class names are reused by
the sidebar's detail-view layout.
============================================================= */
.track-modal-content {
@ -309,3 +311,167 @@
.track-modal-run-btn:hover {
background: color-mix(in srgb, var(--track-accent) 85%, black);
}
/* =============================================================
Track sidebar right panel that lists/edits tracks for the
currently-open note. Reuses the .track-modal-* inner styles.
============================================================= */
.track-sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(420px, calc(100vw - 2rem));
z-index: 60;
display: flex;
flex-direction: column;
background: var(--background, #fff);
border-left: 1px solid var(--border);
box-shadow: -8px 0 24px -12px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.track-sidebar-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
min-height: 48px;
}
.track-sidebar-back,
.track-sidebar-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
}
.track-sidebar-back:hover,
.track-sidebar-close:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}
.track-sidebar-title {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 14px;
font-weight: 600;
min-width: 0;
}
.track-sidebar-subtitle {
font-size: 11px;
font-weight: 400;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-sidebar-list {
flex: 1;
overflow: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.track-sidebar-empty {
padding: 24px 16px;
text-align: center;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
font-size: 13px;
display: flex;
flex-direction: column;
gap: 4px;
}
.track-sidebar-empty-hint {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.track-sidebar-row {
--track-accent: #64748b;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
background: var(--background);
border: 1px solid var(--border);
border-left: 3px solid var(--track-accent);
border-radius: 8px;
text-align: left;
cursor: pointer;
transition: background-color 0.12s ease;
}
.track-sidebar-row[data-trigger="scheduled"] { --track-accent: #6366f1; }
.track-sidebar-row[data-trigger="event"] { --track-accent: #a855f7; }
.track-sidebar-row[data-trigger="manual"] { --track-accent: #64748b; }
.track-sidebar-row[data-active="false"] { opacity: 0.65; }
.track-sidebar-row:hover {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
.track-sidebar-row-icon {
color: var(--track-accent);
margin-top: 2px;
}
.track-sidebar-row-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.track-sidebar-row-title {
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.track-sidebar-row-sub {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
}
.track-sidebar-row-instruction {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-sidebar-detail {
--track-accent: #64748b;
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.track-sidebar-detail[data-trigger="scheduled"] { --track-accent: #6366f1; }
.track-sidebar-detail[data-trigger="event"] { --track-accent: #a855f7; }
.track-sidebar-detail[data-trigger="manual"] { --track-accent: #64748b; }
.track-sidebar-detail[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }