tiptap improvements + removing ask AI button

This commit is contained in:
tusharmagar 2026-01-14 11:49:29 +05:30 committed by Ramnique Singh
parent 73ba7fee99
commit 9c1ddf66ed
10 changed files with 316 additions and 117 deletions

View file

@ -27,6 +27,8 @@
"@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/extension-task-item": "^3.15.3",
"@tiptap/extension-task-list": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",

View file

@ -251,28 +251,6 @@ const collectDirPaths = (nodes: TreeNode[]): string[] =>
const collectFilePaths = (nodes: TreeNode[]): string[] =>
nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : []))
// Sample chat history (will be replaced with real data later)
const chatHistory = [
{
id: 'project-kickoff',
title: 'Project kickoff',
preview: 'Scope, roles, and milestones.',
time: 'Today',
},
{
id: 'design-review',
title: 'Design review',
preview: 'UI polish and sidebar UX.',
time: 'Yesterday',
},
{
id: 'tools-audit',
title: 'Tools audit',
preview: 'MCP inventory and tool gaps.',
time: 'Mon',
},
]
function App() {
// File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
@ -630,9 +608,9 @@ function App() {
setExpandedPaths(newExpanded)
}
// Handle sidebar section changes - switch to chat view for ask-ai and agents
// Handle sidebar section changes - switch to chat view for agents
const handleSectionChange = useCallback((section: ActiveSection) => {
if (section === 'ask-ai' || section === 'agents') {
if (section === 'agents') {
setSelectedPath(null)
setIsGraphOpen(false)
}
@ -971,7 +949,7 @@ function App() {
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="ask-ai" onSectionChange={handleSectionChange}>
<SidebarSectionProvider defaultSection="knowledge" onSectionChange={handleSectionChange}>
<div className="flex h-svh w-full">
{/* Icon sidebar - always visible, fixed position */}
<SidebarIcon />
@ -992,7 +970,6 @@ function App() {
expandedPaths={expandedPaths}
onSelectFile={toggleExpand}
knowledgeActions={knowledgeActions}
chats={chatHistory}
/>
<SidebarInset className="!overflow-hidden min-h-0">
{/* Header with sidebar trigger */}

View file

@ -1,5 +1,12 @@
import { useState, useCallback } from 'react'
import type { Editor } from '@tiptap/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
BoldIcon,
ItalicIcon,
@ -10,42 +17,104 @@ import {
Heading3Icon,
ListIcon,
ListOrderedIcon,
ListTodoIcon,
QuoteIcon,
MinusIcon,
LinkIcon,
CodeSquareIcon,
Undo2Icon,
Redo2Icon,
ExternalLinkIcon,
Trash2Icon,
} from 'lucide-react'
interface EditorToolbarProps {
editor: Editor | null
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
}
export function EditorToolbar({ editor }: EditorToolbarProps) {
if (!editor) return null
export function EditorToolbar({ editor, onSelectionHighlight }: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
const setLink = () => {
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
const openLinkPopover = useCallback(() => {
if (!editor) return
const previousUrl = editor.getAttributes('link').href || ''
setLinkUrl(previousUrl)
if (url === null) return
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
// Highlight the current selection while popover is open
const { from, to } = editor.state.selection
if (from !== to && onSelectionHighlight) {
onSelectionHighlight({ from, to })
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
setIsLinkPopoverOpen(true)
}, [editor, onSelectionHighlight])
const closeLinkPopover = useCallback(() => {
setIsLinkPopoverOpen(false)
setLinkUrl('')
onSelectionHighlight?.(null)
}, [onSelectionHighlight])
const applyLink = useCallback(() => {
if (!editor) return
if (linkUrl === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
} else {
// Ensure URL has protocol
let url = linkUrl.trim()
if (url && !url.match(/^https?:\/\//i) && !url.startsWith('mailto:')) {
url = 'https://' + url
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
closeLinkPopover()
}, [editor, linkUrl, closeLinkPopover])
const removeLink = useCallback(() => {
if (!editor) return
editor.chain().focus().extendMarkRange('link').unsetLink().run()
closeLinkPopover()
}, [editor, closeLinkPopover])
if (!editor) return null
const isLinkActive = editor.isActive('link')
return (
<div className="editor-toolbar">
{/* Undo / Redo */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<Undo2Icon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Shift+Z)"
>
<Redo2Icon className="size-4" />
</Button>
<div className="separator" />
{/* Text formatting */}
<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"
title="Bold (Ctrl+B)"
>
<BoldIcon className="size-4" />
</Button>
@ -55,7 +124,7 @@ export function EditorToolbar({ editor }: EditorToolbarProps) {
onClick={() => editor.chain().focus().toggleItalic().run()}
data-active={editor.isActive('italic') || undefined}
className="data-[active]:bg-accent"
title="Italic"
title="Italic (Ctrl+I)"
>
<ItalicIcon className="size-4" />
</Button>
@ -82,6 +151,7 @@ export function EditorToolbar({ editor }: EditorToolbarProps) {
<div className="separator" />
{/* Headings */}
<Button
variant="ghost"
size="icon-sm"
@ -115,6 +185,7 @@ export function EditorToolbar({ editor }: EditorToolbarProps) {
<div className="separator" />
{/* Lists */}
<Button
variant="ghost"
size="icon-sm"
@ -135,9 +206,20 @@ export function EditorToolbar({ editor }: EditorToolbarProps) {
>
<ListOrderedIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleTaskList().run()}
data-active={editor.isActive('taskList') || undefined}
className="data-[active]:bg-accent"
title="Task List"
>
<ListTodoIcon className="size-4" />
</Button>
<div className="separator" />
{/* Blocks */}
<Button
variant="ghost"
size="icon-sm"
@ -166,16 +248,78 @@ export function EditorToolbar({ editor }: EditorToolbarProps) {
>
<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"
{/* Link with popover */}
<Popover
open={isLinkPopoverOpen}
onOpenChange={(open) => {
if (!open) {
closeLinkPopover()
}
}}
>
<LinkIcon className="size-4" />
</Button>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={openLinkPopover}
data-active={isLinkActive || undefined}
className="data-[active]:bg-accent"
title="Link"
>
<LinkIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="start">
<div className="flex flex-col gap-3">
<div className="text-sm font-medium">
{isLinkActive ? 'Edit Link' : 'Add Link'}
</div>
<Input
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyLink()
}
if (e.key === 'Escape') {
setIsLinkPopoverOpen(false)
}
}}
autoFocus
/>
<div className="flex items-center gap-2">
<Button size="sm" onClick={applyLink} className="flex-1">
{isLinkActive ? 'Update' : 'Apply'}
</Button>
{isLinkActive && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
window.open(linkUrl, '_blank')
}}
title="Open link"
>
<ExternalLinkIcon className="size-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={removeLink}
title="Remove link"
>
<Trash2Icon className="size-4" />
</Button>
</>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
)
}

View file

@ -1,7 +1,11 @@
import { useEditor, EditorContent } from '@tiptap/react'
import { useEditor, EditorContent, Extension } from '@tiptap/react'
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 Placeholder from '@tiptap/extension-placeholder'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { EditorToolbar } from './editor-toolbar'
@ -30,6 +34,41 @@ type WikiLinkMatch = {
query: string
}
type SelectionHighlightRange = { from: number; to: number } | null
// Plugin key for the selection highlight
const selectionHighlightKey = new PluginKey('selectionHighlight')
// Create the selection highlight extension
const createSelectionHighlightExtension = (getRange: () => SelectionHighlightRange) => {
return Extension.create({
name: 'selectionHighlight',
addProseMirrorPlugins() {
return [
new Plugin({
key: selectionHighlightKey,
props: {
decorations(state) {
const range = getRange()
if (!range) return DecorationSet.empty
const { from, to } = range
if (from >= to || from < 0 || to > state.doc.content.size) {
return DecorationSet.empty
}
const decoration = Decoration.inline(from, to, {
class: 'selection-highlight',
})
return DecorationSet.create(state.doc, [decoration])
},
},
}),
]
},
})
}
export function MarkdownEditor({
content,
onChange,
@ -40,6 +79,17 @@ export function MarkdownEditor({
const wrapperRef = useRef<HTMLDivElement>(null)
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
// Keep ref in sync with state for the plugin to access
selectionHighlightRef.current = selectionHighlight
// Memoize the selection highlight extension
const selectionHighlightExtension = useMemo(
() => createSelectionHighlightExtension(() => selectionHighlightRef.current),
[]
)
const editor = useEditor({
extensions: [
@ -62,6 +112,10 @@ export function MarkdownEditor({
}
: undefined,
}),
TaskList,
TaskItem.configure({
nested: true,
}),
Placeholder.configure({
placeholder,
}),
@ -71,6 +125,7 @@ export function MarkdownEditor({
transformCopiedText: true,
transformPastedText: true,
}),
selectionHighlightExtension,
],
content: '',
onUpdate: ({ editor }) => {
@ -184,6 +239,14 @@ export function MarkdownEditor({
}
}, [editor, content])
// Force re-render decorations when selection highlight changes
useEffect(() => {
if (editor) {
// Trigger a transaction to force decoration re-render
editor.view.dispatch(editor.state.tr)
}
}, [editor, selectionHighlight])
const normalizedQuery = normalizeWikiPath(activeWikiLink?.query ?? '').toLowerCase()
const filteredFiles = useMemo(() => {
if (!activeWikiLink) return []
@ -237,7 +300,7 @@ export function MarkdownEditor({
return (
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
<EditorToolbar editor={editor} />
<EditorToolbar editor={editor} onSelectionHighlight={setSelectionHighlight} />
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} />
{wikiLinks ? (

View file

@ -15,8 +15,6 @@ import {
FolderPlus,
Loader2,
Mail,
MessageSquare,
MessageSquarePlus,
Microscope,
Network,
Pencil,
@ -88,11 +86,9 @@ type SidebarContentPanelProps = {
expandedPaths: Set<string>
onSelectFile: (path: string, kind: "file" | "dir") => void
knowledgeActions: KnowledgeActions
chats: { id: string; title: string; preview: string; time: string }[]
} & React.ComponentProps<typeof Sidebar>
const sectionTitles = {
"ask-ai": "Ask AI",
knowledge: "Knowledge",
agents: "Agents",
}
@ -166,7 +162,6 @@ export function SidebarContentPanel({
expandedPaths,
onSelectFile,
knowledgeActions,
chats,
...props
}: SidebarContentPanelProps) {
const { activeSection } = useSidebarSection()
@ -179,9 +174,6 @@ export function SidebarContentPanel({
</div>
</SidebarHeader>
<SidebarContent>
{activeSection === "ask-ai" && (
<ChatSection chats={chats} />
)}
{activeSection === "knowledge" && (
<KnowledgeSection
tree={tree}
@ -200,43 +192,6 @@ export function SidebarContentPanel({
)
}
// Chat Section
function ChatSection({ chats }: { chats: { id: string; title: string; preview: string; time: string }[] }) {
return (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center justify-between">
<span>Recent Chats</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1 transition-colors">
<MessageSquarePlus className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">New Chat</TooltipContent>
</Tooltip>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{chats.map((chat) => (
<SidebarMenuItem key={chat.id}>
<SidebarMenuButton className="h-auto items-start gap-2 py-2">
<MessageSquare className="mt-0.5 size-4" />
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{chat.title}</span>
<span className="text-xs text-muted-foreground">{chat.time}</span>
</div>
<span className="truncate text-xs text-muted-foreground">{chat.preview}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
// Knowledge Section
function KnowledgeSection({
tree,

View file

@ -7,7 +7,6 @@ import {
HelpCircle,
Settings,
Ship,
Sparkles,
Trash2,
} from "lucide-react"
@ -33,7 +32,6 @@ type SecondaryItem = {
}
const navItems: NavItem[] = [
{ id: "ask-ai", title: "Ask AI", icon: Sparkles },
{ id: "knowledge", title: "Knowledge", icon: Brain },
{ id: "agents", title: "Agents", icon: Bot },
]

View file

@ -30,7 +30,6 @@ const SIDEBAR_WIDTH_MIN = 200
const SIDEBAR_WIDTH_MAX = 480
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SIDEBAR_OFFSET = "0px" // Default offset for nested sidebars
type SidebarContextProps = {
@ -100,22 +99,6 @@ function SidebarProvider({
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"

View file

@ -2,7 +2,7 @@
import * as React from "react"
export type ActiveSection = "ask-ai" | "knowledge" | "agents"
export type ActiveSection = "knowledge" | "agents"
type SidebarSectionContextProps = {
activeSection: ActiveSection
@ -20,7 +20,7 @@ export function useSidebarSection() {
}
export function SidebarSectionProvider({
defaultSection = "ask-ai",
defaultSection = "knowledge",
onSectionChange,
children,
}: {

View file

@ -175,17 +175,41 @@
.tiptap-editor .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
margin: 0.5em 0;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5em;
margin: 0.25em 0;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
flex-shrink: 0;
margin-top: 0.25em;
cursor: pointer;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label input[type="checkbox"] {
width: 1em;
height: 1em;
cursor: pointer;
accent-color: var(--primary);
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > div {
flex: 1;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
opacity: 0.6;
}
/* Nested task lists */
.tiptap-editor .ProseMirror ul[data-type="taskList"] ul[data-type="taskList"] {
margin-left: 1.5em;
}
/* Selection */
@ -228,3 +252,32 @@
width: 0;
pointer-events: none;
}
/* Wiki Links */
.tiptap-editor .ProseMirror .wiki-link,
.tiptap-editor .ProseMirror a[data-type="wiki-link"] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
padding: 0.1em 0.3em;
border-radius: 0.25em;
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.tiptap-editor .ProseMirror .wiki-link:hover,
.tiptap-editor .ProseMirror a[data-type="wiki-link"]:hover {
background-color: color-mix(in srgb, var(--primary) 20%, transparent);
}
/* Disabled button state */
.editor-toolbar button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Selection highlight for when editor loses focus (e.g., link popover open) */
.tiptap-editor .ProseMirror .selection-highlight {
background-color: color-mix(in srgb, var(--primary) 25%, transparent);
border-radius: 2px;
}

24
apps/x/pnpm-lock.yaml generated
View file

@ -130,6 +130,12 @@ importers:
'@tiptap/extension-placeholder':
specifier: ^3.15.3
version: 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/extension-task-item':
specifier: ^3.15.3
version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/extension-task-list':
specifier: ^3.15.3
version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/pm':
specifier: ^3.15.3
version: 3.15.3
@ -1775,6 +1781,16 @@ packages:
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/extension-task-item@3.15.3':
resolution: {integrity: sha512-bkrmouc1rE5n9ONw2G7+zCGfBRoF2HJWq8REThPMzg/6+L5GJJ5YTN4UmncaP48U9jHX8xeihjgg9Ypenjl4lw==}
peerDependencies:
'@tiptap/extension-list': ^3.15.3
'@tiptap/extension-task-list@3.15.3':
resolution: {integrity: sha512-nh8iBk1LHVIoqxphLoqZlLAN9fF2i9ZeK+2TjGSS35lfh7sYzRoSjNW0E81Uy48YuCzM1NQYghYR5Qfc7vm4jA==}
peerDependencies:
'@tiptap/extension-list': ^3.15.3
'@tiptap/extension-text@3.15.3':
resolution: {integrity: sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA==}
peerDependencies:
@ -5929,6 +5945,14 @@ snapshots:
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/extension-task-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
dependencies:
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-task-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
dependencies:
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-text@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)