Compare commits

...

5 Commits

Author SHA1 Message Date
Shantur Rathore
2d1f702597 fix(ui): hide download action for local file attachments
Avoid showing a misleading download icon for file:// attachments in the message stream.
2026-02-14 10:52:15 +00:00
Shantur Rathore
2d93d82611 fix(ui): hide synthetic helper text in user messages
Avoid rendering tool trace/read output parts on user messages while keeping the primary prompt text visible.
2026-02-14 09:53:40 +00:00
Shantur Rathore
4e0f064c3a fix(ui): avoid stripping non-path @mentions 2026-02-14 08:18:35 +00:00
VooDisss
e4e10cc630 fix(ui): replace parent directory when selecting child directory 2026-02-14 07:39:39 +02:00
VooDisss
8f6d4c8b09 fix(ui): improve picker deletion, ESC cancel, and SHIFT+ENTER path handling 2026-02-14 06:53:31 +02:00
6 changed files with 209 additions and 42 deletions

View File

@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js" 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 type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions" import { deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) {
const messageParts = () => props.parts 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 = () => const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") 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://")) { 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 return
} }
@@ -372,6 +383,7 @@ export default function MessageItem(props: MessageItemProps) {
messageType={props.record.role} messageType={props.record.role}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
primaryUserTextPartId={primaryUserTextPartId()}
onRendered={props.onContentRendered} onRendered={props.onContentRendered}
/> />
)} )}
@@ -398,17 +410,20 @@ export default function MessageItem(props: MessageItemProps) {
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" /> <img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show> </Show>
<span class="truncate max-w-[180px]">{name}</span> <span class="truncate max-w-[180px]">{name}</span>
<button <Show when={!attachment.url?.startsWith("file://")}>
type="button" <button
onClick={() => void handleAttachmentDownload(attachment)} type="button"
class="attachment-download" onClick={() => void handleAttachmentDownload(attachment)}
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })} class="attachment-download"
> aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> title={t("messageItem.attachment.downloadAriaLabel", { name })}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" /> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" /> <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</button> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
</Show>
<button <button
type="button" type="button"

View File

@@ -13,9 +13,12 @@ interface MessagePartProps {
messageType?: "user" | "assistant" messageType?: "user" | "assistant"
instanceId: string instanceId: string
sessionId: string sessionId: string
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
onRendered?: () => void onRendered?: () => void
} }
export default function MessagePart(props: MessagePartProps) { export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const { preferences } = useConfig() const { preferences } = useConfig()
@@ -28,8 +31,19 @@ interface MessagePartProps {
const shouldHideTextPart = () => { const shouldHideTextPart = () => {
const part = props.part const part = props.part
if (!part || part.type !== "text") return false 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
} }

View File

@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
if (isDeletingFromEnd || isDeletingFromStart || isSelected) { if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
const currentAttachments = options.getAttachments() const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find( const attachment = currentAttachments.find((a) => {
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, 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) { if (attachment) {
e.preventDefault() e.preventDefault()
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
textarea.setSelectionRange(mentionStart, mentionStart) textarea.setSelectionRange(mentionStart, mentionStart)
}, 0) }, 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 return
} }
} }

View File

