mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
add cmd+k palette with chat mode that captures editor cursor context
Cmd+K (Ctrl+K on Win/Linux) now opens a unified palette with two modes: Chat (default) and Search (existing behavior). Tab cycles between them. In Chat mode, if the user triggered the shortcut from the markdown editor, the palette auto-attaches a removable chip showing the note path and precise cursor line. Enter sends the prompt to the right-sidebar copilot — opening the sidebar if closed and starting a fresh chat tab — with the chip carried as a FileMention whose lineNumber is forwarded to the agent as "... at <path> (line N)" so the agent can use workspace-readFile with offset to fetch the right slice on demand. Line numbers are computed against the same getMarkdownWithBlankLines serializer used to write notes to disk, so the reference is byte-identical to what the agent reads back. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a2dfbf16f
commit
b3066a0b7a
6 changed files with 511 additions and 210 deletions
|
|
@ -7,7 +7,7 @@ import './App.css'
|
|||
import z from 'zod';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
|
|
@ -54,7 +54,7 @@ import { Toaster } from "@/components/ui/sonner"
|
|||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } 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'
|
||||
|
|
@ -739,6 +739,12 @@ function App() {
|
|||
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise<void>) | null>(null)
|
||||
const pendingVoiceInputRef = useRef(false)
|
||||
|
||||
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
|
||||
// queued across the new-chat-tab state flush before submit fires.
|
||||
const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map())
|
||||
const [paletteContext, setPaletteContext] = useState<CommandPaletteContext | null>(null)
|
||||
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
|
||||
|
||||
const handleSubmitRecording = useCallback(() => {
|
||||
const text = voice.submit()
|
||||
setIsRecording(false)
|
||||
|
|
@ -885,6 +891,8 @@ function App() {
|
|||
// File tab state
|
||||
const [fileTabs, setFileTabs] = useState<FileTab[]>([])
|
||||
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
|
||||
const activeFileTabIdRef = useRef(activeFileTabId)
|
||||
activeFileTabIdRef.current = activeFileTabId
|
||||
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
|
||||
const fileHistoryHandlersRef = useRef<Map<string, MarkdownHistoryHandlers>>(new Map())
|
||||
const fileTabIdCounterRef = useRef(0)
|
||||
|
|
@ -2155,6 +2163,7 @@ function App() {
|
|||
filename: string
|
||||
mimeType: string
|
||||
size?: number
|
||||
lineNumber?: number
|
||||
}
|
||||
|
||||
const contentParts: ContentPart[] = []
|
||||
|
|
@ -2166,6 +2175,7 @@ function App() {
|
|||
path: mention.path,
|
||||
filename: mention.displayName || mention.path.split('/').pop() || mention.path,
|
||||
mimeType: 'text/markdown',
|
||||
...(mention.lineNumber !== undefined ? { lineNumber: mention.lineNumber } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2651,6 +2661,32 @@ function App() {
|
|||
handleNewChat()
|
||||
}, [chatTabs, activeChatTabId, handleNewChat])
|
||||
|
||||
// Palette → sidebar submission. Opens the sidebar (if closed), forces a fresh chat tab,
|
||||
// queues the message; the pending-submit effect (below) flushes it once state has settled
|
||||
// so handlePromptSubmit sees the new tab's null runId.
|
||||
const submitFromPalette = useCallback((text: string, mention: CommandPaletteMention | null) => {
|
||||
if (!isChatSidebarOpen) setIsChatSidebarOpen(true)
|
||||
handleNewChatTabInSidebar()
|
||||
setPendingPaletteSubmit({ text, mention })
|
||||
}, [isChatSidebarOpen, handleNewChatTabInSidebar])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPaletteSubmit) return
|
||||
const fileMention: FileMention | undefined = pendingPaletteSubmit.mention
|
||||
? {
|
||||
id: `palette-${Date.now()}`,
|
||||
path: pendingPaletteSubmit.mention.path,
|
||||
displayName: pendingPaletteSubmit.mention.displayName,
|
||||
lineNumber: pendingPaletteSubmit.mention.lineNumber,
|
||||
}
|
||||
: undefined
|
||||
void handlePromptSubmitRef.current?.(
|
||||
{ text: pendingPaletteSubmit.text, files: [] },
|
||||
fileMention ? [fileMention] : undefined,
|
||||
)
|
||||
setPendingPaletteSubmit(null)
|
||||
}, [pendingPaletteSubmit])
|
||||
|
||||
const toggleKnowledgePane = useCallback(() => {
|
||||
setIsRightPaneMaximized(false)
|
||||
setIsChatSidebarOpen(prev => !prev)
|
||||
|
|
@ -3059,11 +3095,16 @@ function App() {
|
|||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
|
||||
|
||||
// Keyboard shortcut: Cmd+K / Ctrl+K to open search
|
||||
// Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode).
|
||||
// If an editor tab is currently active, capture cursor context so Chat mode shows the
|
||||
// note + line as a removable chip.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
const activeId = activeFileTabIdRef.current
|
||||
const handle = activeId ? editorRefsByTabId.current.get(activeId) : null
|
||||
setPaletteContext(handle?.getCursorContext() ?? null)
|
||||
setIsSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -4186,6 +4227,10 @@ function App() {
|
|||
aria-hidden={!isActive}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={(el) => {
|
||||
if (el) editorRefsByTabId.current.set(tab.id, el)
|
||||
else editorRefsByTabId.current.delete(tab.id)
|
||||
}}
|
||||
content={tabContent}
|
||||
notePath={tab.path}
|
||||
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
|
||||
|
|
@ -4505,11 +4550,13 @@ function App() {
|
|||
/>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
<SearchDialog
|
||||
<CommandPalette
|
||||
open={isSearchOpen}
|
||||
onOpenChange={setIsSearchOpen}
|
||||
onSelectFile={navigateToFile}
|
||||
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
|
||||
initialContext={paletteContext}
|
||||
onChatSubmit={submitFromPalette}
|
||||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
|
|
|
|||
|
|
@ -91,11 +91,12 @@ export type FileMention = {
|
|||
id: string;
|
||||
path: string; // "knowledge/notes.md"
|
||||
displayName: string; // "notes"
|
||||
lineNumber?: number; // 1-indexed source-line reference (for editor-context mentions)
|
||||
};
|
||||
|
||||
export type MentionsContext = {
|
||||
mentions: FileMention[];
|
||||
addMention: (path: string, displayName: string) => void;
|
||||
addMention: (path: string, displayName: string, lineNumber?: number) => void;
|
||||
removeMention: (id: string) => void;
|
||||
clearMentions: () => void;
|
||||
};
|
||||
|
|
@ -279,13 +280,13 @@ export function PromptInputProvider({
|
|||
// ----- mentions state (for @ file mentions)
|
||||
const [mentionsList, setMentionsList] = useState<FileMention[]>([]);
|
||||
|
||||
const addMention = useCallback((path: string, displayName: string) => {
|
||||
const addMention = useCallback((path: string, displayName: string, lineNumber?: number) => {
|
||||
setMentionsList((prev) => {
|
||||
// Avoid duplicates
|
||||
if (prev.some((m) => m.path === path)) {
|
||||
// Avoid duplicates (same path AND same lineNumber — line-specific mentions are distinct)
|
||||
if (prev.some((m) => m.path === path && m.lineNumber === lineNumber)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, { id: nanoid(), path, displayName }];
|
||||
return [...prev, { id: nanoid(), path, displayName, lineNumber }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { EmailBlockExtension } from '@/extensions/email-block'
|
|||
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
|
||||
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
|
||||
|
||||
// Zero-width space used as invisible marker for blank lines
|
||||
|
|
@ -54,160 +54,221 @@ function postprocessMarkdown(markdown: string): string {
|
|||
}).join('\n')
|
||||
}
|
||||
|
||||
// Custom function to get markdown that preserves empty paragraphs as blank lines
|
||||
function getMarkdownWithBlankLines(editor: Editor): string {
|
||||
const json = editor.getJSON()
|
||||
if (!json.content) return ''
|
||||
type JsonNode = {
|
||||
type?: string
|
||||
content?: JsonNode[]
|
||||
text?: string
|
||||
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
|
||||
attrs?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const blocks: string[] = []
|
||||
|
||||
// Helper to convert a node to markdown text
|
||||
const nodeToText = (node: {
|
||||
type?: string
|
||||
content?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
|
||||
attrs?: Record<string, unknown>
|
||||
}>
|
||||
attrs?: Record<string, unknown>
|
||||
}): string => {
|
||||
if (!node.content) return ''
|
||||
return node.content.map(child => {
|
||||
if (child.type === 'text') {
|
||||
let text = child.text || ''
|
||||
// Apply marks (bold, italic, etc.)
|
||||
if (child.marks) {
|
||||
for (const mark of child.marks) {
|
||||
if (mark.type === 'bold') text = `**${text}**`
|
||||
else if (mark.type === 'italic') text = `*${text}*`
|
||||
else if (mark.type === 'code') text = `\`${text}\``
|
||||
else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})`
|
||||
}
|
||||
// Convert a node's inline content (text + marks + wikiLinks + hardBreaks) to markdown text
|
||||
function nodeToText(node: JsonNode): string {
|
||||
if (!node.content) return ''
|
||||
return node.content.map(child => {
|
||||
if (child.type === 'text') {
|
||||
let text = child.text || ''
|
||||
if (child.marks) {
|
||||
for (const mark of child.marks) {
|
||||
if (mark.type === 'bold') text = `**${text}**`
|
||||
else if (mark.type === 'italic') text = `*${text}*`
|
||||
else if (mark.type === 'code') text = `\`${text}\``
|
||||
else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})`
|
||||
}
|
||||
return text
|
||||
} else if (child.type === 'wikiLink') {
|
||||
const path = (child.attrs?.path as string) || ''
|
||||
return path ? `[[${path}]]` : ''
|
||||
} else if (child.type === 'hardBreak') {
|
||||
return '\n'
|
||||
}
|
||||
return ''
|
||||
}).join('')
|
||||
}
|
||||
return text
|
||||
} else if (child.type === 'wikiLink') {
|
||||
const path = (child.attrs?.path as string) || ''
|
||||
return path ? `[[${path}]]` : ''
|
||||
} else if (child.type === 'hardBreak') {
|
||||
return '\n'
|
||||
}
|
||||
return ''
|
||||
}).join('')
|
||||
}
|
||||
|
||||
for (const node of json.content) {
|
||||
if (node.type === 'paragraph') {
|
||||
const text = nodeToText(node)
|
||||
// If the paragraph contains only the blank line marker or is empty, it's a blank line
|
||||
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) {
|
||||
// Push empty string to represent blank line - will add extra newline when joining
|
||||
blocks.push('')
|
||||
// Recursively serialize a list node (one line per item; nested lists indented two spaces)
|
||||
function serializeList(listNode: JsonNode, indent: number): string[] {
|
||||
const lines: string[] = []
|
||||
const items = (listNode.content || []) as JsonNode[]
|
||||
items.forEach((item, index) => {
|
||||
const indentStr = ' '.repeat(indent)
|
||||
let prefix: string
|
||||
if (listNode.type === 'taskList') {
|
||||
const checked = item.attrs?.checked ? 'x' : ' '
|
||||
prefix = `- [${checked}] `
|
||||
} else if (listNode.type === 'orderedList') {
|
||||
prefix = `${index + 1}. `
|
||||
} else {
|
||||
prefix = '- '
|
||||
}
|
||||
const itemContent = (item.content || []) as JsonNode[]
|
||||
let firstPara = true
|
||||
itemContent.forEach(child => {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
|
||||
lines.push(...serializeList(child, indent + 1))
|
||||
} else {
|
||||
blocks.push(text)
|
||||
const text = nodeToText(child)
|
||||
if (firstPara) {
|
||||
lines.push(indentStr + prefix + text)
|
||||
firstPara = false
|
||||
} else {
|
||||
lines.push(indentStr + ' ' + text)
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'heading') {
|
||||
const level = (node.attrs?.level as number) || 1
|
||||
})
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker
|
||||
// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown.
|
||||
function blockToMarkdown(node: JsonNode): string {
|
||||
switch (node.type) {
|
||||
case 'paragraph': {
|
||||
const text = nodeToText(node)
|
||||
blocks.push('#'.repeat(level) + ' ' + text)
|
||||
} else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') {
|
||||
// Recursively serialize lists to handle nested bullets
|
||||
const serializeList = (
|
||||
listNode: { type?: string; content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> },
|
||||
indent: number
|
||||
): string[] => {
|
||||
const lines: string[] = []
|
||||
const items = (listNode.content || []) as Array<{ content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> }>
|
||||
items.forEach((item, index) => {
|
||||
const indentStr = ' '.repeat(indent)
|
||||
let prefix: string
|
||||
if (listNode.type === 'taskList') {
|
||||
const checked = item.attrs?.checked ? 'x' : ' '
|
||||
prefix = `- [${checked}] `
|
||||
} else if (listNode.type === 'orderedList') {
|
||||
prefix = `${index + 1}. `
|
||||
} else {
|
||||
prefix = '- '
|
||||
}
|
||||
const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
|
||||
let firstPara = true
|
||||
itemContent.forEach(child => {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
|
||||
lines.push(...serializeList(child, indent + 1))
|
||||
} else {
|
||||
const text = nodeToText(child)
|
||||
if (firstPara) {
|
||||
lines.push(indentStr + prefix + text)
|
||||
firstPara = false
|
||||
} else {
|
||||
lines.push(indentStr + ' ' + text)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return lines
|
||||
}
|
||||
blocks.push(serializeList(node, 0).join('\n'))
|
||||
} else if (node.type === 'taskBlock') {
|
||||
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'imageBlock') {
|
||||
blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'embedBlock') {
|
||||
blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'chartBlock') {
|
||||
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'tableBlock') {
|
||||
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'calendarBlock') {
|
||||
blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'emailBlock') {
|
||||
blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'transcriptBlock') {
|
||||
blocks.push('```transcript\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'mermaidBlock') {
|
||||
blocks.push('```mermaid\n' + (node.attrs?.data as string || '') + '\n```')
|
||||
} else if (node.type === 'codeBlock') {
|
||||
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return ''
|
||||
return text
|
||||
}
|
||||
case 'heading': {
|
||||
const level = (node.attrs?.level as number) || 1
|
||||
return '#'.repeat(level) + ' ' + nodeToText(node)
|
||||
}
|
||||
case 'bulletList':
|
||||
case 'orderedList':
|
||||
case 'taskList':
|
||||
return serializeList(node, 0).join('\n')
|
||||
case 'taskBlock':
|
||||
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'imageBlock':
|
||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'chartBlock':
|
||||
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'tableBlock':
|
||||
return '```table\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'calendarBlock':
|
||||
return '```calendar\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'emailBlock':
|
||||
return '```email\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'transcriptBlock':
|
||||
return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'mermaidBlock':
|
||||
return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'codeBlock': {
|
||||
const lang = (node.attrs?.language as string) || ''
|
||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||
} else if (node.type === 'blockquote') {
|
||||
const content = node.content || []
|
||||
const quoteLines = content.map(para => '> ' + nodeToText(para))
|
||||
blocks.push(quoteLines.join('\n'))
|
||||
} else if (node.type === 'horizontalRule') {
|
||||
blocks.push('---')
|
||||
} else if (node.type === 'wikiLink') {
|
||||
return '```' + lang + '\n' + nodeToText(node) + '\n```'
|
||||
}
|
||||
case 'blockquote': {
|
||||
const content = (node.content || []) as JsonNode[]
|
||||
return content.map(para => '> ' + nodeToText(para)).join('\n')
|
||||
}
|
||||
case 'horizontalRule':
|
||||
return '---'
|
||||
case 'wikiLink': {
|
||||
const path = (node.attrs?.path as string) || ''
|
||||
blocks.push(`[[${path}]]`)
|
||||
} else if (node.type === 'image') {
|
||||
return `[[${path}]]`
|
||||
}
|
||||
case 'image': {
|
||||
const src = (node.attrs?.src as string) || ''
|
||||
const alt = (node.attrs?.alt as string) || ''
|
||||
blocks.push(``)
|
||||
return ``
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Custom join: content blocks get \n\n before them, empty blocks add \n each
|
||||
// This produces: 1 empty paragraph = 3 newlines (1 blank line on disk)
|
||||
// Pure helper: serialize a slice of top-level block nodes to markdown.
|
||||
// Custom join: content blocks get \n\n before them, empty blocks add \n each.
|
||||
// 1 empty paragraph = 3 newlines on disk (1 blank line).
|
||||
function serializeBlocksToMarkdown(blocks: JsonNode[]): string {
|
||||
if (blocks.length === 0) return ''
|
||||
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]
|
||||
const block = blockToMarkdown(blocks[i])
|
||||
const isContent = block !== ''
|
||||
|
||||
if (i === 0) {
|
||||
result = block
|
||||
} else if (isContent) {
|
||||
// Content block: add \n\n before it (standard paragraph break)
|
||||
result += '\n\n' + block
|
||||
} else {
|
||||
// Empty block: just add \n (one extra newline for blank line)
|
||||
result += '\n'
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Custom function to get markdown that preserves empty paragraphs as blank lines
|
||||
function getMarkdownWithBlankLines(editor: Editor): string {
|
||||
const json = editor.getJSON() as JsonNode
|
||||
if (!json.content) return ''
|
||||
return serializeBlocksToMarkdown(json.content as JsonNode[])
|
||||
}
|
||||
|
||||
// Compute the cursor's 1-indexed line number in the markdown that getMarkdownWithBlankLines
|
||||
// would produce. Used to attach precise line-references when inserting editor-context mentions.
|
||||
function getCursorContextLine(editor: Editor): number {
|
||||
const $from = editor.state.selection.$from
|
||||
const json = editor.getJSON() as JsonNode
|
||||
const blocks = (json.content ?? []) as JsonNode[]
|
||||
if (blocks.length === 0) return 1
|
||||
|
||||
const blockIndex = $from.index(0)
|
||||
if (blockIndex < 0 || blockIndex >= blocks.length) return 1
|
||||
|
||||
// Line where the cursor's top-level block starts.
|
||||
// Joining: prefix + '\n\n' + nextContentBlock → next block sits two lines below the prefix's last line.
|
||||
let blockStartLine: number
|
||||
if (blockIndex === 0) {
|
||||
blockStartLine = 1
|
||||
} else {
|
||||
const prefix = serializeBlocksToMarkdown(blocks.slice(0, blockIndex))
|
||||
const prefixLineCount = prefix === '' ? 0 : prefix.split('\n').length
|
||||
blockStartLine = prefixLineCount + 2
|
||||
}
|
||||
|
||||
return blockStartLine + computeWithinBlockOffset(blocks[blockIndex], $from)
|
||||
}
|
||||
|
||||
// Lines into the cursor's top-level block. 0 for the common single-line cases (paragraph/heading);
|
||||
// for multi-line containers, computed against how the block serializes.
|
||||
function computeWithinBlockOffset(
|
||||
block: JsonNode,
|
||||
$from: { parentOffset: number; depth: number; index: (depth: number) => number }
|
||||
): number {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
case 'heading': {
|
||||
// Each hardBreak before the cursor moves us down one rendered line.
|
||||
const offset = $from.parentOffset
|
||||
let pos = 0
|
||||
let hbCount = 0
|
||||
for (const child of (block.content ?? [])) {
|
||||
if (pos >= offset) break
|
||||
const size = child.type === 'text' ? (child.text?.length ?? 0) : 1
|
||||
if (child.type === 'hardBreak' && pos < offset) hbCount++
|
||||
pos += size
|
||||
}
|
||||
return hbCount
|
||||
}
|
||||
case 'bulletList':
|
||||
case 'orderedList':
|
||||
case 'taskList':
|
||||
case 'blockquote':
|
||||
// Item index within the container = lines into the block (one item per line for shallow lists/quotes).
|
||||
return $from.depth >= 1 ? $from.index(1) : 0
|
||||
case 'codeBlock': {
|
||||
// +1 for the opening ``` fence line, plus newlines within the code text before the cursor.
|
||||
const text = block.content?.[0]?.text ?? ''
|
||||
const before = text.substring(0, $from.parentOffset)
|
||||
return 1 + (before.match(/\n/g)?.length ?? 0)
|
||||
}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
import { EditorToolbar } from './editor-toolbar'
|
||||
import { FrontmatterProperties } from './frontmatter-properties'
|
||||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
|
|
@ -439,7 +500,12 @@ const TabIndentExtension = Extension.create({
|
|||
},
|
||||
})
|
||||
|
||||
export function MarkdownEditor({
|
||||
export interface MarkdownEditorHandle {
|
||||
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
|
||||
getCursorContext: () => { path: string; lineNumber: number } | null
|
||||
}
|
||||
|
||||
export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor({
|
||||
content,
|
||||
onChange,
|
||||
onPrimaryHeadingCommit,
|
||||
|
|
@ -454,7 +520,7 @@ export function MarkdownEditor({
|
|||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
}: MarkdownEditorProps) {
|
||||
}, ref) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
|
||||
|
|
@ -789,6 +855,17 @@ export function MarkdownEditor({
|
|||
})
|
||||
}, [editor, wikiLinks])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCursorContext: () => {
|
||||
if (!notePath || !editor) return null
|
||||
try {
|
||||
return { path: notePath, lineNumber: getCursorContextLine(editor) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}), [notePath, editor])
|
||||
|
||||
const updateRowboatMentionState = useCallback(() => {
|
||||
if (!editor) return
|
||||
const { selection } = editor.state
|
||||
|
|
@ -1452,4 +1529,4 @@ export function MarkdownEditor({
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||
import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
|
|
@ -22,21 +22,50 @@ interface SearchResult {
|
|||
}
|
||||
|
||||
type SearchType = 'knowledge' | 'chat'
|
||||
type Mode = 'chat' | 'search'
|
||||
|
||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||
if (section === 'knowledge') return ['knowledge']
|
||||
return ['chat'] // "tasks" tab maps to chat
|
||||
}
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
export type CommandPaletteContext = {
|
||||
path: string
|
||||
lineNumber: number
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
|
||||
export type CommandPaletteMention = {
|
||||
path: string
|
||||
displayName: string
|
||||
lineNumber?: number
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
// Search mode
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
// Chat mode
|
||||
initialContext?: CommandPaletteContext | null
|
||||
onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelectFile,
|
||||
onSelectRun,
|
||||
initialContext,
|
||||
onChatSubmit,
|
||||
}: CommandPaletteProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const [mode, setMode] = useState<Mode>('chat')
|
||||
const [chatInput, setChatInput] = useState('')
|
||||
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
|
||||
const chatInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
|
@ -45,17 +74,45 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
)
|
||||
const debouncedQuery = useDebounce(query, 250)
|
||||
|
||||
// Sync filter preselection when dialog opens
|
||||
// On open: always reset to Chat mode (per spec — no mode persistence), sync context chip
|
||||
// and reset search filters.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode('chat')
|
||||
setChatInput('')
|
||||
setContextChip(initialContext ?? null)
|
||||
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||
}
|
||||
}, [open, activeSection])
|
||||
}, [open, activeSection, initialContext])
|
||||
|
||||
// Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't
|
||||
// swallow it. Only fires while the dialog is open.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMode(prev => (prev === 'chat' ? 'search' : 'chat'))
|
||||
}
|
||||
document.addEventListener('keydown', handler, true)
|
||||
return () => document.removeEventListener('keydown', handler, true)
|
||||
}, [open])
|
||||
|
||||
// Refocus the appropriate input on mode change so the user can start typing immediately.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const target = mode === 'chat' ? chatInputRef : searchInputRef
|
||||
target.current?.focus()
|
||||
}, [open, mode])
|
||||
|
||||
const toggleType = useCallback((type: SearchType) => {
|
||||
setActiveTypes(new Set([type]))
|
||||
}, [])
|
||||
|
||||
// Search query effect (only meaningful while in search mode, but the debounce keeps running
|
||||
// harmlessly otherwise — empty query skips the IPC call below).
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery.trim()) {
|
||||
setResults([])
|
||||
|
|
@ -89,11 +146,12 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
return () => { cancelled = true }
|
||||
}, [debouncedQuery, activeTypes])
|
||||
|
||||
// Reset state when dialog closes
|
||||
// Reset transient state on close.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setChatInput('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
|
@ -106,6 +164,20 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
}
|
||||
}, [onOpenChange, onSelectFile, onSelectRun])
|
||||
|
||||
const submitChat = useCallback(() => {
|
||||
const text = chatInput.trim()
|
||||
if (!text && !contextChip) return
|
||||
const mention: CommandPaletteMention | null = contextChip
|
||||
? {
|
||||
path: contextChip.path,
|
||||
displayName: deriveDisplayName(contextChip.path),
|
||||
lineNumber: contextChip.lineNumber,
|
||||
}
|
||||
: null
|
||||
onChatSubmit(text, mention)
|
||||
onOpenChange(false)
|
||||
}, [chatInput, contextChip, onChatSubmit, onOpenChange])
|
||||
|
||||
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
||||
const chatResults = results.filter(r => r.type === 'chat')
|
||||
|
||||
|
|
@ -113,76 +185,178 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Search"
|
||||
description="Search across knowledge and chats"
|
||||
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
|
||||
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
|
||||
showCloseButton={false}
|
||||
className="top-[20%] translate-y-0"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
{/* Mode strip */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
<ModeButton
|
||||
active={mode === 'chat'}
|
||||
onClick={() => setMode('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
label="Chat"
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === 'search'}
|
||||
onClick={() => setMode('search')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Search"
|
||||
/>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span>
|
||||
</div>
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
{mode === 'chat' ? (
|
||||
<div className="flex flex-col">
|
||||
<input
|
||||
ref={chatInputRef}
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// cmdk's Command component intercepts Enter for item selection — stop it
|
||||
// before bubbling so we control the chat submit ourselves.
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
submitChat()
|
||||
}
|
||||
}}
|
||||
placeholder="Ask copilot anything…"
|
||||
autoFocus
|
||||
className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{contextChip && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs">
|
||||
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{deriveDisplayName(contextChip.path)}</span>
|
||||
<span className="text-muted-foreground">· Line {contextChip.lineNumber}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextChip(null)}
|
||||
aria-label="Remove context"
|
||||
className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
{!contextChip && (
|
||||
<div className="flex items-center px-3 pb-3">
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<FilterToggle
|
||||
active={activeTypes.has('knowledge')}
|
||||
onClick={() => toggleType('knowledge')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Knowledge"
|
||||
/>
|
||||
<FilterToggle
|
||||
active={activeTypes.has('chat')}
|
||||
onClick={() => toggleType('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chats"
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
{!query.trim() && (
|
||||
<CommandEmpty>Type to search...</CommandEmpty>
|
||||
)}
|
||||
{query.trim() && !isSearching && results.length === 0 && (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{knowledgeResults.length > 0 && (
|
||||
<CommandGroup heading="Knowledge">
|
||||
{knowledgeResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`knowledge-${result.path}`}
|
||||
value={`knowledge-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{chatResults.length > 0 && (
|
||||
<CommandGroup heading="Chats">
|
||||
{chatResults.map((result) => (
|
||||
<CommandItem
|
||||
key={`chat-${result.path}`}
|
||||
value={`chat-${result.title}-${result.path}`}
|
||||
onSelect={() => handleSelect(result)}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{result.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</>
|
||||
)}
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette.
|
||||
export const SearchDialog = CommandPalette
|
||||
|
||||
function deriveDisplayName(path: string): string {
|
||||
const base = path.split('/').pop() ?? path
|
||||
return base.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
active,
|
||||
onClick,
|
||||
|
|
|
|||
|
|
@ -566,7 +566,8 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
for (const part of msg.content) {
|
||||
if (part.type === "attachment") {
|
||||
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
|
||||
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
|
||||
const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : '';
|
||||
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`);
|
||||
} else {
|
||||
textSegments.push(part.text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const UserAttachmentPart = z.object({
|
|||
filename: z.string(), // display name ("photo.png")
|
||||
mimeType: z.string(), // MIME type ("image/png", "text/plain")
|
||||
size: z.number().optional(), // bytes
|
||||
lineNumber: z.number().int().min(1).optional(), // 1-indexed line in source file (for editor-context references)
|
||||
});
|
||||
|
||||
// Any single part of a user message (text or attachment)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue