Skip to content
6 changes: 6 additions & 0 deletions packages/ui/src/components/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ export default function MessageItem(props: MessageItemProps) {
return typeof firstText?.id === "string" ? firstText.id : null
}

const primaryUserPromptDisplayMetadata = () => {
if (!isUser()) return undefined
return props.record.clientPromptDisplayMetadata
}

const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")

Expand Down Expand Up @@ -680,6 +685,7 @@ export default function MessageItem(props: MessageItemProps) {
instanceId={props.instanceId}
sessionId={props.sessionId}
primaryUserTextPartId={primaryUserTextPartId()}
displayMetadataOverride={part.id === primaryUserTextPartId() ? primaryUserPromptDisplayMetadata() : undefined}
onRendered={props.onContentRendered}
/>
</div>
Expand Down
102 changes: 91 additions & 11 deletions packages/ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Match, Show, Suspense, Switch, lazy } from "solid-js"
import { For, Match, Show, Suspense, Switch, createMemo, createSignal, lazy } from "solid-js"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
import { useI18n } from "../lib/i18n"
import { splitPromptDisplaySections, type PromptDisplayMetadata } from "../lib/prompt-display-metadata"

type ToolCallPart = Extract<ClientPart, { type: "tool" }>

Expand All @@ -16,11 +18,13 @@ interface MessagePartProps {
// 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
displayMetadataOverride?: PromptDisplayMetadata
onRendered?: () => void
}

export default function MessagePart(props: MessagePartProps) {

const { t } = useI18n()
const { isDark } = useTheme()
const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}`
Expand Down Expand Up @@ -52,6 +56,14 @@ export default function MessagePart(props: MessagePartProps) {
return typeof id === "string" && id.length > 0
}

const promptDisplaySegments = createMemo(() => {
if (props.messageType !== "user") return null
if (props.part?.type !== "text") return null
if (typeof props.part.text !== "string") return null

return splitPromptDisplaySections(props.part.text, props.displayMetadataOverride)
})

function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
Expand Down Expand Up @@ -111,11 +123,52 @@ export default function MessagePart(props: MessagePartProps) {
}
}

function createSegmentTextPart(text: string, index: number): TextPart {
return {
id: `${String((props.part as { id?: string }).id ?? "text")}:display:${index}`,
type: "text",
text,
synthetic: false,
}
}

function handleReasoningClick(e: Event) {
e.preventDefault()
toggleItemExpanded(reasoningId())
}

function PastedTextDisclosure(disclosureProps: { text: string; index: number }) {
const [hasExpanded, setHasExpanded] = createSignal(false)

return (
<details
class="rounded-md border border-base bg-surface-secondary px-3 py-2"
onToggle={(event) => {
if ((event.currentTarget as HTMLDetailsElement).open) {
setHasExpanded(true)
}
}}
>
<summary class="cursor-pointer select-none text-xs font-medium text-secondary">
{t("messagePart.pastedText.summary")}
</summary>
<Show when={hasExpanded()}>
<div class="pt-2">
<Markdown
part={createSegmentTextPart(disclosureProps.text, disclosureProps.index)}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size="base"
escapeRawHtml
onRendered={props.onRendered}
/>
</div>
</Show>
</details>
)
}

return (
<Switch>
<Match when={partType() === "text"}>
Expand All @@ -127,16 +180,43 @@ export default function MessagePart(props: MessagePartProps) {
data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered}
/>
<Show
when={promptDisplaySegments()}
fallback={
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered}
/>
</Show>
}
>
{(segments) => (
<div class="flex flex-col gap-2">
<For each={segments().filter((segment) => segment.text.length > 0)}>
{(segment, index) =>
segment.kind === "pasted" ? (
<PastedTextDisclosure text={segment.text} index={index()} />
) : (
<Markdown
part={createSegmentTextPart(segment.text, index())}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size="base"
escapeRawHtml
onRendered={props.onRendered}
/>
)
}
</For>
</div>
)}
</Show>
</div>
</Show>
Expand Down
19 changes: 11 additions & 8 deletions packages/ui/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid"
import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
import { preparePromptSubmission } from "./prompt-input/submitPrompt"
import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances"
import { agents, executeCustomCommand } from "../stores/sessions"
Expand Down Expand Up @@ -383,13 +383,16 @@ export default function PromptInput(props: PromptInputProps) {
commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)

const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
const resolvedPrompt = isKnownSlashCommand
? resolvedCommandArgs
? `${commandToken} ${resolvedCommandArgs}`
: commandToken
: resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt
const submission = preparePromptSubmission({
mode: isKnownSlashCommand ? "slash" : isShellMode ? "shell" : "message",
text,
attachments: currentAttachments,
commandToken,
commandArgs,
})
const resolvedCommandArgs = submission.resolvedCommandArgs
const resolvedPrompt = submission.submitPrompt
const historyEntry = submission.historyEntry

const refreshHistory = () => recordHistoryEntry(historyEntry)

Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/components/prompt-input/submitPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { createTextAttachment } from "../../types/attachment"
import { preparePromptSubmission } from "./submitPrompt"

describe("preparePromptSubmission", () => {
it("keeps placeholder-backed pasted text intact for message submission while resolving history text", () => {
const attachment = createTextAttachment("alpha\nbeta\ngamma\ndelta", "pasted #1 (4 lines)", "paste-1.txt")

const result = preparePromptSubmission({
mode: "message",
text: "Intro\n[pasted #1]\nOutro",
attachments: [attachment],
})

assert.equal(result.submitPrompt, "Intro\n[pasted #1]\nOutro")
assert.equal(result.historyEntry, "Intro\nalpha\nbeta\ngamma\ndelta\nOutro")
})
})
46 changes: 46 additions & 0 deletions packages/ui/src/components/prompt-input/submitPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { resolvePastedPlaceholders } from "../../lib/prompt-placeholders"
import type { Attachment } from "../../types/attachment"

export type PromptSubmissionMode = "message" | "shell" | "slash"

export interface PromptSubmissionResult {
historyEntry: string
submitPrompt: string
resolvedCommandArgs: string
}

export function preparePromptSubmission(input: {
mode: PromptSubmissionMode
text: string
attachments: Attachment[]
commandToken?: string
commandArgs?: string
}): PromptSubmissionResult {
const attachments = input.attachments ?? []

if (input.mode === "slash") {
const resolvedCommandArgs = resolvePastedPlaceholders(input.commandArgs ?? "", attachments)
const historyEntry = resolvedCommandArgs ? `${input.commandToken ?? ""} ${resolvedCommandArgs}` : (input.commandToken ?? "")
return {
historyEntry,
submitPrompt: historyEntry,
resolvedCommandArgs,
}
}

const resolvedPrompt = resolvePastedPlaceholders(input.text, attachments)

if (input.mode === "message") {
return {
historyEntry: resolvedPrompt,
submitPrompt: input.text,
resolvedCommandArgs: "",
}
}

return {
historyEntry: resolvedPrompt,
submitPrompt: resolvedPrompt,
resolvedCommandArgs: "",
}
}
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/en/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Delete this item",
"messagePart.actions.deleteFailedTitle": "Delete failed",
"messagePart.actions.deleteFailedMessage": "Failed to delete item",
"messagePart.pastedText.summary": "Pasted text",
"messageItem.attachment.defaultName": "attachment",
"messageItem.attachment.downloadAriaLabel": "Download {name}",
"messageItem.agentMeta.agentLabel": "Agent: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/es/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Eliminar este elemento",
"messagePart.actions.deleteFailedTitle": "Error al eliminar",
"messagePart.actions.deleteFailedMessage": "No se pudo eliminar el elemento",
"messagePart.pastedText.summary": "Texto pegado",
"messageItem.attachment.defaultName": "adjunto",
"messageItem.attachment.downloadAriaLabel": "Descargar {name}",
"messageItem.agentMeta.agentLabel": "Agente: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/fr/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Supprimer cet élément",
"messagePart.actions.deleteFailedTitle": "Échec de suppression",
"messagePart.actions.deleteFailedMessage": "Impossible de supprimer l'élément",
"messagePart.pastedText.summary": "Texte collé",
"messageItem.attachment.defaultName": "piece-jointe",
"messageItem.attachment.downloadAriaLabel": "Télécharger {name}",
"messageItem.agentMeta.agentLabel": "Agent : {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/he/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "מחק פריט זה",
"messagePart.actions.deleteFailedTitle": "המחיקה נכשלה",
"messagePart.actions.deleteFailedMessage": "מחיקת הפריט נכשלה",
"messagePart.pastedText.summary": "טקסט שהודבק",
"messageItem.attachment.defaultName": "קובץ מצורף",
"messageItem.attachment.downloadAriaLabel": "הורד {name}",
"messageItem.agentMeta.agentLabel": "סוכן: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ja/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "この項目を削除",
"messagePart.actions.deleteFailedTitle": "削除に失敗しました",
"messagePart.actions.deleteFailedMessage": "項目の削除に失敗しました",
"messagePart.pastedText.summary": "貼り付けたテキスト",
"messageItem.attachment.defaultName": "添付ファイル",
"messageItem.attachment.downloadAriaLabel": "{name} をダウンロード",
"messageItem.agentMeta.agentLabel": "エージェント: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ru/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Удалить этот элемент",
"messagePart.actions.deleteFailedTitle": "Ошибка удаления",
"messagePart.actions.deleteFailedMessage": "Не удалось удалить элемент",
"messagePart.pastedText.summary": "Вставленный текст",
"messageItem.attachment.defaultName": "вложение",
"messageItem.attachment.downloadAriaLabel": "Скачать {name}",
"messageItem.agentMeta.agentLabel": "Агент: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "删除此项",
"messagePart.actions.deleteFailedTitle": "删除失败",
"messagePart.actions.deleteFailedMessage": "删除失败",
"messagePart.pastedText.summary": "粘贴的文本",
"messageItem.attachment.defaultName": "附件",
"messageItem.attachment.downloadAriaLabel": "下载 {name}",
"messageItem.agentMeta.agentLabel": "智能体:{agent}",
Expand Down
Loading
Loading