@@ -1,8 +1,8 @@
import { createSignal, type Accessor, type Setter } from "solid-js" import { createSignal, type Accessor, type Setter } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { Agent } from "../../types/session" import type { Agent } from "../../types/session"
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment" import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments" import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import type { PickerMode } from "./types" import type { PickerMode } from "./types"
import type { PickerSelectAction } from "../unified-picker" import type { PickerSelectAction } from "../unified-picker"
@@ -213,18 +213,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
return trimmed.length > 0 ? trimmed : "." 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 (isFolder) {
if (action === "tab") { if (action === "tab") {
// TAB on directory: autocomplete directory name and show its contents. // TAB on directory: autocomplete directory name and show its contents.
@@ -236,8 +224,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const mentionText = `@${folderMention}` const mentionText = `@${folderMention}`
if (action === "shiftEnter") { if (action === "shiftEnter") {
// SHIFT+ENTER on directory: attach path as text only. // SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending
addPathOnlyAttachment(folderMention)
replaceMentionToken(mentionText, { trailingSpace: true }) replaceMentionToken(mentionText, { trailingSpace: true })
} else { } else {
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL. // ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
@@ -251,6 +238,20 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
) )
if (!alreadyAttached) { if (!alreadyAttached) {
// Remove any parent/child directory attachments that overlap with this one
// (e.g., if "docs/" is attached and user selects "docs/screenshots/", replace parent with child)
for (const att of existingAttachments) {
if (
att.source.type === "file" &&
att.source.mime === "inode/directory" &&
(normalizedFolderPath.startsWith(att.source.path + "/") || // new is child of existing
att.source.path.startsWith(normalizedFolderPath + "/")) // new is parent of existing
) {
// Remove the overlapping directory attachment
removeAttachment(options.instanceId(), options.sessionId(), att.id)
}
}
const attachment = createFileAttachment( const attachment = createFileAttachment(
normalizedFolderPath, normalizedFolderPath,
dirFilename, dirFilename,
@@ -274,8 +275,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
} }
if (action === "shiftEnter") { if (action === "shiftEnter") {
// SHIFT+ENTER on file: attach path as text only. // SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
addPathOnlyAttachment(normalizedPath)
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
} else { } else {
// ENTER/click on file: attach file (existing behavior). // ENTER/click on file: attach file (existing behavior).
@@ -316,6 +316,25 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition() const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) { if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos)) 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)
}
} }
setShowPicker(false) setShowPicker(false)
setAtPosition(null) setAtPosition(null)

View File

@@ -268,6 +268,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const workspaceChanged = lastWorkspaceId !== props.workspaceId const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery 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) { if (!isInitialized() || workspaceChanged || queryChanged) {
setIsInitialized(true) setIsInitialized(true)
lastWorkspaceId = props.workspaceId lastWorkspaceId = props.workspaceId

View File

@@ -1,12 +1,100 @@
import type { Attachment } from "../types/attachment" import type { Attachment, FileSource } from "../types/attachment"
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) { if (!prompt) {
return prompt return prompt
} }
// First, strip `@` from path-like mentions that do NOT have a backing file attachment.
// This is intended for SHIFT+ENTER selection where we keep `@path` in the textarea for
// easy deletion, but send `path` to the API.
//
// IMPORTANT: avoid rewriting plain `@mentions` or email addresses.
const fileAttachmentPaths = new Set(
attachments
.filter((a): a is Attachment & { source: FileSource } => a.source.type === "file")
.map((a) => a.source.path),
)
const isPathLike = (value: string) => {
if (!value) return false
if (value.includes("/") || value.includes("\\")) return true
if (value.startsWith("./") || value.startsWith("../")) return true
if (value.startsWith("~")) return true
if (value.endsWith("/")) return true
// Root-level files (no `/`) still commonly have an extension.
const ext = value.split(".").pop()?.toLowerCase()
if (!ext || ext === value.toLowerCase()) return false
// Keep this list intentionally small and code-focused to avoid matching domains like `example.com`.
const allowedExts = new Set([
"ts",
"tsx",
"js",
"jsx",
"mjs",
"cjs",
"json",
"md",
"txt",
"yml",
"yaml",
"toml",
"css",
"html",
"htm",
"svg",
"png",
"jpg",
"jpeg",
"gif",
"pdf",
"rs",
"go",
"py",
"java",
"kt",
"swift",
"sh",
"bash",
"zsh",
"sql",
"lock",
])
return allowedExts.has(ext)
}
const stripTrailingPunctuation = (value: string) => {
const match = value.match(/^(.*?)([)\]}.,!?:;]+)?$/)
if (!match) return { core: value, trailing: "" }
return { core: match[1] ?? value, trailing: match[2] ?? "" }
}
let result = prompt.replace(/(^|[\s([{"'`])@([^\s@]+)/g, (full, prefix, rawToken) => {
const { core, trailing } = stripTrailingPunctuation(String(rawToken))
if (!core) return full
// If this path has a file attachment, keep the `@` (attachment is sent separately).
if (fileAttachmentPaths.has(core) || fileAttachmentPaths.has(core.replace(/\/$/, ""))) {
return `${prefix}@${core}${trailing}`
}
// Only strip for path-like tokens; leave plain `@mentions` intact.
if (!isPathLike(core)) {
return `${prefix}@${core}${trailing}`
}
return `${prefix}${core}${trailing}`
})
// Then, resolve [pasted #N] placeholders
if (!result.includes("[pasted #")) {
return result
}
if (!attachments || attachments.length === 0) { if (!attachments || attachments.length === 0) {
return prompt return result
} }
const lookup = new Map<string, string>() const lookup = new Map<string, string>()
@@ -26,10 +114,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
} }
if (lookup.size === 0) { 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) const replacement = lookup.get(fullMatch)
return typeof replacement === "string" ? replacement : fullMatch return typeof replacement === "string" ? replacement : fullMatch
}) })