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..1e4b239f 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -236,7 +236,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const mentionText = `@${folderMention}` if (action === "shiftEnter") { - // SHIFT+ENTER on directory: attach path as text only. + // SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending addPathOnlyAttachment(folderMention) replaceMentionToken(mentionText, { trailingSpace: true }) } else { @@ -274,7 +274,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr } if (action === "shiftEnter") { - // SHIFT+ENTER on file: attach path as text only. + // SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending addPathOnlyAttachment(normalizedPath) replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) } else { @@ -316,6 +316,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..81411418 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -268,6 +268,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,6 +350,17 @@ const UnifiedPicker: Component = (props) => { return items } + // Add root directory as first item when query is "/" + if (mode() === "mention" && props.searchQuery === "/") { + const rootFile: FileItem = { + path: "/", + relativePath: "/", + isDirectory: true, + isGitFile: false, + } + items.push({ type: "file", file: rootFile }) + } + filteredAgents().forEach((agent) => items.push({ type: "agent", agent })) files().forEach((file) => items.push({ type: "file", file })) return items @@ -524,8 +542,37 @@ const UnifiedPicker: Component = (props) => { 0}> - {t("unifiedPicker.sections.files")} + {props.searchQuery === "/" ? t("unifiedPicker.sections.directories") : t("unifiedPicker.sections.files")} + + { + const rootFile: FileItem = { + path: "/", + relativePath: "/", + isDirectory: true, + isGitFile: false, + } + props.onSelect({ type: "file", file: rootFile }, "click") + }} + > + + + + + / (root) + + + {(file) => { const itemIndex = allItems().findIndex( diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 1d8ee1eb..ace86bac 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -1,12 +1,52 @@ -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 } + // Get file attachments (ENTER case) - these are sent separately, keep @ in prompt + const fileAttachments = new Set( + attachments + .filter((a) => a.source.type === "file" && "path" in a.source) + .map((a) => (a.source as { path: string }).path), + ) + + // Build a set of paths that were added via SHIFT+ENTER (text attachments with path: display) + // These need @ stripped from the prompt + 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 + + // For each path attachment, find and replace @path with path in the prompt + // This is more precise than regex and won't affect regular @mentions + for (const path of pathAttachments) { + // Try both with and without trailing slash + const variants = [path, path + "/"] + + for (const variant of variants) { + // Skip if this path is also a file attachment (should keep @) + if (fileAttachments.has(variant) || fileAttachments.has(variant.replace(/\/$/, ""))) { + continue + } + + // Replace @path with path (exact match) + const searchPattern = "@" + variant + result = result.split(searchPattern).join(variant) + } + } + + // Then, resolve [pasted #N] placeholders + if (!result.includes("[pasted #")) { + return result + } + if (!attachments || attachments.length === 0) { - return prompt + return result } const lookup = new Map() @@ -26,10 +66,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 }