Compare commits
5 Commits
fix_local_
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1f702597 | ||
|
|
2d93d82611 | ||
|
|
4e0f064c3a | ||
|
|
e4e10cc630 | ||
|
|
8f6d4c8b09 |
@@ -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) {
|
||||
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[180px]">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
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">
|
||||
<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>
|
||||
</button>
|
||||
<Show when={!attachment.url?.startsWith("file://")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
class="attachment-download"
|
||||
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||
title={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,9 +13,12 @@ interface MessagePartProps {
|
||||
messageType?: "user" | "assistant"
|
||||
instanceId: 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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, createTextAttachment } from "../../types/attachment"
|
||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import type { PickerMode } from "./types"
|
||||
import type { PickerSelectAction } from "../unified-picker"
|
||||
|
||||
@@ -213,18 +213,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
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.
|
||||
@@ -236,8 +224,7 @@ 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 (BACKSPACE works), remove @ when sending
|
||||
replaceMentionToken(mentionText, { trailingSpace: true })
|
||||
} else {
|
||||
// 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) {
|
||||
// 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(
|
||||
normalizedFolderPath,
|
||||
dirFilename,
|
||||
@@ -274,8 +275,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
}
|
||||
|
||||
if (action === "shiftEnter") {
|
||||
// SHIFT+ENTER on file: attach path as text only.
|
||||
addPathOnlyAttachment(normalizedPath)
|
||||
// SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
|
||||
} else {
|
||||
// ENTER/click on file: attach file (existing behavior).
|
||||
@@ -316,6 +316,25 @@ 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)
|
||||
}
|
||||
}
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
|
||||
@@ -268,6 +268,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (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
|
||||
|
||||
@@ -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 {
|
||||
if (!prompt || !prompt.includes("[pasted #")) {
|
||||
if (!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) {
|
||||
return prompt
|
||||
return result
|
||||
}
|
||||
|
||||
const lookup = new Map<string, string>()
|
||||
@@ -26,10 +114,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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user