From 4284abf0854fc35239f8c7583202b30b2f6a0524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 8 May 2026 19:02:56 +0200 Subject: [PATCH 1/2] feat(ui): fold hidden prompt sections in chat history Add a CodeNomad-specific hidden prompt syntax for user messages so long planning and instruction blocks can stay out of the main chat flow without changing the text sent to the model. User messages now strip `` markers before prompt submission, render hidden sections inside collapsed details blocks in the message history, and persist that display-only structure across optimistic message replacement and full app reloads. Validation: - npx tsx --test "packages/ui/src/lib/hidden-prompt-sections.test.ts" "packages/ui/src/stores/message-prompt-display.test.ts" - npm run typecheck --workspace @codenomad/ui - npm run build --workspace @codenomad/ui --- packages/ui/src/components/message-item.tsx | 22 ++-- packages/ui/src/components/message-part.tsx | 86 +++++++++++-- .../ui/src/lib/hidden-prompt-sections.test.ts | 44 +++++++ packages/ui/src/lib/hidden-prompt-sections.ts | 95 ++++++++++++++ .../ui/src/lib/i18n/messages/en/messaging.ts | 1 + .../ui/src/lib/i18n/messages/es/messaging.ts | 1 + .../ui/src/lib/i18n/messages/fr/messaging.ts | 1 + .../ui/src/lib/i18n/messages/he/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ja/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ru/messaging.ts | 1 + .../lib/i18n/messages/zh-Hans/messaging.ts | 1 + .../src/stores/message-prompt-display.test.ts | 70 ++++++++++ .../ui/src/stores/message-prompt-display.ts | 121 ++++++++++++++++++ .../src/stores/message-v2/instance-store.ts | 44 ++++++- packages/ui/src/stores/message-v2/types.ts | 2 + packages/ui/src/stores/session-actions.ts | 7 +- 16 files changed, 475 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/lib/hidden-prompt-sections.test.ts create mode 100644 packages/ui/src/lib/hidden-prompt-sections.ts create mode 100644 packages/ui/src/stores/message-prompt-display.test.ts create mode 100644 packages/ui/src/stores/message-prompt-display.ts diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 1a6668510..7b47c6faa 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 primaryUserPromptDisplayText = () => { + if (!isUser()) return undefined + return props.record.clientPromptDisplayText + } + const fileAttachments = () => messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") @@ -592,14 +597,15 @@ export default function MessageItem(props: MessageItemProps) { {(part) => { return (
- +
) }} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b51a52820..84566a0bb 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 } 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 + displayTextOverride?: string 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,15 @@ 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.displayTextOverride !== "string" || props.displayTextOverride.length === 0) return null + + const segments = splitHiddenPromptSections(props.displayTextOverride) + return segments.some((segment) => segment.hidden) ? segments : null + }) + function reasoningSegmentHasText(segment: unknown): boolean { if (typeof segment === "string") { return segment.trim().length > 0 @@ -111,6 +124,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 +149,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 000000000..c885adf3e --- /dev/null +++ b/packages/ui/src/lib/hidden-prompt-sections.test.ts @@ -0,0 +1,44 @@ +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 text", () => { + const result = preparePromptDisplayText("Visible\nHidden\nPlan\nDone") + + assert.equal(result.promptToSend, "Visible\nHidden\nPlan\nDone") + assert.equal(result.displayText, "Visible\nHidden\nPlan\nDone") + }) + + it("leaves prompts without markers unchanged", () => { + const result = preparePromptDisplayText("Visible only") + + assert.equal(result.promptToSend, "Visible only") + assert.equal(result.displayText, undefined) + }) +}) + +describe("splitHiddenPromptSections", () => { + it("splits wrapped hidden prompt sections", () => { + assert.deepEqual(splitHiddenPromptSections("IntroSecretOutro"), [ + { hidden: false, text: "Intro" }, + { hidden: true, text: "Secret" }, + { hidden: false, text: "Outro" }, + ]) + }) + + it("supports explicit start/end hide markers", () => { + assert.deepEqual(splitHiddenPromptSections("IntroSecretOutro"), [ + { hidden: false, text: "Intro" }, + { hidden: true, text: "Secret" }, + { hidden: false, text: "Outro" }, + ]) + }) + + it("falls back to visible text when a hide section is left unclosed", () => { + assert.deepEqual(splitHiddenPromptSections("IntroSecret"), [ + { hidden: false, text: "IntroSecret" }, + ]) + }) +}) 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 000000000..72f71fb53 --- /dev/null +++ b/packages/ui/src/lib/hidden-prompt-sections.ts @@ -0,0 +1,95 @@ +export interface HiddenPromptSectionSegment { + hidden: boolean + text: string +} + +export interface PreparedPromptDisplayText { + promptToSend: string + displayText?: string +} + +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 === "" +} + +export function hasHiddenPromptMarkers(text: string): boolean { + HIDDEN_PROMPT_TOKEN_REGEX.lastIndex = 0 + return HIDDEN_PROMPT_TOKEN_REGEX.test(text) +} + +export function stripHiddenPromptMarkers(text: string): string { + return text.replace(HIDDEN_PROMPT_TOKEN_REGEX, "") +} + +export function preparePromptDisplayText(text: string): PreparedPromptDisplayText { + if (!hasHiddenPromptMarkers(text)) { + return { promptToSend: text } + } + + return { + promptToSend: stripHiddenPromptMarkers(text), + displayText: text, + } +} + +export function splitHiddenPromptSections(text: string): HiddenPromptSectionSegment[] { + HIDDEN_PROMPT_TOKEN_REGEX.lastIndex = 0 + const segments: HiddenPromptSectionSegment[] = [] + let currentHidden = false + let currentText = "" + let hiddenStartToken = "" + let lastIndex = 0 + + const pushSegment = (hidden: boolean, value: string) => { + if (!value) return + const previous = segments[segments.length - 1] + if (previous && previous.hidden === hidden) { + previous.text += value + return + } + segments.push({ hidden, text: value }) + } + + 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) { + pushSegment(false, currentText) + currentHidden = true + currentText = "" + hiddenStartToken = token + } else if (isHiddenPromptCloseToken(normalizedToken) && currentHidden) { + pushSegment(true, currentText) + currentHidden = false + currentText = "" + hiddenStartToken = "" + } else { + currentText += token + } + + lastIndex = start + token.length + } + + currentText += text.slice(lastIndex) + + if (currentHidden) { + pushSegment(false, `${hiddenStartToken}${currentText}`) + } else { + pushSegment(false, currentText) + } + + 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 03c3cc0ea..aab744de1 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 d4e79ce2d..3dd4e247d 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 4dd33c0ae..a53176e5f 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 0199ce318..b14721433 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 e20464f32..bdff4967b 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 a14c312eb..5f02c3563 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 f8022d13f..efc7fb62a 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 000000000..f22eeabe6 --- /dev/null +++ b/packages/ui/src/stores/message-prompt-display.test.ts @@ -0,0 +1,70 @@ +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) + + setPromptDisplayOverride(instanceId, sessionId, oldMessageId, "VisibleHidden") + assert.equal( + getPromptDisplayOverride(instanceId, sessionId, oldMessageId), + "VisibleHidden", + ) + + movePromptDisplayOverride(instanceId, sessionId, oldMessageId, newMessageId) + assert.equal(getPromptDisplayOverride(instanceId, sessionId, oldMessageId), undefined) + assert.equal( + getPromptDisplayOverride(instanceId, sessionId, newMessageId), + "VisibleHidden", + ) + + 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 000000000..060a6193f --- /dev/null +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -0,0 +1,121 @@ +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 (typeof value === "string" && value.length > 0) { + 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. + } +} + +export function getPromptDisplayOverride(instanceId: string, sessionId: string, messageId: string): string | undefined { + ensureLoaded() + return promptDisplayOverrides.get(makeKey(instanceId, sessionId, messageId)) +} + +export function setPromptDisplayOverride( + instanceId: string, + sessionId: string, + messageId: string, + displayText: string | undefined, +): void { + ensureLoaded() + const key = makeKey(instanceId, sessionId, messageId) + const previous = promptDisplayOverrides.get(key) + if (displayText && displayText.length > 0) { + if (previous === displayText) return + promptDisplayOverrides.set(key, displayText) + } 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 ae2a3b3ce..783e53394 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, +): string | undefined { + if (typeof input.clientPromptDisplayText === "string") { + return input.clientPromptDisplayText + } + + const persisted = getPromptDisplayOverride(instanceId, input.sessionId, input.id) + if (typeof persisted === "string") { + return persisted + } + + return previous?.clientPromptDisplayText +} + 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 clientPromptDisplayText = 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, + clientPromptDisplayText, 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, clientPromptDisplayText) }) 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 clientPromptDisplayText = 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, + clientPromptDisplayText, revision, partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, } + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayText) 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, @@ -1096,8 +1133,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 +1234,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 581a896e4..842dbaf80 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -20,6 +20,7 @@ export interface MessageRecord { updatedAt: number revision: number isEphemeral?: boolean + clientPromptDisplayText?: string partIds: string[] parts: Record } @@ -141,6 +142,7 @@ export interface MessageUpsertInput { createdAt?: number updatedAt?: number isEphemeral?: boolean + clientPromptDisplayText?: string bumpRevision?: boolean } diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 3cdf03211..6f9fa23e2 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, + clientPromptDisplayText: preparedPrompt.displayText, }) withSession(instanceId, sessionId, () => { From bba876ff03f3b0c9d323d1551d9ffedc2aa543eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 8 May 2026 19:16:47 +0200 Subject: [PATCH 2/2] fix(ui): narrow hidden prompt persistence Use segment-length metadata instead of persisting the full original prompt body in localStorage so hidden prompt sections keep their collapsed display state without creating an extra long-lived copy of the prompt text. This also clears the persisted metadata when revert pruning removes user messages, keeping the side store aligned with message deletion paths and avoiding stale hidden-section leftovers. Validation: - npx tsx --test "packages/ui/src/lib/hidden-prompt-sections.test.ts" "packages/ui/src/stores/message-prompt-display.test.ts" - npm run typecheck --workspace @codenomad/ui - npm run build --workspace @codenomad/ui --- packages/ui/src/components/message-item.tsx | 22 ++--- packages/ui/src/components/message-part.tsx | 9 +- .../ui/src/lib/hidden-prompt-sections.test.ts | 33 +++++-- packages/ui/src/lib/hidden-prompt-sections.ts | 99 +++++++++++++------ .../src/stores/message-prompt-display.test.ts | 12 ++- .../ui/src/stores/message-prompt-display.ts | 33 +++++-- .../src/stores/message-v2/instance-store.ts | 28 +++--- packages/ui/src/stores/message-v2/types.ts | 5 +- packages/ui/src/stores/session-actions.ts | 2 +- 9 files changed, 159 insertions(+), 84 deletions(-) diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 7b47c6faa..f8825e57f 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -164,9 +164,9 @@ export default function MessageItem(props: MessageItemProps) { return typeof firstText?.id === "string" ? firstText.id : null } - const primaryUserPromptDisplayText = () => { + const primaryUserPromptDisplayMetadata = () => { if (!isUser()) return undefined - return props.record.clientPromptDisplayText + return props.record.clientPromptDisplayMetadata } const fileAttachments = () => @@ -597,15 +597,15 @@ export default function MessageItem(props: MessageItemProps) { {(part) => { return (
- +
) }} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 84566a0bb..0fc40f33f 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -4,7 +4,7 @@ import { Markdown } from "./markdown" import { useTheme } from "../lib/theme" import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message" import { useI18n } from "../lib/i18n" -import { splitHiddenPromptSections } from "../lib/hidden-prompt-sections" +import { splitHiddenPromptSections, type HiddenPromptDisplayMetadata } from "../lib/hidden-prompt-sections" type ToolCallPart = Extract @@ -18,7 +18,7 @@ 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 - displayTextOverride?: string + displayMetadataOverride?: HiddenPromptDisplayMetadata onRendered?: () => void } @@ -59,10 +59,9 @@ export default function MessagePart(props: MessagePartProps) { const hiddenPromptSegments = createMemo(() => { if (props.messageType !== "user") return null if (props.part?.type !== "text") return null - if (typeof props.displayTextOverride !== "string" || props.displayTextOverride.length === 0) return null + if (typeof props.part.text !== "string") return null - const segments = splitHiddenPromptSections(props.displayTextOverride) - return segments.some((segment) => segment.hidden) ? segments : null + return splitHiddenPromptSections(props.part.text, props.displayMetadataOverride) }) function reasoningSegmentHasText(segment: unknown): boolean { diff --git a/packages/ui/src/lib/hidden-prompt-sections.test.ts b/packages/ui/src/lib/hidden-prompt-sections.test.ts index c885adf3e..22091cf6c 100644 --- a/packages/ui/src/lib/hidden-prompt-sections.test.ts +++ b/packages/ui/src/lib/hidden-prompt-sections.test.ts @@ -4,24 +4,39 @@ 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 text", () => { + 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.equal(result.displayText, "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.displayText, undefined) + 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("IntroSecretOutro"), [ + assert.deepEqual(splitHiddenPromptSections(wrapped.promptToSend, wrapped.displayMetadata), [ { hidden: false, text: "Intro" }, { hidden: true, text: "Secret" }, { hidden: false, text: "Outro" }, @@ -29,16 +44,16 @@ describe("splitHiddenPromptSections", () => { }) it("supports explicit start/end hide markers", () => { - assert.deepEqual(splitHiddenPromptSections("IntroSecretOutro"), [ + const result = preparePromptDisplayText("IntroSecretOutro") + + assert.deepEqual(splitHiddenPromptSections(result.promptToSend, result.displayMetadata), [ { hidden: false, text: "Intro" }, { hidden: true, text: "Secret" }, { hidden: false, text: "Outro" }, ]) }) - it("falls back to visible text when a hide section is left unclosed", () => { - assert.deepEqual(splitHiddenPromptSections("IntroSecret"), [ - { hidden: false, text: "IntroSecret" }, - ]) + 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 index 72f71fb53..f2e9b8c97 100644 --- a/packages/ui/src/lib/hidden-prompt-sections.ts +++ b/packages/ui/src/lib/hidden-prompt-sections.ts @@ -3,9 +3,18 @@ export interface HiddenPromptSectionSegment { text: string } +export interface HiddenPromptDisplaySegmentMetadata { + hidden: boolean + length: number +} + +export interface HiddenPromptDisplayMetadata { + segments: HiddenPromptDisplaySegmentMetadata[] +} + export interface PreparedPromptDisplayText { promptToSend: string - displayText?: string + displayMetadata?: HiddenPromptDisplayMetadata } const HIDDEN_PROMPT_TOKEN_REGEX = /<\/codenomad:hide>|||/gi @@ -22,13 +31,19 @@ function isHiddenPromptCloseToken(token: string): boolean { return token === "" || token === "" } -export function hasHiddenPromptMarkers(text: string): boolean { +function hasHiddenPromptMarkers(text: string): boolean { HIDDEN_PROMPT_TOKEN_REGEX.lastIndex = 0 return HIDDEN_PROMPT_TOKEN_REGEX.test(text) } -export function stripHiddenPromptMarkers(text: string): string { - return text.replace(HIDDEN_PROMPT_TOKEN_REGEX, "") +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 { @@ -36,29 +51,12 @@ export function preparePromptDisplayText(text: string): PreparedPromptDisplayTex return { promptToSend: text } } - return { - promptToSend: stripHiddenPromptMarkers(text), - displayText: text, - } -} - -export function splitHiddenPromptSections(text: string): HiddenPromptSectionSegment[] { HIDDEN_PROMPT_TOKEN_REGEX.lastIndex = 0 const segments: HiddenPromptSectionSegment[] = [] let currentHidden = false let currentText = "" - let hiddenStartToken = "" let lastIndex = 0 - - const pushSegment = (hidden: boolean, value: string) => { - if (!value) return - const previous = segments[segments.length - 1] - if (previous && previous.hidden === hidden) { - previous.text += value - return - } - segments.push({ hidden, text: value }) - } + let foundHiddenSegment = false for (const match of text.matchAll(HIDDEN_PROMPT_TOKEN_REGEX)) { const token = match[0] @@ -67,17 +65,16 @@ export function splitHiddenPromptSections(text: string): HiddenPromptSectionSegm const normalizedToken = normalizeHiddenPromptToken(token) if (isHiddenPromptOpenToken(normalizedToken) && !currentHidden) { - pushSegment(false, currentText) + pushHiddenPromptSectionSegment(segments, false, currentText) currentHidden = true currentText = "" - hiddenStartToken = token } else if (isHiddenPromptCloseToken(normalizedToken) && currentHidden) { - pushSegment(true, currentText) + pushHiddenPromptSectionSegment(segments, true, currentText) + foundHiddenSegment = true currentHidden = false currentText = "" - hiddenStartToken = "" } else { - currentText += token + return { promptToSend: text } } lastIndex = start + token.length @@ -86,9 +83,51 @@ export function splitHiddenPromptSections(text: string): HiddenPromptSectionSegm currentText += text.slice(lastIndex) if (currentHidden) { - pushSegment(false, `${hiddenStartToken}${currentText}`) - } else { - pushSegment(false, currentText) + 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/stores/message-prompt-display.test.ts b/packages/ui/src/stores/message-prompt-display.test.ts index f22eeabe6..c0f2aa0eb 100644 --- a/packages/ui/src/stores/message-prompt-display.test.ts +++ b/packages/ui/src/stores/message-prompt-display.test.ts @@ -49,17 +49,19 @@ describe("message prompt display overrides", () => { clearPromptDisplayOverridesForInstance(instanceId) - setPromptDisplayOverride(instanceId, sessionId, oldMessageId, "VisibleHidden") - assert.equal( + const metadata = { segments: [{ hidden: false, length: 7 }, { hidden: true, length: 6 }] } + + setPromptDisplayOverride(instanceId, sessionId, oldMessageId, metadata) + assert.deepEqual( getPromptDisplayOverride(instanceId, sessionId, oldMessageId), - "VisibleHidden", + metadata, ) movePromptDisplayOverride(instanceId, sessionId, oldMessageId, newMessageId) assert.equal(getPromptDisplayOverride(instanceId, sessionId, oldMessageId), undefined) - assert.equal( + assert.deepEqual( getPromptDisplayOverride(instanceId, sessionId, newMessageId), - "VisibleHidden", + metadata, ) clearPromptDisplayOverride(instanceId, sessionId, newMessageId) diff --git a/packages/ui/src/stores/message-prompt-display.ts b/packages/ui/src/stores/message-prompt-display.ts index 060a6193f..947638dad 100644 --- a/packages/ui/src/stores/message-prompt-display.ts +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -1,7 +1,9 @@ +import type { HiddenPromptDisplayMetadata } from "../lib/hidden-prompt-sections" + const STORAGE_KEY = "codenomad:hidden-prompt-display:v1" let loaded = false -const promptDisplayOverrides = new Map() +const promptDisplayOverrides = new Map() function makeKey(instanceId: string, sessionId: string, messageId: string): string { return `${instanceId}:${sessionId}:${messageId}` @@ -25,9 +27,9 @@ function ensureLoaded(): void { try { const raw = storage.getItem(STORAGE_KEY) if (!raw) return - const parsed = JSON.parse(raw) as Record + const parsed = JSON.parse(raw) as Record for (const [key, value] of Object.entries(parsed)) { - if (typeof value === "string" && value.length > 0) { + if (isPromptDisplayMetadata(value)) { promptDisplayOverrides.set(key, value) } } @@ -47,7 +49,21 @@ function persist(): void { } } -export function getPromptDisplayOverride(instanceId: string, sessionId: string, messageId: string): string | undefined { +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)) } @@ -56,14 +72,15 @@ export function setPromptDisplayOverride( instanceId: string, sessionId: string, messageId: string, - displayText: string | undefined, + displayMetadata: HiddenPromptDisplayMetadata | undefined, ): void { ensureLoaded() const key = makeKey(instanceId, sessionId, messageId) const previous = promptDisplayOverrides.get(key) - if (displayText && displayText.length > 0) { - if (previous === displayText) return - promptDisplayOverrides.set(key, displayText) + 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) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 783e53394..2a117a10a 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -114,19 +114,19 @@ function createEmptyUsageState(): SessionUsageState { function resolveClientPromptDisplayText( instanceId: string, - input: Pick, - previous?: Pick, -): string | undefined { - if (typeof input.clientPromptDisplayText === "string") { - return input.clientPromptDisplayText + input: Pick, + previous?: Pick, +) { + if (input.clientPromptDisplayMetadata) { + return input.clientPromptDisplayMetadata } const persisted = getPromptDisplayOverride(instanceId, input.sessionId, input.id) - if (typeof persisted === "string") { + if (persisted) { return persisted } - return previous?.clientPromptDisplayText + return previous?.clientPromptDisplayMetadata } function extractUsageEntry(info: MessageInfo | undefined): UsageEntry | null { @@ -440,7 +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 clientPromptDisplayText = resolveClientPromptDisplayText(instanceId, input, previous) + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input, previous) normalizedRecords[input.id] = { id: input.id, sessionId: input.sessionId, @@ -449,12 +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, - clientPromptDisplayText, + 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, clientPromptDisplayText) + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) }) const infoList = infos ? Array.from(infos) : undefined @@ -545,7 +545,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState("messages", input.id, (previous) => { const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0 - const clientPromptDisplayText = resolveClientPromptDisplayText(instanceId, input, previous) + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input, previous) const record: MessageRecord = { id: input.id, sessionId: input.sessionId, @@ -554,12 +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, - clientPromptDisplayText, + clientPromptDisplayMetadata, revision, partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, } - setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayText) + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) nextRecord = record return record }) @@ -1058,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) => { diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 842dbaf80..3302c4e67 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,7 +21,7 @@ export interface MessageRecord { updatedAt: number revision: number isEphemeral?: boolean - clientPromptDisplayText?: string + clientPromptDisplayMetadata?: HiddenPromptDisplayMetadata partIds: string[] parts: Record } @@ -142,7 +143,7 @@ export interface MessageUpsertInput { createdAt?: number updatedAt?: number isEphemeral?: boolean - clientPromptDisplayText?: string + clientPromptDisplayMetadata?: HiddenPromptDisplayMetadata bumpRevision?: boolean } diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 6f9fa23e2..71b8d3ecb 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -179,7 +179,7 @@ async function sendMessage( createdAt, updatedAt: createdAt, isEphemeral: true, - clientPromptDisplayText: preparedPrompt.displayText, + clientPromptDisplayMetadata: preparedPrompt.displayMetadata, }) withSession(instanceId, sessionId, () => {