mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
restyle
This commit is contained in:
parent
88e27c04b7
commit
d0406564db
8 changed files with 1172 additions and 572 deletions
|
|
@ -46,6 +46,12 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||||
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
||||||
import { trackBus } from '@x/core/dist/knowledge/track/bus.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.
|
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||||
|
|
@ -773,11 +779,48 @@ export function setupIpcHandlers() {
|
||||||
'voice:synthesize': async (_event, args) => {
|
'voice:synthesize': async (_event, args) => {
|
||||||
return voice.synthesizeSpeech(args.text);
|
return voice.synthesizeSpeech(args.text);
|
||||||
},
|
},
|
||||||
// Track handler
|
// Track handlers
|
||||||
'track:run': async (_event, args) => {
|
'track:run': async (_event, args) => {
|
||||||
const result = await triggerTrackUpdate(args.trackId, args.filePath);
|
const result = await triggerTrackUpdate(args.trackId, args.filePath);
|
||||||
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
|
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 handler
|
||||||
'billing:getInfo': async () => {
|
'billing:getInfo': async () => {
|
||||||
return await getBillingInfo();
|
return await getBillingInfo();
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
|
||||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||||
import { OnboardingModal } from '@/components/onboarding'
|
import { OnboardingModal } from '@/components/onboarding'
|
||||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||||
|
import { TrackModal } from '@/components/track-modal'
|
||||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||||
|
|
@ -2687,6 +2688,27 @@ function App() {
|
||||||
setPendingPaletteSubmit(null)
|
setPendingPaletteSubmit(null)
|
||||||
}, [pendingPaletteSubmit])
|
}, [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(() => {
|
const toggleKnowledgePane = useCallback(() => {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setIsChatSidebarOpen(prev => !prev)
|
setIsChatSidebarOpen(prev => !prev)
|
||||||
|
|
@ -4560,6 +4582,7 @@ function App() {
|
||||||
/>
|
/>
|
||||||
</SidebarSectionProvider>
|
</SidebarSectionProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<TrackModal />
|
||||||
<OnboardingModal
|
<OnboardingModal
|
||||||
open={showOnboarding}
|
open={showOnboarding}
|
||||||
onComplete={handleOnboardingComplete}
|
onComplete={handleOnboardingComplete}
|
||||||
|
|
|
||||||
522
apps/x/apps/renderer/src/components/track-modal.tsx
Normal file
522
apps/x/apps/renderer/src/components/track-modal.tsx
Normal 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\//, '')
|
||||||
|
}
|
||||||
|
|
@ -1,43 +1,41 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { mergeAttributes, Node } from '@tiptap/react'
|
import { mergeAttributes, Node } from '@tiptap/react'
|
||||||
import { ReactNodeViewRenderer, NodeViewWrapper } 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 { parse as parseYaml } from 'yaml'
|
||||||
import { Streamdown } from 'streamdown'
|
|
||||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
||||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
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 {
|
function truncate(text: string, maxLen: number): string {
|
||||||
const clean = text.replace(/\s+/g, ' ').trim()
|
const clean = text.replace(/\s+/g, ' ').trim()
|
||||||
if (clean.length <= maxLen) return clean
|
if (clean.length <= maxLen) return clean
|
||||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'metadata' | 'instruction' | 'criteria'
|
// Detail shape for the open-track-modal window event. Defined here so the
|
||||||
function TrackBlockView({ node, deleteNode, updateAttributes, extension }: {
|
// 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> }
|
node: { attrs: Record<string, unknown> }
|
||||||
deleteNode: () => void
|
deleteNode: () => void
|
||||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||||
extension: { options: { notePath?: string } }
|
extension: { options: { notePath?: string } }
|
||||||
}) {
|
}) {
|
||||||
const raw = node.attrs.data as 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>(() => {
|
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,232 +45,76 @@ function TrackBlockView({ node, deleteNode, updateAttributes, extension }: {
|
||||||
|
|
||||||
const trackId = track?.trackId ?? ''
|
const trackId = track?.trackId ?? ''
|
||||||
const instruction = track?.instruction ?? ''
|
const instruction = track?.instruction ?? ''
|
||||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
|
||||||
const active = track?.active ?? true
|
const active = track?.active ?? true
|
||||||
const lastRunAt = track?.lastRunAt ?? ''
|
const schedule = track?.schedule
|
||||||
const lastRunId = track?.lastRunId ?? ''
|
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
|
||||||
const notePath = extension.options.notePath
|
const notePath = extension.options.notePath
|
||||||
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
|
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 allTrackStatus = useTrackStatus()
|
||||||
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
||||||
const runStatus = runState.status
|
const isRunning = runState.status === 'running'
|
||||||
const runSummary = runState.summary ?? runState.error ?? null
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpen = (e: React.MouseEvent) => {
|
||||||
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) => {
|
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (runStatus === 'running' || !trackId || !trackFilePath) return
|
if (!trackId || !notePath) return
|
||||||
try {
|
const detail: OpenTrackModalDetail = {
|
||||||
await window.ipc.invoke('track:run', { trackId, filePath: trackFilePath })
|
trackId,
|
||||||
} catch (err) {
|
filePath: notePath,
|
||||||
console.error('[TrackBlock] Run failed:', err)
|
initialYaml: raw,
|
||||||
|
onDeleted: () => deleteNode(),
|
||||||
}
|
}
|
||||||
}, [runStatus, trackId, trackFilePath])
|
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
|
||||||
|
'rowboat:open-track-modal',
|
||||||
|
{ detail },
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
const tabs: { key: Tab; label: string }[] = [
|
const handleKey = (e: React.KeyboardEvent) => {
|
||||||
{ key: 'instruction', label: 'Instruction' },
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
{ key: 'criteria', label: 'Match Criteria' },
|
e.preventDefault()
|
||||||
{ key: 'metadata', label: 'Metadata' },
|
handleOpen(e as unknown as React.MouseEvent)
|
||||||
]
|
}
|
||||||
|
}
|
||||||
const isRunning = runStatus === 'running'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="track-block-wrapper" data-type="track-block">
|
<NodeViewWrapper
|
||||||
<div
|
className="track-block-chip-wrapper"
|
||||||
className={`track-block-card ${!active ? 'track-block-paused' : ''} ${isRunning ? 'track-block-running' : ''}`}
|
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()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
title={instruction ? `${trackId}: ${instruction}` : trackId}
|
||||||
>
|
>
|
||||||
<button
|
{isRunning
|
||||||
className="track-block-delete"
|
? <Loader2 size={13} className="animate-spin track-block-chip-icon" />
|
||||||
onClick={deleteNode}
|
: <Radio size={13} className="track-block-chip-icon" />}
|
||||||
aria-label="Delete track block"
|
<span className="track-block-chip-id">{trackId || 'track'}</span>
|
||||||
>
|
{instruction && (
|
||||||
<X size={14} />
|
<span className="track-block-chip-sep">·</span>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
{instruction && (
|
||||||
{/* Expanded view */}
|
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{!active && <span className="track-block-chip-paused-label">paused</span>}
|
||||||
|
</button>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tiptap extension — unchanged schema, parseHTML, serialize
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const TrackBlockExtension = Node.create({
|
export const TrackBlockExtension = Node.create({
|
||||||
name: 'trackBlock',
|
name: 'trackBlock',
|
||||||
group: 'block',
|
group: 'block',
|
||||||
|
|
|
||||||
|
|
@ -611,381 +611,110 @@
|
||||||
.tiptap-editor .ProseMirror .task-block-last-run {
|
.tiptap-editor .ProseMirror .task-block-last-run {
|
||||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
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-chip-wrapper {
|
||||||
.tiptap-editor .ProseMirror .track-block-wrapper {
|
--track-accent: #64748b; /* default: manual/slate */
|
||||||
margin: 8px 0;
|
margin: 4px 0;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .track-block-card {
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||||
position: relative;
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
|
||||||
border: 1px solid var(--border);
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
|
||||||
border-left: 3px solid var(--primary);
|
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||||
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-card.track-block-paused {
|
.tiptap-editor .ProseMirror .track-block-chip {
|
||||||
border-left-color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
display: inline-flex;
|
||||||
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;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
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;
|
cursor: pointer;
|
||||||
min-width: 0;
|
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
|
||||||
user-select: none;
|
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;
|
flex-shrink: 0;
|
||||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
color: var(--track-accent);
|
||||||
transition: transform 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .track-block-chevron-open {
|
.tiptap-editor .ProseMirror .track-block-chip-id {
|
||||||
transform: rotate(90deg);
|
font-weight: 600;
|
||||||
}
|
color: var(--track-accent);
|
||||||
|
white-space: nowrap;
|
||||||
.tiptap-editor .ProseMirror .track-block-icon {
|
|
||||||
color: var(--primary);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .track-block-label {
|
.tiptap-editor .ProseMirror .track-block-chip-sep {
|
||||||
font-size: 11px;
|
color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--primary);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .track-block-summary {
|
.tiptap-editor .ProseMirror .track-block-chip-instruction {
|
||||||
font-size: 13px;
|
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||||
color: var(--foreground);
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror .track-block-meta {
|
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
|
||||||
flex-shrink: 0;
|
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-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 6px;
|
text-transform: uppercase;
|
||||||
border-radius: 4px;
|
letter-spacing: 0.05em;
|
||||||
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 {
|
|
||||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
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-chip-wrapper.ProseMirror-selectednode .track-block-chip {
|
||||||
.tiptap-editor .ProseMirror .track-block-expanded {
|
outline: 2px solid var(--track-accent);
|
||||||
border-top: 1px solid var(--border);
|
outline-offset: 2px;
|
||||||
}
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared block styles (image, embed, chart, table) */
|
/* Shared block styles (image, embed, chart, table) */
|
||||||
|
|
|
||||||
311
apps/x/apps/renderer/src/styles/track-modal.css
Normal file
311
apps/x/apps/renderer/src/styles/track-modal.css
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,17 @@ export async function fetch(filePath: string, trackId: string): Promise<z.infer<
|
||||||
return blocks.find(b => b.track.trackId === trackId) ?? null;
|
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> {
|
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
|
||||||
let content = await fs.readFile(absPath(filePath), 'utf-8');
|
let content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||||
const openTag = `<!--track-target:${trackId}-->`;
|
const openTag = `<!--track-target:${trackId}-->`;
|
||||||
|
|
@ -107,3 +118,73 @@ export async function updateTrackBlock(filepath: string, trackId: string, update
|
||||||
content = lines.join('\n');
|
content = lines.join('\n');
|
||||||
await fs.writeFile(absPath(filepath), content, 'utf-8');
|
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');
|
||||||
|
}
|
||||||
|
|
@ -577,6 +577,55 @@ const ipcSchemas = {
|
||||||
error: z.string().optional(),
|
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 channels
|
||||||
'billing:getInfo': {
|
'billing:getInfo': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue