Tiptap integrated

This commit is contained in:
tusharmagar 2026-01-12 00:04:17 +05:30 committed by Ramnique Singh
parent 8834238768
commit 23ae0d8be0
7 changed files with 1234 additions and 16 deletions

View file

@ -25,6 +25,11 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"@x/preload": "workspace:*",
"@x/shared": "workspace:*",
"ai": "^5.0.117",
@ -39,6 +44,7 @@
"streamdown": "^1.6.10",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1",
"use-stick-to-bottom": "^1.1.1",
"zod": "^4.2.1"

View file

@ -1,12 +1,14 @@
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, useRef } from 'react'
import { workspace } from '@x/shared';
import { RunEvent } from '@x/shared/src/runs.js';
import type { ChatStatus, LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Button } from './components/ui/button';
import { MessageSquare } from 'lucide-react';
import { MessageSquare, CheckIcon, LoaderIcon } from 'lucide-react';
import { MarkdownEditor } from './components/markdown-editor';
import { useDebounce } from './hooks/use-debounce';
import { SidebarIcon } from '@/components/sidebar-icon';
import { SidebarContentPanel } from '@/components/sidebar-content';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
@ -224,9 +226,16 @@ function App() {
// File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string>('')
const [editorContent, setEditorContent] = useState<string>('')
const [tree, setTree] = useState<TreeNode[]>([])
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
// Auto-save state
const [isSaving, setIsSaving] = useState(false)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
const debouncedContent = useDebounce(editorContent, 500)
const initialContentRef = useRef<string>('')
// Chat state
const [message, setMessage] = useState<string>('')
const [conversation, setConversation] = useState<ConversationItem[]>([])
@ -268,6 +277,9 @@ function App() {
useEffect(() => {
if (!selectedPath) {
setFileContent('')
setEditorContent('')
initialContentRef.current = ''
setLastSaved(null)
return
}
(async () => {
@ -276,16 +288,48 @@ function App() {
if (stat.kind === 'file') {
const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath })
setFileContent(result.data)
setEditorContent(result.data)
initialContentRef.current = result.data
setLastSaved(null)
} else {
setFileContent('')
setEditorContent('')
initialContentRef.current = ''
}
} catch (err) {
console.error('Failed to load file:', err)
setFileContent('')
setEditorContent('')
initialContentRef.current = ''
}
})()
}, [selectedPath])
// Auto-save when content changes
useEffect(() => {
if (!selectedPath || !selectedPath.endsWith('.md')) return
if (debouncedContent === initialContentRef.current) return
if (!debouncedContent) return
const saveFile = async () => {
setIsSaving(true)
try {
await window.ipc.invoke('workspace:writeFile', {
path: selectedPath,
data: debouncedContent,
opts: { encoding: 'utf8' }
})
initialContentRef.current = debouncedContent
setLastSaved(new Date())
} catch (err) {
console.error('Failed to save file:', err)
} finally {
setIsSaving(false)
}
}
saveFile()
}, [debouncedContent, selectedPath])
// Listen to run events
useEffect(() => {
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
@ -685,24 +729,48 @@ function App() {
{selectedPath ? selectedPath : 'Chat'}
</span>
{selectedPath && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedPath(null)}
className="ml-auto text-foreground"
>
<MessageSquare className="h-4 w-4 mr-2" />
Back to Chat
</Button>
<>
{/* Save status indicator */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{isSaving ? (
<>
<LoaderIcon className="h-3 w-3 animate-spin" />
<span>Saving...</span>
</>
) : lastSaved ? (
<>
<CheckIcon className="h-3 w-3 text-green-500" />
<span>Saved</span>
</>
) : null}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedPath(null)}
className="ml-auto text-foreground"
>
<MessageSquare className="h-4 w-4 mr-2" />
Back to Chat
</Button>
</>
)}
</header>
{selectedPath ? (
<div className="flex-1 overflow-auto p-4">
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
{fileContent || 'Loading...'}
</pre>
</div>
selectedPath.endsWith('.md') ? (
<MarkdownEditor
content={editorContent}
onChange={setEditorContent}
placeholder="Start writing..."
/>
) : (
<div className="flex-1 overflow-auto p-4">
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
{fileContent || 'Loading...'}
</pre>
</div>
)
) : (
<div className="flex min-h-0 flex-1 flex-col">
<Conversation className="relative flex-1 overflow-y-auto">

View file

@ -0,0 +1,181 @@
import type { Editor } from '@tiptap/react'
import { Button } from '@/components/ui/button'
import {
BoldIcon,
ItalicIcon,
StrikethroughIcon,
CodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
MinusIcon,
LinkIcon,
CodeSquareIcon,
} from 'lucide-react'
interface EditorToolbarProps {
editor: Editor | null
}
export function EditorToolbar({ editor }: EditorToolbarProps) {
if (!editor) return null
const setLink = () => {
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
if (url === null) return
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
return (
<div className="editor-toolbar">
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleBold().run()}
data-active={editor.isActive('bold') || undefined}
className="data-[active]:bg-accent"
title="Bold"
>
<BoldIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
data-active={editor.isActive('italic') || undefined}
className="data-[active]:bg-accent"
title="Italic"
>
<ItalicIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleStrike().run()}
data-active={editor.isActive('strike') || undefined}
className="data-[active]:bg-accent"
title="Strikethrough"
>
<StrikethroughIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleCode().run()}
data-active={editor.isActive('code') || undefined}
className="data-[active]:bg-accent"
title="Inline Code"
>
<CodeIcon className="size-4" />
</Button>
<div className="separator" />
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
data-active={editor.isActive('heading', { level: 1 }) || undefined}
className="data-[active]:bg-accent"
title="Heading 1"
>
<Heading1Icon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
data-active={editor.isActive('heading', { level: 2 }) || undefined}
className="data-[active]:bg-accent"
title="Heading 2"
>
<Heading2Icon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
data-active={editor.isActive('heading', { level: 3 }) || undefined}
className="data-[active]:bg-accent"
title="Heading 3"
>
<Heading3Icon className="size-4" />
</Button>
<div className="separator" />
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
data-active={editor.isActive('bulletList') || undefined}
className="data-[active]:bg-accent"
title="Bullet List"
>
<ListIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
data-active={editor.isActive('orderedList') || undefined}
className="data-[active]:bg-accent"
title="Ordered List"
>
<ListOrderedIcon className="size-4" />
</Button>
<div className="separator" />
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
data-active={editor.isActive('blockquote') || undefined}
className="data-[active]:bg-accent"
title="Blockquote"
>
<QuoteIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
data-active={editor.isActive('codeBlock') || undefined}
className="data-[active]:bg-accent"
title="Code Block"
>
<CodeSquareIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
<MinusIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={setLink}
data-active={editor.isActive('link') || undefined}
className="data-[active]:bg-accent"
title="Link"
>
<LinkIcon className="size-4" />
</Button>
</div>
)
}

View file

@ -0,0 +1,81 @@
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useRef } from 'react'
import { EditorToolbar } from './editor-toolbar'
import '@/styles/editor.css'
interface MarkdownEditorProps {
content: string
onChange: (markdown: string) => void
placeholder?: string
}
export function MarkdownEditor({ content, onChange, placeholder = 'Start writing...' }: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
}),
Placeholder.configure({
placeholder,
}),
Markdown.configure({
html: false,
transformCopiedText: true,
transformPastedText: true,
}),
],
content: '',
onUpdate: ({ editor }) => {
if (isInternalUpdate.current) return
const markdown = editor.storage.markdown.getMarkdown()
onChange(markdown)
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none',
},
},
})
// Update editor content when prop changes (e.g., file selection changes)
useEffect(() => {
if (editor && content !== undefined) {
const currentContent = editor.storage.markdown?.getMarkdown() || ''
if (currentContent !== content) {
isInternalUpdate.current = true
editor.commands.setContent(content)
isInternalUpdate.current = false
}
}
}, [editor, content])
// Handle keyboard shortcuts
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
// The parent component handles saving via onChange
}
}, [])
return (
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
<EditorToolbar editor={editor} />
<EditorContent editor={editor} />
</div>
)
}

View file

@ -0,0 +1,23 @@
import { useState, useEffect } from 'react'
/**
* Debounce a value by a specified delay
* @param value The value to debounce
* @param delay The delay in milliseconds
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}

View file

@ -0,0 +1,214 @@
/* Tiptap Editor Styles */
.tiptap-editor {
display: flex;
flex-direction: column;
height: 100%;
}
.tiptap-editor .ProseMirror {
flex: 1;
padding: 1rem;
outline: none;
overflow-y: auto;
}
.tiptap-editor .ProseMirror:focus {
outline: none;
}
/* Placeholder */
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--muted-foreground);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Typography */
.tiptap-editor .ProseMirror {
font-size: 1rem;
line-height: 1.75;
color: var(--foreground);
}
.tiptap-editor .ProseMirror > * + * {
margin-top: 0.75em;
}
/* Headings */
.tiptap-editor .ProseMirror h1 {
font-size: 2em;
font-weight: 700;
line-height: 1.2;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.tiptap-editor .ProseMirror h2 {
font-size: 1.5em;
font-weight: 600;
line-height: 1.3;
margin-top: 1.25em;
margin-bottom: 0.5em;
}
.tiptap-editor .ProseMirror h3 {
font-size: 1.25em;
font-weight: 600;
line-height: 1.4;
margin-top: 1em;
margin-bottom: 0.5em;
}
.tiptap-editor .ProseMirror h1:first-child,
.tiptap-editor .ProseMirror h2:first-child,
.tiptap-editor .ProseMirror h3:first-child {
margin-top: 0;
}
/* Paragraphs */
.tiptap-editor .ProseMirror p {
margin: 0;
}
/* Lists */
.tiptap-editor .ProseMirror ul,
.tiptap-editor .ProseMirror ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.tiptap-editor .ProseMirror ul {
list-style-type: disc;
}
.tiptap-editor .ProseMirror ol {
list-style-type: decimal;
}
.tiptap-editor .ProseMirror li {
margin: 0.25em 0;
}
.tiptap-editor .ProseMirror li p {
margin: 0;
}
/* Blockquote */
.tiptap-editor .ProseMirror blockquote {
border-left: 3px solid var(--border);
padding-left: 1em;
margin-left: 0;
margin-right: 0;
color: var(--muted-foreground);
font-style: italic;
}
/* Code */
.tiptap-editor .ProseMirror code {
background-color: var(--muted);
border-radius: 0.25em;
padding: 0.15em 0.3em;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
}
/* Code Block */
.tiptap-editor .ProseMirror pre {
background-color: var(--muted);
border-radius: 0.5em;
padding: 1em;
overflow-x: auto;
margin: 0.75em 0;
}
.tiptap-editor .ProseMirror pre code {
background: none;
padding: 0;
font-size: 0.875em;
color: inherit;
}
/* Horizontal Rule */
.tiptap-editor .ProseMirror hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.5em 0;
}
/* Links */
.tiptap-editor .ProseMirror a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.tiptap-editor .ProseMirror a:hover {
opacity: 0.8;
}
/* Strong and Emphasis */
.tiptap-editor .ProseMirror strong {
font-weight: 600;
}
.tiptap-editor .ProseMirror em {
font-style: italic;
}
.tiptap-editor .ProseMirror s {
text-decoration: line-through;
}
/* Task Lists */
.tiptap-editor .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5em;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
flex-shrink: 0;
margin-top: 0.25em;
}
/* Selection */
.tiptap-editor .ProseMirror ::selection {
background-color: var(--ring);
color: var(--background);
}
/* Toolbar */
.editor-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
background-color: var(--background);
}
.editor-toolbar .separator {
width: 1px;
height: 1.5rem;
background-color: var(--border);
margin: 0 0.25rem;
align-self: center;
}
/* Content area centering */
.tiptap-editor .ProseMirror {
margin-left: 20%;
margin-right: 20%;
padding-left: 1rem;
padding-right: 1rem;
}