diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 1a666851..f8825e57 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -164,6 +164,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") @@ -598,6 +603,7 @@ export default function MessageItem(props: MessageItemProps) { instanceId={props.instanceId} sessionId={props.sessionId} primaryUserTextPartId={primaryUserTextPartId()} + displayMetadataOverride={part.id === primaryUserTextPartId() ? primaryUserPromptDisplayMetadata() : undefined} onRendered={props.onContentRendered} /> diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b51a5282..0fc40f33 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,8 +1,10 @@ -import { Match, Show, Suspense, Switch, lazy } from "solid-js" +import { For, Match, Show, Suspense, Switch, createMemo, 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 { splitHiddenPromptSections, type HiddenPromptDisplayMetadata } from "../lib/hidden-prompt-sections" type ToolCallPart = Extract @@ -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?: HiddenPromptDisplayMetadata 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 || ""}` @@ -52,6 +56,14 @@ export default function MessagePart(props: MessagePartProps) { return typeof id === "string" && id.length > 0 } + const hiddenPromptSegments = createMemo(() => { + if (props.messageType !== "user") return null + if (props.part?.type !== "text") return null + if (typeof props.part.text !== "string") return null + + return splitHiddenPromptSections(props.part.text, props.displayMetadataOverride) + }) + function reasoningSegmentHasText(segment: unknown): boolean { if (typeof segment === "string") { return segment.trim().length > 0 @@ -111,6 +123,15 @@ 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()) @@ -127,16 +148,58 @@ 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} > - {plainTextContent()}}> - + {plainTextContent()}}> + + + } + > + {(segments) => ( +
+ segment.text.length > 0)}> + {(segment, index) => + segment.hidden ? ( +
+ + {t("messagePart.hiddenPrompt.summary")} + +
+ +
+
+ ) : ( + + ) + } +
+
+ )}
diff --git a/packages/ui/src/lib/hidden-prompt-sections.test.ts b/packages/ui/src/lib/hidden-prompt-sections.test.ts new file mode 100644 index 00000000..22091cf6 --- /dev/null +++ b/packages/ui/src/lib/hidden-prompt-sections.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { preparePromptDisplayText, splitHiddenPromptSections } from "./hidden-prompt-sections" + +describe("preparePromptDisplayText", () => { + it("strips wrapped hidden markers before sending while preserving display metadata", () => { + const result = preparePromptDisplayText("Visible\nHidden\nPlan\nDone") + + assert.equal(result.promptToSend, "Visible\nHidden\nPlan\nDone") + assert.deepEqual(result.displayMetadata, { + segments: [ + { hidden: false, length: 8 }, + { hidden: true, length: 11 }, + { hidden: false, length: 5 }, + ], + }) + }) + + it("leaves prompts without markers unchanged", () => { + const result = preparePromptDisplayText("Visible only") + + assert.equal(result.promptToSend, "Visible only") + assert.equal(result.displayMetadata, undefined) + }) + + it("treats malformed markers as plain text for both display and send", () => { + const result = preparePromptDisplayText("IntroSecret") + + assert.equal(result.promptToSend, "IntroSecret") + assert.equal(result.displayMetadata, undefined) + }) +}) + +describe("splitHiddenPromptSections", () => { + const wrapped = preparePromptDisplayText("IntroSecretOutro") + + it("splits wrapped hidden prompt sections", () => { + assert.deepEqual(splitHiddenPromptSections(wrapped.promptToSend, wrapped.displayMetadata), [ + { hidden: false, text: "Intro" }, + { hidden: true, text: "Secret" }, + { hidden: false, text: "Outro" }, + ]) + }) + + it("supports explicit start/end hide markers", () => { + const result = preparePromptDisplayText("IntroSecretOutro") + + assert.deepEqual(splitHiddenPromptSections(result.promptToSend, result.displayMetadata), [ + { hidden: false, text: "Intro" }, + { hidden: true, text: "Secret" }, + { hidden: false, text: "Outro" }, + ]) + }) + + it("returns null when metadata does not match the text", () => { + assert.equal(splitHiddenPromptSections("Too short", wrapped.displayMetadata), null) + }) +}) diff --git a/packages/ui/src/lib/hidden-prompt-sections.ts b/packages/ui/src/lib/hidden-prompt-sections.ts new file mode 100644 index 00000000..f2e9b8c9 --- /dev/null +++ b/packages/ui/src/lib/hidden-prompt-sections.ts @@ -0,0 +1,134 @@ +export interface HiddenPromptSectionSegment { + hidden: boolean + text: string +} + +export interface HiddenPromptDisplaySegmentMetadata { + hidden: boolean + length: number +} + +export interface HiddenPromptDisplayMetadata { + segments: HiddenPromptDisplaySegmentMetadata[] +} + +export interface PreparedPromptDisplayText { + promptToSend: string + displayMetadata?: HiddenPromptDisplayMetadata +} + +const HIDDEN_PROMPT_TOKEN_REGEX = /<\/codenomad:hide>|||/gi + +function normalizeHiddenPromptToken(token: string): string { + return token.toLowerCase().replace(/\s+/g, "") +} + +function isHiddenPromptOpenToken(token: string): boolean { + return token === "" || token === "" +} + +function isHiddenPromptCloseToken(token: string): boolean { + return token === "" || token === "" +} + +function hasHiddenPromptMarkers(text: string): boolean { + HIDDEN_PROMPT_TOKEN_REGEX.lastIndex = 0 + return HIDDEN_PROMPT_TOKEN_REGEX.test(text) +} + +function pushHiddenPromptSectionSegment(segments: HiddenPromptSectionSegment[], hidden: boolean, text: string): void { + if (!text) return + const previous = segments[segments.length - 1] + if (previous && previous.hidden === hidden) { + previous.text += text + return + } + segments.push({ hidden, text }) +} + +export function preparePromptDisplayText(text: string): PreparedPromptDisplayText { + if (!hasHiddenPromptMarkers(text)) { + return { promptToSend: text } + } + + HIDDEN_PROMPT_TOKEN_REGEX.lastIndex = 0 + const segments: HiddenPromptSectionSegment[] = [] + let currentHidden = false + let currentText = "" + let lastIndex = 0 + let foundHiddenSegment = false + + for (const match of text.matchAll(HIDDEN_PROMPT_TOKEN_REGEX)) { + const token = match[0] + const start = match.index ?? 0 + currentText += text.slice(lastIndex, start) + + const normalizedToken = normalizeHiddenPromptToken(token) + if (isHiddenPromptOpenToken(normalizedToken) && !currentHidden) { + pushHiddenPromptSectionSegment(segments, false, currentText) + currentHidden = true + currentText = "" + } else if (isHiddenPromptCloseToken(normalizedToken) && currentHidden) { + pushHiddenPromptSectionSegment(segments, true, currentText) + foundHiddenSegment = true + currentHidden = false + currentText = "" + } else { + return { promptToSend: text } + } + + lastIndex = start + token.length + } + + currentText += text.slice(lastIndex) + + if (currentHidden) { + return { promptToSend: text } + } + + pushHiddenPromptSectionSegment(segments, false, currentText) + + if (!foundHiddenSegment) { + return { promptToSend: text } + } + + const promptToSend = segments.map((segment) => segment.text).join("") + const displayMetadata: HiddenPromptDisplayMetadata = { + segments: segments.map((segment) => ({ hidden: segment.hidden, length: segment.text.length })), + } + + return { + promptToSend, + displayMetadata, + } +} + +export function splitHiddenPromptSections( + text: string, + metadata: HiddenPromptDisplayMetadata | undefined, +): HiddenPromptSectionSegment[] | null { + if (!metadata || !Array.isArray(metadata.segments) || metadata.segments.length === 0) { + return null + } + + const segments: HiddenPromptSectionSegment[] = [] + let offset = 0 + + for (const segment of metadata.segments) { + if (!segment || typeof segment.length !== "number" || segment.length < 0) { + return null + } + const nextOffset = offset + segment.length + if (nextOffset > text.length) { + return null + } + pushHiddenPromptSectionSegment(segments, Boolean(segment.hidden), text.slice(offset, nextOffset)) + offset = nextOffset + } + + if (offset !== text.length) { + return null + } + + return segments +} diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index 03c3cc0e..aab744de 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -126,6 +126,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "Delete this item", "messagePart.actions.deleteFailedTitle": "Delete failed", "messagePart.actions.deleteFailedMessage": "Failed to delete item", + "messagePart.hiddenPrompt.summary": "Hidden prompt section", "messageItem.attachment.defaultName": "attachment", "messageItem.attachment.downloadAriaLabel": "Download {name}", "messageItem.agentMeta.agentLabel": "Agent: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index d4e79ce2..3dd4e247 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -128,6 +128,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.hiddenPrompt.summary": "Sección de prompt oculta", "messageItem.attachment.defaultName": "adjunto", "messageItem.attachment.downloadAriaLabel": "Descargar {name}", "messageItem.agentMeta.agentLabel": "Agente: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index 4dd33c0a..a53176e5 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -128,6 +128,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.hiddenPrompt.summary": "Section de prompt masquée", "messageItem.attachment.defaultName": "piece-jointe", "messageItem.attachment.downloadAriaLabel": "Télécharger {name}", "messageItem.agentMeta.agentLabel": "Agent : {agent}", diff --git a/packages/ui/src/lib/i18n/messages/he/messaging.ts b/packages/ui/src/lib/i18n/messages/he/messaging.ts index 0199ce31..b1472143 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -126,6 +126,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "מחק פריט זה", "messagePart.actions.deleteFailedTitle": "המחיקה נכשלה", "messagePart.actions.deleteFailedMessage": "מחיקת הפריט נכשלה", + "messagePart.hiddenPrompt.summary": "מקטע פרומפט מוסתר", "messageItem.attachment.defaultName": "קובץ מצורף", "messageItem.attachment.downloadAriaLabel": "הורד {name}", "messageItem.agentMeta.agentLabel": "סוכן: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index e20464f3..bdff4967 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -128,6 +128,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "この項目を削除", "messagePart.actions.deleteFailedTitle": "削除に失敗しました", "messagePart.actions.deleteFailedMessage": "項目の削除に失敗しました", + "messagePart.hiddenPrompt.summary": "非表示のプロンプトセクション", "messageItem.attachment.defaultName": "添付ファイル", "messageItem.attachment.downloadAriaLabel": "{name} をダウンロード", "messageItem.agentMeta.agentLabel": "エージェント: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index a14c312e..5f02c356 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -128,6 +128,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "Удалить этот элемент", "messagePart.actions.deleteFailedTitle": "Ошибка удаления", "messagePart.actions.deleteFailedMessage": "Не удалось удалить элемент", + "messagePart.hiddenPrompt.summary": "Скрытый раздел промпта", "messageItem.attachment.defaultName": "вложение", "messageItem.attachment.downloadAriaLabel": "Скачать {name}", "messageItem.agentMeta.agentLabel": "Агент: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index f8022d13..efc7fb62 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -128,6 +128,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "删除此项", "messagePart.actions.deleteFailedTitle": "删除失败", "messagePart.actions.deleteFailedMessage": "删除失败", + "messagePart.hiddenPrompt.summary": "已隐藏的提示区段", "messageItem.attachment.defaultName": "附件", "messageItem.attachment.downloadAriaLabel": "下载 {name}", "messageItem.agentMeta.agentLabel": "智能体:{agent}", diff --git a/packages/ui/src/stores/message-prompt-display.test.ts b/packages/ui/src/stores/message-prompt-display.test.ts new file mode 100644 index 00000000..c0f2aa0e --- /dev/null +++ b/packages/ui/src/stores/message-prompt-display.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + clearPromptDisplayOverride, + clearPromptDisplayOverridesForInstance, + getPromptDisplayOverride, + movePromptDisplayOverride, + setPromptDisplayOverride, +} from "./message-prompt-display" + +class MemoryStorage { + private entries = new Map() + + getItem(key: string): string | null { + return this.entries.has(key) ? this.entries.get(key)! : null + } + + setItem(key: string, value: string): void { + this.entries.set(key, value) + } + + removeItem(key: string): void { + this.entries.delete(key) + } + + clear(): void { + this.entries.clear() + } +} + +type WindowWithMemoryStorage = { + localStorage: { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void + clear(): void + } +} + +describe("message prompt display overrides", () => { + it("persists and moves hidden prompt display text by message id", () => { + const instanceId = `instance-${Date.now()}` + const sessionId = "session-1" + const oldMessageId = "temp-msg" + const newMessageId = "real-msg" + const storage = new MemoryStorage() + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + + clearPromptDisplayOverridesForInstance(instanceId) + + const metadata = { segments: [{ hidden: false, length: 7 }, { hidden: true, length: 6 }] } + + setPromptDisplayOverride(instanceId, sessionId, oldMessageId, metadata) + assert.deepEqual( + getPromptDisplayOverride(instanceId, sessionId, oldMessageId), + metadata, + ) + + movePromptDisplayOverride(instanceId, sessionId, oldMessageId, newMessageId) + assert.equal(getPromptDisplayOverride(instanceId, sessionId, oldMessageId), undefined) + assert.deepEqual( + getPromptDisplayOverride(instanceId, sessionId, newMessageId), + metadata, + ) + + clearPromptDisplayOverride(instanceId, sessionId, newMessageId) + assert.equal(getPromptDisplayOverride(instanceId, sessionId, newMessageId), undefined) + + delete (globalThis as unknown as { window?: unknown }).window + }) +}) diff --git a/packages/ui/src/stores/message-prompt-display.ts b/packages/ui/src/stores/message-prompt-display.ts new file mode 100644 index 00000000..947638da --- /dev/null +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -0,0 +1,138 @@ +import type { HiddenPromptDisplayMetadata } from "../lib/hidden-prompt-sections" + +const STORAGE_KEY = "codenomad:hidden-prompt-display:v1" + +let loaded = false +const promptDisplayOverrides = new Map() + +function makeKey(instanceId: string, sessionId: string, messageId: string): string { + return `${instanceId}:${sessionId}:${messageId}` +} + +function readStorage(): Storage | null { + if (typeof window === "undefined" || !window.localStorage) { + return null + } + + return window.localStorage +} + +function ensureLoaded(): void { + if (loaded) return + loaded = true + + const storage = readStorage() + if (!storage) return + + try { + const raw = storage.getItem(STORAGE_KEY) + if (!raw) return + const parsed = JSON.parse(raw) as Record + for (const [key, value] of Object.entries(parsed)) { + if (isPromptDisplayMetadata(value)) { + promptDisplayOverrides.set(key, value) + } + } + } catch { + promptDisplayOverrides.clear() + } +} + +function persist(): void { + const storage = readStorage() + if (!storage) return + + try { + storage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(promptDisplayOverrides))) + } catch { + // Ignore persistence failures. + } +} + +function isPromptDisplayMetadata(value: unknown): value is HiddenPromptDisplayMetadata { + if (!value || typeof value !== "object") return false + const segments = (value as HiddenPromptDisplayMetadata).segments + if (!Array.isArray(segments) || segments.length === 0) return false + return segments.every( + (segment) => + segment && typeof segment === "object" && typeof segment.hidden === "boolean" && typeof segment.length === "number" && segment.length >= 0, + ) +} + +export function getPromptDisplayOverride( + instanceId: string, + sessionId: string, + messageId: string, +): HiddenPromptDisplayMetadata | undefined { + ensureLoaded() + return promptDisplayOverrides.get(makeKey(instanceId, sessionId, messageId)) +} + +export function setPromptDisplayOverride( + instanceId: string, + sessionId: string, + messageId: string, + displayMetadata: HiddenPromptDisplayMetadata | undefined, +): void { + ensureLoaded() + const key = makeKey(instanceId, sessionId, messageId) + const previous = promptDisplayOverrides.get(key) + if (displayMetadata && isPromptDisplayMetadata(displayMetadata)) { + const serialized = JSON.stringify(displayMetadata) + if (previous && JSON.stringify(previous) === serialized) return + promptDisplayOverrides.set(key, displayMetadata) + } else { + if (!promptDisplayOverrides.has(key)) return + promptDisplayOverrides.delete(key) + } + persist() +} + +export function movePromptDisplayOverride(instanceId: string, sessionId: string, oldMessageId: string, newMessageId: string): void { + ensureLoaded() + const oldKey = makeKey(instanceId, sessionId, oldMessageId) + const nextValue = promptDisplayOverrides.get(oldKey) + if (!nextValue) return + + const newKey = makeKey(instanceId, sessionId, newMessageId) + if (oldKey === newKey) return + promptDisplayOverrides.delete(oldKey) + promptDisplayOverrides.set(newKey, nextValue) + persist() +} + +export function clearPromptDisplayOverride(instanceId: string, sessionId: string, messageId: string): void { + ensureLoaded() + if (!promptDisplayOverrides.delete(makeKey(instanceId, sessionId, messageId))) { + return + } + persist() +} + +export function clearPromptDisplayOverridesForSession(instanceId: string, sessionId: string): void { + ensureLoaded() + const prefix = `${instanceId}:${sessionId}:` + let changed = false + for (const key of promptDisplayOverrides.keys()) { + if (key.startsWith(prefix)) { + promptDisplayOverrides.delete(key) + changed = true + } + } + if (!changed) return + persist() +} + +export function clearPromptDisplayOverridesForInstance(instanceId: string): void { + ensureLoaded() + const prefix = `${instanceId}:` + let changed = false + for (const key of promptDisplayOverrides.keys()) { + if (key.startsWith(prefix)) { + promptDisplayOverrides.delete(key) + changed = true + } + } + if (!changed) return + persist() +} diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index ae2a3b3c..2a117a10 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -2,6 +2,14 @@ import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import type { SetStoreFunction } from "solid-js/store" import { getLogger } from "../../lib/logger" +import { + clearPromptDisplayOverride, + clearPromptDisplayOverridesForInstance, + clearPromptDisplayOverridesForSession, + getPromptDisplayOverride, + movePromptDisplayOverride, + setPromptDisplayOverride, +} from "../message-prompt-display" import type { ClientPart, MessageInfo } from "../../types/message" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" import type { @@ -104,6 +112,23 @@ function createEmptyUsageState(): SessionUsageState { } } +function resolveClientPromptDisplayText( + instanceId: string, + input: Pick, + previous?: Pick, +) { + if (input.clientPromptDisplayMetadata) { + return input.clientPromptDisplayMetadata + } + + const persisted = getPromptDisplayOverride(instanceId, input.sessionId, input.id) + if (persisted) { + return persisted + } + + return previous?.clientPromptDisplayMetadata +} + function extractUsageEntry(info: MessageInfo | undefined): UsageEntry | null { if (!info || info.role !== "assistant") return null const messageId = typeof info.id === "string" ? info.id : undefined @@ -415,6 +440,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const normalizedParts = normalizeParts(input.id, input.parts) const shouldBump = Boolean(input.bumpRevision || normalizedParts) const previous = state.messages[input.id] + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input, previous) normalizedRecords[input.id] = { id: input.id, sessionId: input.sessionId, @@ -423,10 +449,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt createdAt: input.createdAt ?? previous?.createdAt ?? now, updatedAt: input.updatedAt ?? now, isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false, + clientPromptDisplayMetadata, revision: previous ? previous.revision + (shouldBump ? 1 : 0) : 0, partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, } + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) }) const infoList = infos ? Array.from(infos) : undefined @@ -517,6 +545,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState("messages", input.id, (previous) => { const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0 + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input, previous) const record: MessageRecord = { id: input.id, sessionId: input.sessionId, @@ -525,10 +554,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt createdAt: input.createdAt ?? previous?.createdAt ?? now, updatedAt: input.updatedAt ?? now, isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false, + clientPromptDisplayMetadata, revision, partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, } + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) nextRecord = record return record }) @@ -702,6 +733,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const record = state.messages[messageId] const sessionIds = new Set() + if (record?.sessionId) { + clearPromptDisplayOverride(instanceId, record.sessionId, messageId) + } + if (record?.sessionId) { sessionIds.add(record.sessionId) } else { @@ -812,6 +847,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const existing = state.messages[options.oldId] if (!existing) return + movePromptDisplayOverride(instanceId, existing.sessionId, options.oldId, options.newId) + const cloned: MessageRecord = { ...existing, id: options.newId, @@ -1021,6 +1058,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const keptIds = session.messageIds.slice(0, stopIndex) if (removedIds.length === 0) return + removedIds.forEach((messageId) => clearPromptDisplayOverride(instanceId, sessionId, messageId)) + setState("sessions", sessionId, "messageIds", keptIds) setState("messages", (prev) => { @@ -1096,8 +1135,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.scrollState[key] } - function clearSession(sessionId: string) { - if (!sessionId) return + function clearSession(sessionId: string) { + if (!sessionId) return + + clearPromptDisplayOverridesForSession(instanceId, sessionId) const messageIds = Object.values(state.messages) .filter((record) => record.sessionId === sessionId) @@ -1195,6 +1236,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function clearInstance() { + clearPromptDisplayOverridesForInstance(instanceId) messageInfoCache.clear() setState(reconcile(createInitialState(instanceId))) } diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 581a896e..3302c4e6 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,4 +1,5 @@ import type { ClientPart } from "../../types/message" +import type { HiddenPromptDisplayMetadata } from "../../lib/hidden-prompt-sections" import type { PermissionRequestLike } from "../../types/permission" import type { QuestionRequest } from "../../types/question" @@ -20,6 +21,7 @@ export interface MessageRecord { updatedAt: number revision: number isEphemeral?: boolean + clientPromptDisplayMetadata?: HiddenPromptDisplayMetadata partIds: string[] parts: Record } @@ -141,6 +143,7 @@ export interface MessageUpsertInput { createdAt?: number updatedAt?: number isEphemeral?: boolean + clientPromptDisplayMetadata?: HiddenPromptDisplayMetadata bumpRevision?: boolean } diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 3cdf0321..71b8d3ec 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1,4 +1,5 @@ import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" +import { preparePromptDisplayText } from "../lib/hidden-prompt-sections" import { instances } from "./instances" import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" @@ -98,12 +99,13 @@ async function sendMessage( const textPartId = createId("prt") const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments) + const preparedPrompt = preparePromptDisplayText(resolvedPrompt) const optimisticParts: any[] = [ { id: textPartId, type: "text" as const, - text: resolvedPrompt, + text: preparedPrompt.promptToSend, synthetic: true, renderCache: undefined, }, @@ -112,7 +114,7 @@ async function sendMessage( const requestParts: any[] = [ { type: "text" as const, - text: resolvedPrompt, + text: preparedPrompt.promptToSend, }, ] @@ -177,6 +179,7 @@ async function sendMessage( createdAt, updatedAt: createdAt, isEphemeral: true, + clientPromptDisplayMetadata: preparedPrompt.displayMetadata, }) withSession(instanceId, sessionId, () => {