mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 08:12:38 +02:00
feat: tracks — frontmatter directives, sidebar UI, multi-trigger
Recasts the old "track blocks" as "tracks" — directives stored in a note's frontmatter rather than inline YAML fences and HTML-comment target regions. The motivation is UX: the inline anatomy made notes feel like config, leaked into the editing surface, and competed with the writing flow. Frontmatter is invisible to the body editor, so moving directives there reclaims the body as just markdown the user wrote. The runtime agent now edits the note body freely via standard workspace tools rather than rewriting a constrained target region. Each track's instruction names an H2 section to own; the agent finds or creates that section, updates only its content, and self-heals position on subsequent runs. Triggers are now a unified array per track. cron / window / once / event in any combination, including multi-trigger setups (the flagship example: a priorities track that rebuilds at three day-windows and reacts to incoming gmail / calendar events). window is forgiving — fires once per day anywhere inside its band — so users opening the app late in the morning still get the morning run. The chip-in-editor is gone. Tracks are managed from a right-side sidebar opened by a Radio-icon button at the top-right of the editor toolbar. Cmd+K is no longer a Copilot entry point — search- only — pending a more intuitive invocation surface later. Today.md ships as the flagship demo of what tracks can do, with a versioned migration system so future template updates roll out cleanly to existing users (existing body preserved, old version backed up). Copilot is tuned to listen for any signal that the user wants something dynamic — not just the literal word "track". Strong phrasings get acted on directly; one-off questions about decaying information are answered first and then offered as a track. New or edited tracks run once by default so the user immediately sees content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4709e6eb89
commit
db6757514c
36 changed files with 2043 additions and 2275 deletions
|
|
@ -54,9 +54,9 @@ import {
|
|||
fetchYaml,
|
||||
listNotesWithTracks,
|
||||
setNoteTracksActive,
|
||||
updateTrackBlock,
|
||||
replaceTrackBlockYaml,
|
||||
deleteTrackBlock,
|
||||
updateTrack,
|
||||
replaceTrackYaml,
|
||||
deleteTrack,
|
||||
} from '@x/core/dist/knowledge/track/fileops.js';
|
||||
import { browserIpcHandlers } from './browser/ipc.js';
|
||||
|
||||
|
|
@ -815,12 +815,12 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
// Track handlers
|
||||
'track:run': async (_event, args) => {
|
||||
const result = await triggerTrackUpdate(args.trackId, args.filePath);
|
||||
const result = await triggerTrackUpdate(args.id, args.filePath);
|
||||
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
|
||||
},
|
||||
'track:get': async (_event, args) => {
|
||||
try {
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
const yaml = await fetchYaml(args.filePath, args.id);
|
||||
if (yaml === null) return { success: false, error: 'Track not found' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
|
|
@ -829,8 +829,8 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'track:update': async (_event, args) => {
|
||||
try {
|
||||
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
await updateTrack(args.filePath, args.id, args.updates as Record<string, unknown>);
|
||||
const yaml = await fetchYaml(args.filePath, args.id);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after update' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
|
|
@ -839,8 +839,8 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'track:replaceYaml': async (_event, args) => {
|
||||
try {
|
||||
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
await replaceTrackYaml(args.filePath, args.id, args.yaml);
|
||||
const yaml = await fetchYaml(args.filePath, args.id);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
|
|
@ -849,7 +849,7 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'track:delete': async (_event, args) => {
|
||||
try {
|
||||
await deleteTrackBlock(args.filePath, args.trackId);
|
||||
await deleteTrack(args.filePath, args.id);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
|
|
@ -858,7 +858,7 @@ export function setupIpcHandlers() {
|
|||
'track:setNoteActive': async (_event, args) => {
|
||||
try {
|
||||
const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active);
|
||||
if (!note) return { success: false, error: 'No track blocks found in note' };
|
||||
if (!note) return { success: false, error: 'No tracks found in note' };
|
||||
return { success: true, note };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
|||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
|
||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||
import { TrackModal } from '@/components/track-modal'
|
||||
import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog'
|
||||
import { TrackSidebar } from '@/components/track-sidebar'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
|
|
@ -351,20 +351,20 @@ const buildSuggestedTopicExplorePrompt = ({
|
|||
'Treat a clear confirmation from me as explicit approval to proceed.',
|
||||
`If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`,
|
||||
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
|
||||
'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
|
||||
'Add a track to the note (a `track:` entry in its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
|
||||
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const buildBackgroundAgentSetupPrompt = () => [
|
||||
'Help me set up a background agent.',
|
||||
'In this flow, a background agent is the same thing as a note-based track block. Do not tell me they are separate concepts.',
|
||||
'In this flow, a background agent is the same thing as a track on a note (a `track:` entry in the note frontmatter). Do not tell me they are separate concepts.',
|
||||
'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.',
|
||||
'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.',
|
||||
'Start with a short, plain-English explanation of what a background agent is.',
|
||||
'Do not make the explanation too terse.',
|
||||
'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.',
|
||||
'Do not mention triggers, event-based vs schedule-based behavior, track blocks, skills, note paths, or other internal implementation details unless I ask.',
|
||||
'Do not mention triggers, event-based vs schedule-based behavior, tracks, skills, note paths, or other internal implementation details unless I ask.',
|
||||
'In the first reply, tell me that you will create this in my Tasks folder by default.',
|
||||
'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.',
|
||||
'Then ask only what I want it to monitor or update and how often I want it to run.',
|
||||
|
|
@ -874,7 +874,6 @@ function App() {
|
|||
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
|
||||
// queued across the new-chat-tab state flush before submit fires.
|
||||
const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map())
|
||||
const [paletteContext, setPaletteContext] = useState<CommandPaletteContext | null>(null)
|
||||
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
|
||||
|
||||
const handleSubmitRecording = useCallback(() => {
|
||||
|
|
@ -2933,8 +2932,7 @@ function App() {
|
|||
setPendingPaletteSubmit(null)
|
||||
}, [pendingPaletteSubmit])
|
||||
|
||||
// Listener for track-block "Edit with Copilot" events
|
||||
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
|
||||
// Listener for "Edit with Copilot" events from the track sidebar.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<{
|
||||
|
|
@ -3539,16 +3537,11 @@ function App() {
|
|||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
|
||||
|
||||
// Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode).
|
||||
// If an editor tab is currently active, capture cursor context so Chat mode shows the
|
||||
// note + line as a removable chip.
|
||||
// Keyboard shortcut: Cmd+K / Ctrl+K opens the search palette (search-only).
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
const activeId = activeFileTabIdRef.current
|
||||
const handle = activeId ? editorRefsByTabId.current.get(activeId) : null
|
||||
setPaletteContext(handle?.getCursorContext() ?? null)
|
||||
setIsSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -5090,12 +5083,10 @@ function App() {
|
|||
onOpenChange={setIsSearchOpen}
|
||||
onSelectFile={navigateToFile}
|
||||
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
|
||||
initialContext={paletteContext}
|
||||
onChatSubmit={submitFromPalette}
|
||||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<TrackModal />
|
||||
<TrackSidebar />
|
||||
<OnboardingModal
|
||||
open={showOnboarding}
|
||||
onComplete={handleOnboardingComplete}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: Ba
|
|||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Notes that contain track blocks. Toggle a note inactive to pause every background agent in it.
|
||||
Notes that contain tracks. Toggle a note inactive to pause every background agent in it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
FileTextIcon,
|
||||
FileIcon,
|
||||
FileTypeIcon,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -42,6 +43,7 @@ interface EditorToolbarProps {
|
|||
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
||||
onImageUpload?: (file: File) => Promise<void> | void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
onOpenTracks?: () => void
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
|
|
@ -49,6 +51,7 @@ export function EditorToolbar({
|
|||
onSelectionHighlight,
|
||||
onImageUpload,
|
||||
onExport,
|
||||
onOpenTracks,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -385,6 +388,19 @@ export function EditorToolbar({
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tracks — pushed to far right */}
|
||||
{onOpenTracks && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onOpenTracks}
|
||||
title="Tracks"
|
||||
className="ml-auto"
|
||||
>
|
||||
<Radio className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ function fieldsFromRaw(raw: string | null): FieldEntry[] {
|
|||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
|
||||
function fieldsToRaw(fields: FieldEntry[]): string | null {
|
||||
function fieldsToRaw(fields: FieldEntry[], preserveRaw: string | null): string | null {
|
||||
const record: Record<string, string | string[]> = {}
|
||||
for (const { key, value } of fields) {
|
||||
if (key.trim()) record[key.trim()] = value
|
||||
}
|
||||
return buildFrontmatter(record)
|
||||
return buildFrontmatter(record, preserveRaw)
|
||||
}
|
||||
|
||||
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
|
||||
|
|
@ -45,10 +45,12 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
|
|||
}, [editingNewKey])
|
||||
|
||||
const commit = useCallback((updated: FieldEntry[]) => {
|
||||
const newRaw = fieldsToRaw(updated)
|
||||
// Use the latest raw seen as the preserve-source so structured keys
|
||||
// (like `track:`) survive a round-trip through this UI.
|
||||
const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current)
|
||||
lastCommittedRaw.current = newRaw
|
||||
onRawChange(newRaw)
|
||||
}, [onRawChange])
|
||||
}, [onRawChange, raw])
|
||||
|
||||
// For scalar fields: update local state immediately, commit on blur
|
||||
const updateLocalValue = useCallback((index: number, newValue: string) => {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
|
|||
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
|
||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { TrackBlockExtension } from '@/extensions/track-block'
|
||||
import { PromptBlockExtension } from '@/extensions/prompt-block'
|
||||
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { IframeBlockExtension } from '@/extensions/iframe-block'
|
||||
|
|
@ -48,36 +46,6 @@ function preprocessMarkdown(markdown: string): string {
|
|||
})
|
||||
}
|
||||
|
||||
// Convert track-target open/close HTML comment markers into placeholder divs
|
||||
// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom
|
||||
// nodes. Content *between* the markers is left untouched — tiptap-markdown
|
||||
// parses it naturally as whatever it is (paragraphs, lists, custom-block
|
||||
// fences, etc.), all rendered live by the existing extension set.
|
||||
//
|
||||
// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag
|
||||
// line until a blank line terminates it, and markdown inline rules (bold,
|
||||
// italics, links) don't apply inside the block. Without surrounding blank
|
||||
// lines, the line right after our placeholder div gets absorbed as HTML and
|
||||
// its markdown is not parsed.
|
||||
//
|
||||
// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n`
|
||||
// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks
|
||||
// on save; a `\n?` regex on reload would only consume one of those two
|
||||
// newlines, so every cycle would add a net newline on each side of every
|
||||
// marker — causing tracks running on an open note to steadily inflate the
|
||||
// file with blank lines around target regions.
|
||||
function preprocessTrackTargets(md: string): string {
|
||||
return md
|
||||
.replace(
|
||||
/\n*<!--track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
.replace(
|
||||
/\n*<!--\/track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
}
|
||||
|
||||
// Post-process to clean up any zero-width spaces in the output
|
||||
function postprocessMarkdown(markdown: string): string {
|
||||
// Remove lines that contain only the zero-width space marker
|
||||
|
|
@ -189,12 +157,6 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'promptBlock':
|
||||
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'trackBlock':
|
||||
return '```track\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'trackTargetOpen':
|
||||
return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'trackTargetClose':
|
||||
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'imageBlock':
|
||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
|
|
@ -697,10 +659,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
TrackBlockExtension.configure({ notePath }),
|
||||
PromptBlockExtension.configure({ notePath }),
|
||||
TrackTargetOpenExtension,
|
||||
TrackTargetCloseExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
IframeBlockExtension,
|
||||
|
|
@ -1100,9 +1059,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||
isInternalUpdate.current = true
|
||||
// Pre-process to preserve blank lines, then wrap track-target comment
|
||||
// regions into placeholder divs so TrackTargetExtension can pick them up.
|
||||
const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
|
||||
const preprocessed = preprocessMarkdown(content)
|
||||
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||
isInternalUpdate.current = false
|
||||
|
|
@ -1472,6 +1429,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onExport={onExport}
|
||||
onOpenTracks={notePath ? () => {
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-track-sidebar', {
|
||||
detail: { filePath: notePath },
|
||||
}))
|
||||
} : undefined}
|
||||
/>
|
||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||
<FrontmatterProperties
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react'
|
||||
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
|
|
@ -22,13 +22,14 @@ interface SearchResult {
|
|||
}
|
||||
|
||||
type SearchType = 'knowledge' | 'chat'
|
||||
type Mode = 'chat' | 'search'
|
||||
|
||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||
if (section === 'knowledge') return ['knowledge']
|
||||
return ['chat'] // "tasks" tab maps to chat
|
||||
return ['chat']
|
||||
}
|
||||
|
||||
// Retained for any remaining programmatic Copilot entry points (background-agent
|
||||
// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot.
|
||||
export type CommandPaletteContext = {
|
||||
path: string
|
||||
lineNumber: number
|
||||
|
|
@ -43,12 +44,8 @@ export type CommandPaletteMention = {
|
|||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
// Search mode
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
// Chat mode
|
||||
initialContext?: CommandPaletteContext | null
|
||||
onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
|
|
@ -56,14 +53,8 @@ export function CommandPalette({
|
|||
onOpenChange,
|
||||
onSelectFile,
|
||||
onSelectRun,
|
||||
initialContext,
|
||||
onChatSubmit,
|
||||
}: CommandPaletteProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const [mode, setMode] = useState<Mode>('chat')
|
||||
const [chatInput, setChatInput] = useState('')
|
||||
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
|
||||
const chatInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -74,45 +65,23 @@ export function CommandPalette({
|
|||
)
|
||||
const debouncedQuery = useDebounce(query, 250)
|
||||
|
||||
// On open: always reset to Chat mode (per spec — no mode persistence), sync context chip
|
||||
// and reset search filters.
|
||||
// Sync filters and clear query when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode('chat')
|
||||
setChatInput('')
|
||||
setContextChip(initialContext ?? null)
|
||||
setQuery('')
|
||||
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||
}
|
||||
}, [open, activeSection, initialContext])
|
||||
}, [open, activeSection])
|
||||
|
||||
// Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't
|
||||
// swallow it. Only fires while the dialog is open.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMode(prev => (prev === 'chat' ? 'search' : 'chat'))
|
||||
}
|
||||
document.addEventListener('keydown', handler, true)
|
||||
return () => document.removeEventListener('keydown', handler, true)
|
||||
searchInputRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
// Refocus the appropriate input on mode change so the user can start typing immediately.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const target = mode === 'chat' ? chatInputRef : searchInputRef
|
||||
target.current?.focus()
|
||||
}, [open, mode])
|
||||
|
||||
const toggleType = useCallback((type: SearchType) => {
|
||||
setActiveTypes(new Set([type]))
|
||||
}, [])
|
||||
|
||||
// Search query effect (only meaningful while in search mode, but the debounce keeps running
|
||||
// harmlessly otherwise — empty query skips the IPC call below).
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery.trim()) {
|
||||
setResults([])
|
||||
|
|
@ -133,25 +102,19 @@ export function CommandPalette({
|
|||
})
|
||||
.catch((err) => {
|
||||
console.error('Search failed:', err)
|
||||
if (!cancelled) {
|
||||
setResults([])
|
||||
}
|
||||
if (!cancelled) setResults([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
if (!cancelled) setIsSearching(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [debouncedQuery, activeTypes])
|
||||
|
||||
// Reset transient state on close.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setChatInput('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
|
@ -164,20 +127,6 @@ export function CommandPalette({
|
|||
}
|
||||
}, [onOpenChange, onSelectFile, onSelectRun])
|
||||
|
||||
const submitChat = useCallback(() => {
|
||||
const text = chatInput.trim()
|
||||
if (!text && !contextChip) return
|
||||
const mention: CommandPaletteMention | null = contextChip
|
||||
? {
|
||||
path: contextChip.path,
|
||||
displayName: deriveDisplayName(contextChip.path),
|
||||
lineNumber: contextChip.lineNumber,
|
||||
}
|
||||
: null
|
||||
onChatSubmit(text, mention)
|
||||
onOpenChange(false)
|
||||
}, [chatInput, contextChip, onChatSubmit, onOpenChange])
|
||||
|
||||
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
||||
const chatResults = results.filter(r => r.type === 'chat')
|
||||
|
||||
|
|
@ -185,178 +134,77 @@ export function CommandPalette({
|
|||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
|
||||
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
|
||||
title="Search"
|
||||
description="Search across knowledge and chats"
|
||||
showCloseButton={false}
|
||||
className="top-[20%] translate-y-0"
|
||||
>
|
||||
{/* Mode strip */}
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<ModeButton
|
||||
active={mode === 'chat'}
|
||||
onClick={() => setMode('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chat"
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === 'search'}
|
||||
onClick={() => setMode('search')}
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Search"
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
/>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span>
|
||||
</div>
|
||||
|
||||
{mode === 'chat' ? (
|
||||
<div className="flex flex-col">
|
||||
<input
|
||||
ref={chatInputRef}
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// cmdk's Command component intercepts Enter for item selection — stop it
|
||||
// before bubbling so we control the chat submit ourselves.
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
submitChat()
|
||||
}
|
||||
}}
|
||||
placeholder="Ask copilot anything…"
|
||||
autoFocus
|
||||
className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{contextChip && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs">
|
||||
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{deriveDisplayName(contextChip.path)}</span>
|
||||
<span className="text-muted-foreground">· Line {contextChip.lineNumber}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextChip(null)}
|
||||
aria-label="Remove context"
|
||||
className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
{!contextChip && (
|
||||
<div className="flex items-center px-3 pb-3">
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</>
|
||||
)}
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette.
|
||||
export const SearchDialog = CommandPalette
|
||||
|
||||
function deriveDisplayName(path: string): string {
|
||||
const base = path.split('/').pop() ?? path
|
||||
return base.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
active,
|
||||
onClick,
|
||||
|
|
@ -370,17 +218,19 @@ function FilterToggle({
|
|||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Back-compat export: thin alias to CommandPalette.
|
||||
export const SearchDialog = CommandPalette
|
||||
|
|
|
|||
|
|
@ -1,530 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import '@/styles/track-modal.css'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
|
||||
Trash2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
import type { OpenTrackModalDetail } from '@/extensions/track-block'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CRON_PHRASES: Record<string, string> = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Hourly',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'0 */6 * * *': 'Every 6 hours',
|
||||
'0 */12 * * *': 'Every 12 hours',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 8 * * *': 'Daily at 8 AM',
|
||||
'0 9 * * *': 'Daily at 9 AM',
|
||||
'0 12 * * *': 'Daily at noon',
|
||||
'0 18 * * *': 'Daily at 6 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9 AM',
|
||||
'0 17 * * 1-5': 'Weekdays at 5 PM',
|
||||
'0 0 * * 0': 'Sundays at midnight',
|
||||
'0 0 * * 1': 'Mondays at midnight',
|
||||
'0 0 1 * *': 'First of each month',
|
||||
}
|
||||
|
||||
function describeCron(expr: string): string {
|
||||
return CRON_PHRASES[expr.trim()] ?? expr
|
||||
}
|
||||
|
||||
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
|
||||
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
|
||||
|
||||
function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary {
|
||||
if (!schedule) return { icon: 'bolt', text: 'Manual only' }
|
||||
if (schedule.type === 'once') {
|
||||
return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` }
|
||||
}
|
||||
if (schedule.type === 'cron') {
|
||||
return { icon: 'timer', text: describeCron(schedule.expression) }
|
||||
}
|
||||
if (schedule.type === 'window') {
|
||||
return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` }
|
||||
}
|
||||
return { icon: 'calendar', text: 'Scheduled' }
|
||||
}
|
||||
|
||||
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
|
||||
if (icon === 'timer') return <Clock size={size} />
|
||||
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
|
||||
return <Zap size={size} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = 'what' | 'when' | 'event' | 'details'
|
||||
|
||||
export function TrackModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
|
||||
const [yaml, setYaml] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('what')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Listen for the open event and seed modal state.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<OpenTrackModalDetail>
|
||||
const d = ev.detail
|
||||
if (!d?.trackId || !d?.filePath) return
|
||||
setDetail(d)
|
||||
setYaml(d.initialYaml ?? '')
|
||||
setActiveTab('what')
|
||||
setEditingRaw(false)
|
||||
setRawDraft('')
|
||||
setShowAdvanced(false)
|
||||
setConfirmingDelete(false)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
void fetchFresh(d)
|
||||
}
|
||||
window.addEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) })
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
if (!yaml) return null
|
||||
try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null }
|
||||
}, [yaml])
|
||||
|
||||
const trackId = track?.trackId ?? detail?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const lastRunAt = track?.lastRunAt ?? ''
|
||||
const lastRunId = track?.lastRunId ?? ''
|
||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
||||
const model = track?.model ?? ''
|
||||
const provider = track?.provider ?? ''
|
||||
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : ''
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
|
||||
{ key: 'what', label: 'What to track', visible: true },
|
||||
{ key: 'when', label: 'When to run', visible: !!schedule },
|
||||
{ key: 'event', label: 'Event matching', visible: !!eventMatchCriteria },
|
||||
{ key: 'details', label: 'Details', visible: true },
|
||||
]
|
||||
const shown = visibleTabs.filter(t => t.visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown.some(t => t.key === activeTab)) setActiveTab('what')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule, eventMatchCriteria])
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IPC-backed mutations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const runUpdate = useCallback(async (updates: Record<string, unknown>) => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:update', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
updates,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleToggleActive = useCallback(() => {
|
||||
void runUpdate({ active: !active })
|
||||
}, [active, runUpdate])
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!detail || isRunning) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [detail, isRunning])
|
||||
|
||||
const handleSaveRaw = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:replaceYaml', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
yaml: rawDraft,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
setEditingRaw(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail, rawDraft])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:delete', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
if (res?.success) {
|
||||
// Tell the editor to remove the node so Tiptap's next save doesn't
|
||||
// re-create the track block on disk.
|
||||
try { detail.onDeleted() } catch { /* editor may have unmounted */ }
|
||||
setOpen(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!detail) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
|
||||
detail: {
|
||||
trackId: detail.trackId,
|
||||
filePath: detail.filePath,
|
||||
},
|
||||
}))
|
||||
setOpen(false)
|
||||
}, [detail])
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<div className="track-modal-header">
|
||||
<div className="track-modal-header-left">
|
||||
<div className="track-modal-icon-wrap">
|
||||
<Radio size={16} />
|
||||
</div>
|
||||
<div className="track-modal-title-col">
|
||||
<DialogHeader className="space-y-0">
|
||||
<DialogTitle className="track-modal-title">
|
||||
{trackId || 'Track'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="track-modal-subtitle">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
|
||||
{scheduleSummary.text}
|
||||
{eventMatchCriteria && triggerType === 'scheduled' && (
|
||||
<span className="track-modal-subtitle-sep">· also event-driven</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-modal-header-actions">
|
||||
<label className="track-modal-toggle">
|
||||
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
|
||||
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="track-modal-tabs">
|
||||
{shown.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="track-modal-body">
|
||||
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest…</div>}
|
||||
|
||||
{activeTab === 'what' && (
|
||||
<div className="track-modal-prose">
|
||||
{instruction
|
||||
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
|
||||
: <span className="track-modal-empty">No instruction set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'when' && schedule && (
|
||||
<div className="track-modal-when">
|
||||
<div className="track-modal-when-headline">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
|
||||
<span>{scheduleSummary.text}</span>
|
||||
</div>
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
|
||||
{schedule.type === 'cron' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'window' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
|
||||
<dt>Window</dt><dd>{schedule.startTime} – {schedule.endTime}</dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'once' && (
|
||||
<>
|
||||
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="track-modal-prose">
|
||||
{eventMatchCriteria
|
||||
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
|
||||
: <span className="track-modal-empty">No event matching set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-details">
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
|
||||
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
|
||||
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
|
||||
{model && (<>
|
||||
<dt>Model</dt><dd><code>{model}</code></dd>
|
||||
</>)}
|
||||
{provider && (<>
|
||||
<dt>Provider</dt><dd><code>{provider}</code></dd>
|
||||
</>)}
|
||||
{lastRunAt && (<>
|
||||
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
|
||||
</>)}
|
||||
{lastRunId && (<>
|
||||
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
|
||||
</>)}
|
||||
{lastRunSummary && (<>
|
||||
<dt>Summary</dt><dd>{lastRunSummary}</dd>
|
||||
</>)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced (raw YAML) — all tabs */}
|
||||
<div className="track-modal-advanced">
|
||||
<button
|
||||
className="track-modal-advanced-toggle"
|
||||
onClick={() => {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
if (next) {
|
||||
setRawDraft(yaml)
|
||||
setEditingRaw(true)
|
||||
} else {
|
||||
setEditingRaw(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
<Code2 size={12} />
|
||||
Advanced (raw YAML)
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="track-modal-raw-editor">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="track-modal-textarea"
|
||||
/>
|
||||
<div className="track-modal-raw-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveRaw}
|
||||
disabled={saving || rawDraft.trim() === yaml.trim()}
|
||||
>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone — on Details tab only */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-danger-zone">
|
||||
{confirmingDelete ? (
|
||||
<div className="track-modal-confirm">
|
||||
<span>Delete this track and its generated content?</span>
|
||||
<div className="track-modal-confirm-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
Yes, delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="track-modal-delete-btn"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete track block
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="track-modal-error">{error}</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="track-modal-footer">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditWithCopilot}
|
||||
disabled={saving}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || saving}
|
||||
className="track-modal-run-btn"
|
||||
>
|
||||
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
||||
{isRunning ? 'Running…' : 'Run now'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
627
apps/x/apps/renderer/src/components/track-sidebar.tsx
Normal file
627
apps/x/apps/renderer/src/components/track-sidebar.tsx
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import '@/styles/track-modal.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
|
||||
Trash2, ChevronDown, ChevronUp, ChevronLeft, X,
|
||||
} from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackSchema, type Trigger } from '@x/shared/dist/track.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
export type OpenTrackSidebarDetail = {
|
||||
filePath: string
|
||||
selectId?: string
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
const CRON_PHRASES: Record<string, string> = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Hourly',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'0 */6 * * *': 'Every 6 hours',
|
||||
'0 */12 * * *': 'Every 12 hours',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 8 * * *': 'Daily at 8 AM',
|
||||
'0 9 * * *': 'Daily at 9 AM',
|
||||
'0 12 * * *': 'Daily at noon',
|
||||
'0 18 * * *': 'Daily at 6 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9 AM',
|
||||
'0 17 * * 1-5': 'Weekdays at 5 PM',
|
||||
'0 0 * * 0': 'Sundays at midnight',
|
||||
'0 0 * * 1': 'Mondays at midnight',
|
||||
'0 0 1 * *': 'First of each month',
|
||||
}
|
||||
|
||||
function describeCron(expr: string): string {
|
||||
return CRON_PHRASES[expr.trim()] ?? expr
|
||||
}
|
||||
|
||||
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
|
||||
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
|
||||
|
||||
function describeTrigger(t: Trigger): ScheduleSummary {
|
||||
if (t.type === 'once') return { icon: 'target', text: `Once at ${formatDateTime(t.runAt)}` }
|
||||
if (t.type === 'cron') return { icon: 'timer', text: describeCron(t.expression) }
|
||||
if (t.type === 'window') return { icon: 'calendar', text: `${t.startTime}–${t.endTime}` }
|
||||
return { icon: 'bolt', text: 'Event-driven' }
|
||||
}
|
||||
|
||||
function summarizeTriggers(triggers: Trigger[] | undefined): ScheduleSummary {
|
||||
if (!triggers || triggers.length === 0) return { icon: 'bolt', text: 'Manual only' }
|
||||
const timed = triggers.filter(t => t.type !== 'event')
|
||||
const events = triggers.filter(t => t.type === 'event')
|
||||
if (timed.length === 0) {
|
||||
return { icon: 'bolt', text: events.length > 1 ? `${events.length} event triggers` : 'Event-driven' }
|
||||
}
|
||||
const first = describeTrigger(timed[0])
|
||||
let text = first.text
|
||||
if (timed.length > 1) text += ` (+${timed.length - 1})`
|
||||
if (events.length > 0) text += ' · also event-driven'
|
||||
return { icon: first.icon, text }
|
||||
}
|
||||
|
||||
|
||||
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
|
||||
if (icon === 'timer') return <Clock size={size} />
|
||||
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
|
||||
return <Zap size={size} />
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
|
||||
type Track = z.infer<typeof TrackSchema>
|
||||
|
||||
function parseTracksFromFile(content: string): Track[] {
|
||||
if (!content.startsWith('---')) return []
|
||||
const close = /\r?\n---\r?\n/.exec(content)
|
||||
if (!close) return []
|
||||
const yamlText = content.slice(3, close.index).trim()
|
||||
if (!yamlText) return []
|
||||
let fm: unknown
|
||||
try { fm = parseYaml(yamlText) } catch { return [] }
|
||||
if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return []
|
||||
const raw = (fm as Record<string, unknown>).track
|
||||
if (!Array.isArray(raw)) return []
|
||||
const tracks: Track[] = []
|
||||
for (const entry of raw) {
|
||||
const result = TrackSchema.safeParse(entry)
|
||||
if (result.success) tracks.push(result.data)
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
type Tab = 'what' | 'when' | 'event' | 'details'
|
||||
|
||||
export function TrackSidebar() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filePath, setFilePath] = useState<string>('')
|
||||
const [tracks, setTracks] = useState<Track[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Detail-view state (per-track local UI)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('what')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const knowledgeRelPath = useMemo(() => stripKnowledgePrefix(filePath), [filePath])
|
||||
const allTrackStatus = useTrackStatus()
|
||||
|
||||
const refresh = useCallback(async (relPath: string) => {
|
||||
if (!relPath) { setTracks([]); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('workspace:readFile', { path: `knowledge/${relPath}` })
|
||||
if (res?.data) {
|
||||
setTracks(parseTracksFromFile(res.data))
|
||||
} else {
|
||||
setTracks([])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setTracks([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<OpenTrackSidebarDetail>
|
||||
const d = ev.detail
|
||||
if (!d?.filePath) return
|
||||
setFilePath(d.filePath)
|
||||
setSelectedId(d.selectId ?? null)
|
||||
setActiveTab('what')
|
||||
setEditingRaw(false)
|
||||
setRawDraft('')
|
||||
setShowAdvanced(false)
|
||||
setConfirmingDelete(false)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
void refresh(stripKnowledgePrefix(d.filePath))
|
||||
}
|
||||
window.addEventListener('rowboat:open-track-sidebar', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-track-sidebar', handler as EventListener)
|
||||
}, [refresh])
|
||||
|
||||
// Re-fetch when a run completes for a track in this file.
|
||||
useEffect(() => {
|
||||
if (!open || !knowledgeRelPath) return
|
||||
let stale = false
|
||||
for (const [, state] of allTrackStatus) {
|
||||
if (state.status === 'done' || state.status === 'error') {
|
||||
stale = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (stale) void refresh(knowledgeRelPath)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allTrackStatus, open, knowledgeRelPath])
|
||||
|
||||
const selected = useMemo(
|
||||
() => (selectedId ? tracks.find(t => t.id === selectedId) ?? null : null),
|
||||
[selectedId, tracks],
|
||||
)
|
||||
|
||||
// Seed raw editor draft when entering advanced mode.
|
||||
useEffect(() => {
|
||||
if (showAdvanced && selected) {
|
||||
try {
|
||||
// Lazy import yaml stringify only when needed; avoid top-level dep cycle.
|
||||
import('yaml').then(({ stringify }) => {
|
||||
setRawDraft(stringify(selected).trimEnd())
|
||||
})
|
||||
} catch {
|
||||
setRawDraft('')
|
||||
}
|
||||
}
|
||||
}, [showAdvanced, selected])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const runUpdate = useCallback(async (id: string, updates: Record<string, unknown>) => {
|
||||
if (!knowledgeRelPath) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:update', { id, filePath: knowledgeRelPath, updates })
|
||||
if (!res?.success && res?.error) setError(res.error)
|
||||
await refresh(knowledgeRelPath)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath, refresh])
|
||||
|
||||
const handleToggleActive = useCallback((id: string, currentlyActive: boolean) => {
|
||||
void runUpdate(id, { active: !currentlyActive })
|
||||
}, [runUpdate])
|
||||
|
||||
const handleRun = useCallback(async (id: string) => {
|
||||
if (!knowledgeRelPath) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', { id, filePath: knowledgeRelPath })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [knowledgeRelPath])
|
||||
|
||||
const handleSaveRaw = useCallback(async () => {
|
||||
if (!knowledgeRelPath || !selectedId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:replaceYaml', { id: selectedId, filePath: knowledgeRelPath, yaml: rawDraft })
|
||||
if (res?.success) {
|
||||
setEditingRaw(false)
|
||||
await refresh(knowledgeRelPath)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath, selectedId, rawDraft, refresh])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!knowledgeRelPath || !selectedId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:delete', { id: selectedId, filePath: knowledgeRelPath })
|
||||
if (res?.success) {
|
||||
setSelectedId(null)
|
||||
setConfirmingDelete(false)
|
||||
await refresh(knowledgeRelPath)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath, selectedId, refresh])
|
||||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!filePath || !selectedId) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
|
||||
detail: { trackId: selectedId, filePath },
|
||||
}))
|
||||
setOpen(false)
|
||||
}, [filePath, selectedId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const noteTitle = filePath
|
||||
? (filePath.split('/').pop() ?? filePath).replace(/\.md$/, '')
|
||||
: 'Tracks'
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 right-0 z-60 flex w-[min(420px,calc(100vw-2rem))] flex-col overflow-hidden border-l border-border bg-background shadow-2xl">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-3 text-sidebar-foreground">
|
||||
<Radio className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium">Tracks</span>
|
||||
<span className="truncate text-xs text-sidebar-foreground/60">{noteTitle}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selected && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{!loading && tracks.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-1.5 px-6 py-12 text-center">
|
||||
<Radio className="size-6 text-muted-foreground/50" />
|
||||
<div className="text-sm text-muted-foreground">No tracks in this note yet.</div>
|
||||
<div className="text-xs text-muted-foreground/70">
|
||||
Ask Copilot “track Chicago time hourly” to add one.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-border">
|
||||
{tracks.map(t => {
|
||||
const sched = summarizeTriggers(t.triggers)
|
||||
const runState = allTrackStatus.get(`${t.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
const paused = t.active === false
|
||||
const instructionPreview = t.instruction.split('\n')[0].trim()
|
||||
return (
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-accent ${paused ? 'opacity-60' : ''}`}
|
||||
onClick={() => { setSelectedId(t.id); setActiveTab('what') }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">{t.id}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{paused ? 'Paused · ' : ''}{sched.text}
|
||||
</span>
|
||||
{instructionPreview && (
|
||||
<span className="truncate text-xs text-muted-foreground/70">
|
||||
{instructionPreview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity hover:bg-background hover:text-foreground ${isRunning ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
onClick={(ev) => { ev.stopPropagation(); void handleRun(t.id) }}
|
||||
disabled={isRunning}
|
||||
aria-label={isRunning ? `Running ${t.id}` : `Run ${t.id}`}
|
||||
title={isRunning ? `Running…` : `Run ${t.id}`}
|
||||
>
|
||||
{isRunning ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
|
||||
</button>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (() => {
|
||||
const triggers: Trigger[] = selected.triggers ?? []
|
||||
const timedTriggers = triggers.filter((t): t is Exclude<Trigger, { type: 'event' }> => t.type !== 'event')
|
||||
const eventTriggers = triggers.filter((t): t is Extract<Trigger, { type: 'event' }> => t.type === 'event')
|
||||
const sched = summarizeTriggers(triggers)
|
||||
const runState = allTrackStatus.get(`${selected.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
const paused = selected.active === false
|
||||
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
|
||||
{ key: 'what', label: 'What', visible: true },
|
||||
{ key: 'when', label: 'Schedule', visible: timedTriggers.length > 0 },
|
||||
{ key: 'event', label: 'Events', visible: eventTriggers.length > 0 },
|
||||
{ key: 'details', label: 'Details', visible: true },
|
||||
]
|
||||
const shown = visibleTabs.filter(t => t.visible)
|
||||
|
||||
return (
|
||||
<div className={`flex flex-1 flex-col overflow-hidden ${paused ? 'opacity-80' : ''}`}>
|
||||
{/* Subheader: back arrow + track id */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSelectedId(null)
|
||||
setShowAdvanced(false)
|
||||
setEditingRaw(false)
|
||||
setConfirmingDelete(false)
|
||||
}}
|
||||
aria-label="Back to tracks"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<span className="truncate text-sm font-medium">{selected.id}</span>
|
||||
</div>
|
||||
|
||||
{/* Status row: schedule summary + active toggle */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-2">
|
||||
<span className="truncate text-xs text-muted-foreground">{sched.text}</span>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<Switch
|
||||
checked={!paused}
|
||||
onCheckedChange={() => handleToggleActive(selected.id, !paused)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{paused ? 'Paused' : 'Active'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
{shown.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto px-3 py-3">
|
||||
{activeTab === 'what' && (
|
||||
<div className="text-sm leading-relaxed">
|
||||
{selected.instruction ? (
|
||||
<Streamdown className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{selected.instruction}
|
||||
</Streamdown>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No instruction set.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'when' && timedTriggers.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{timedTriggers.map((trig, idx) => {
|
||||
const tSched = describeTrigger(trig)
|
||||
return (
|
||||
<div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<ScheduleIcon icon={tSched.icon} size={14} />
|
||||
<span>{tSched.text}</span>
|
||||
</div>
|
||||
<DetailGrid>
|
||||
<DetailRow label="Type" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.type}</code>} />
|
||||
{trig.type === 'cron' && (
|
||||
<DetailRow label="Expression" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.expression}</code>} />
|
||||
)}
|
||||
{trig.type === 'window' && (
|
||||
<DetailRow label="Window" value={`${trig.startTime} – ${trig.endTime}`} />
|
||||
)}
|
||||
{trig.type === 'once' && (
|
||||
<DetailRow label="Runs at" value={formatDateTime(trig.runAt)} />
|
||||
)}
|
||||
</DetailGrid>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{eventTriggers.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">No event matching set.</span>
|
||||
) : eventTriggers.map((trig, idx) => (
|
||||
<div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
||||
<Streamdown className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{trig.matchCriteria}
|
||||
</Streamdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<DetailGrid>
|
||||
<DetailRow label="ID" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.id}</code>} />
|
||||
<DetailRow label="File" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px] break-all">{filePath}</code>} />
|
||||
<DetailRow label="Status" value={paused ? 'Paused' : 'Active'} />
|
||||
{selected.model && <DetailRow label="Model" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.model}</code>} />}
|
||||
{selected.provider && <DetailRow label="Provider" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.provider}</code>} />}
|
||||
{selected.lastRunAt && <DetailRow label="Last run" value={formatDateTime(selected.lastRunAt)} />}
|
||||
{selected.lastRunSummary && <DetailRow label="Summary" value={selected.lastRunSummary} />}
|
||||
</DetailGrid>
|
||||
)}
|
||||
|
||||
{/* Advanced — raw YAML */}
|
||||
<div className="mt-6 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
setEditingRaw(next)
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
<Code2 className="size-3" />
|
||||
Advanced (raw YAML)
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setShowAdvanced(false); setEditingRaw(false) }}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveRaw} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone — Details tab only */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm">
|
||||
<span className="text-destructive">Delete this track?</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete track
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/20 px-3 py-2.5">
|
||||
<Button variant="outline" size="sm" onClick={handleEditWithCopilot} disabled={saving}>
|
||||
<Sparkles className="size-3" />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleRun(selected.id)}
|
||||
disabled={isRunning || saving}
|
||||
>
|
||||
{isRunning ? <Loader2 className="size-3 animate-spin" /> : <Play className="size-3" />}
|
||||
{isRunning ? 'Running…' : 'Run now'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailGrid({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 text-xs">
|
||||
{children}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-foreground">{value}</dd>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
import { useMemo, type ComponentType } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Radio, Loader2, type LucideProps } from 'lucide-react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
function resolveIcon(iconName: string): ComponentType<LucideProps> | null {
|
||||
const key = iconName
|
||||
.split('-')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join('')
|
||||
const component = (LucideIcons as Record<string, unknown>)[key]
|
||||
if (component != null) return component as ComponentType<LucideProps>
|
||||
return null
|
||||
}
|
||||
|
||||
function TrackIcon({ icon, size }: { icon?: string; size: number }) {
|
||||
if (icon) {
|
||||
const Icon = resolveIcon(icon)
|
||||
if (Icon) return <Icon size={size} />
|
||||
}
|
||||
return <Radio size={size} />
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
// Detail shape for the open-track-modal window event. Defined here so the
|
||||
// consumer (TrackModal) can import it without a circular dependency.
|
||||
export type OpenTrackModalDetail = {
|
||||
trackId: string
|
||||
/** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */
|
||||
filePath: string
|
||||
/** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */
|
||||
initialYaml: string
|
||||
/** Invoked after a successful IPC delete so the editor can remove the node. */
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chip (display-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TrackBlockView({ node, deleteNode, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
const cleaned = raw.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
try {
|
||||
return TrackBlockSchema.parse(parseYaml(cleaned))
|
||||
} catch(error) { console.error('error', error); return null }
|
||||
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
|
||||
|
||||
const trackId = track?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const notePath = extension.options.notePath
|
||||
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
|
||||
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!trackId || !notePath) return
|
||||
const detail: OpenTrackModalDetail = {
|
||||
trackId,
|
||||
filePath: notePath,
|
||||
initialYaml: raw,
|
||||
onDeleted: () => deleteNode(),
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
|
||||
'rowboat:open-track-modal',
|
||||
{ detail },
|
||||
))
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="track-block-chip-wrapper"
|
||||
data-type="track-block"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
data-trackid={trackId}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={handleKey}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={instruction ? `${trackId}: ${instruction}` : trackId}
|
||||
>
|
||||
<span className="track-block-chip-icon">
|
||||
{isRunning
|
||||
? <Loader2 size={24} className="animate-spin" />
|
||||
: <TrackIcon icon={track?.icon} size={24} />}
|
||||
</span>
|
||||
<span className="track-block-chip-id">{trackId || 'track'}</span>
|
||||
{instruction && <span className="track-block-chip-sep">·</span>}
|
||||
{instruction && (
|
||||
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
|
||||
)}
|
||||
{!active && <span className="track-block-chip-paused-label">paused</span>}
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiptap extension — unchanged schema, parseHTML, serialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackBlockExtension = Node.create({
|
||||
name: 'trackBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
notePath: undefined as string | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-track')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TrackBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```track\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
|
||||
/**
|
||||
* Track target markers — two Tiptap atom nodes that represent the open and
|
||||
* close HTML comment markers bracketing a track's output region on disk:
|
||||
*
|
||||
* <!--track-target:ID--> → TrackTargetOpenExtension
|
||||
* content in between → regular Tiptap nodes (paragraphs, lists,
|
||||
* custom blocks, whatever tiptap-markdown parses)
|
||||
* <!--/track-target:ID--> → TrackTargetCloseExtension
|
||||
*
|
||||
* The markers are *semantic boundaries*, not a UI container. Content between
|
||||
* them is real, editable document content — fully rendered by the existing
|
||||
* extension set and freely editable by the user. The backend's updateContent()
|
||||
* in fileops.ts still locates the region on disk by these comment markers.
|
||||
*
|
||||
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
|
||||
* regex replace, converting each comment into a placeholder div that these
|
||||
* extensions' parseHTML rules pick up. No content capture.
|
||||
*
|
||||
* Save path: both Tiptap's built-in markdown serializer
|
||||
* (`addStorage().markdown.serialize`) AND the app's custom serializer
|
||||
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
|
||||
* back out — they must stay in sync.
|
||||
*/
|
||||
|
||||
type MarkerVariant = 'open' | 'close'
|
||||
|
||||
function buildMarkerExtension(variant: MarkerVariant) {
|
||||
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
|
||||
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
|
||||
const commentFor = (id: string) =>
|
||||
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
|
||||
|
||||
return Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
trackId: { default: '' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${htmlType}"]`,
|
||||
getAttrs(el) {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
return { trackId: el.getAttribute('data-track-id') ?? '' }
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': htmlType,
|
||||
'data-track-id': (node.attrs.trackId as string) ?? '',
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(
|
||||
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
|
||||
node: { attrs: { trackId: string } },
|
||||
) {
|
||||
state.write(commentFor(node.attrs.trackId ?? ''))
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled via preprocessTrackTargets → parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const TrackTargetOpenExtension = buildMarkerExtension('open')
|
||||
export const TrackTargetCloseExtension = buildMarkerExtension('close')
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import z from 'zod';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TrackEvent } from '@x/shared/dist/track-block.js';
|
||||
import { TrackEvent } from '@x/shared/dist/track.js';
|
||||
|
||||
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ function getSnapshot(): Map<string, TrackState> {
|
|||
/**
|
||||
* Returns a Map of all track run states, keyed by "trackId:filePath".
|
||||
*
|
||||
* Usage in a track block component:
|
||||
* Usage in a track-aware component:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
|
||||
*
|
||||
|
|
|
|||
|
|
@ -133,9 +133,19 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
|
|||
}
|
||||
|
||||
/**
|
||||
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
|
||||
* Returns a flat record where scalar values are strings and list values are string[].
|
||||
* Skips `---` delimiters and blank lines.
|
||||
* Keys that hold structured (nested object/array-of-object) data and must NOT
|
||||
* be mangled by the flat-string FrontmatterProperties UI. These pass through
|
||||
* unchanged on a round-trip — never exposed as editable fields, never
|
||||
* re-emitted by buildFrontmatter (callers must splice them back from the
|
||||
* original raw if they want to preserve them on save — see the helpers below).
|
||||
*/
|
||||
const STRUCTURED_KEYS = new Set(['track'])
|
||||
|
||||
/**
|
||||
* Extract editable top-level YAML key/value pairs from raw frontmatter.
|
||||
* Returns a flat record where scalar values are strings and list-of-string
|
||||
* values are string[]. Structured keys (e.g. `track:`) and any nested-object
|
||||
* shapes are filtered out — they are not editable via this surface.
|
||||
*/
|
||||
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {}
|
||||
|
|
@ -143,10 +153,12 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
|
|||
|
||||
const lines = raw.split('\n')
|
||||
let currentKey: string | null = null
|
||||
let pendingNested = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '---' || line.trim() === '') {
|
||||
currentKey = null
|
||||
pendingNested = false
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -155,39 +167,61 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
|
|||
if (topMatch) {
|
||||
const key = topMatch[1]
|
||||
const value = topMatch[2].trim()
|
||||
pendingNested = false
|
||||
if (STRUCTURED_KEYS.has(key)) {
|
||||
currentKey = null
|
||||
continue
|
||||
}
|
||||
if (value) {
|
||||
result[key] = value
|
||||
currentKey = null
|
||||
} else {
|
||||
// List will follow
|
||||
currentKey = key
|
||||
result[key] = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// List item under current key
|
||||
if (currentKey) {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
const arr = result[currentKey]
|
||||
if (Array.isArray(arr)) {
|
||||
arr.push(itemMatch[1].trim())
|
||||
}
|
||||
if (!currentKey) continue
|
||||
|
||||
// List item under current key.
|
||||
const itemMatch = line.match(/^\s+-\s+(.*)$/)
|
||||
if (itemMatch) {
|
||||
const item = itemMatch[1].trim()
|
||||
// If the list-item line itself contains a `key: value` pair, this is a
|
||||
// nested-object shape (e.g. `- id: chicago-time` under `track:`). We
|
||||
// can't represent that as a flat string array — drop the whole key.
|
||||
if (/^\w[\w\s]*\w?:\s*\S/.test(item)) {
|
||||
delete result[currentKey]
|
||||
currentKey = null
|
||||
pendingNested = true
|
||||
continue
|
||||
}
|
||||
const arr = result[currentKey]
|
||||
if (Array.isArray(arr)) arr.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Indented continuation of a nested object — keep dropping its parent.
|
||||
if (pendingNested && /^\s/.test(line)) continue
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
|
||||
* Returns null if no non-empty fields remain.
|
||||
* Convert a Record of editable frontmatter fields back to a raw YAML
|
||||
* frontmatter string. If `preserveRaw` is provided, structured keys (e.g.
|
||||
* `track:`) are spliced back from the original raw byte-for-byte, so
|
||||
* round-trips through the FrontmatterProperties UI never lose them.
|
||||
*/
|
||||
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
|
||||
export function buildFrontmatter(
|
||||
fields: Record<string, string | string[]>,
|
||||
preserveRaw: string | null = null,
|
||||
): string | null {
|
||||
const lines: string[] = []
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (STRUCTURED_KEYS.has(key)) continue
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue
|
||||
lines.push(`${key}:`)
|
||||
|
|
@ -200,8 +234,55 @@ export function buildFrontmatter(fields: Record<string, string | string[]>): str
|
|||
lines.push(`${key}: ${trimmed}`)
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null
|
||||
return `---\n${lines.join('\n')}\n---`
|
||||
|
||||
// Splice preserved structured-key blocks (e.g. track:) back from preserveRaw.
|
||||
const preservedBlocks: string[] = []
|
||||
if (preserveRaw) {
|
||||
for (const key of STRUCTURED_KEYS) {
|
||||
const block = extractTopLevelBlock(preserveRaw, key)
|
||||
if (block) preservedBlocks.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0 && preservedBlocks.length === 0) return null
|
||||
const allLines = [...lines, ...preservedBlocks.flatMap(b => b.split('\n'))]
|
||||
return `---\n${allLines.join('\n')}\n---`
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the byte-for-byte line block for a top-level key in raw frontmatter,
|
||||
* including its nested children (any indented lines that follow), or null if
|
||||
* the key is absent. Used to round-trip structured keys safely.
|
||||
*/
|
||||
function extractTopLevelBlock(raw: string, key: string): string | null {
|
||||
const lines = raw.split('\n')
|
||||
let start = -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === '---') continue
|
||||
const m = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
|
||||
if (m && m[1] === key) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (start === -1) return null
|
||||
let end = start
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === '---') break
|
||||
if (/^\s/.test(line)) {
|
||||
end = i
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
// blank line — end of this top-level block
|
||||
break
|
||||
}
|
||||
// another top-level key — stop
|
||||
break
|
||||
}
|
||||
return lines.slice(start, end + 1).join('\n')
|
||||
}
|
||||
|
||||
/** Map known tag values → category for legacy flat-list frontmatter. */
|
||||
|
|
|
|||
|
|
@ -656,159 +656,11 @@
|
|||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
}
|
||||
/* =============================================================
|
||||
Track Block — inline chip (display-only)
|
||||
The chip just opens a modal (TrackModal). All mutations live in the
|
||||
modal and go through IPC, so the editor never writes track state.
|
||||
(Track inline chip and target-marker styles removed — tracks now
|
||||
live entirely in the note's frontmatter and are managed via the
|
||||
right-side track sidebar.)
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
|
||||
--track-accent: #64748b; /* default: manual/slate */
|
||||
margin: 8px 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 24px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
color: var(--foreground);
|
||||
background: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:hover {
|
||||
background: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:active {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-running {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
|
||||
animation: track-chip-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes track-chip-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
|
||||
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-icon {
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="up-next"] .track-block-chip-icon { color: #3b82f6; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="calendar"] .track-block-chip-icon { color: #22c55e; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="emails"] .track-block-chip-icon { color: #f59e0b; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="what-you-missed"] .track-block-chip-icon { color: #3b82f6; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="priorities"] .track-block-chip-icon { color: #ef4444; }
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-id {
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--foreground) 75%, transparent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-sep {
|
||||
color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-instruction {
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Track target markers — thin visual bookends around a track's
|
||||
output region. The content BETWEEN these markers is normal,
|
||||
editable document content (rendered by the existing extensions).
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
|
||||
position: relative;
|
||||
height: 1px;
|
||||
margin: 14px 0 6px 0;
|
||||
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
|
||||
content: 'track: ' attr(data-track-id);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 8px;
|
||||
padding: 0 6px;
|
||||
background: var(--background, #fff);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
|
||||
height: 1px;
|
||||
margin: 6px 0 14px 0;
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
|
||||
outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
outline-offset: 1px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
/* =============================================================
|
||||
Track Modal — dialog overlay for track block details / edits
|
||||
Track sidebar styles. Filename is legacy (predates the modal →
|
||||
sidebar refactor); the .track-modal-* class names are reused by
|
||||
the sidebar's detail-view layout.
|
||||
============================================================= */
|
||||
|
||||
.track-modal-content {
|
||||
|
|
@ -309,3 +311,167 @@
|
|||
.track-modal-run-btn:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 85%, black);
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Track sidebar — right panel that lists/edits tracks for the
|
||||
currently-open note. Reuses the .track-modal-* inner styles.
|
||||
============================================================= */
|
||||
|
||||
.track-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background, #fff);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -8px 0 24px -12px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.track-sidebar-back,
|
||||
.track-sidebar-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-back:hover,
|
||||
.track-sidebar-close:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-sidebar-subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-sidebar-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.track-sidebar-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.track-sidebar-empty-hint {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row {
|
||||
--track-accent: #64748b;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--track-accent);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.track-sidebar-row[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-sidebar-row[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-sidebar-row[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-sidebar-row[data-active="false"] { opacity: 0.65; }
|
||||
|
||||
.track-sidebar-row:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row-icon {
|
||||
color: var(--track-accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.track-sidebar-row-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-sidebar-row-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.track-sidebar-row-sub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row-instruction {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-sidebar-detail {
|
||||
--track-accent: #64748b;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.track-sidebar-detail[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-sidebar-detail[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-sidebar-detail[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-sidebar-detail[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue