diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 201c9330..0d1aaaa9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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) | 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>(new Map()) + const [paletteContext, setPaletteContext] = useState(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([]) const [activeFileTabId, setActiveFileTabId] = useState(null) + const activeFileTabIdRef = useRef(activeFileTabId) + activeFileTabIdRef.current = activeFileTabId const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) const fileHistoryHandlersRef = useRef>(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} > { + 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() { /> - { void navigateToView({ type: 'chat', runId: id }) }} + initialContext={paletteContext} + onChatSubmit={submitFromPalette} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx index 98263434..467547b1 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx @@ -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([]); - 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 }]; }); }, []); diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index d2d5314f..ee1f6033 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -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 }> + attrs?: Record +} - 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 }> - attrs?: Record - }> - attrs?: Record - }): 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>; attrs?: Record }, - indent: number - ): string[] => { - const lines: string[] = [] - const items = (listNode.content || []) as Array<{ content?: Array>; attrs?: Record }> - 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 }> }>; attrs?: Record }> - 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(`![${alt}](${src})`) + return `![${alt}](${src})` } + 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(function MarkdownEditor({ content, onChange, onPrimaryHeadingCommit, @@ -454,7 +520,7 @@ export function MarkdownEditor({ onFrontmatterChange, onExport, notePath, -}: MarkdownEditorProps) { +}, ref) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) const [activeWikiLink, setActiveWikiLink] = useState(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({ ) -} +}) diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 32bca1b3..66a37802 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -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('chat') + const [chatInput, setChatInput] = useState('') + const [contextChip, setContextChip] = useState(null) + const chatInputRef = useRef(null) + const searchInputRef = useRef(null) + const [query, setQuery] = useState('') const [results, setResults] = useState([]) 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 }: - + {/* Mode strip */}
- toggleType('knowledge')} - icon={} - label="Knowledge" - /> - toggleType('chat')} + setMode('chat')} icon={} - label="Chats" + label="Chat" /> + setMode('search')} + icon={} + label="Search" + /> + Tab to switch
- - {!query.trim() && ( - Type to search... - )} - {query.trim() && !isSearching && results.length === 0 && ( - No results found. - )} - {knowledgeResults.length > 0 && ( - - {knowledgeResults.map((result) => ( - handleSelect(result)} - > - -
- {result.title} - {result.preview} -
-
- ))} -
- )} - {chatResults.length > 0 && ( - - {chatResults.map((result) => ( - handleSelect(result)} - > - -
- {result.title} - {result.preview} -
-
- ))} -
- )} -
+ + {mode === 'chat' ? ( +
+ 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 && ( +
+ + + {deriveDisplayName(contextChip.path)} + · Line {contextChip.lineNumber} + + + Enter to send +
+ )} + {!contextChip && ( +
+ Enter to send +
+ )} +
+ ) : ( + <> + +
+ toggleType('knowledge')} + icon={} + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" + /> +
+ + {!query.trim() && ( + Type to search... + )} + {query.trim() && !isSearching && results.length === 0 && ( + No results found. + )} + {knowledgeResults.length > 0 && ( + + {knowledgeResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} + {chatResults.length > 0 && ( + + {chatResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} +
+ + )}
) } +// 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 ( + + ) +} + function FilterToggle({ active, onClick, diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 34e2b401..fea801de 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -566,7 +566,8 @@ export function convertFromMessages(messages: z.infer[]): 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); } diff --git a/apps/x/packages/shared/src/message.ts b/apps/x/packages/shared/src/message.ts index be761853..2aefe3f3 100644 --- a/apps/x/packages/shared/src/message.ts +++ b/apps/x/packages/shared/src/message.ts @@ -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)