mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-19 18:35:18 +02:00
parent
9df1bb6765
commit
d7dc27a77e
12 changed files with 860 additions and 40 deletions
|
|
@ -31,6 +31,7 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
|||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
import { search } from '@x/core/dist/search/search.js';
|
||||
import { versionHistory } from '@x/core';
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
|
@ -105,6 +106,18 @@ let watcher: FSWatcher | null = null;
|
|||
const changeQueue = new Set<string>();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Emit knowledge commit event to all renderer windows
|
||||
*/
|
||||
function emitKnowledgeCommitEvent(): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('knowledge:didCommit', {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit workspace change event to all renderer windows
|
||||
*/
|
||||
|
|
@ -283,6 +296,9 @@ export function stopServicesWatcher(): void {
|
|||
* Add new handlers here as you add channels to IPCChannels
|
||||
*/
|
||||
export function setupIpcHandlers() {
|
||||
// Forward knowledge commit events to renderer for panel refresh
|
||||
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||
|
||||
registerIpcHandlers({
|
||||
'app:getVersions': async () => {
|
||||
// args is null for this channel (no request payload)
|
||||
|
|
@ -498,6 +514,19 @@ export function setupIpcHandlers() {
|
|||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||
},
|
||||
// Knowledge version history handlers
|
||||
'knowledge:history': async (_event, args) => {
|
||||
const commits = await versionHistory.getFileHistory(args.path);
|
||||
return { commits };
|
||||
},
|
||||
'knowledge:fileAtCommit': async (_event, args) => {
|
||||
const content = await versionHistory.getFileAtCommit(args.path, args.oid);
|
||||
return { content };
|
||||
},
|
||||
'knowledge:restore': async (_event, args) => {
|
||||
await versionHistory.restoreFile(args.path, args.oid);
|
||||
return { ok: true };
|
||||
},
|
||||
// Search handler
|
||||
'search:query': async (_event, args) => {
|
||||
return search(args.query, args.limit, args.types);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
|||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
|
|
@ -49,6 +49,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
|
|||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
|
||||
|
|
@ -506,6 +507,13 @@ function App() {
|
|||
const initialContentRef = useRef<string>('')
|
||||
const renameInProgressRef = useRef(false)
|
||||
|
||||
// Version history state
|
||||
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
|
||||
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
|
||||
oid: string
|
||||
content: string
|
||||
} | null>(null)
|
||||
|
||||
// Chat state
|
||||
const [, setMessage] = useState<string>('')
|
||||
const [conversation, setConversation] = useState<ConversationItem[]>([])
|
||||
|
|
@ -1072,6 +1080,14 @@ function App() {
|
|||
saveFile()
|
||||
}, [debouncedContent, setHistory])
|
||||
|
||||
// Close version history panel when switching files
|
||||
useEffect(() => {
|
||||
if (versionHistoryPath && selectedPath !== versionHistoryPath) {
|
||||
setVersionHistoryPath(null)
|
||||
setViewingHistoricalVersion(null)
|
||||
}
|
||||
}, [selectedPath, versionHistoryPath])
|
||||
|
||||
// Load runs list (all pages)
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -3213,6 +3229,31 @@ function App() {
|
|||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (versionHistoryPath) {
|
||||
setVersionHistoryPath(null)
|
||||
setViewingHistoricalVersion(null)
|
||||
} else {
|
||||
setVersionHistoryPath(selectedPath)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0",
|
||||
versionHistoryPath && "bg-accent text-foreground"
|
||||
)}
|
||||
aria-label="Version history"
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !selectedTask && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -3276,41 +3317,80 @@ function App() {
|
|||
</div>
|
||||
) : selectedPath ? (
|
||||
selectedPath.endsWith('.md') ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{openMarkdownTabs.map((tab) => {
|
||||
const isActive = activeFileTabId
|
||||
? tab.id === activeFileTabId || tab.path === selectedPath
|
||||
: tab.path === selectedPath
|
||||
const tabContent = editorContentByPath[tab.path]
|
||||
?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'min-h-0 flex-1 flex-col overflow-hidden',
|
||||
isActive ? 'flex' : 'hidden'
|
||||
)}
|
||||
data-file-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<MarkdownEditor
|
||||
content={tabContent}
|
||||
onChange={(markdown) => handleEditorChange(tab.path, markdown)}
|
||||
placeholder="Start writing..."
|
||||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
||||
onHistoryHandlersChange={(handlers) => {
|
||||
if (handlers) {
|
||||
fileHistoryHandlersRef.current.set(tab.id, handlers)
|
||||
} else {
|
||||
fileHistoryHandlersRef.current.delete(tab.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{openMarkdownTabs.map((tab) => {
|
||||
const isActive = activeFileTabId
|
||||
? tab.id === activeFileTabId || tab.path === selectedPath
|
||||
: tab.path === selectedPath
|
||||
const isViewingHistory = viewingHistoricalVersion && isActive && versionHistoryPath === tab.path
|
||||
const tabContent = isViewingHistory
|
||||
? viewingHistoricalVersion.content
|
||||
: editorContentByPath[tab.path]
|
||||
?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'min-h-0 flex-1 flex-col overflow-hidden',
|
||||
isActive ? 'flex' : 'hidden'
|
||||
)}
|
||||
data-file-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<MarkdownEditor
|
||||
content={tabContent}
|
||||
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
|
||||
placeholder="Start writing..."
|
||||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
||||
onHistoryHandlersChange={(handlers) => {
|
||||
if (handlers) {
|
||||
fileHistoryHandlersRef.current.set(tab.id, handlers)
|
||||
} else {
|
||||
fileHistoryHandlersRef.current.delete(tab.id)
|
||||
}
|
||||
}}
|
||||
editable={!isViewingHistory}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{versionHistoryPath && (
|
||||
<VersionHistoryPanel
|
||||
path={versionHistoryPath}
|
||||
onClose={() => {
|
||||
setVersionHistoryPath(null)
|
||||
setViewingHistoricalVersion(null)
|
||||
}}
|
||||
onSelectVersion={(oid, content) => {
|
||||
if (oid === null) {
|
||||
setViewingHistoricalVersion(null)
|
||||
} else {
|
||||
setViewingHistoricalVersion({ oid, content })
|
||||
}
|
||||
}}
|
||||
onRestore={async (oid) => {
|
||||
try {
|
||||
await window.ipc.invoke('knowledge:restore', {
|
||||
path: versionHistoryPath.startsWith('knowledge/')
|
||||
? versionHistoryPath.slice('knowledge/'.length)
|
||||
: versionHistoryPath,
|
||||
oid,
|
||||
})
|
||||
// Reload file content
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: versionHistoryPath })
|
||||
handleEditorChange(versionHistoryPath, result.data)
|
||||
setViewingHistoricalVersion(null)
|
||||
setVersionHistoryPath(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to restore version:', err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ interface MarkdownEditorProps {
|
|||
onImageUpload?: (file: File) => Promise<string | null>
|
||||
editorSessionKey?: number
|
||||
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -282,6 +283,7 @@ export function MarkdownEditor({
|
|||
onImageUpload,
|
||||
editorSessionKey = 0,
|
||||
onHistoryHandlersChange,
|
||||
editable = true,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -303,6 +305,7 @@ export function MarkdownEditor({
|
|||
)
|
||||
|
||||
const editor = useEditor({
|
||||
editable,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
|
|
@ -517,6 +520,13 @@ export function MarkdownEditor({
|
|||
}
|
||||
}, [editor, onHistoryHandlersChange])
|
||||
|
||||
// Update editable state when prop changes
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(editable)
|
||||
}
|
||||
}, [editor, editable])
|
||||
|
||||
// Force re-render decorations when selection highlight changes
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
|
|
|
|||
177
apps/x/apps/renderer/src/components/version-history-panel.tsx
Normal file
177
apps/x/apps/renderer/src/components/version-history-panel.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { X, Clock } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface CommitInfo {
|
||||
oid: string
|
||||
message: string
|
||||
timestamp: number
|
||||
author: string
|
||||
}
|
||||
|
||||
interface VersionHistoryPanelProps {
|
||||
path: string // knowledge-relative file path (e.g. "knowledge/People/John.md")
|
||||
onClose: () => void
|
||||
onSelectVersion: (oid: string | null, content: string) => void // null = current
|
||||
onRestore: (oid: string) => void
|
||||
}
|
||||
|
||||
function formatTimestamp(unixSeconds: number): { date: string; time: string } {
|
||||
const d = new Date(unixSeconds * 1000)
|
||||
const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
|
||||
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
|
||||
return { date, time }
|
||||
}
|
||||
|
||||
export function VersionHistoryPanel({
|
||||
path,
|
||||
onClose,
|
||||
onSelectVersion,
|
||||
onRestore,
|
||||
}: VersionHistoryPanelProps) {
|
||||
const [commits, setCommits] = useState<CommitInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedOid, setSelectedOid] = useState<string | null>(null) // null = current/latest
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Strip "knowledge/" prefix for IPC calls
|
||||
const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('knowledge:history', { path: relPath })
|
||||
setCommits(result.commits)
|
||||
} catch (err) {
|
||||
console.error('Failed to load version history:', err)
|
||||
setError('Failed to load history')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [relPath])
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [loadHistory])
|
||||
|
||||
// Refresh when new commits land
|
||||
useEffect(() => {
|
||||
return window.ipc.on('knowledge:didCommit', () => {
|
||||
loadHistory()
|
||||
})
|
||||
}, [loadHistory])
|
||||
|
||||
const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => {
|
||||
if (isLatest) {
|
||||
setSelectedOid(null)
|
||||
// Read current file content
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
onSelectVersion(null, result.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to read current file:', err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedOid(oid)
|
||||
try {
|
||||
const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid })
|
||||
onSelectVersion(oid, result.content)
|
||||
} catch (err) {
|
||||
console.error('Failed to load file at commit:', err)
|
||||
}
|
||||
}, [path, relPath, onSelectVersion])
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (selectedOid) {
|
||||
onRestore(selectedOid)
|
||||
}
|
||||
}, [selectedOid, onRestore])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-[280px] shrink-0 border-l border-border bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="text-sm font-medium text-foreground">Version history</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
aria-label="Close version history"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Commit list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
) : commits.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
No history available
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{commits.map((commit, index) => {
|
||||
const isLatest = index === 0
|
||||
const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid
|
||||
const { date, time } = formatTimestamp(commit.timestamp)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={commit.oid}
|
||||
type="button"
|
||||
onClick={() => handleSelectCommit(commit.oid, isLatest)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 transition-colors',
|
||||
isSelected
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isLatest && (
|
||||
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-foreground">
|
||||
{date} · {time}
|
||||
</span>
|
||||
</div>
|
||||
{isLatest && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Current version
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{selectedOid && (
|
||||
<div className="shrink-0 border-t border-border p-3">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleRestore}
|
||||
>
|
||||
Restore this version
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue