From 1ef01da0199eb47907d6640987bdfd908be76ebe Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 13 Feb 2026 22:52:42 +0000 Subject: [PATCH 1/6] feat(ui): improve picker actions and directory attach --- .../prompt-input/usePromptPicker.ts | 197 ++++++++++++------ packages/ui/src/components/unified-picker.tsx | 15 +- 2 files changed, 143 insertions(+), 69 deletions(-) diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index ada32cc9..98e53bc6 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -1,9 +1,10 @@ import { createSignal, type Accessor, type Setter } from "solid-js" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { Agent } from "../../types/session" -import { createAgentAttachment, createFileAttachment } from "../../types/attachment" +import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment" import { addAttachment, getAttachments } from "../../stores/attachments" import type { PickerMode } from "./types" +import type { PickerSelectAction } from "../unified-picker" type PickerItem = | { type: "agent"; agent: Agent } @@ -37,7 +38,7 @@ type PromptPickerController = { setIgnoredAtPositions: Setter> handleInput: (e: Event) => void - handlePickerSelect: (item: PickerItem) => void + handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void handlePickerClose: () => void } @@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr setAtPosition(null) } - function handlePickerSelect(item: PickerItem) { + function handlePickerSelect(item: PickerItem, action: PickerSelectAction) { const textarea = options.getTextarea() if (item.type === "command") { + // For commands, Tab/Enter/Shift+Enter/click all mean "select". const name = item.command.name const currentPrompt = options.prompt() @@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr } }, 0) } else if (item.type === "agent") { + // For agents, Tab/Enter/Shift+Enter/click all mean "select". const agentName = item.agent.name const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) const alreadyAttached = existingAttachments.some( @@ -163,76 +166,144 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const relativePath = item.file.relativePath ?? displayPath const isFolder = item.file.isDirectory ?? displayPath.endsWith("/") - if (isFolder) { - const currentPrompt = options.prompt() - const pos = atPosition() - const cursorPos = textarea?.selectionStart || 0 - const folderMention = - relativePath === "." || relativePath === "" - ? "/" - : relativePath.replace(/\/+$/, "") + "/" - - if (pos !== null) { - const before = currentPrompt.substring(0, pos + 1) - const after = currentPrompt.substring(cursorPos) - const newPrompt = before + folderMention + after - options.setPrompt(newPrompt) - setSearchQuery(folderMention) - - setTimeout(() => { - const nextTextarea = options.getTextarea() - if (nextTextarea) { - const newCursorPos = pos + 1 + folderMention.length - nextTextarea.setSelectionRange(newCursorPos, newCursorPos) - } - }, 0) - } - - return - } - - const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath - const pathSegments = normalizedPath.split("/") - const filename = (() => { - const candidate = pathSegments[pathSegments.length - 1] || normalizedPath - return candidate === "." ? "/" : candidate - })() - - const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) - const alreadyAttached = existingAttachments.some( - (att) => att.source.type === "file" && att.source.path === normalizedPath, - ) - - if (!alreadyAttached) { - const attachment = createFileAttachment( - normalizedPath, - filename, - "text/plain", - undefined, - options.instanceFolder(), - ) - addAttachment(options.instanceId(), options.sessionId(), attachment) - } - - const currentPrompt = options.prompt() const pos = atPosition() const cursorPos = textarea?.selectionStart || 0 - if (pos !== null) { + const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => { + if (pos === null) return + const currentPrompt = options.prompt() const before = currentPrompt.substring(0, pos) const after = currentPrompt.substring(cursorPos) - const attachmentText = `@${normalizedPath}` - const newPrompt = before + attachmentText + " " + after - options.setPrompt(newPrompt) + const suffix = opts?.trailingSpace ? " " : "" + const nextPrompt = before + mentionText + suffix + after + options.setPrompt(nextPrompt) setTimeout(() => { const nextTextarea = options.getTextarea() - if (nextTextarea) { - const newCursorPos = pos + attachmentText.length + 1 - nextTextarea.setSelectionRange(newCursorPos, newCursorPos) - } + if (!nextTextarea) return + const nextCursorPos = pos + mentionText.length + suffix.length + nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) }, 0) } + + const replaceMentionQueryAfterAt = (value: string) => { + // Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation. + if (pos === null) return + const currentPrompt = options.prompt() + const before = currentPrompt.substring(0, pos + 1) + const after = currentPrompt.substring(cursorPos) + const nextPrompt = before + value + after + options.setPrompt(nextPrompt) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (!nextTextarea) return + const nextCursorPos = pos + 1 + value.length + nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) + }, 0) + } + + const folderMention = + relativePath === "." || relativePath === "" + ? "/" + : relativePath.replace(/\/+$/, "") + "/" + + const normalizedFolderPath = (() => { + const trimmed = relativePath.replace(/\/+$/, "") + return trimmed.length > 0 ? trimmed : "." + })() + + const addPathOnlyAttachment = (value: string) => { + const display = `path: ${value}` + const filename = value + const existing = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existing.some( + (att) => att.source.type === "text" && att.source.value === value && att.display === display, + ) + if (!alreadyAttached) { + addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename)) + } + } + + if (isFolder) { + if (action === "tab") { + // TAB on directory: autocomplete directory name and show its contents. + replaceMentionQueryAfterAt(folderMention) + setSearchQuery(folderMention) + return + } + + const mentionText = `@${folderMention}` + + if (action === "shiftEnter") { + // SHIFT+ENTER on directory: attach path as text only. + addPathOnlyAttachment(folderMention) + 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 dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/` + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory", + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedFolderPath, + dirFilename, + "inode/directory", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + replaceMentionToken(mentionText, { trailingSpace: true }) + } + } else { + const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath + + if (action === "tab") { + // TAB on file: autocomplete the file path but do not attach. + replaceMentionToken(`@${normalizedPath}`) + setSearchQuery(normalizedPath) + return + } + + if (action === "shiftEnter") { + // SHIFT+ENTER on file: attach path as text only. + addPathOnlyAttachment(normalizedPath) + replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) + } else { + // ENTER/click on file: attach file (existing behavior). + const pathSegments = normalizedPath.split("/") + const filename = (() => { + const candidate = pathSegments[pathSegments.length - 1] || normalizedPath + return candidate === "." ? "/" : candidate + })() + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedPath, + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedPath, + filename, + "text/plain", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) + } + } } setShowPicker(false) diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 0abc00be..7284d145 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -74,10 +74,12 @@ type PickerItem = | { type: "file"; file: FileItem } | { type: "command"; command: SDKCommand } +export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter" + interface UnifiedPickerProps { open: boolean mode?: "mention" | "command" - onSelect: (item: PickerItem) => void + onSelect: (item: PickerItem, action: PickerSelectAction) => void onClose: () => void agents: Agent[] commands?: SDKCommand[] @@ -356,7 +358,7 @@ const UnifiedPicker: Component = (props) => { } function handleSelect(item: PickerItem) { - props.onSelect(item) + props.onSelect(item, "click") } function handleKeyDown(e: KeyboardEvent) { @@ -379,7 +381,8 @@ const UnifiedPicker: Component = (props) => { e.stopPropagation() const selected = items[selectedIndex()] if (selected) { - handleSelect(selected) + const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" + props.onSelect(selected, action) } } else if (e.key === "Escape") { e.preventDefault() @@ -443,7 +446,7 @@ const UnifiedPicker: Component = (props) => {
handleSelect({ type: "command", command })} + onClick={() => props.onSelect({ type: "command", command }, "click")} >
@@ -479,7 +482,7 @@ const UnifiedPicker: Component = (props) => { itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" }`} data-picker-selected={itemIndex === selectedIndex()} - onClick={() => handleSelect({ type: "agent", agent })} + onClick={() => props.onSelect({ type: "agent", agent }, "click")} >
= (props) => { itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" }`} data-picker-selected={itemIndex === selectedIndex()} - onClick={() => handleSelect({ type: "file", file })} + onClick={() => props.onSelect({ type: "file", file }, "click")} >
Date: Mon, 16 Feb 2026 01:11:53 +0200 Subject: [PATCH 2/6] fix(ui): improve picker actions, directory navigation, @ handling, and message display --- packages/ui/src/components/message-item.tsx | 41 ++++++++++----- packages/ui/src/components/message-part.tsx | 20 ++++++-- .../prompt-input/usePromptKeyDown.ts | 30 +++++++++-- .../prompt-input/usePromptPicker.ts | 26 +++++++++- packages/ui/src/components/unified-picker.tsx | 49 +++++++++++++++++- packages/ui/src/lib/prompt-placeholders.ts | 50 +++++++++++++++++-- packages/ui/src/stores/session-actions.ts | 5 +- 7 files changed, 193 insertions(+), 28 deletions(-) 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} {name} - + + +
diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 3e07f574..008eef09 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -23,15 +23,52 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen let result = prompt // For each path attachment (SHIFT+ENTER), find and replace @path with path in the prompt - // We ALWAYS strip @ for SHIFT+ENTER paths, even if there's also a file attachment for (const path of pathAttachments) { - // Try both with and without trailing slash - const variants = [path, path + "/"] + if (!path) continue + + // The path should already have ./ prefix from usePromptPicker + // We need to find @path in prompt and replace with path + + // For "./docs/" path, try to match @docs/, @./docs/, @docs, etc. + const basePath = path.replace(/^\.\//, "").replace(/\/+$/, "") // "docs" + const withSlash = basePath + "/" // "docs/" + + const patterns = [ + "@" + path, // @./docs/ + "@" + basePath, // @docs + "@" + withSlash, // @docs/ + ] + + for (const pattern of patterns) { + if (result.includes(pattern)) { + result = result.replace(pattern, path) + } + } + } - for (const variant of variants) { - // Replace @path with path (exact match) - const searchPattern = "@" + variant - result = result.split(searchPattern).join(variant) + // Also strip @ for paths that have file attachments (ENTER case) + for (const filePath of fileAttachments) { + if (!filePath || filePath.length === 0) continue + + // Special case: if attachment is "./" or ".", handle separately + if (filePath === "./" || filePath === ".") { + result = result.replace("@./", "./") + result = result.replace("@.", "./") + continue + } + + // Normal path handling + const pathToFind = filePath.replace(/^\.\//, "") + const patterns = [ + "@" + filePath, + "@./" + pathToFind, + "@" + pathToFind, + ] + + for (const pattern of patterns) { + if (result.includes(pattern)) { + result = result.replace(pattern, filePath) + } } } From 32113ea1008432cf75d98d59db3291bf0d3b92b3 Mon Sep 17 00:00:00 2001 From: VooDisss Date: Mon, 16 Feb 2026 05:03:27 +0200 Subject: [PATCH 5/6] fix(ui): resolve root path @. and @./ correctly --- packages/ui/src/lib/prompt-placeholders.ts | 81 ++++++++-------------- 1 file changed, 28 insertions(+), 53 deletions(-) diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 008eef09..b5c8ee55 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -5,15 +5,12 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen 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), + .filter((a): a is Attachment & { source: FileSource } => a.source.type === "file") + .map((a) => a.source.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:")) @@ -22,57 +19,35 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen let result = prompt - // For each path attachment (SHIFT+ENTER), find and replace @path with path in the prompt - for (const path of pathAttachments) { + // 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 - - // The path should already have ./ prefix from usePromptPicker - // We need to find @path in prompt and replace with path - - // For "./docs/" path, try to match @docs/, @./docs/, @docs, etc. - const basePath = path.replace(/^\.\//, "").replace(/\/+$/, "") // "docs" - const withSlash = basePath + "/" // "docs/" - - const patterns = [ - "@" + path, // @./docs/ - "@" + basePath, // @docs - "@" + withSlash, // @docs/ - ] - - for (const pattern of patterns) { - if (result.includes(pattern)) { - result = result.replace(pattern, path) - } - } + const withoutPrefix = path.startsWith("./") ? path.slice(2) : path + const withPrefix = path.startsWith("./") ? path : "./" + path + result = result.replace("@" + withoutPrefix, withPrefix) + result = result.replace("@" + withoutPrefix + "/", withPrefix + "/") } - // Also strip @ for paths that have file attachments (ENTER case) - for (const filePath of fileAttachments) { - if (!filePath || filePath.length === 0) continue + // Step 4: Convert placeholders back to ./ + result = result.replace("___ROOT___", "./") + result = result.replace("___ROOT_NOSLASH___", "./") - // Special case: if attachment is "./" or ".", handle separately - if (filePath === "./" || filePath === ".") { - result = result.replace("@./", "./") - result = result.replace("@.", "./") - continue - } - - // Normal path handling - const pathToFind = filePath.replace(/^\.\//, "") - const patterns = [ - "@" + filePath, - "@./" + pathToFind, - "@" + pathToFind, - ] - - for (const pattern of patterns) { - if (result.includes(pattern)) { - result = result.replace(pattern, filePath) - } - } - } - - // Then, resolve [pasted #N] placeholders + // Step 5: Resolve [pasted #N] placeholders if (!result.includes("[pasted #")) { return result } @@ -87,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 From b7f638f07d9feb8eece8e462e74d962a7ec6999c Mon Sep 17 00:00:00 2001 From: VooDisss Date: Mon, 16 Feb 2026 05:21:22 +0200 Subject: [PATCH 6/6] fix(i18n): add workspace root translation key --- packages/ui/src/components/unified-picker.tsx | 2 +- packages/ui/src/lib/i18n/messages/en/commands.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 810771b3..70a0be97 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -571,7 +571,7 @@ const UnifiedPicker: Component = (props) => { d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> - . (workspace root) + . {t("unifiedPicker.sections.workspaceRoot")}
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",