diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 2f8eb26e..503464b4 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -1,5 +1,5 @@ import { For, Show, createSignal } from "solid-js" -import { Copy, Split, Trash2, Undo } from "lucide-solid" +import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid" import type { MessageInfo, ClientPart } from "../types/message" import { partHasRenderableText } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" @@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard" import { useI18n } from "../lib/i18n" import { showAlertDialog } from "../stores/alerts" import { deleteMessagePart } from "../stores/session-actions" +import { isTauriHost } from "../lib/runtime-env" interface MessageItemProps { record: MessageRecord @@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) { const messageParts = () => props.parts + // User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads). + // We only want to display the primary prompt text for the user message; other synthetic text + // parts should be hidden. + const primaryUserTextPartId = () => { + if (!isUser()) return null + const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined + return typeof firstText?.id === "string" ? firstText.id : null + } + const fileAttachments = () => messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") @@ -96,7 +106,8 @@ export default function MessageItem(props: MessageItemProps) { } if (url.startsWith("file://")) { - window.open(url, "_blank", "noopener") + // Local filesystem URLs are not reliably downloadable from the message stream. + // We hide the download action for these chips. return } @@ -372,6 +383,7 @@ export default function MessageItem(props: MessageItemProps) { messageType={props.record.role} instanceId={props.instanceId} sessionId={props.sessionId} + primaryUserTextPartId={primaryUserTextPartId()} onRendered={props.onContentRendered} /> )} @@ -398,17 +410,20 @@ export default function MessageItem(props: MessageItemProps) { {name} - void handleAttachmentDownload(attachment)} - class="attachment-download" - aria-label={t("messageItem.attachment.downloadAriaLabel", { name })} - > - - - - - + + void handleAttachmentDownload(attachment)} + class="attachment-download" + aria-label={t("messageItem.attachment.downloadAriaLabel", { name })} + title={t("messageItem.attachment.downloadAriaLabel", { name })} + > + + + + + + void } - export default function MessagePart(props: MessagePartProps) { + export default function MessagePart(props: MessagePartProps) { const { isDark } = useTheme() const { preferences } = useConfig() @@ -28,8 +31,19 @@ interface MessagePartProps { const shouldHideTextPart = () => { const part = props.part if (!part || part.type !== "text") return false - // Keep optimistic user prompts visible; hide synthetic assistant text. - return Boolean((part as any).synthetic) && props.messageType !== "user" + + const isSynthetic = Boolean((part as any).synthetic) + if (!isSynthetic) return false + + // Keep optimistic user prompts visible; hide other synthetic user helper parts. + if (props.messageType === "user") { + const primaryId = props.primaryUserTextPartId + if (!primaryId) return false + return part.id !== primaryId + } + + // Hide synthetic assistant text. + return true } diff --git a/packages/ui/src/components/prompt-input/usePromptKeyDown.ts b/packages/ui/src/components/prompt-input/usePromptKeyDown.ts index 18d1746e..ab979216 100644 --- a/packages/ui/src/components/prompt-input/usePromptKeyDown.ts +++ b/packages/ui/src/components/prompt-input/usePromptKeyDown.ts @@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) { if (isDeletingFromEnd || isDeletingFromStart || isSelected) { const currentAttachments = options.getAttachments() - const attachment = currentAttachments.find( - (a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, - ) + const attachment = currentAttachments.find((a) => { + if (a.source.type === "agent") { + return a.filename === name + } + if (a.source.type === "file") { + // Match either by filename (basename) or by path (for full paths like @docs/file.txt) + return ( + a.filename === name || + a.source.path === name || + a.source.path.endsWith("/" + name) || + a.source.path === name.replace(/\/$/, "") + ) + } + if (a.source.type === "text") { + // For text attachments (path-only mentions), match by value + return a.source.value === name || a.source.value.endsWith("/" + name) + } + return false + }) if (attachment) { e.preventDefault() @@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) { textarea.setSelectionRange(mentionStart, mentionStart) }, 0) + // Check if there are any @ remaining in the text - if not, close the picker + if (!newText.includes("@") && options.isPickerOpen()) { + options.closePicker() + // Clear ignoredAtPositions since we deleted the entire @mention + // This ensures typing @ again will open the picker + options.setIgnoredAtPositions(new Set()) + } + return } } diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index 98e53bc6..b0056bc9 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -204,13 +204,16 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr } const folderMention = - relativePath === "." || relativePath === "" - ? "/" - : relativePath.replace(/\/+$/, "") + "/" + relativePath === "." || relativePath === "" || relativePath === "./" + ? "./" + : (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/") const normalizedFolderPath = (() => { const trimmed = relativePath.replace(/\/+$/, "") - return trimmed.length > 0 ? trimmed : "." + // If it's root "./", just return "./" + if (trimmed === "" || trimmed === ".") return "./" + // Otherwise remove any leading ./ and add ./ prefix + return "./" + trimmed.replace(/^\.\//, "") })() const addPathOnlyAttachment = (value: string) => { @@ -236,13 +239,14 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const mentionText = `@${folderMention}` if (action === "shiftEnter") { - // SHIFT+ENTER on directory: attach path as text only. - addPathOnlyAttachment(folderMention) + // SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending + // Always prefix with ./ for consistency + const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath + addPathOnlyAttachment(normalizedFolderPathWithPrefix) replaceMentionToken(mentionText, { trailingSpace: true }) } else { // ENTER/click on directory: attach as a file part pointing at a file:// directory URL. - const dirLabel = - normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath + const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/` const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) @@ -274,11 +278,15 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr } if (action === "shiftEnter") { - // SHIFT+ENTER on file: attach path as text only. - addPathOnlyAttachment(normalizedPath) - replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) + // SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending + // Always prefix with ./ for consistency + const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath + addPathOnlyAttachment(normalizedPathWithPrefix) + replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true }) } else { // ENTER/click on file: attach file (existing behavior). + // Always prefix with ./ for consistency + const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath const pathSegments = normalizedPath.split("/") const filename = (() => { const candidate = pathSegments[pathSegments.length - 1] || normalizedPath @@ -287,12 +295,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) const alreadyAttached = existingAttachments.some( - (att) => att.source.type === "file" && att.source.path === normalizedPath, + (att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix, ) if (!alreadyAttached) { const attachment = createFileAttachment( - normalizedPath, + normalizedPathWithPrefix, filename, "text/plain", undefined, @@ -301,7 +309,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr addAttachment(options.instanceId(), options.sessionId(), attachment) } - replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) + replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true }) } } } @@ -316,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const pos = atPosition() if (pickerMode() === "mention" && pos !== null) { setIgnoredAtPositions((prev) => new Set(prev).add(pos)) + + // Remove the partial @mention text from the textarea when ESC is pressed + const textarea = options.getTextarea() + if (textarea) { + const currentPrompt = options.prompt() + const cursorPos = textarea.selectionStart + // Remove text from @ position to cursor position + const before = currentPrompt.substring(0, pos) + const after = currentPrompt.substring(cursorPos) + options.setPrompt(before + after) + + // Restore cursor position to where @ was + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (nextTextarea) { + nextTextarea.setSelectionRange(pos, pos) + } + }, 0) + + // Clear ignoredAtPositions so typing @ again will work + setIgnoredAtPositions(new Set()) + } } setShowPicker(false) setAtPosition(null) diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 7284d145..70a0be97 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -51,9 +51,7 @@ function normalizeQuery(rawQuery: string) { if (!trimmed) { return "" } - if (trimmed === "." || trimmed === "./") { - return "" - } + // Don't normalize "." - it's used for workspace root return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "") } @@ -268,6 +266,13 @@ const UnifiedPicker: Component = (props) => { const workspaceChanged = lastWorkspaceId !== props.workspaceId const queryChanged = lastQuery !== props.searchQuery + if (queryChanged) { + // Reset selectedIndex to 0 when query changes to avoid ghost state + // This ensures proper highlighting when navigating back to root or changing queries + setSelectedIndex(0) + resetScrollPosition() + } + if (!isInitialized() || workspaceChanged || queryChanged) { setIsInitialized(true) lastWorkspaceId = props.workspaceId @@ -343,7 +348,22 @@ const UnifiedPicker: Component = (props) => { return items } - filteredAgents().forEach((agent) => items.push({ type: "agent", agent })) + // Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/") + const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./" + if (mode() === "mention" && isExactRootQuery) { + const rootFile: FileItem = { + path: ".", + relativePath: ".", + isDirectory: true, + isGitFile: false, + } + items.push({ type: "file", file: rootFile }) + } + + // Don't show agents for exact root path queries + if (!isExactRootQuery) { + filteredAgents().forEach((agent) => items.push({ type: "agent", agent })) + } files().forEach((file) => items.push({ type: "file", file })) return items } @@ -467,7 +487,7 @@ const UnifiedPicker: Component = (props) => { - 0}> + 0 && !(props.searchQuery === "." || props.searchQuery === "./")}> {t("unifiedPicker.sections.agents")} @@ -522,10 +542,39 @@ const UnifiedPicker: Component = (props) => { - 0}> + 0 || props.searchQuery === "." || props.searchQuery === "./")}> {t("unifiedPicker.sections.files")} + + { + const rootFile: FileItem = { + path: ".", + relativePath: ".", + isDirectory: true, + isGitFile: false, + } + props.onSelect({ type: "file", file: rootFile }, "click") + }} + > + + + + + . {t("unifiedPicker.sections.workspaceRoot")} + + + {(file) => { const itemIndex = allItems().findIndex( diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index 66ff78f7..8ddd31db 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -158,6 +158,7 @@ export const commandMessages = { "unifiedPicker.sections.commands": "COMMANDS", "unifiedPicker.sections.agents": "AGENTS", "unifiedPicker.sections.files": "FILES", + "unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT", "unifiedPicker.badge.subagent": "subagent", "unifiedPicker.footer.navigate": "navigate", "unifiedPicker.footer.select": "select", diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 1d8ee1eb..b5c8ee55 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -1,12 +1,59 @@ -import type { Attachment } from "../types/attachment" +import type { Attachment, FileSource } from "../types/attachment" export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { - if (!prompt || !prompt.includes("[pasted #")) { + if (!prompt) { return prompt } + const fileAttachments = new Set( + attachments + .filter((a): a is Attachment & { source: FileSource } => a.source.type === "file") + .map((a) => a.source.path), + ) + + const pathAttachments = new Set( + attachments + .filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:")) + .map((a) => (a.source as { value: string }).value), + ) + + let result = prompt + + // Step 1: Handle root paths FIRST using unique placeholders + // Replace longer pattern first to avoid partial match issues + result = result.replace(/@(\.\/)/g, "___ROOT___") + result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___") + // Note: The regex @(\.)(?!\.) means @. NOT followed by another . + + // Step 2: Build set of non-root paths + const allPaths = new Set() + for (const p of fileAttachments) { + if (p && p !== "." && p !== "./") allPaths.add(p) + } + for (const p of pathAttachments) { + if (p && p !== "." && p !== "./") allPaths.add(p) + } + + // Step 3: Replace @path with ./path for non-root paths + for (const path of allPaths) { + if (!path) continue + const withoutPrefix = path.startsWith("./") ? path.slice(2) : path + const withPrefix = path.startsWith("./") ? path : "./" + path + result = result.replace("@" + withoutPrefix, withPrefix) + result = result.replace("@" + withoutPrefix + "/", withPrefix + "/") + } + + // Step 4: Convert placeholders back to ./ + result = result.replace("___ROOT___", "./") + result = result.replace("___ROOT_NOSLASH___", "./") + + // Step 5: Resolve [pasted #N] placeholders + if (!result.includes("[pasted #")) { + return result + } + if (!attachments || attachments.length === 0) { - return prompt + return result } const lookup = new Map() @@ -15,7 +62,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen const source = attachment?.source if (!source || source.type !== "text") continue const display = attachment?.display - const value = source.value + const value = (source as { value?: string }).value if (typeof display !== "string" || typeof value !== "string") continue const match = display.match(/pasted #(\d+)/) if (!match) continue @@ -26,10 +73,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen } if (lookup.size === 0) { - return prompt + return result } - return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { + return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { const replacement = lookup.get(fullMatch) return typeof replacement === "string" ? replacement : fullMatch }) diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 4771fae3..456f5456 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -140,8 +140,11 @@ async function sendMessage( const display: string | undefined = att.display const value: unknown = source.value const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display) + const isPathPlaceholder = typeof display === "string" && /^path:/.test(display) - if (isPastedPlaceholder || typeof value !== "string") { + // Skip path: attachments from being sent as separate parts (content is already in prompt) + // Skip pasted placeholders too (already resolved in prompt) + if (isPastedPlaceholder || isPathPlaceholder || typeof value !== "string") { continue }