diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index f7efd64d..f7ebb083 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@tailwindcss/vite": "^4.1.18", + "@tiptap/extension-image": "^3.16.0", "@tiptap/extension-link": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3", "@tiptap/extension-task-item": "^3.15.3", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fe0089e7..a5c17fe1 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; @@ -399,6 +399,9 @@ function App() { // Track permission responses (toolCallId -> response) const [permissionResponses, setPermissionResponses] = useState>(new Map()) + // Workspace root for full paths + const [workspaceRoot, setWorkspaceRoot] = useState('') + // Load directory tree const loadDirectory = useCallback(async () => { try { @@ -1033,6 +1036,45 @@ function App() { setIsGraphOpen(false) }, []) + // Handle image upload for the markdown editor + const handleImageUpload = useCallback(async (file: File): Promise => { + try { + // Read file as data URL (includes mime type) + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(file) + }) + + // Also save to .assets folder for persistence + const timestamp = Date.now() + const extension = file.name.split('.').pop() || 'png' + const filename = `image-${timestamp}.${extension}` + const assetsPath = 'knowledge/.assets' + const imagePath = `${assetsPath}/${filename}` + + try { + // Extract base64 data (remove data URL prefix) + const base64Data = dataUrl.split(',')[1] + await window.ipc.invoke('workspace:writeFile', { + path: imagePath, + data: base64Data, + opts: { encoding: 'base64', mkdirp: true } + }) + } catch (err) { + console.error('Failed to save image to disk:', err) + // Continue anyway - image will still display via data URL + } + + // Return data URL for immediate display in editor + return dataUrl + } catch (error) { + console.error('Failed to upload image:', error) + return null + } + }, []) + // Keyboard shortcut: Ctrl+L to open main chat view useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1106,8 +1148,7 @@ function App() { return visible }, [knowledgeFiles, expandedPaths]) - // Get workspace root for full paths - const [workspaceRoot, setWorkspaceRoot] = useState('') + // Load workspace root on mount useEffect(() => { window.ipc.invoke('workspace:getRoot', null).then(result => { setWorkspaceRoot(result.root) @@ -1461,6 +1502,22 @@ function App() { ) : null} )} + {!isGraphOpen && ( + + )} {!selectedPath && isGraphOpen && ( + + )} ) } diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index e34e7c6d..1bda92a9 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -3,9 +3,11 @@ import { Plugin, PluginKey } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' +import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' +import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { EditorToolbar } from './editor-toolbar' @@ -27,6 +29,7 @@ interface MarkdownEditorProps { onChange: (markdown: string) => void placeholder?: string wikiLinks?: WikiLinkConfig + onImageUpload?: (file: File) => Promise } type WikiLinkMatch = { @@ -74,6 +77,7 @@ export function MarkdownEditor({ onChange, placeholder = 'Start writing...', wikiLinks, + onImageUpload, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -105,6 +109,14 @@ export function MarkdownEditor({ target: '_blank', }, }), + Image.configure({ + inline: false, + allowBase64: true, + HTMLAttributes: { + class: 'editor-image', + }, + }), + ImageUploadPlaceholderExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -298,9 +310,15 @@ export function MarkdownEditor({ const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition) + // Create image upload handler that shows placeholder + const handleImageUploadWithPlaceholder = useMemo(() => { + if (!editor || !onImageUpload) return undefined + return createImageUploadHandler(editor, onImageUpload) + }, [editor, onImageUpload]) + return (
- +
{wikiLinks ? ( diff --git a/apps/x/apps/renderer/src/extensions/image-upload.tsx b/apps/x/apps/renderer/src/extensions/image-upload.tsx new file mode 100644 index 00000000..4ba1644d --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/image-upload.tsx @@ -0,0 +1,168 @@ +import { mergeAttributes } from '@tiptap/react' +import { Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import type { Editor } from '@tiptap/react' +import { Loader2, ImageIcon } from 'lucide-react' + +// Component for the upload placeholder +function ImageUploadPlaceholder({ node }: { node: { attrs: { progress?: number } } }) { + const progress = node.attrs.progress || 0 + + return ( + +
+ {progress < 100 ? ( + <> + + + Uploading image... + + {progress > 0 && ( +
+
+
+ )} + + ) : ( + <> + + + Processing... + + + )} +
+ + ) +} + +// Extension for the upload placeholder node +export const ImageUploadPlaceholderExtension = Node.create({ + name: 'imageUploadPlaceholder', + group: 'block', + atom: true, + draggable: false, + selectable: true, + + addAttributes() { + return { + id: { + default: null, + }, + progress: { + default: 0, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="image-upload-placeholder"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-upload-placeholder' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageUploadPlaceholder) + }, +}) + +// Helper to insert placeholder and handle upload +export function createImageUploadHandler( + editor: Editor | null, + uploadFn: (file: File) => Promise +) { + return async (file: File) => { + if (!editor) return + + // Generate unique ID for this upload + const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2)}` + + // Insert placeholder at current position + editor + .chain() + .focus() + .insertContent({ + type: 'imageUploadPlaceholder', + attrs: { id: uploadId, progress: 0 }, + }) + .run() + + try { + // Perform the upload + const imageUrl = await uploadFn(file) + + if (imageUrl) { + // Find and replace the placeholder with the actual image + const { state } = editor + let placeholderPos: number | null = null + + state.doc.descendants((node, pos) => { + if ( + node.type.name === 'imageUploadPlaceholder' && + node.attrs.id === uploadId + ) { + placeholderPos = pos + return false + } + return true + }) + + if (placeholderPos !== null) { + editor + .chain() + .focus() + .deleteRange({ from: placeholderPos, to: placeholderPos + 1 }) + .insertContentAt(placeholderPos, { + type: 'image', + attrs: { src: imageUrl }, + }) + .run() + } + } else { + // Upload failed - remove placeholder + removePlaceholder(editor, uploadId) + } + } catch (error) { + console.error('Image upload failed:', error) + removePlaceholder(editor, uploadId) + } + } +} + +function removePlaceholder( + editor: Editor | null, + uploadId: string +) { + if (!editor) return + + const { state } = editor + let placeholderPos: number | null = null + + state.doc.descendants((node, pos) => { + if ( + node.type.name === 'imageUploadPlaceholder' && + node.attrs.id === uploadId + ) { + placeholderPos = pos + return false + } + return true + }) + + if (placeholderPos !== null) { + editor + .chain() + .focus() + .deleteRange({ from: placeholderPos, to: placeholderPos + 1 }) + .run() + } +} diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index df639bc3..d94f0ffa 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -281,3 +281,49 @@ background-color: color-mix(in srgb, var(--primary) 25%, transparent); border-radius: 2px; } + +/* Images */ +.tiptap-editor .ProseMirror img, +.tiptap-editor .ProseMirror .editor-image { + max-width: 100%; + height: auto; + border-radius: 0.5em; + margin: 0.75em 0; + display: block; +} + +.tiptap-editor .ProseMirror img.ProseMirror-selectednode { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* Image upload placeholder */ +.tiptap-editor .ProseMirror .image-upload-placeholder { + margin: 0.75em 0; +} + +.tiptap-editor .ProseMirror .image-upload-placeholder > div { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + border: 2px dashed var(--border); + border-radius: 0.5rem; + background-color: color-mix(in srgb, var(--muted) 30%, transparent); +} + +.tiptap-editor .ProseMirror .image-upload-placeholder .progress-bar { + width: 8rem; + height: 0.375rem; + background-color: var(--muted); + border-radius: 9999px; + overflow: hidden; +} + +.tiptap-editor .ProseMirror .image-upload-placeholder .progress-bar > div { + height: 100%; + background-color: var(--primary); + transition: width 0.3s ease; +} diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 108f1bb7..76d41173 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@tiptap/extension-image': + specifier: ^3.16.0 + version: 3.16.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) '@tiptap/extension-link': specifier: ^3.15.3 version: 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) @@ -2473,6 +2476,11 @@ packages: '@tiptap/core': ^3.15.3 '@tiptap/pm': ^3.15.3 + '@tiptap/extension-image@3.16.0': + resolution: {integrity: sha512-mTjt4kdyVtY/2dJcfxAgBae/dkH+r6GwARl7NlPtnI3EzpELFR65FNuOQyTxFXP3yfV9uMtPpq6Wevk8aLTsxQ==} + peerDependencies: + '@tiptap/core': ^3.16.0 + '@tiptap/extension-italic@3.15.3': resolution: {integrity: sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==} peerDependencies: @@ -9221,6 +9229,10 @@ snapshots: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) '@tiptap/pm': 3.15.3 + '@tiptap/extension-image@3.16.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + dependencies: + '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/extension-italic@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': dependencies: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)