Add tracks — auto-updating note blocks with scheduled and event-driven triggers

Track blocks are YAML-fenced sections embedded in markdown notes whose output
is rewritten by a background agent. Three trigger types: manual (Run button or
Copilot), scheduled (cron / window / once with a 2 min grace window), and
event-driven (Gmail/Calendar sync events routed via an LLM classifier with a
second-pass agent decision). Output lives between <!--track-target:ID-->
comment markers that render as editable content in the Tiptap editor so users
can read and extend AI-generated content inline.

Core:
- Schedule and event pipelines run as independent polling loops (15s / 5s),
  both calling the same triggerTrackUpdate orchestrator. Events are FIFO via
  monotonic IDs; a per-track Set guards against duplicate runs.
- Track-run agent builds three message variants (manual/timed/event) — the
  event variant includes a Pass 2 directive to skip updates on false positives
  flagged by the liberal Pass 1 router.
- IPC surface: track:run/get/update/replaceYaml/delete plus tracks:events
  forward of the pub-sub bus to the renderer.
- Gmail emits per-thread events; Calendar bundles a digest per sync.

Copilot:
- New `tracks` skill (auto-generated canonical schema from Zod via
  z.toJSONSchema) teaches block creation, editing, and proactive suggestion.
- `run-track-block` tool with optional `context` parameter for backfills
  (e.g. seeding a new email-tracking block from existing synced emails).

Renderer:
- Tiptap chip (display-only) opens a rich modal with tabs, toggle, schedule
  details, raw YAML editor, and confirm-to-delete. All mutations go through
  IPC so the backend stays the single writer.
- Target regions use two atom marker nodes (open/close) around real editable
  content — custom blocks render natively, users can add their own notes.
- "Edit with Copilot" seeds a chat session with the note attached.

Docs: apps/x/TRACKS.md covers product flows, technical pipeline, and a
catalog of every LLM prompt involved with file+line pointers.
This commit is contained in:
Ramnique Singh 2026-04-14 13:51:45 +05:30 committed by GitHub
parent ab0147d475
commit e2c13f0f6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 3405 additions and 2 deletions

View file

