This commit is contained in:
Ramnique Singh 2026-04-14 10:08:35 +05:30
parent 88e27c04b7
commit d0406564db
8 changed files with 1172 additions and 572 deletions

View file

@ -46,6 +46,12 @@ 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.
@ -773,11 +779,48 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text);
},
// Track handler
// 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

@ -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

@ -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

@ -1,43 +1,41 @@
import { z } from 'zod'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { useMemo } from 'react'
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { Radio, ChevronRight, X, Clock, Code2, Check, Play, Loader2 } from 'lucide-react'
import { Radio, Loader2 } from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { Streamdown } from 'streamdown'
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
import { useTrackStatus } from '@/hooks/use-track-status'
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,
})
}
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() + '…'
}
type Tab = 'metadata' | 'instruction' | 'criteria'
function TrackBlockView({ node, deleteNode, updateAttributes, extension }: {
// 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 [expanded, setExpanded] = useState(false)
const [activeTab, setActiveTab] = useState<Tab>('instruction')
const [editingRaw, setEditingRaw] = useState(false)
const [rawDraft, setRawDraft] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
try {
@ -47,232 +45,76 @@ function TrackBlockView({ node, deleteNode, updateAttributes, extension }: {
const trackId = track?.trackId ?? ''
const instruction = track?.instruction ?? ''
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
const active = track?.active ?? true
const lastRunAt = track?.lastRunAt ?? ''
const lastRunId = track?.lastRunId ?? ''
const lastRunSummary = track?.lastRunSummary ?? ''
const schedule = track?.schedule
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
const notePath = extension.options.notePath
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
// Track run status from the global hook
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 runStatus = runState.status
const runSummary = runState.summary ?? runState.error ?? null
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 handleStartEdit = () => {
setRawDraft(raw)
setEditingRaw(true)
}
const handleSaveRaw = () => {
updateAttributes({ data: rawDraft })
setEditingRaw(false)
}
const handleCancelEdit = () => {
setEditingRaw(false)
}
const handleRun = useCallback(async (e: React.MouseEvent) => {
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation()
if (runStatus === 'running' || !trackId || !trackFilePath) return
try {
await window.ipc.invoke('track:run', { trackId, filePath: trackFilePath })
} catch (err) {
console.error('[TrackBlock] Run failed:', err)
if (!trackId || !notePath) return
const detail: OpenTrackModalDetail = {
trackId,
filePath: notePath,
initialYaml: raw,
onDeleted: () => deleteNode(),
}
}, [runStatus, trackId, trackFilePath])
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
'rowboat:open-track-modal',
{ detail },
))
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'instruction', label: 'Instruction' },
{ key: 'criteria', label: 'Match Criteria' },
{ key: 'metadata', label: 'Metadata' },
]
const isRunning = runStatus === 'running'
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-wrapper" data-type="track-block">
<div
className={`track-block-card ${!active ? 'track-block-paused' : ''} ${isRunning ? 'track-block-running' : ''}`}
<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}
>
<button
className="track-block-delete"
onClick={deleteNode}
aria-label="Delete track block"
>
<X size={14} />
</button>
{/* Collapsed view */}
<div
className="track-block-collapsed"
onClick={() => setExpanded(!expanded)}
>
<ChevronRight
size={14}
className={`track-block-chevron ${expanded ? 'track-block-chevron-open' : ''}`}
/>
<Radio size={14} className="track-block-icon" />
<span className="track-block-label">Track</span>
{!active && <span className="track-block-badge track-block-badge-paused">paused</span>}
<span className="track-block-summary">{truncate(instruction, 60)}</span>
{lastRunAt && !isRunning && (
<span className="track-block-meta">
<Clock size={11} />
{formatDateTime(lastRunAt)}
</span>
)}
{isRunning && (
<span className="track-block-meta track-block-meta-running">
<Loader2 size={11} className="animate-spin" />
Running
</span>
)}
<button
className="track-block-run-btn"
onClick={handleRun}
disabled={isRunning}
aria-label="Run track"
title="Run track"
>
{isRunning
? <Loader2 size={13} className="animate-spin" />
: <Play size={13} />}
</button>
</div>
{/* Status bar */}
{runSummary && runStatus !== 'running' && (
<div className={`track-block-status-bar ${runStatus === 'error' ? 'track-block-status-error' : 'track-block-status-done'}`}>
{runSummary}
</div>
{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>
)}
{/* Expanded view */}
{expanded && (
<div className="track-block-expanded">
<div className="track-block-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`track-block-tab ${activeTab === tab.key ? 'track-block-tab-active' : ''}`}
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
>
{tab.label}
</button>
))}
<button
className={`track-block-tab track-block-tab-raw ${editingRaw ? 'track-block-tab-active' : ''}`}
onClick={handleStartEdit}
>
<Code2 size={12} />
Edit Raw
</button>
</div>
{editingRaw ? (
<div className="track-block-raw-editor">
<textarea
ref={textareaRef}
className="track-block-textarea"
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
rows={10}
spellCheck={false}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSaveRaw()
}
}}
/>
<div className="track-block-raw-actions">
<button className="track-block-btn track-block-btn-secondary" onClick={handleCancelEdit}>
Cancel
</button>
<button className="track-block-btn track-block-btn-primary" onClick={handleSaveRaw}>
<Check size={12} />
Save
</button>
</div>
</div>
) : (
<div className="track-block-panel">
{activeTab === 'instruction' && (
<div className="track-block-panel-text">
{instruction
? <Streamdown className="track-block-markdown">{instruction}</Streamdown>
: <span className="track-block-empty">No instruction set</span>}
</div>
)}
{activeTab === 'criteria' && (
<div className="track-block-panel-text">
{eventMatchCriteria
? <Streamdown className="track-block-markdown">{eventMatchCriteria}</Streamdown>
: <span className="track-block-empty">No event match criteria set</span>}
</div>
)}
{activeTab === 'metadata' && (
<div className="track-block-metadata-grid">
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Track ID</span>
<span className="track-block-metadata-value"><code>{trackId}</code></span>
</div>
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Status</span>
<span className="track-block-metadata-value">
<span className={`track-block-badge ${active ? 'track-block-badge-active' : 'track-block-badge-paused'}`}>
{active ? 'active' : 'paused'}
</span>
</span>
</div>
{lastRunAt && (
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Last run</span>
<span className="track-block-metadata-value">{formatDateTime(lastRunAt)}</span>
</div>
)}
{lastRunId && (
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Run ID</span>
<span className="track-block-metadata-value"><code>{lastRunId}</code></span>
</div>
)}
{lastRunSummary && (
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Summary</span>
<span className="track-block-metadata-value">{lastRunSummary}</span>
</div>
)}
</div>
)}
</div>
)}
</div>
{instruction && (
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
)}
</div>
{!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',

View file

@ -611,381 +611,110 @@
.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.
============================================================= */
/* Track block styles */
.tiptap-editor .ProseMirror .track-block-wrapper {
margin: 8px 0;
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
--track-accent: #64748b; /* default: manual/slate */
margin: 4px 0;
display: inline-block;
}
.tiptap-editor .ProseMirror .track-block-card {
position: relative;
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 8px;
background-color: color-mix(in srgb, var(--primary) 4%, transparent);
cursor: default;
transition: background-color 0.15s ease;
user-select: none;
}
.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-card.track-block-paused {
border-left-color: color-mix(in srgb, var(--foreground) 25%, transparent);
background-color: color-mix(in srgb, var(--muted) 30%, transparent);
opacity: 0.7;
}
.tiptap-editor .ProseMirror .track-block-card:hover {
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
}
.tiptap-editor .ProseMirror .track-block-wrapper.ProseMirror-selectednode .track-block-card {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
/* Delete button */
.tiptap-editor .ProseMirror .track-block-delete {
position: absolute;
top: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease;
z-index: 1;
}
.tiptap-editor .ProseMirror .track-block-card:hover .track-block-delete {
opacity: 1;
}
.tiptap-editor .ProseMirror .track-block-delete:hover {
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
/* Collapsed row */
.tiptap-editor .ProseMirror .track-block-collapsed {
display: flex;
.tiptap-editor .ProseMirror .track-block-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 32px 10px 12px;
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;
min-width: 0;
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
user-select: none;
}
.tiptap-editor .ProseMirror .track-block-chevron {
.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: color-mix(in srgb, var(--foreground) 40%, transparent);
transition: transform 0.15s ease;
color: var(--track-accent);
}
.tiptap-editor .ProseMirror .track-block-chevron-open {
transform: rotate(90deg);
}
.tiptap-editor .ProseMirror .track-block-icon {
color: var(--primary);
.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-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--primary);
.tiptap-editor .ProseMirror .track-block-chip-sep {
color: color-mix(in srgb, var(--foreground) 25%, transparent);
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-summary {
font-size: 13px;
color: var(--foreground);
.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;
flex: 1;
}
.tiptap-editor .ProseMirror .track-block-meta {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
flex-shrink: 0;
white-space: nowrap;
}
/* Re-run button */
.tiptap-editor .ProseMirror .track-block-run-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 35%, transparent);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s ease, color 0.1s ease, background-color 0.1s ease;
}
.tiptap-editor .ProseMirror .track-block-card:hover .track-block-run-btn {
opacity: 1;
}
.tiptap-editor .ProseMirror .track-block-run-btn:hover {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, transparent);
}
.tiptap-editor .ProseMirror .track-block-run-btn:disabled {
opacity: 1;
color: var(--primary);
cursor: default;
background: none;
}
/* Running state */
.tiptap-editor .ProseMirror .track-block-card.track-block-running {
border-left-color: color-mix(in srgb, var(--primary) 70%, transparent);
animation: track-pulse 2s ease-in-out infinite;
}
@keyframes track-pulse {
0%, 100% { background-color: color-mix(in srgb, var(--primary) 4%, transparent); }
50% { background-color: color-mix(in srgb, var(--primary) 10%, transparent); }
}
.tiptap-editor .ProseMirror .track-block-meta-running {
color: var(--primary);
font-weight: 500;
}
/* Status bar */
.tiptap-editor .ProseMirror .track-block-status-bar {
padding: 6px 14px;
font-size: 12px;
line-height: 1.4;
border-top: 1px solid var(--border);
animation: track-status-fade-in 0.15s ease;
}
@keyframes track-status-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.tiptap-editor .ProseMirror .track-block-status-done {
color: color-mix(in srgb, var(--foreground) 65%, transparent);
}
.tiptap-editor .ProseMirror .track-block-status-error {
color: var(--destructive, #ef4444);
}
/* Badges */
.tiptap-editor .ProseMirror .track-block-badge {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-badge-active {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.tiptap-editor .ProseMirror .track-block-badge-paused {
text-transform: uppercase;
letter-spacing: 0.05em;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: color-mix(in srgb, var(--foreground) 8%, transparent);
background: color-mix(in srgb, var(--foreground) 10%, transparent);
padding: 1px 6px;
border-radius: 999px;
}
/* Expanded area */
.tiptap-editor .ProseMirror .track-block-expanded {
border-top: 1px solid var(--border);
}
/* Tabs */
.tiptap-editor .ProseMirror .track-block-tabs {
display: flex;
gap: 0;
padding: 0 12px;
border-bottom: 1px solid var(--border);
}
.tiptap-editor .ProseMirror .track-block-tab {
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 50%, 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;
}
.tiptap-editor .ProseMirror .track-block-tab:hover {
color: var(--foreground);
}
.tiptap-editor .ProseMirror .track-block-tab-active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tiptap-editor .ProseMirror .track-block-tab-raw {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Tab panel content */
.tiptap-editor .ProseMirror .track-block-panel {
padding: 12px 14px;
}
.tiptap-editor .ProseMirror .track-block-panel-text {
font-size: 13px;
line-height: 1.6;
color: var(--foreground);
white-space: pre-wrap;
user-select: text;
}
.tiptap-editor .ProseMirror .track-block-empty {
color: color-mix(in srgb, var(--foreground) 35%, transparent);
font-style: italic;
}
.tiptap-editor .ProseMirror .track-block-markdown {
font-size: 13px;
line-height: 1.6;
user-select: text;
}
.tiptap-editor .ProseMirror .track-block-markdown > *:first-child {
margin-top: 0;
}
.tiptap-editor .ProseMirror .track-block-markdown > *:last-child {
margin-bottom: 0;
}
/* Metadata grid */
.tiptap-editor .ProseMirror .track-block-metadata-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.tiptap-editor .ProseMirror .track-block-metadata-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.tiptap-editor .ProseMirror .track-block-metadata-label {
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
min-width: 70px;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-metadata-value {
font-size: 13px;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .track-block-metadata-value code {
font-size: 12px;
padding: 1px 5px;
border-radius: 3px;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
font-family: var(--font-mono, ui-monospace, monospace);
}
/* Raw editor */
.tiptap-editor .ProseMirror .track-block-raw-editor {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.tiptap-editor .ProseMirror .track-block-textarea {
width: 100%;
font-size: 12px;
font-family: var(--font-mono, ui-monospace, monospace);
line-height: 1.5;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
color: var(--foreground);
resize: vertical;
}
.tiptap-editor .ProseMirror .track-block-textarea:focus {
outline: 2px solid var(--primary);
outline-offset: -1px;
}
.tiptap-editor .ProseMirror .track-block-raw-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.tiptap-editor .ProseMirror .track-block-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.1s ease;
}
.tiptap-editor .ProseMirror .track-block-btn-secondary {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .track-block-btn-secondary:hover {
background: color-mix(in srgb, var(--foreground) 14%, transparent);
}
.tiptap-editor .ProseMirror .track-block-btn-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.tiptap-editor .ProseMirror .track-block-btn-primary:hover {
background: color-mix(in srgb, var(--primary) 85%, black);
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
outline: 2px solid var(--track-accent);
outline-offset: 2px;
}
/* Shared block styles (image, embed, chart, table) */

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);
}

View file

@ -69,6 +69,17 @@ export async function fetch(filePath: string, trackId: string): Promise<z.infer<
return blocks.find(b => b.track.trackId === trackId) ?? null;
}
/**
* Fetch a track block and return its canonical YAML string (or null if not found).
* Useful for IPC handlers that need to return the fresh YAML without taking a
* dependency on the `yaml` package themselves.
*/
export async function fetchYaml(filePath: string, trackId: string): Promise<string | null> {
const block = await fetch(filePath, trackId);
if (!block) return null;
return stringifyYaml(block.track).trimEnd();
}
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
let content = await fs.readFile(absPath(filePath), 'utf-8');
const openTag = `<!--track-target:${trackId}-->`;
@ -106,4 +117,74 @@ export async function updateTrackBlock(filepath: string, trackId: string, update
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
content = lines.join('\n');
await fs.writeFile(absPath(filepath), content, 'utf-8');
}
/**
* Replace the entire YAML of a track block on disk with a new string.
* Unlike updateTrackBlock (which merges), this writes the raw YAML verbatim
* used when the user explicitly edits raw YAML in the modal.
* The new YAML must still parse to a valid TrackBlock with a matching trackId,
* otherwise the write is rejected.
*/
export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> {
const block = await fetch(filePath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filePath}`);
}
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
if (!parsed.success) {
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
}
if (parsed.data.trackId !== trackId) {
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const yamlLines = newYaml.trimEnd().split('\n');
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
}
/**
* Remove a track block and its sibling target region from the file.
*/
export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> {
const block = await fetch(filePath, trackId);
if (!block) {
// Already gone — treat as success.
return;
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const openTag = `<!--track-target:${trackId}-->`;
const closeTag = `<!--/track-target:${trackId}-->`;
// Find target region (may not exist)
let targetStart = -1;
let targetEnd = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(openTag)) { targetStart = i; }
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
}
// Build a list of [start, end] ranges to remove, sorted descending so
// indices stay valid as we splice.
const ranges: Array<[number, number]> = [];
ranges.push([block.fenceStart, block.fenceEnd]);
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
ranges.push([targetStart, targetEnd]);
}
ranges.sort((a, b) => b[0] - a[0]);
for (const [start, end] of ranges) {
lines.splice(start, end - start + 1);
// Also drop a trailing blank line if the removal left two in a row.
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
lines.splice(start, 1);
}
}
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
}

View file

@ -577,6 +577,55 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'track:get': {
req: z.object({
trackId: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
// Fresh, authoritative YAML of the track block from disk.
// Renderer should use this for display/edit — never its Tiptap node attr.
yaml: z.string().optional(),
error: z.string().optional(),
}),
},
'track:update': {
req: z.object({
trackId: z.string(),
filePath: z.string(),
// Partial TrackBlock updates — merged into the block's YAML on disk.
// Backend is the sole writer; avoids races with scheduler/runner writes.
updates: z.record(z.string(), z.unknown()),
}),
res: z.object({
success: z.boolean(),
yaml: z.string().optional(),
error: z.string().optional(),
}),
},
'track:replaceYaml': {
req: z.object({
trackId: z.string(),
filePath: z.string(),
yaml: z.string(),
}),
res: z.object({
success: z.boolean(),
yaml: z.string().optional(),
error: z.string().optional(),
}),
},
'track:delete': {
req: z.object({
trackId: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
// Billing channels
'billing:getInfo': {
req: z.null(),