diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index c52f1019..5d882f88 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -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", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e780bd72..b25436a9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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(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 ( - +
{/* Icon sidebar - always visible, fixed position */} @@ -992,7 +970,6 @@ function App() { expandedPaths={expandedPaths} onSelectFile={toggleExpand} knowledgeActions={knowledgeActions} - chats={chatHistory} /> {/* Header with sidebar trigger */} diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index e49acc2e..eaeee811 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -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 (
+ {/* Undo / Redo */} + + + +
+ + {/* Text formatting */} @@ -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)" > @@ -82,6 +151,7 @@ export function EditorToolbar({ editor }: EditorToolbarProps) {
+ {/* Headings */} +
+ {/* Blocks */} - + + + + +
+
+ {isLinkActive ? 'Edit Link' : 'Add Link'} +
+ setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + applyLink() + } + if (e.key === 'Escape') { + setIsLinkPopoverOpen(false) + } + }} + autoFocus + /> +
+ + {isLinkActive && ( + <> + + + + )} +
+
+
+
) } diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 716f432d..e34e7c6d 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -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(null) const [activeWikiLink, setActiveWikiLink] = useState(null) const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null) + const [selectionHighlight, setSelectionHighlight] = useState(null) + const selectionHighlightRef = useRef(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 (
- +
{wikiLinks ? ( diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index bc03888e..21c99710 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -15,8 +15,6 @@ import { FolderPlus, Loader2, Mail, - MessageSquare, - MessageSquarePlus, Microscope, Network, Pencil, @@ -88,11 +86,9 @@ type SidebarContentPanelProps = { expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void knowledgeActions: KnowledgeActions - chats: { id: string; title: string; preview: string; time: string }[] } & React.ComponentProps 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({
- {activeSection === "ask-ai" && ( - - )} {activeSection === "knowledge" && ( - - Recent Chats - - - - - New Chat - - - - - {chats.map((chat) => ( - - - -
-
- {chat.title} - {chat.time} -
- {chat.preview} -
-
-
- ))} -
-
- - ) -} - // Knowledge Section function KnowledgeSection({ tree, diff --git a/apps/x/apps/renderer/src/components/sidebar-icon.tsx b/apps/x/apps/renderer/src/components/sidebar-icon.tsx index 99ba7eb2..36ab6111 100644 --- a/apps/x/apps/renderer/src/components/sidebar-icon.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-icon.tsx @@ -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 }, ] diff --git a/apps/x/apps/renderer/src/components/ui/sidebar.tsx b/apps/x/apps/renderer/src/components/ui/sidebar.tsx index e1db86d6..921a3ebb 100644 --- a/apps/x/apps/renderer/src/components/ui/sidebar.tsx +++ b/apps/x/apps/renderer/src/components/ui/sidebar.tsx @@ -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" diff --git a/apps/x/apps/renderer/src/contexts/sidebar-context.tsx b/apps/x/apps/renderer/src/contexts/sidebar-context.tsx index 066fadba..8aa528e2 100644 --- a/apps/x/apps/renderer/src/contexts/sidebar-context.tsx +++ b/apps/x/apps/renderer/src/contexts/sidebar-context.tsx @@ -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, }: { diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 39df500a..df639bc3 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -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; +} diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 82d1ffc1..e4d1ef11 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -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)