@ -44,6 +44,14 @@ 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 {
fetchYaml,
updateTrackBlock,
replaceTrackBlockYaml,
deleteTrackBlock,
} from '@x/core/dist/knowledge/track/fileops.js';
/**
* Convert markdown to a styled HTML document for PDF/DOCX export.
@ -362,6 +370,19 @@ export async function startServicesWatcher(): Promise<void> {
});
}
let tracksWatcher: (() => void) | null = null;
export function startTracksWatcher(): void {
if (tracksWatcher) return;
tracksWatcher = trackBus.subscribe((event) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('tracks:events', event);
}
}
});
}
export function stopRunsWatcher(): void {
if (runsWatcher) {
runsWatcher();
@ -758,6 +779,48 @@ 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.trackId, 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);
if (yaml === null) return { success: false, error: 'Track not found' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'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);
if (yaml === null) return { success: false, error: 'Track vanished after update' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:replaceYaml': async (_event, args) => {
try {
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
const yaml = await fetchYaml(args.filePath, args.trackId);
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:delete': async (_event, args) => {
try {
await deleteTrackBlock(args.filePath, args.trackId);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
// Billing handler
'billing:getInfo': async () => {
return await getBillingInfo();

View file

@ -4,6 +4,7 @@ import {
setupIpcHandlers,
startRunsWatcher,
startServicesWatcher,
startTracksWatcher,
startWorkspaceWatcher,
stopRunsWatcher,
stopServicesWatcher,
@ -22,6 +23,9 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
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 initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
@ -228,6 +232,15 @@ app.whenReady().then(async () => {
// start services watcher
startServicesWatcher();
// start tracks watcher
startTracksWatcher();
// start track scheduler (cron/window/once)
initTrackScheduler();
// start track event processor (consumes events/pending/, triggers matching tracks)
initTrackEventProcessor();
// start gmail sync
initGmailSync();

View file

@ -55,6 +55,7 @@
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1",
"use-stick-to-bottom": "^1.1.1",
"yaml": "^2.8.2",
"zod": "^4.2.1"
},
"devDependencies": {

View file

@ -55,6 +55,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { OnboardingModal } from '@/components/onboarding'
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackModal } from '@/components/track-modal'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { VersionHistoryPanel } from '@/components/version-history-panel'
import { FileCardProvider } from '@/contexts/file-card-context'
@ -2687,6 +2688,27 @@ function App() {
setPendingPaletteSubmit(null)
}, [pendingPaletteSubmit])
// Listener for track-block "Edit with Copilot" events
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
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
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.`,
{ path: filePath, displayName },
)
}
window.addEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
}, [submitFromPalette])
const toggleKnowledgePane = useCallback(() => {
setIsRightPaneMaximized(false)
setIsChatSidebarOpen(prev => !prev)
@ -4560,6 +4582,7 @@ function App() {
/>
</SidebarSectionProvider>
<Toaster />
<TrackModal />
<OnboardingModal
open={showOnboarding}
onComplete={handleOnboardingComplete}

View file

@ -9,6 +9,8 @@ import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { TrackBlockExtension } from '@/extensions/track-block'
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
@ -42,6 +44,31 @@ 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. We consume any adjacent newlines in the match
// and emit exactly `\n\n<div></div>\n\n` so the HTML block starts and ends on
// its own line.
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
@ -140,6 +167,12 @@ function blockToMarkdown(node: JsonNode): string {
return serializeList(node, 0).join('\n')
case 'taskBlock':
return '```task\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':
@ -638,6 +671,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}),
ImageUploadPlaceholderExtension,
TaskBlockExtension,
TrackBlockExtension.configure({ notePath }),
TrackTargetOpenExtension,
TrackTargetCloseExtension,
ImageBlockExtension,
EmbedBlockExtension,
ChartBlockExtension,
@ -1032,8 +1068,9 @@ 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
const preprocessed = preprocessMarkdown(content)
// 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))
// Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
isInternalUpdate.current = false

View file

@ -0,0 +1,522 @@
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 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>
{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,178 @@
import { z } from 'zod'
import { useMemo } from 'react'
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { Radio, Loader2 } 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 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 track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
try {
return TrackBlockSchema.parse(parseYaml(raw))
} catch { 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'}
>
<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}
>
{isRunning
? <Loader2 size={13} className="animate-spin track-block-chip-icon" />
: <Radio size={13} className="track-block-chip-icon" />}
<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

@ -0,0 +1,90 @@
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

@ -0,0 +1,72 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { TrackEvent } from '@x/shared/dist/track-block.js';
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
export interface TrackState {
status: TrackRunStatus;
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>();
const listeners = new Set<() => void>();
let subscribed = false;
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
store = new Map(store);
fn(store);
for (const listener of listeners) listener();
}
function ensureSubscription() {
if (subscribed) return;
subscribed = true;
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
const key = `${event.trackId}:${event.filePath}`;
if (event.type === 'track_run_start') {
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
} else if (event.type === 'track_run_complete') {
updateStore(s => s.set(key, {
status: event.error ? 'error' : 'done',
runId: event.runId,
summary: event.summary ?? null,
error: event.error ?? null,
}));
// Auto-clear after 5 seconds
setTimeout(() => {
updateStore(s => s.delete(key));
}, 5000);
}
}) as (event: z.infer<typeof TrackEvent>) => void);
}
function subscribe(onStoreChange: () => void): () => void {
ensureSubscription();
listeners.add(onStoreChange);
return () => { listeners.delete(onStoreChange); };
}
function getSnapshot(): Map<string, TrackState> {
return store;
}
/**
* Returns a Map of all track run states, keyed by "trackId:filePath".
*
* Usage in a track block component:
* const trackStatus = useTrackStatus();
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
*
* Usage for a global indicator:
* const trackStatus = useTrackStatus();
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
*/
export function useTrackStatus(): Map<string, TrackState> {
return useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -611,6 +611,155 @@
.tiptap-editor .ProseMirror .task-block-last-run {
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.
============================================================= */
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
--track-accent: #64748b; /* default: manual/slate */
margin: 4px 0;
display: inline-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;
max-width: 100%;
padding: 6px 12px;
font-family: inherit;
font-size: 13px;
line-height: 1.3;
color: var(--foreground);
background: color-mix(in srgb, var(--track-accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--track-accent) 35%, transparent);
border-left: 3px solid var(--track-accent);
border-radius: 999px;
cursor: pointer;
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
user-select: none;
}
.tiptap-editor .ProseMirror .track-block-chip:hover {
background: color-mix(in srgb, var(--track-accent) 14%, transparent);
box-shadow: 0 1px 4px color-mix(in srgb, var(--track-accent) 20%, 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: var(--track-accent);
}
.tiptap-editor .ProseMirror .track-block-chip-id {
font-weight: 600;
color: var(--track-accent);
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) 80%, 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,

View file

@ -0,0 +1,311 @@
/* =============================================================
Track Modal dialog overlay for track block details / edits
============================================================= */
.track-modal-content {
--track-accent: #64748b;
}
.track-modal-content[data-trigger="scheduled"] { --track-accent: #6366f1; }
.track-modal-content[data-trigger="event"] { --track-accent: #a855f7; }
.track-modal-content[data-trigger="manual"] { --track-accent: #64748b; }
.track-modal-content[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
/* Header */
.track-modal-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--track-accent) 6%, transparent);
border-left: 4px solid var(--track-accent);
}
.track-modal-header-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.track-modal-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: color-mix(in srgb, var(--track-accent) 15%, transparent);
color: var(--track-accent);
flex-shrink: 0;
}
.track-modal-title-col {
min-width: 0;
flex: 1;
}
.track-modal-title {
font-size: 16px;
font-weight: 600;
color: var(--foreground);
font-family: var(--font-mono, ui-monospace, monospace);
}
.track-modal-subtitle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
margin-top: 2px;
}
.track-modal-subtitle-sep {
margin-left: 2px;
}
.track-modal-header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.track-modal-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.track-modal-toggle-label {
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
}
/* Tabs */
.track-modal-tabs {
display: flex;
padding: 0 20px;
border-bottom: 1px solid var(--border);
}
.track-modal-tab {
padding: 10px 14px;
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.1s ease, border-color 0.1s ease;
white-space: nowrap;
}
.track-modal-tab:hover {
color: var(--foreground);
}
.track-modal-tab-active {
color: var(--track-accent);
border-bottom-color: var(--track-accent);
}
/* Body */
.track-modal-body {
padding: 18px 20px;
max-height: 60vh;
overflow-y: auto;
}
.track-modal-loading {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
margin-bottom: 10px;
}
.track-modal-prose {
font-size: 13.5px;
line-height: 1.6;
color: var(--foreground);
}
.track-modal-markdown {
user-select: text;
}
.track-modal-markdown > *:first-child { margin-top: 0; }
.track-modal-markdown > *:last-child { margin-bottom: 0; }
.track-modal-empty {
color: color-mix(in srgb, var(--foreground) 40%, transparent);
font-style: italic;
}
/* When-to-run panel */
.track-modal-when {
display: flex;
flex-direction: column;
gap: 14px;
}
.track-modal-when-headline {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: var(--track-accent);
padding: 12px 14px;
background: color-mix(in srgb, var(--track-accent) 10%, transparent);
border-radius: 8px;
border-left: 3px solid var(--track-accent);
}
/* Description list (Details / When) */
.track-modal-dl {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 16px;
row-gap: 8px;
margin: 0;
font-size: 13px;
}
.track-modal-dl dt {
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-weight: 500;
}
.track-modal-dl dd {
margin: 0;
color: var(--foreground);
word-break: break-word;
}
.track-modal-dl code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
padding: 1px 6px;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
border-radius: 3px;
}
/* Advanced / raw YAML disclosure */
.track-modal-advanced {
margin-top: 20px;
padding-top: 14px;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 12%, transparent);
}
.track-modal-advanced-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 11px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: none;
border: none;
cursor: pointer;
}
.track-modal-advanced-toggle:hover {
color: var(--foreground);
}
.track-modal-raw-editor {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.track-modal-textarea {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.5;
min-height: 200px;
}
.track-modal-raw-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Danger zone */
.track-modal-danger-zone {
margin-top: 20px;
padding-top: 14px;
border-top: 1px dashed color-mix(in srgb, var(--destructive, #ef4444) 20%, transparent);
}
.track-modal-delete-btn {
color: color-mix(in srgb, var(--destructive, #ef4444) 85%, var(--foreground));
border-color: color-mix(in srgb, var(--destructive, #ef4444) 30%, transparent);
}
.track-modal-delete-btn:hover {
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
color: var(--destructive, #ef4444);
}
.track-modal-confirm {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: color-mix(in srgb, var(--destructive, #ef4444) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
flex-wrap: wrap;
}
.track-modal-confirm-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
/* Error */
.track-modal-error {
margin: 0 20px 14px 20px;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
color: var(--destructive, #ef4444);
font-size: 12px;
}
/* Footer */
.track-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
.track-modal-run-btn {
background: var(--track-accent);
color: white;
}
.track-modal-run-btn:hover {
background: color-mix(in srgb, var(--track-accent) 85%, black);
}