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:
Ramnique Singh 2026-04-13 17:00:37 +05:30
parent 4a2dfbf16f
commit b3066a0b7a
6 changed files with 511 additions and 210 deletions

View file

@ -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 />

View file

@ -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 }];
});
}, []);

View file

@ -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(`![${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<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>
)
}
})

View file

@ -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,

View file

@ -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);
}

View file

@ -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)