Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/x/apps/main/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
152 changes: 116 additions & 36 deletions apps/x/apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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[]>([])
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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">
Expand Down
10 changes: 10 additions & 0 deletions apps/x/apps/renderer/src/components/markdown-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -282,6 +283,7 @@ export function MarkdownEditor({
onImageUpload,
editorSessionKey = 0,
onHistoryHandlersChange,
editable = true,
}: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
Expand All @@ -303,6 +305,7 @@ export function MarkdownEditor({
)

const editor = useEditor({
editable,
extensions: [
StarterKit.configure({
heading: {
Expand Down Expand Up @@ -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) {
Expand Down
Loading