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 01/11] 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 02/11] 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, () => { From 55d14f81447387b52539ebd65a222e702153009f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 21 May 2026 09:34:17 +0200 Subject: [PATCH 03/11] feat: TASK-059 collapse pasted prompt text in history Rescope the hidden prompt section work to the pasted-text history use case so long placeholder-backed pastes stay fully visible to the model while rendering as collapsed disclosures in user message history. This replaces the hidden-marker-specific metadata helper with prompt display metadata that resolves pasted placeholders at send time, preserves optimistic and persisted display metadata, and keeps the existing prompt attachment flow intact when users edit or remove placeholders before submission. The history renderer and localized disclosure label now reflect pasted text rather than hidden prompt syntax. Add focused regression coverage for prompt preparation, metadata persistence, and casing-edited pasted placeholders, and validate the change with targeted tests, UI typecheck, and UI production build. --- packages/ui/src/components/message-part.tsx | 14 +- .../ui/src/lib/hidden-prompt-sections.test.ts | 59 ----- packages/ui/src/lib/hidden-prompt-sections.ts | 134 ----------- .../ui/src/lib/i18n/messages/en/messaging.ts | 2 +- .../ui/src/lib/i18n/messages/es/messaging.ts | 2 +- .../ui/src/lib/i18n/messages/fr/messaging.ts | 2 +- .../ui/src/lib/i18n/messages/he/messaging.ts | 2 +- .../ui/src/lib/i18n/messages/ja/messaging.ts | 2 +- .../ui/src/lib/i18n/messages/ru/messaging.ts | 2 +- .../lib/i18n/messages/zh-Hans/messaging.ts | 2 +- .../src/lib/prompt-display-metadata.test.ts | 73 ++++++ .../ui/src/lib/prompt-display-metadata.ts | 208 ++++++++++++++++++ packages/ui/src/lib/prompt-placeholders.ts | 84 +------ .../src/stores/message-prompt-display.test.ts | 5 +- .../ui/src/stores/message-prompt-display.ts | 22 +- packages/ui/src/stores/message-v2/types.ts | 6 +- packages/ui/src/stores/session-actions.ts | 6 +- 17 files changed, 317 insertions(+), 308 deletions(-) delete mode 100644 packages/ui/src/lib/hidden-prompt-sections.test.ts delete mode 100644 packages/ui/src/lib/hidden-prompt-sections.ts create mode 100644 packages/ui/src/lib/prompt-display-metadata.test.ts create mode 100644 packages/ui/src/lib/prompt-display-metadata.ts diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 0fc40f33f..4c830bbae 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, type HiddenPromptDisplayMetadata } from "../lib/hidden-prompt-sections" +import { splitPromptDisplaySections, type PromptDisplayMetadata } from "../lib/prompt-display-metadata" 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 - displayMetadataOverride?: HiddenPromptDisplayMetadata + displayMetadataOverride?: PromptDisplayMetadata onRendered?: () => void } @@ -56,12 +56,12 @@ export default function MessagePart(props: MessagePartProps) { return typeof id === "string" && id.length > 0 } - const hiddenPromptSegments = createMemo(() => { + 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 splitHiddenPromptSections(props.part.text, props.displayMetadataOverride) + return splitPromptDisplaySections(props.part.text, props.displayMetadataOverride) }) function reasoningSegmentHasText(segment: unknown): boolean { @@ -149,7 +149,7 @@ export default function MessagePart(props: MessagePartProps) { data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined} > {plainTextContent()}}> segment.text.length > 0)}> {(segment, index) => - segment.hidden ? ( + segment.kind === "pasted" ? (
- {t("messagePart.hiddenPrompt.summary")} + {t("messagePart.pastedText.summary")}
{ - 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 deleted file mode 100644 index f2e9b8c97..000000000 --- a/packages/ui/src/lib/hidden-prompt-sections.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 aab744de1..2325d2b7c 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -126,7 +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", + "messagePart.pastedText.summary": "Pasted text", "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 3dd4e247d..650b7c5f0 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -128,7 +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", + "messagePart.pastedText.summary": "Texto pegado", "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 a53176e5f..9d8b754dd 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -128,7 +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", + "messagePart.pastedText.summary": "Texte collé", "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 b14721433..2005617f7 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -126,7 +126,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "מחק פריט זה", "messagePart.actions.deleteFailedTitle": "המחיקה נכשלה", "messagePart.actions.deleteFailedMessage": "מחיקת הפריט נכשלה", - "messagePart.hiddenPrompt.summary": "מקטע פרומפט מוסתר", + "messagePart.pastedText.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 bdff4967b..bf1b82ce0 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -128,7 +128,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "この項目を削除", "messagePart.actions.deleteFailedTitle": "削除に失敗しました", "messagePart.actions.deleteFailedMessage": "項目の削除に失敗しました", - "messagePart.hiddenPrompt.summary": "非表示のプロンプトセクション", + "messagePart.pastedText.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 5f02c3563..11bf9202b 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -128,7 +128,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "Удалить этот элемент", "messagePart.actions.deleteFailedTitle": "Ошибка удаления", "messagePart.actions.deleteFailedMessage": "Не удалось удалить элемент", - "messagePart.hiddenPrompt.summary": "Скрытый раздел промпта", + "messagePart.pastedText.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 efc7fb62a..1224f734f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -128,7 +128,7 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "删除此项", "messagePart.actions.deleteFailedTitle": "删除失败", "messagePart.actions.deleteFailedMessage": "删除失败", - "messagePart.hiddenPrompt.summary": "已隐藏的提示区段", + "messagePart.pastedText.summary": "粘贴的文本", "messageItem.attachment.defaultName": "附件", "messageItem.attachment.downloadAriaLabel": "下载 {name}", "messageItem.agentMeta.agentLabel": "智能体:{agent}", diff --git a/packages/ui/src/lib/prompt-display-metadata.test.ts b/packages/ui/src/lib/prompt-display-metadata.test.ts new file mode 100644 index 000000000..397b9b394 --- /dev/null +++ b/packages/ui/src/lib/prompt-display-metadata.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { createTextAttachment } from "../types/attachment" +import { preparePromptDisplayText, resolvePastedPlaceholders, splitPromptDisplaySections } from "./prompt-display-metadata" + +describe("preparePromptDisplayText", () => { + it("keeps pasted text fully visible to the model while storing display metadata", () => { + const attachment = createTextAttachment("line 1\nline 2\nline 3\nline 4", "pasted #1 (4 lines)", "paste-1.txt") + + const result = preparePromptDisplayText("Summarize this:\n[pasted #1]\nThanks", [attachment]) + + assert.equal(result.promptToSend, "Summarize this:\nline 1\nline 2\nline 3\nline 4\nThanks") + assert.deepEqual(result.displayMetadata, { + segments: [ + { kind: "inline", length: 16 }, + { kind: "pasted", length: 27 }, + { kind: "inline", length: 7 }, + ], + }) + }) + + it("falls back to plain text rendering when no placeholder-backed pasted structure remains", () => { + const pastedText = "line 1\nline 2\nline 3\nline 4" + const attachment = createTextAttachment(pastedText, "pasted #1 (4 lines)", "paste-1.txt") + + const result = preparePromptDisplayText(`Summarize this:\n${pastedText}\nThanks`, [attachment]) + + assert.equal(result.promptToSend, `Summarize this:\n${pastedText}\nThanks`) + assert.equal(result.displayMetadata, undefined) + }) + + it("resolves loose pasted placeholders consistently", () => { + const attachment = createTextAttachment("alpha\nbeta\ngamma\ndelta", "pasted #1 (4 lines)", "paste-1.txt") + + assert.equal(resolvePastedPlaceholders("Before [ pasted # 1 ] After", [attachment]), "Before alpha\nbeta\ngamma\ndelta After") + }) + + it("resolves pasted placeholders when the placeholder casing is edited", () => { + const attachment = createTextAttachment("alpha\nbeta\ngamma\ndelta", "pasted #1 (4 lines)", "paste-1.txt") + + const result = preparePromptDisplayText("Before [Pasted #1] After", [attachment]) + + assert.equal(result.promptToSend, "Before alpha\nbeta\ngamma\ndelta After") + assert.deepEqual(result.displayMetadata, { + segments: [ + { kind: "inline", length: 7 }, + { kind: "pasted", length: 22 }, + { kind: "inline", length: 6 }, + ], + }) + }) +}) + +describe("splitPromptDisplaySections", () => { + it("reconstructs inline and pasted display sections", () => { + const attachment = createTextAttachment("A\nB\nC\nD", "pasted #1 (4 lines)", "paste-1.txt") + const prepared = preparePromptDisplayText("Intro\n[pasted #1]\nOutro", [attachment]) + + assert.deepEqual(splitPromptDisplaySections(prepared.promptToSend, prepared.displayMetadata), [ + { kind: "inline", text: "Intro\n" }, + { kind: "pasted", text: "A\nB\nC\nD" }, + { kind: "inline", text: "\nOutro" }, + ]) + }) + + it("returns null when metadata no longer matches the text", () => { + assert.equal( + splitPromptDisplaySections("short", { segments: [{ kind: "inline", length: 10 }] }), + null, + ) + }) +}) diff --git a/packages/ui/src/lib/prompt-display-metadata.ts b/packages/ui/src/lib/prompt-display-metadata.ts new file mode 100644 index 000000000..8acea5a12 --- /dev/null +++ b/packages/ui/src/lib/prompt-display-metadata.ts @@ -0,0 +1,208 @@ +import type { Attachment, FileSource, TextSource } from "../types/attachment" + +export type PromptDisplaySegmentKind = "inline" | "pasted" + +export interface PromptDisplaySegment { + kind: PromptDisplaySegmentKind + text: string +} + +export interface PromptDisplaySegmentMetadata { + kind: PromptDisplaySegmentKind + length: number +} + +export interface PromptDisplayMetadata { + segments: PromptDisplaySegmentMetadata[] +} + +export interface PreparedPromptDisplayText { + promptToSend: string + displayMetadata?: PromptDisplayMetadata +} + +const PASTED_PLACEHOLDER_REGEX = /\[\s*pasted\s*#\s*(\d+)\s*\]/gi + +function hasPastedPlaceholders(text: string): boolean { + PASTED_PLACEHOLDER_REGEX.lastIndex = 0 + return PASTED_PLACEHOLDER_REGEX.test(text) +} + +function pushInlineSegment(segments: PromptDisplaySegment[], text: string): void { + if (!text) return + const previous = segments[segments.length - 1] + if (previous && previous.kind === "inline") { + previous.text += text + return + } + segments.push({ kind: "inline", text }) +} + +function pushPastedSegment(segments: PromptDisplaySegment[], text: string): void { + if (!text) return + segments.push({ kind: "pasted", text }) +} + +function resolvePathMentions(prompt: string, attachments: Attachment[] = []): string { + if (!prompt) { + return prompt + } + + const fileAttachments = new Set( + attachments + .filter((a): a is Attachment & { source: FileSource } => a.source.type === "file") + .map((a) => a.source.path), + ) + + const pathAttachments = new Set( + attachments + .filter( + (a): a is Attachment & { source: TextSource } => + a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:"), + ) + .map((a) => a.source.value), + ) + + let result = prompt + + result = result.replace(/@(\.\/)/g, "___ROOT___") + result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___") + + const allPaths = new Set() + for (const path of fileAttachments) { + if (path && path !== "." && path !== "./") allPaths.add(path) + } + for (const path of pathAttachments) { + if (path && path !== "." && path !== "./") allPaths.add(path) + } + + for (const path of allPaths) { + const withoutPrefix = path.startsWith("./") ? path.slice(2) : path + const withPrefix = path.startsWith("./") ? path : `./${path}` + result = result.replace(`@${withoutPrefix}`, withPrefix) + result = result.replace(`@${withoutPrefix}/`, `${withPrefix}/`) + } + + result = result.replace("___ROOT___", "./") + result = result.replace("___ROOT_NOSLASH___", "./") + + return result +} + +function createPastedLookup(attachments: Attachment[]): Map { + const lookup = new Map() + + for (const attachment of attachments) { + if (attachment?.source.type !== "text") continue + if (typeof attachment.display !== "string") continue + const match = attachment.display.match(/pasted #(\d+)/i) + if (!match) continue + if (!lookup.has(match[1])) { + lookup.set(match[1], attachment.source.value) + } + } + + return lookup +} + +export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { + const result = resolvePathMentions(prompt, attachments) + if (!hasPastedPlaceholders(result)) { + return result + } + + const lookup = createPastedLookup(attachments) + if (lookup.size === 0) { + return result + } + + return result.replace(PASTED_PLACEHOLDER_REGEX, (fullMatch, counter: string) => { + const replacement = lookup.get(counter) + return typeof replacement === "string" ? replacement : fullMatch + }) +} + +export function preparePromptDisplayText(prompt: string, attachments: Attachment[] = []): PreparedPromptDisplayText { + const resolvedBase = resolvePathMentions(prompt, attachments) + if (!hasPastedPlaceholders(resolvedBase)) { + return { promptToSend: resolvedBase } + } + + const lookup = createPastedLookup(attachments) + if (lookup.size === 0) { + return { promptToSend: resolvedBase } + } + + PASTED_PLACEHOLDER_REGEX.lastIndex = 0 + + const segments: PromptDisplaySegment[] = [] + let lastIndex = 0 + let foundResolvablePlaceholder = false + let failed = false + + for (const match of resolvedBase.matchAll(PASTED_PLACEHOLDER_REGEX)) { + const start = match.index ?? 0 + const counter = match[1] + const replacement = lookup.get(counter) + if (typeof replacement !== "string") { + failed = true + break + } + + pushInlineSegment(segments, resolvedBase.slice(lastIndex, start)) + pushPastedSegment(segments, replacement) + foundResolvablePlaceholder = true + lastIndex = start + match[0].length + } + + if (failed || !foundResolvablePlaceholder) { + return { promptToSend: resolvePastedPlaceholders(prompt, attachments) } + } + + pushInlineSegment(segments, resolvedBase.slice(lastIndex)) + + return { + promptToSend: segments.map((segment) => segment.text).join(""), + displayMetadata: { + segments: segments.map((segment) => ({ kind: segment.kind, length: segment.text.length })), + }, + } +} + +export function splitPromptDisplaySections( + text: string, + metadata: PromptDisplayMetadata | undefined, +): PromptDisplaySegment[] | null { + if (!metadata || !Array.isArray(metadata.segments) || metadata.segments.length === 0) { + return null + } + + const segments: PromptDisplaySegment[] = [] + let offset = 0 + + for (const segment of metadata.segments) { + if (!segment || typeof segment.length !== "number" || segment.length < 0) { + return null + } + if (segment.kind !== "inline" && segment.kind !== "pasted") { + return null + } + const nextOffset = offset + segment.length + if (nextOffset > text.length) { + return null + } + const nextText = text.slice(offset, nextOffset) + if (segment.kind === "inline") { + pushInlineSegment(segments, nextText) + } else { + pushPastedSegment(segments, nextText) + } + offset = nextOffset + } + + if (offset !== text.length) { + return null + } + + return segments +} diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index b5c8ee555..75bc4d2d1 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -1,83 +1 @@ -import type { Attachment, FileSource } from "../types/attachment" - -export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { - if (!prompt) { - return prompt - } - - const fileAttachments = new Set( - attachments - .filter((a): a is Attachment & { source: FileSource } => a.source.type === "file") - .map((a) => a.source.path), - ) - - const pathAttachments = new Set( - attachments - .filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:")) - .map((a) => (a.source as { value: string }).value), - ) - - let result = prompt - - // Step 1: Handle root paths FIRST using unique placeholders - // Replace longer pattern first to avoid partial match issues - result = result.replace(/@(\.\/)/g, "___ROOT___") - result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___") - // Note: The regex @(\.)(?!\.) means @. NOT followed by another . - - // Step 2: Build set of non-root paths - const allPaths = new Set() - for (const p of fileAttachments) { - if (p && p !== "." && p !== "./") allPaths.add(p) - } - for (const p of pathAttachments) { - if (p && p !== "." && p !== "./") allPaths.add(p) - } - - // Step 3: Replace @path with ./path for non-root paths - for (const path of allPaths) { - if (!path) continue - const withoutPrefix = path.startsWith("./") ? path.slice(2) : path - const withPrefix = path.startsWith("./") ? path : "./" + path - result = result.replace("@" + withoutPrefix, withPrefix) - result = result.replace("@" + withoutPrefix + "/", withPrefix + "/") - } - - // Step 4: Convert placeholders back to ./ - result = result.replace("___ROOT___", "./") - result = result.replace("___ROOT_NOSLASH___", "./") - - // Step 5: Resolve [pasted #N] placeholders - if (!result.includes("[pasted #")) { - return result - } - - if (!attachments || attachments.length === 0) { - return result - } - - const lookup = new Map() - - for (const attachment of attachments) { - const source = attachment?.source - if (!source || source.type !== "text") continue - const display = attachment?.display - const value = (source as { value?: string }).value - if (typeof display !== "string" || typeof value !== "string") continue - const match = display.match(/pasted #(\d+)/) - if (!match) continue - const placeholder = `[pasted #${match[1]}]` - if (!lookup.has(placeholder)) { - lookup.set(placeholder, value) - } - } - - if (lookup.size === 0) { - return result - } - - return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { - const replacement = lookup.get(fullMatch) - return typeof replacement === "string" ? replacement : fullMatch - }) -} +export { resolvePastedPlaceholders } from "./prompt-display-metadata" diff --git a/packages/ui/src/stores/message-prompt-display.test.ts b/packages/ui/src/stores/message-prompt-display.test.ts index c0f2aa0eb..c153e2f73 100644 --- a/packages/ui/src/stores/message-prompt-display.test.ts +++ b/packages/ui/src/stores/message-prompt-display.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import type { PromptDisplayMetadata } from "../lib/prompt-display-metadata" import { clearPromptDisplayOverride, clearPromptDisplayOverridesForInstance, @@ -39,7 +40,7 @@ type WindowWithMemoryStorage = { } describe("message prompt display overrides", () => { - it("persists and moves hidden prompt display text by message id", () => { + it("persists and moves prompt display metadata by message id", () => { const instanceId = `instance-${Date.now()}` const sessionId = "session-1" const oldMessageId = "temp-msg" @@ -49,7 +50,7 @@ describe("message prompt display overrides", () => { clearPromptDisplayOverridesForInstance(instanceId) - const metadata = { segments: [{ hidden: false, length: 7 }, { hidden: true, length: 6 }] } + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 7 }, { kind: "pasted", length: 6 }] } setPromptDisplayOverride(instanceId, sessionId, oldMessageId, metadata) assert.deepEqual( diff --git a/packages/ui/src/stores/message-prompt-display.ts b/packages/ui/src/stores/message-prompt-display.ts index 947638dad..0289f0870 100644 --- a/packages/ui/src/stores/message-prompt-display.ts +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -1,9 +1,9 @@ -import type { HiddenPromptDisplayMetadata } from "../lib/hidden-prompt-sections" +import type { PromptDisplayMetadata } from "../lib/prompt-display-metadata" -const STORAGE_KEY = "codenomad:hidden-prompt-display:v1" +const STORAGE_KEY = "codenomad:prompt-display:v2" let loaded = false -const promptDisplayOverrides = new Map() +const promptDisplayOverrides = new Map() function makeKey(instanceId: string, sessionId: string, messageId: string): string { return `${instanceId}:${sessionId}:${messageId}` @@ -27,7 +27,7 @@ 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 (isPromptDisplayMetadata(value)) { promptDisplayOverrides.set(key, value) @@ -49,13 +49,17 @@ function persist(): void { } } -function isPromptDisplayMetadata(value: unknown): value is HiddenPromptDisplayMetadata { +function isPromptDisplayMetadata(value: unknown): value is PromptDisplayMetadata { if (!value || typeof value !== "object") return false - const segments = (value as HiddenPromptDisplayMetadata).segments + const segments = (value as PromptDisplayMetadata).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, + segment && + typeof segment === "object" && + (segment.kind === "inline" || segment.kind === "pasted") && + typeof segment.length === "number" && + segment.length >= 0, ) } @@ -63,7 +67,7 @@ export function getPromptDisplayOverride( instanceId: string, sessionId: string, messageId: string, -): HiddenPromptDisplayMetadata | undefined { +): PromptDisplayMetadata | undefined { ensureLoaded() return promptDisplayOverrides.get(makeKey(instanceId, sessionId, messageId)) } @@ -72,7 +76,7 @@ export function setPromptDisplayOverride( instanceId: string, sessionId: string, messageId: string, - displayMetadata: HiddenPromptDisplayMetadata | undefined, + displayMetadata: PromptDisplayMetadata | undefined, ): void { ensureLoaded() const key = makeKey(instanceId, sessionId, messageId) diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 3302c4e67..6a5811d0c 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,5 +1,5 @@ import type { ClientPart } from "../../types/message" -import type { HiddenPromptDisplayMetadata } from "../../lib/hidden-prompt-sections" +import type { PromptDisplayMetadata } from "../../lib/prompt-display-metadata" import type { PermissionRequestLike } from "../../types/permission" import type { QuestionRequest } from "../../types/question" @@ -21,7 +21,7 @@ export interface MessageRecord { updatedAt: number revision: number isEphemeral?: boolean - clientPromptDisplayMetadata?: HiddenPromptDisplayMetadata + clientPromptDisplayMetadata?: PromptDisplayMetadata partIds: string[] parts: Record } @@ -143,7 +143,7 @@ export interface MessageUpsertInput { createdAt?: number updatedAt?: number isEphemeral?: boolean - clientPromptDisplayMetadata?: HiddenPromptDisplayMetadata + clientPromptDisplayMetadata?: PromptDisplayMetadata bumpRevision?: boolean } diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 71b8d3ecb..40b9b7ce6 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1,5 +1,4 @@ -import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" -import { preparePromptDisplayText } from "../lib/hidden-prompt-sections" +import { preparePromptDisplayText } from "../lib/prompt-display-metadata" import { instances } from "./instances" import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" @@ -98,8 +97,7 @@ async function sendMessage( const messageId = createId("msg") const textPartId = createId("prt") - const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments) - const preparedPrompt = preparePromptDisplayText(resolvedPrompt) + const preparedPrompt = preparePromptDisplayText(prompt, attachments) const optimisticParts: any[] = [ { From 080ee6c16fa03e6569fd8a422fe3db15f378b1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 21 May 2026 13:29:04 +0200 Subject: [PATCH 04/11] fix: TASK-059 preserve pasted collapse through prompt submission Keep placeholder-backed pasted text intact for message submission so the send path can generate collapsed history metadata instead of flattening the prompt too early in the composer. This introduces a prompt submission helper that separates the history entry from the actual submitted message, normalizes pasted text line endings for display metadata generation, and adds focused regression coverage for Windows CRLF hydration and message-mode placeholder handling. Validated with focused prompt submission tests plus UI typecheck and production build to reduce the risk of regressions while resuming the rescoped PR 407 behavior. --- packages/ui/src/components/prompt-input.tsx | 19 ++++---- .../prompt-input/submitPrompt.test.ts | 20 ++++++++ .../components/prompt-input/submitPrompt.ts | 46 +++++++++++++++++++ .../src/lib/prompt-display-metadata.test.ts | 13 ++++++ .../ui/src/lib/prompt-display-metadata.ts | 6 ++- 5 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/components/prompt-input/submitPrompt.test.ts create mode 100644 packages/ui/src/components/prompt-input/submitPrompt.ts diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index e5d562906..d4c780db7 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -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" @@ -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) diff --git a/packages/ui/src/components/prompt-input/submitPrompt.test.ts b/packages/ui/src/components/prompt-input/submitPrompt.test.ts new file mode 100644 index 000000000..ade3a3809 --- /dev/null +++ b/packages/ui/src/components/prompt-input/submitPrompt.test.ts @@ -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") + }) +}) diff --git a/packages/ui/src/components/prompt-input/submitPrompt.ts b/packages/ui/src/components/prompt-input/submitPrompt.ts new file mode 100644 index 000000000..8af78fce5 --- /dev/null +++ b/packages/ui/src/components/prompt-input/submitPrompt.ts @@ -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: "", + } +} diff --git a/packages/ui/src/lib/prompt-display-metadata.test.ts b/packages/ui/src/lib/prompt-display-metadata.test.ts index 397b9b394..0a59cd65e 100644 --- a/packages/ui/src/lib/prompt-display-metadata.test.ts +++ b/packages/ui/src/lib/prompt-display-metadata.test.ts @@ -50,6 +50,19 @@ describe("preparePromptDisplayText", () => { ], }) }) + + it("normalizes pasted CRLF content so collapsed metadata survives LF hydration", () => { + const attachment = createTextAttachment("a\r\nb\r\nc\r\nd", "pasted #1 (4 lines)", "paste-1.txt") + + const prepared = preparePromptDisplayText("Intro\n[pasted #1]\nOutro", [attachment]) + + assert.equal(prepared.promptToSend, "Intro\na\nb\nc\nd\nOutro") + assert.deepEqual(splitPromptDisplaySections("Intro\na\nb\nc\nd\nOutro", prepared.displayMetadata), [ + { kind: "inline", text: "Intro\n" }, + { kind: "pasted", text: "a\nb\nc\nd" }, + { kind: "inline", text: "\nOutro" }, + ]) + }) }) describe("splitPromptDisplaySections", () => { diff --git a/packages/ui/src/lib/prompt-display-metadata.ts b/packages/ui/src/lib/prompt-display-metadata.ts index 8acea5a12..395f9da8c 100644 --- a/packages/ui/src/lib/prompt-display-metadata.ts +++ b/packages/ui/src/lib/prompt-display-metadata.ts @@ -23,6 +23,10 @@ export interface PreparedPromptDisplayText { const PASTED_PLACEHOLDER_REGEX = /\[\s*pasted\s*#\s*(\d+)\s*\]/gi +function normalizeLineEndings(text: string): string { + return text.replace(/\r\n?/g, "\n") +} + function hasPastedPlaceholders(text: string): boolean { PASTED_PLACEHOLDER_REGEX.lastIndex = 0 return PASTED_PLACEHOLDER_REGEX.test(text) @@ -98,7 +102,7 @@ function createPastedLookup(attachments: Attachment[]): Map { const match = attachment.display.match(/pasted #(\d+)/i) if (!match) continue if (!lookup.has(match[1])) { - lookup.set(match[1], attachment.source.value) + lookup.set(match[1], normalizeLineEndings(attachment.source.value)) } } From acff400d9189c3936d8eeedc17c0f1ad7636abb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 21 May 2026 14:20:28 +0200 Subject: [PATCH 05/11] fix: TASK-059 persist and lazy-render pasted history blocks Preserve collapsed pasted prompt sections after reopening sessions by storing prompt display metadata under stable session and message keys instead of ephemeral workspace instance ids. This also migrates legacy instance-scoped storage entries forward, adds focused persistence coverage for reopen and storage-key migration, and lazy-mounts pasted disclosure markdown so closed blocks avoid the expensive initial markdown and highlight render path. Validated with focused pasted-text tests plus UI typecheck and production build so the resumed PR 407 scope keeps the fixed placeholder-backed submission flow while improving reopen behavior and rendering cost. --- packages/ui/src/components/message-part.tsx | 51 ++++++++++++------- .../src/stores/message-prompt-display.test.ts | 47 +++++++++++++++++ .../ui/src/stores/message-prompt-display.ts | 46 ++++++++++++++--- 3 files changed, 119 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 4c830bbae..cef8daf8b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Suspense, Switch, createMemo, 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" @@ -137,6 +137,38 @@ export default function MessagePart(props: MessagePartProps) { toggleItemExpanded(reasoningId()) } + function PastedTextDisclosure(disclosureProps: { text: string; index: number }) { + const [hasExpanded, setHasExpanded] = createSignal(false) + + return ( +
{ + if ((event.currentTarget as HTMLDetailsElement).open) { + setHasExpanded(true) + } + }} + > + + {t("messagePart.pastedText.summary")} + + +
+ +
+
+
+ ) + } + return ( @@ -169,22 +201,7 @@ export default function MessagePart(props: MessagePartProps) { segment.text.length > 0)}> {(segment, index) => segment.kind === "pasted" ? ( -
- - {t("messagePart.pastedText.summary")} - -
- -
-
+ ) : ( { const newMessageId = "real-msg" const storage = new MemoryStorage() ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() clearPromptDisplayOverridesForInstance(instanceId) @@ -70,4 +73,48 @@ describe("message prompt display overrides", () => { delete (globalThis as unknown as { window?: unknown }).window }) + + it("finds persisted metadata after reopening with a different instance id", () => { + const firstInstanceId = `instance-a-${Date.now()}` + const reopenedInstanceId = `instance-b-${Date.now()}` + const sessionId = "session-stable" + const messageId = "msg-1" + const storage = new MemoryStorage() + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + clearPromptDisplayOverridesForSession(firstInstanceId, sessionId) + + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 5 }, { kind: "pasted", length: 12 }] } + setPromptDisplayOverride(firstInstanceId, sessionId, messageId, metadata) + + assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), metadata) + + clearPromptDisplayOverride(reopenedInstanceId, sessionId, messageId) + assert.equal(getPromptDisplayOverride(firstInstanceId, sessionId, messageId), undefined) + + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("migrates legacy instance-scoped storage keys to stable reopen keys", () => { + const storage = new MemoryStorage() + const legacyInstanceId = "legacy-instance" + const reopenedInstanceId = "reopened-instance" + const sessionId = "session-legacy" + const messageId = "msg-legacy" + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 4 }, { kind: "pasted", length: 9 }] } + + storage.setItem( + "codenomad:prompt-display:v2", + JSON.stringify({ [`${legacyInstanceId}:${sessionId}:${messageId}`]: metadata }), + ) + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), metadata) + assert.equal(storage.getItem("codenomad:prompt-display:v3")?.includes(`${sessionId}:${messageId}`), true) + + clearPromptDisplayOverride(reopenedInstanceId, sessionId, messageId) + 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 index 0289f0870..608f3d122 100644 --- a/packages/ui/src/stores/message-prompt-display.ts +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -1,12 +1,27 @@ import type { PromptDisplayMetadata } from "../lib/prompt-display-metadata" -const STORAGE_KEY = "codenomad:prompt-display:v2" +const STORAGE_KEY = "codenomad:prompt-display:v3" let loaded = false const promptDisplayOverrides = new Map() -function makeKey(instanceId: string, sessionId: string, messageId: string): string { - return `${instanceId}:${sessionId}:${messageId}` +function makeKey(_instanceId: string, sessionId: string, messageId: string): string { + return `${sessionId}:${messageId}` +} + +function isLegacyInstanceScopedKey(key: string): boolean { + return key.split(":").length === 3 +} + +function migrateStoredKey(key: string): string { + if (!isLegacyInstanceScopedKey(key)) { + return key + } + const [, sessionId, messageId] = key.split(":") + if (!sessionId || !messageId) { + return key + } + return `${sessionId}:${messageId}` } function readStorage(): Storage | null { @@ -25,14 +40,24 @@ function ensureLoaded(): void { if (!storage) return try { + const parsedEntries: Record[] = [] 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) + if (raw) { + parsedEntries.push(JSON.parse(raw) as Record) + } + const legacyRaw = storage.getItem("codenomad:prompt-display:v2") + if (legacyRaw) { + parsedEntries.push(JSON.parse(legacyRaw) as Record) + } + if (parsedEntries.length === 0) return + for (const parsed of parsedEntries) { + for (const [key, value] of Object.entries(parsed)) { + if (isPromptDisplayMetadata(value)) { + promptDisplayOverrides.set(migrateStoredKey(key), value) + } } } + persist() } catch { promptDisplayOverrides.clear() } @@ -140,3 +165,8 @@ export function clearPromptDisplayOverridesForInstance(instanceId: string): void if (!changed) return persist() } + +export function resetPromptDisplayOverrideStateForTests(): void { + loaded = false + promptDisplayOverrides.clear() +} From 2e0f6860e6064b6bbe683c07321dc935fc5163fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 21 May 2026 14:57:24 +0200 Subject: [PATCH 06/11] fix: TASK-059 align v3 prompt display cleanup semantics Update prompt display metadata cleanup to match the stable v3 session/message storage key model so clearing a session or instance removes the correct persisted pasted-collapse entries instead of relying on obsolete instance-scoped prefixes. This adds focused regression coverage for session-level and instance-level v3 cleanup behavior, keeps legacy key cleanup support in place, and passes the instance store's known session ids into broader cleanup so reopen persistence does not regress while stale metadata is removed correctly. Validated with focused pasted-text tests plus UI typecheck and production build to keep the rescoped PR 407 history-collapse behavior stable across reopen and cleanup paths. --- .../src/stores/message-prompt-display.test.ts | 38 +++++++++++++++++++ .../ui/src/stores/message-prompt-display.ts | 12 +++--- .../src/stores/message-v2/instance-store.ts | 2 +- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/stores/message-prompt-display.test.ts b/packages/ui/src/stores/message-prompt-display.test.ts index 716f326ba..0889b9905 100644 --- a/packages/ui/src/stores/message-prompt-display.test.ts +++ b/packages/ui/src/stores/message-prompt-display.test.ts @@ -117,4 +117,42 @@ describe("message prompt display overrides", () => { clearPromptDisplayOverride(reopenedInstanceId, sessionId, messageId) delete (globalThis as unknown as { window?: unknown }).window }) + + it("clears stable v3 entries for a session", () => { + const instanceId = `instance-${Date.now()}` + const storage = new MemoryStorage() + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 3 }, { kind: "pasted", length: 8 }] } + setPromptDisplayOverride(instanceId, "session-a", "msg-1", metadata) + setPromptDisplayOverride(instanceId, "session-b", "msg-2", metadata) + + clearPromptDisplayOverridesForSession(instanceId, "session-a") + + assert.equal(getPromptDisplayOverride("other-instance", "session-a", "msg-1"), undefined) + assert.deepEqual(getPromptDisplayOverride("other-instance", "session-b", "msg-2"), metadata) + + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("clears stable v3 entries for all known instance sessions", () => { + const instanceId = `instance-${Date.now()}` + const storage = new MemoryStorage() + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 2 }, { kind: "pasted", length: 5 }] } + setPromptDisplayOverride(instanceId, "session-a", "msg-1", metadata) + setPromptDisplayOverride(instanceId, "session-b", "msg-2", metadata) + setPromptDisplayOverride(instanceId, "session-c", "msg-3", metadata) + + clearPromptDisplayOverridesForInstance(instanceId, ["session-a", "session-b"]) + + assert.equal(getPromptDisplayOverride("reopened", "session-a", "msg-1"), undefined) + assert.equal(getPromptDisplayOverride("reopened", "session-b", "msg-2"), undefined) + assert.deepEqual(getPromptDisplayOverride("reopened", "session-c", "msg-3"), metadata) + + 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 index 608f3d122..8054fd4d9 100644 --- a/packages/ui/src/stores/message-prompt-display.ts +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -140,10 +140,11 @@ export function clearPromptDisplayOverride(instanceId: string, sessionId: string export function clearPromptDisplayOverridesForSession(instanceId: string, sessionId: string): void { ensureLoaded() - const prefix = `${instanceId}:${sessionId}:` + const stablePrefix = `${sessionId}:` + const legacyPrefix = `${instanceId}:${sessionId}:` let changed = false for (const key of promptDisplayOverrides.keys()) { - if (key.startsWith(prefix)) { + if (key.startsWith(stablePrefix) || key.startsWith(legacyPrefix)) { promptDisplayOverrides.delete(key) changed = true } @@ -152,12 +153,13 @@ export function clearPromptDisplayOverridesForSession(instanceId: string, sessio persist() } -export function clearPromptDisplayOverridesForInstance(instanceId: string): void { +export function clearPromptDisplayOverridesForInstance(instanceId: string, sessionIds: string[] = []): void { ensureLoaded() - const prefix = `${instanceId}:` let changed = false for (const key of promptDisplayOverrides.keys()) { - if (key.startsWith(prefix)) { + const shouldDeleteStableKey = sessionIds.some((sessionId) => key.startsWith(`${sessionId}:`)) + const shouldDeleteLegacyKey = key.startsWith(`${instanceId}:`) + if (shouldDeleteStableKey || shouldDeleteLegacyKey) { promptDisplayOverrides.delete(key) changed = true } diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 862429600..b23f04016 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -1261,7 +1261,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function clearInstance() { - clearPromptDisplayOverridesForInstance(instanceId) + clearPromptDisplayOverridesForInstance(instanceId, Object.keys(state.sessions)) messageInfoCache.clear() setState(reconcile(createInitialState(instanceId))) } From 383739f043d0f02827cd931898627c2b62cf2631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 30 May 2026 12:15:00 +0200 Subject: [PATCH 07/11] fix: TASK-059 polish pasted text collapse shell Preserve the approved pasted-text history shell by keeping the compact header, right-aligned action cluster, and contour-free icon treatment while leaving the expanded markdown body on the normal rendering path. The disclosure now shows line-count context, uses the ChevronsUpDown/ChevronsDownUp affordance, and keeps copy available without moving the duration placement back into the rejected layout. Add a tiny pasted-text display helper with focused coverage and localize the new line-count and copy accessibility strings across the supported UI locales so the shell remains discoverable without hardcoded English. Validation in this worktree: npx tsx --test packages/ui/src/lib/pasted-text-display.test.ts, npm run typecheck --workspace @codenomad/ui, and npm run build --workspace @codenomad/ui. nomadworks_validate was also run and still fails because of pre-existing repository-wide codemap defects outside TASK-059 scope. --- packages/ui/src/components/message-part.tsx | 51 +++++++++++++++++-- .../ui/src/lib/i18n/messages/en/messaging.ts | 3 ++ .../ui/src/lib/i18n/messages/es/messaging.ts | 3 ++ .../ui/src/lib/i18n/messages/fr/messaging.ts | 3 ++ .../ui/src/lib/i18n/messages/he/messaging.ts | 3 ++ .../ui/src/lib/i18n/messages/ja/messaging.ts | 3 ++ .../ui/src/lib/i18n/messages/ru/messaging.ts | 3 ++ .../lib/i18n/messages/zh-Hans/messaging.ts | 3 ++ .../ui/src/lib/pasted-text-display.test.ts | 14 +++++ packages/ui/src/lib/pasted-text-display.ts | 4 ++ 10 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/lib/pasted-text-display.test.ts create mode 100644 packages/ui/src/lib/pasted-text-display.ts diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index cef8daf8b..df3d911d6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,10 +1,13 @@ import { For, Match, Show, Suspense, Switch, createMemo, createSignal, lazy } from "solid-js" +import { ChevronsDownUp, ChevronsUpDown, Copy } from "lucide-solid" 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" +import { copyToClipboard } from "../lib/clipboard" +import { getPastedTextLineCount } from "../lib/pasted-text-display" type ToolCallPart = Extract @@ -139,21 +142,59 @@ export default function MessagePart(props: MessagePartProps) { function PastedTextDisclosure(disclosureProps: { text: string; index: number }) { const [hasExpanded, setHasExpanded] = createSignal(false) + const [isOpen, setIsOpen] = createSignal(false) + const [copied, setCopied] = createSignal(false) + const lineCount = () => getPastedTextLineCount(disclosureProps.text) + const lineCountLabel = () => + lineCount() === 1 + ? t("messagePart.pastedText.lines.one", { count: String(lineCount()) }) + : t("messagePart.pastedText.lines.other", { count: String(lineCount()) }) + const copyLabel = () => (copied() ? t("codeBlockInline.actions.copied") : t("codeBlockInline.actions.copy")) + + const handleCopy = async (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const success = await copyToClipboard(disclosureProps.text) + setCopied(success) + setTimeout(() => setCopied(false), 2000) + } return (
{ - if ((event.currentTarget as HTMLDetailsElement).open) { + const nextOpen = (event.currentTarget as HTMLDetailsElement).open + setIsOpen(nextOpen) + if (nextOpen) { setHasExpanded(true) } }} > - - {t("messagePart.pastedText.summary")} + + + {t("messagePart.pastedText.summary")} + {lineCountLabel()} + + + + + -
+
{ + it("counts single-line pasted text", () => { + assert.equal(getPastedTextLineCount("alpha"), 1) + }) + + it("counts multi-line pasted text", () => { + assert.equal(getPastedTextLineCount("alpha\nbeta\ngamma"), 3) + }) +}) diff --git a/packages/ui/src/lib/pasted-text-display.ts b/packages/ui/src/lib/pasted-text-display.ts new file mode 100644 index 000000000..93c32a977 --- /dev/null +++ b/packages/ui/src/lib/pasted-text-display.ts @@ -0,0 +1,4 @@ +export function getPastedTextLineCount(text: string): number { + if (!text) return 0 + return text.split("\n").length +} From 231e573eb7d32373f4b90fd9c11d739335896e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 5 Jun 2026 18:35:26 +0200 Subject: [PATCH 08/11] fix: task-078 restore UI typecheck on integrated batch Fix the current upstream/dev baseline type mismatches exposed during the integrated PR batch validation. Align the session SDK imports with the v2 surface and narrow the git status workspace payload typing so the merged batch typechecks cleanly without changing feature behavior. --- packages/ui/src/types/session.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 58cfa1e9d..a1dffe370 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -12,7 +12,7 @@ export type { Agent as SDKAgent, Provider as SDKProvider, Model as SDKModel -} from "@opencode-ai/sdk" +} from "@opencode-ai/sdk/v2" export type SessionStatus = "idle" | "working" | "compacting" @@ -62,7 +62,7 @@ export function mapSdkSessionRetry(status: SDKSessionStatus | null | undefined): // Our client-specific Session interface extending SDK Session export interface Session - extends Omit { + extends Omit { instanceId: string // Client-specific field parentId: string | null // Client-specific field (override parentID) agent: string // Client-specific field @@ -81,7 +81,7 @@ export interface Session // Adapter function to convert SDK Session to client Session export function createClientSession( - sdkSession: import("@opencode-ai/sdk").Session, + sdkSession: SDKSession, instanceId: string, agent: string = "", model: { providerId: string; modelId: string } = { providerId: "", modelId: "" }, From 224b6bd4a37babe185f6ea6a07ddf749942b0232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 6 Jun 2026 10:36:24 +0200 Subject: [PATCH 09/11] fix: TASK-079 address hidden prompt section feedback Keep the Greptile-requested prompt display fixes while removing accidental internal task and evidence artifacts from branch history. This squashes the previous fix and cleanup commits into a single public commit containing only the prompt display store, tests, and prompt input integration changes. --- packages/ui/src/components/prompt-input.tsx | 8 ++-- .../src/stores/message-prompt-display.test.ts | 32 ++++++++++++++ .../ui/src/stores/message-prompt-display.ts | 44 ++++++++++--------- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index d4c780db7..27acedcad 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -391,7 +391,7 @@ export default function PromptInput(props: PromptInputProps) { commandArgs, }) const resolvedCommandArgs = submission.resolvedCommandArgs - const resolvedPrompt = submission.submitPrompt + const submitPrompt = submission.submitPrompt const historyEntry = submission.historyEntry const refreshHistory = () => recordHistoryEntry(historyEntry) @@ -426,9 +426,9 @@ export default function PromptInput(props: PromptInputProps) { try { if (isShellMode) { if (props.onRunShell) { - await props.onRunShell(resolvedPrompt) + await props.onRunShell(submitPrompt) } else { - await props.onSend(resolvedPrompt, []) + await props.onSend(submitPrompt, []) } } else if (isKnownSlashCommand) { if (props.onCommand) { @@ -437,7 +437,7 @@ export default function PromptInput(props: PromptInputProps) { await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs) } } else { - await props.onSend(resolvedPrompt, currentAttachments) + await props.onSend(submitPrompt, currentAttachments) } if (!isKnownSlashCommand) { void refreshHistory() diff --git a/packages/ui/src/stores/message-prompt-display.test.ts b/packages/ui/src/stores/message-prompt-display.test.ts index 0889b9905..3cbaa93c3 100644 --- a/packages/ui/src/stores/message-prompt-display.test.ts +++ b/packages/ui/src/stores/message-prompt-display.test.ts @@ -113,8 +113,40 @@ describe("message prompt display overrides", () => { assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), metadata) assert.equal(storage.getItem("codenomad:prompt-display:v3")?.includes(`${sessionId}:${messageId}`), true) + assert.equal(storage.getItem("codenomad:prompt-display:v2"), null) clearPromptDisplayOverride(reopenedInstanceId, sessionId, messageId) + resetPromptDisplayOverrideStateForTests() + assert.equal(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), undefined) + + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("migrates legacy keys without rewriting stored stable v3 keys", () => { + const storage = new MemoryStorage() + const legacyInstanceId = "legacy-instance" + const reopenedInstanceId = "reopened-instance" + const sessionId = "session:with-colon" + const messageId = "msg-with-colon" + const stableSessionId = "stable-session" + const stableMessageId = "msg:with:colons" + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 4 }, { kind: "pasted", length: 9 }] } + + storage.setItem( + "codenomad:prompt-display:v2", + JSON.stringify({ [`${legacyInstanceId}:${sessionId}:${messageId}`]: metadata }), + ) + storage.setItem( + "codenomad:prompt-display:v3", + JSON.stringify({ [`${stableSessionId}:${stableMessageId}`]: metadata }), + ) + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), metadata) + assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, stableSessionId, stableMessageId), metadata) + assert.equal(storage.getItem("codenomad:prompt-display:v2"), null) + 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 index 8054fd4d9..8b6d395af 100644 --- a/packages/ui/src/stores/message-prompt-display.ts +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -1,6 +1,7 @@ import type { PromptDisplayMetadata } from "../lib/prompt-display-metadata" const STORAGE_KEY = "codenomad:prompt-display:v3" +const LEGACY_STORAGE_KEY = "codenomad:prompt-display:v2" let loaded = false const promptDisplayOverrides = new Map() @@ -10,18 +11,17 @@ function makeKey(_instanceId: string, sessionId: string, messageId: string): str } function isLegacyInstanceScopedKey(key: string): boolean { - return key.split(":").length === 3 + const firstSeparator = key.indexOf(":") + if (firstSeparator <= 0) return false + const secondSeparator = key.indexOf(":", firstSeparator + 1) + return secondSeparator > firstSeparator + 1 && secondSeparator < key.length - 1 } function migrateStoredKey(key: string): string { if (!isLegacyInstanceScopedKey(key)) { return key } - const [, sessionId, messageId] = key.split(":") - if (!sessionId || !messageId) { - return key - } - return `${sessionId}:${messageId}` + return key.slice(key.indexOf(":") + 1) } function readStorage(): Storage | null { @@ -40,37 +40,39 @@ function ensureLoaded(): void { if (!storage) return try { - const parsedEntries: Record[] = [] const raw = storage.getItem(STORAGE_KEY) if (raw) { - parsedEntries.push(JSON.parse(raw) as Record) + loadStoredEntries(JSON.parse(raw) as Record, false) } - const legacyRaw = storage.getItem("codenomad:prompt-display:v2") + const legacyRaw = storage.getItem(LEGACY_STORAGE_KEY) if (legacyRaw) { - parsedEntries.push(JSON.parse(legacyRaw) as Record) + loadStoredEntries(JSON.parse(legacyRaw) as Record, true) } - if (parsedEntries.length === 0) return - for (const parsed of parsedEntries) { - for (const [key, value] of Object.entries(parsed)) { - if (isPromptDisplayMetadata(value)) { - promptDisplayOverrides.set(migrateStoredKey(key), value) - } - } - } - persist() + if (!raw && !legacyRaw) return + if (persist() && legacyRaw) storage.removeItem(LEGACY_STORAGE_KEY) } catch { promptDisplayOverrides.clear() } } -function persist(): void { +function loadStoredEntries(parsed: Record, migrateLegacyKeys: boolean): void { + for (const [key, value] of Object.entries(parsed)) { + if (isPromptDisplayMetadata(value)) { + promptDisplayOverrides.set(migrateLegacyKeys ? migrateStoredKey(key) : key, value) + } + } +} + +function persist(): boolean { const storage = readStorage() - if (!storage) return + if (!storage) return false try { storage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(promptDisplayOverrides))) + return true } catch { // Ignore persistence failures. + return false } } From 4ae7baa8b4622d1dfeb131756e9a9616e93c9a44 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 7 Jun 2026 11:59:56 +0100 Subject: [PATCH 10/11] Support OpenCode SDK 1.16 runtime APIs (#526) ## Summary - upgrade the UI OpenCode SDK usage for the 1.16 v2 session list/response shapes - migrate session list/search handling to v2 while preserving unsupported legacy session actions - support mixed legacy/v2 permission and question queues, events, hydration, and reply routing during the 1.16 transition ## Validation - npm run typecheck --workspace packages/ui - npx tsx --test "packages/ui/src/types/permission.test.ts" "packages/ui/src/stores/message-v2/instance-store.test.ts" --- package-lock.json | 8 +- packages/ui/package.json | 2 +- packages/ui/src/App.tsx | 2 +- .../components/permission-approval-modal.tsx | 20 +- packages/ui/src/components/tool-call.tsx | 19 +- .../components/tool-call/permission-block.tsx | 6 +- .../components/tool-call/question-block.tsx | 2 +- packages/ui/src/lib/api-client.ts | 2 +- packages/ui/src/lib/sse-manager.ts | 40 +- packages/ui/src/stores/instances.ts | 281 ++++++++++--- packages/ui/src/stores/message-v2/bridge.ts | 18 +- .../stores/message-v2/instance-store.test.ts | 16 +- .../src/stores/message-v2/instance-store.ts | 1 - packages/ui/src/stores/message-v2/types.ts | 4 +- .../ui/src/stores/permission-auto-accept.ts | 6 +- .../ui/src/stores/request-locations.test.ts | 44 +++ packages/ui/src/stores/request-locations.ts | 31 ++ packages/ui/src/stores/session-api.ts | 372 ++++++++---------- packages/ui/src/stores/session-events.ts | 32 +- packages/ui/src/stores/session-metadata.ts | 17 +- .../ui/src/stores/session-pagination-model.ts | 25 ++ .../ui/src/stores/session-pagination.test.ts | 29 ++ packages/ui/src/stores/session-state.ts | 109 +++-- packages/ui/src/stores/sessions.ts | 6 +- packages/ui/src/types/message.ts | 4 +- packages/ui/src/types/permission.test.ts | 47 +-- packages/ui/src/types/permission.ts | 201 ++++------ packages/ui/src/types/question.ts | 24 +- 28 files changed, 831 insertions(+), 537 deletions(-) create mode 100644 packages/ui/src/stores/request-locations.test.ts create mode 100644 packages/ui/src/stores/request-locations.ts create mode 100644 packages/ui/src/stores/session-pagination-model.ts create mode 100644 packages/ui/src/stores/session-pagination.test.ts diff --git a/package-lock.json b/package-lock.json index 59eecf817..3e9ea4e26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3266,9 +3266,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.15.13", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.13.tgz", - "integrity": "sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.16.0.tgz", + "integrity": "sha512-S4H2e9j4rdHs5BQOCjmVEdqdXmKwPFKjXPbPUaWiRJpAjBcZ/uIBpoZkmV+x9BLzc+vrE6WAffMZieQgukt4DA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -13503,7 +13503,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.15.13", + "@opencode-ai/sdk": "1.16.0", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 06740a563..bc2c83b20 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,7 +13,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.15.13", + "@opencode-ai/sdk": "1.16.0", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2f90b3cb8..55acd97ee 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -418,7 +418,7 @@ const App: Component = () => { clearActiveParentSession(instanceId) try { - await fetchSessions(instanceId, { start: 0, limit: getSessionFetchLimit(instanceId) }) + await fetchSessions(instanceId, { reset: true, limit: getSessionFetchLimit(instanceId) }) } catch (error) { log.error("Failed to refresh sessions after closing", error) } diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 33b6d2d32..d5a3c6b2c 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,11 +1,12 @@ import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js" -import type { PermissionRequestLike } from "../types/permission" +import type { PermissionRequest } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" import { activeInterruption, getPermissionQueue, + getPermissionEnqueuedAtForInstance, getQuestionQueue, getQuestionEnqueuedAtForInstance, sendPermissionResponse, @@ -32,7 +33,7 @@ type ResolvedToolCall = { function resolveToolCallFromPermission( instanceId: string, - permission: PermissionRequestLike, + permission: PermissionRequest, ): ResolvedToolCall | null { const sessionId = getPermissionSessionId(permission) const messageId = getPermissionMessageId(permission) @@ -158,7 +159,7 @@ const PermissionApprovalModal: Component = (props) }) } - async function handlePermissionDecision(permission: PermissionRequestLike, response: "once" | "always" | "reject", message?: string) { + async function handlePermissionDecision(permission: PermissionRequest, response: "once" | "always" | "reject", message?: string) { const permissionId = permission?.id if (!permissionId) return @@ -168,7 +169,8 @@ const PermissionApprovalModal: Component = (props) setPermissionItemError(permissionId, null) try { - const sessionId = getPermissionSessionId(permission) || "" + const sessionId = getPermissionSessionId(permission) + if (!sessionId) throw new Error("Permission request is missing sessionID") await sendPermissionResponse(props.instanceId, sessionId, permissionId, response, message) if (rejectingPermissionId() === permissionId) { setRejectingPermissionId(null) @@ -189,7 +191,7 @@ const PermissionApprovalModal: Component = (props) const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) type InterruptionItem = - | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike } + | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequest } | { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest } const orderedQueue = createMemo(() => { @@ -197,7 +199,7 @@ const PermissionApprovalModal: Component = (props) kind: "permission" as const, id: permission.id, sessionId: getPermissionSessionId(permission) || "", - createdAt: (permission as any)?.time?.created ?? Date.now(), + createdAt: getPermissionEnqueuedAtForInstance(props.instanceId, permission.id), payload: permission, })) @@ -386,7 +388,7 @@ const PermissionApprovalModal: Component = (props) type="button" class="tool-call-permission-button" disabled={permissionSubmitting().has(item.id)} - onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")} + onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "once")} > {t("permissionApproval.actions.allowOnce")} @@ -394,7 +396,7 @@ const PermissionApprovalModal: Component = (props) type="button" class="tool-call-permission-button" disabled={permissionSubmitting().has(item.id)} - onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")} + onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "always")} > {t("permissionApproval.actions.alwaysAllow")} @@ -435,7 +437,7 @@ const PermissionApprovalModal: Component = (props) disabled={permissionSubmitting().has(item.id)} onClick={() => void handlePermissionDecision( - item.payload as PermissionRequestLike, + item.payload as PermissionRequest, "reject", rejectReason().trim() || undefined, ) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index cc10fceea..f72190b16 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -7,9 +7,9 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useConfig } from "../stores/preferences" import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances" import { copyToClipboard } from "../lib/clipboard" -import type { PermissionRequestLike } from "../types/permission" +import type { PermissionRequest } from "../types/permission" import { getPermissionSessionId } from "../types/permission" -import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import type { QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" import { resolveToolRenderer } from "./tool-call/renderers" import { QuestionToolBlock } from "./tool-call/question-block" @@ -119,7 +119,7 @@ function ToolCallDetails(props: { isDark: () => boolean t: ReturnType["t"] store: () => ReturnType - pendingPermission: () => { permission: PermissionRequestLike; active: boolean } | undefined + pendingPermission: () => { permission: PermissionRequest; active: boolean } | undefined pendingQuestion: () => { request: QuestionRequest; active: boolean } | undefined isPermissionActive: () => boolean isQuestionActive: () => boolean @@ -226,12 +226,13 @@ function ToolCallDetails(props: { }) }) - async function handlePermissionResponse(permission: PermissionRequestLike, response: "once" | "always" | "reject", message?: string) { + async function handlePermissionResponse(permission: PermissionRequest, response: "once" | "always" | "reject", message?: string) { if (!permission) return setPermissionSubmitting(true) setPermissionError(null) try { - const sessionId = getPermissionSessionId(permission) || props.sessionId + const sessionId = getPermissionSessionId(permission) + if (!sessionId) throw new Error("Permission request is missing sessionID") await sendPermissionResponse(props.instanceId, sessionId, permission.id, response, message) } catch (error) { log.error("Failed to send permission response", error) @@ -292,8 +293,7 @@ function ToolCallDetails(props: { setQuestionSubmitting(true) setQuestionError(null) try { - const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId - await sendQuestionReply(props.instanceId, sessionId, request.id, normalized) + await sendQuestionReply(props.instanceId, request.sessionID, request.id, normalized) } catch (error) { log.error("Failed to send question reply", error) setQuestionError(error instanceof Error ? error.message : props.t("toolCall.question.errors.unableToReply")) @@ -310,8 +310,7 @@ function ToolCallDetails(props: { setQuestionSubmitting(true) setQuestionError(null) try { - const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId - await sendQuestionReject(props.instanceId, sessionId, request.id) + await sendQuestionReject(props.instanceId, request.sessionID, request.id) } catch (error) { log.error("Failed to reject question", error) setQuestionError(error instanceof Error ? error.message : props.t("toolCall.question.errors.unableToDismiss")) @@ -648,6 +647,7 @@ export default function ToolCall(props: ToolCallProps) { const isPermissionActive = createMemo(() => { const pending = pendingPermission() if (!pending?.permission) return false + if (pending.active) return true const active = activeRequest() return active?.kind === "permission" && active.id === pending.permission.id }) @@ -655,6 +655,7 @@ export default function ToolCall(props: ToolCallProps) { const isQuestionActive = createMemo(() => { const pending = pendingQuestion() if (!pending?.request) return false + if (pending.active) return true const active = activeRequest() return active?.kind === "question" && active.id === pending.request.id }) diff --git a/packages/ui/src/components/tool-call/permission-block.tsx b/packages/ui/src/components/tool-call/permission-block.tsx index 100b8bf43..a07fe4502 100644 --- a/packages/ui/src/components/tool-call/permission-block.tsx +++ b/packages/ui/src/components/tool-call/permission-block.tsx @@ -1,5 +1,5 @@ import { Show, createEffect, createSignal, type Accessor, type JSXElement } from "solid-js" -import type { PermissionRequestLike } from "../../types/permission" +import type { PermissionRequest } from "../../types/permission" import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission" import { getPermissionSessionId } from "../../types/permission" import { useI18n } from "../../lib/i18n" @@ -10,11 +10,11 @@ import { getRelativePath } from "./utils" type PermissionResponse = "once" | "always" | "reject" export type PermissionToolBlockProps = { - permission: Accessor + permission: Accessor active: Accessor submitting: Accessor error: Accessor - onRespond: (permission: PermissionRequestLike, sessionId: string, response: PermissionResponse, message?: string) => void | Promise + onRespond: (permission: PermissionRequest, sessionId: string, response: PermissionResponse, message?: string) => void | Promise onRejectReasonOpenChange?: (open: boolean) => void renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null fallbackSessionId: Accessor diff --git a/packages/ui/src/components/tool-call/question-block.tsx b/packages/ui/src/components/tool-call/question-block.tsx index dee5fee91..b7542aa44 100644 --- a/packages/ui/src/components/tool-call/question-block.tsx +++ b/packages/ui/src/components/tool-call/question-block.tsx @@ -1,6 +1,6 @@ import { createMemo, Show, For, createEffect, type Accessor } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" -import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import type { QuestionRequest } from "../../types/question" import { useI18n } from "../../lib/i18n" type QuestionOption = { label: string; description: string } diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 44b8183ff..dfc512ea0 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -50,7 +50,7 @@ import { attachEventSourceHandlers } from "./event-source-handlers" const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events" -const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE +const API_BASE = import.meta.env?.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH) export const CODENOMAD_API_BASE = API_BASE diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index e6354e2cd..8fa3dfdc6 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -16,6 +16,14 @@ import type { EventSessionUpdated, EventSessionStatus, } from "@opencode-ai/sdk" +import type { + EventPermissionV2Asked, + EventPermissionV2Replied, + EventQuestionV2Asked, + EventQuestionV2Rejected, + EventQuestionV2Replied, +} from "@opencode-ai/sdk/v2" +import type { LegacyPermissionAskedEvent, LegacyPermissionRepliedEvent } from "../types/permission" import { serverEvents } from "./server-events" import type { BackgroundProcess, @@ -73,10 +81,15 @@ type SSEEvent = | EventSessionError | EventSessionIdle | EventSessionStatus - | { type: "permission.updated" | "permission.asked"; properties?: any } - | { type: "permission.replied"; properties?: any } + | EventPermissionV2Asked + | EventPermissionV2Replied + | LegacyPermissionAskedEvent + | LegacyPermissionRepliedEvent | { type: "question.asked"; properties?: any } | { type: "question.replied" | "question.rejected"; properties?: any } + | EventQuestionV2Asked + | EventQuestionV2Replied + | EventQuestionV2Rejected | EventLspUpdated | TuiToastEvent | BackgroundProcessUpdatedEvent @@ -158,13 +171,19 @@ class SSEManager { case "session.diff": this.onSessionDiff?.(instanceId, event as EventSessionDiff) break - case "permission.updated": case "permission.asked": + case "permission.updated": this.onPermissionUpdated?.(instanceId, event as any) break case "permission.replied": this.onPermissionReplied?.(instanceId, event as any) break + case "permission.v2.asked": + this.onPermissionUpdated?.(instanceId, event as EventPermissionV2Asked) + break + case "permission.v2.replied": + this.onPermissionReplied?.(instanceId, event as EventPermissionV2Replied) + break case "question.asked": this.onQuestionAsked?.(instanceId, event as any) break @@ -172,6 +191,13 @@ class SSEManager { case "question.rejected": this.onQuestionAnswered?.(instanceId, event as any) break + case "question.v2.asked": + this.onQuestionAsked?.(instanceId, event as EventQuestionV2Asked) + break + case "question.v2.replied": + case "question.v2.rejected": + this.onQuestionAnswered?.(instanceId, event as EventQuestionV2Replied | EventQuestionV2Rejected) + break case "lsp.updated": this.onLspUpdated?.(instanceId, event as EventLspUpdated) break @@ -209,10 +235,10 @@ class SSEManager { onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void onSessionDiff?: (instanceId: string, event: EventSessionDiff) => void - onPermissionUpdated?: (instanceId: string, event: any) => void - onPermissionReplied?: (instanceId: string, event: any) => void - onQuestionAsked?: (instanceId: string, event: any) => void - onQuestionAnswered?: (instanceId: string, event: any) => void + onPermissionUpdated?: (instanceId: string, event: EventPermissionV2Asked | LegacyPermissionAskedEvent) => void + onPermissionReplied?: (instanceId: string, event: EventPermissionV2Replied | LegacyPermissionRepliedEvent) => void + onQuestionAsked?: (instanceId: string, event: EventQuestionV2Asked | { type: "question.asked"; properties?: any }) => void + onQuestionAnswered?: (instanceId: string, event: EventQuestionV2Replied | EventQuestionV2Rejected | { type: "question.replied" | "question.rejected"; properties?: any }) => void onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index b0aaa2e0f..e3bada698 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -1,9 +1,9 @@ import { createSignal } from "solid-js" import type { Instance, LogEntry } from "../types/instance" import type { LspStatus } from "@opencode-ai/sdk/v2" -import type { PermissionReply, PermissionRequestLike } from "../types/permission" -import { getPermissionCreatedAt, getPermissionSessionId, mergePermissionRequest } from "../types/permission" -import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import type { PermissionReply, PermissionRequest, PermissionSource } from "../types/permission" +import { getPermissionSessionId, mergePermissionRequest } from "../types/permission" +import type { QuestionRequest, QuestionSource } from "../types/question" import { getQuestionSessionId } from "../types/question" import { requestData } from "../lib/opencode-api" import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager" @@ -22,11 +22,12 @@ import { import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, + getWorktrees, reloadWorktreeMap, reloadWorktrees, } from "./worktrees" import { getRootClient } from "./opencode-client" -import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForSession, syncOpenCodeWorkspaces } from "./opencode-workspaces" +import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForSession, getOpenCodeWorkspaceIdForWorktree, syncOpenCodeWorkspaces } from "./opencode-workspaces" import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { sessions, setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" @@ -52,6 +53,7 @@ import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" import { showWorkspaceLaunchError } from "./launch-errors" import { activeSidecarToken } from "./sidecars" +import { buildV2RequestLocations, type V2Location } from "./request-locations" const log = getLogger("api") @@ -68,14 +70,47 @@ const [instanceLogs, setInstanceLogs] = createSignal>(ne const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) // Interruption queues (permissions + questions) per instance -const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) +const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() +const permissionEnqueuedAt = new Map() +const permissionSourceByInstance = new Map>() const [questionQueues, setQuestionQueues] = createSignal>(new Map()) const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) const questionSessionCounts = new Map>() const questionEnqueuedAt = new Map() +const questionSourceByInstance = new Map>() + +function ensurePermissionEnqueuedAt(permission: PermissionRequest): number { + const existing = permissionEnqueuedAt.get(permission.id) + if (existing) return existing + const now = Date.now() + permissionEnqueuedAt.set(permission.id, now) + return now +} + +function setPermissionSource(instanceId: string, requestId: string, source: PermissionSource): void { + let sources = permissionSourceByInstance.get(instanceId) + if (!sources) { + sources = new Map() + permissionSourceByInstance.set(instanceId, sources) + } + sources.set(requestId, source) +} + +function getPermissionSource(instanceId: string, requestId: string): PermissionSource { + return permissionSourceByInstance.get(instanceId)?.get(requestId) ?? "v2" +} + +function deletePermissionSource(instanceId: string, requestId: string): void { + const sources = permissionSourceByInstance.get(instanceId) + if (!sources) return + sources.delete(requestId) + if (sources.size === 0) { + permissionSourceByInstance.delete(instanceId) + } +} function ensureQuestionEnqueuedAt(request: QuestionRequest): number { const existing = questionEnqueuedAt.get(request.id) @@ -85,10 +120,47 @@ function ensureQuestionEnqueuedAt(request: QuestionRequest): number { return now } +function setQuestionSource(instanceId: string, requestId: string, source: QuestionSource): void { + let sources = questionSourceByInstance.get(instanceId) + if (!sources) { + sources = new Map() + questionSourceByInstance.set(instanceId, sources) + } + sources.set(requestId, source) +} + +function getQuestionSource(instanceId: string, requestId: string): QuestionSource { + return questionSourceByInstance.get(instanceId)?.get(requestId) ?? "v2" +} + +function deleteQuestionSource(instanceId: string, requestId: string): void { + const sources = questionSourceByInstance.get(instanceId) + if (!sources) return + sources.delete(requestId) + if (sources.size === 0) { + questionSourceByInstance.delete(instanceId) + } +} + type InterruptionKind = "permission" | "question" type ActiveInterruption = { kind: InterruptionKind; id: string } | null +async function getV2RequestLocations(instanceId: string): Promise { + const instance = instances().get(instanceId) + const worktrees = getWorktrees(instanceId) + const workspaceBySlug = new Map() + + for (const worktree of worktrees) { + if (!worktree.slug || worktree.slug === "root") continue + const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, worktree.slug) + if (!workspace) continue + workspaceBySlug.set(worktree.slug, workspace) + } + + return buildV2RequestLocations(instance?.folder, worktrees, workspaceBySlug) +} + const [activeInterruption, setActiveInterruption] = createSignal>(new Map()) function syncHasInstancesFlag() { @@ -207,16 +279,36 @@ async function syncPendingPermissions(instanceId: string): Promise { try { const syncStartedAt = Date.now() - const remote = await requestData( + const remote: Array<{ request: PermissionRequest; source: PermissionSource }> = [] + const legacyRemote = await requestData( instance.client.permission.list(), "permission.list", - ) + ).catch((error) => { + log.warn("Failed to list legacy pending permissions", { instanceId, error }) + return [] + }) + for (const permission of legacyRemote) { + setPermissionSource(instanceId, permission.id, "legacy") + remote.push({ request: permission, source: "legacy" }) + } - const remotePendingIds = new Set(remote.map((item) => item.id)) + for (const location of await getV2RequestLocations(instanceId)) { + const response = await requestData<{ location?: unknown; data: PermissionRequest[] }>( + instance.client.v2.permission.request.list({ location }), + "v2.permission.request.list", + ) + log.info("v2.permission.request.list", { instanceId, location, resolvedLocation: response.location }) + for (const permission of response.data) { + setPermissionSource(instanceId, permission.id, "v2") + remote.push({ request: permission, source: "v2" }) + } + } + + const remotePendingIds = new Set(remote.map((item) => item.request.id)) pruneRepliedPermissions(instanceId, remotePendingIds, syncStartedAt) - const pendingRemote = remote.filter((item) => !hasRepliedPermission(instanceId, item.id)) - const remoteIds = new Set(pendingRemote.map((item) => item.id)) + const pendingRemote = remote.filter((item) => !hasRepliedPermission(instanceId, item.request.id)) + const remoteIds = new Set(pendingRemote.map((item) => item.request.id)) const local = getPermissionQueue(instanceId) // Remove any stale local permissions missing from server. @@ -228,8 +320,8 @@ async function syncPendingPermissions(instanceId: string): Promise { } // Upsert all server-side pending permissions. - for (const permission of pendingRemote) { - const queuedPermission = addPermissionToQueue(instanceId, permission) ?? permission + for (const { request: permission, source } of pendingRemote) { + const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission upsertPermissionV2(instanceId, queuedPermission) } drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) @@ -243,12 +335,32 @@ async function syncPendingQuestions(instanceId: string): Promise { if (!instance?.client) return try { - const remote = await requestData( + const remote: Array<{ request: QuestionRequest; source: QuestionSource }> = [] + const legacyRemote = await requestData( instance.client.question.list(), "question.list", - ) + ).catch((error) => { + log.warn("Failed to list legacy pending questions", { instanceId, error }) + return [] + }) + for (const request of legacyRemote) { + setQuestionSource(instanceId, request.id, "legacy") + remote.push({ request, source: "legacy" }) + } + + for (const location of await getV2RequestLocations(instanceId)) { + const response = await requestData<{ location?: unknown; data: QuestionRequest[] }>( + instance.client.v2.question.request.list({ location }), + "v2.question.request.list", + ) + log.info("v2.question.request.list", { instanceId, location, resolvedLocation: response.location }) + for (const request of response.data) { + setQuestionSource(instanceId, request.id, "v2") + remote.push({ request, source: "v2" }) + } + } - const remoteIds = new Set(remote.map((item) => item.id)) + const remoteIds = new Set(remote.map((item) => item.request.id)) const local = getQuestionQueue(instanceId) // Remove any stale local requests missing from server. @@ -260,9 +372,9 @@ async function syncPendingQuestions(instanceId: string): Promise { } // Upsert all server-side pending questions. - for (const request of remote) { + for (const { request, source } of remote) { ensureQuestionEnqueuedAt(request) - addQuestionToQueue(instanceId, request) + addQuestionToQueue(instanceId, request, source) upsertQuestionV2(instanceId, request) } } catch (error) { @@ -668,7 +780,7 @@ function clearLogs(id: string) { } // Permission management functions -function getPermissionQueue(instanceId: string): PermissionRequestLike[] { +function getPermissionQueue(instanceId: string): PermissionRequest[] { const queue = permissionQueues().get(instanceId) if (!queue) { return [] @@ -706,6 +818,15 @@ function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string) return questionEnqueuedAt.get(requestId) ?? Date.now() } +function getPermissionEnqueuedAtForInstance(instanceId: string, permissionId: string): number { + const queue = getPermissionQueue(instanceId) + const match = queue.find((permission) => permission.id === permissionId) + if (match) { + return ensurePermissionEnqueuedAt(match) + } + return permissionEnqueuedAt.get(permissionId) ?? Date.now() +} + function computeActiveInterruption(instanceId: string): ActiveInterruption { const permissions = getPermissionQueue(instanceId) const questions = getQuestionQueue(instanceId) @@ -715,7 +836,7 @@ function computeActiveInterruption(instanceId: string): ActiveInterruption { if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id } if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id } - const permTime = getPermissionCreatedAt(firstPermission) + const permTime = firstPermission ? ensurePermissionEnqueuedAt(firstPermission) : Number.MAX_SAFE_INTEGER const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id } return { kind: "question", id: firstQuestion!.id } @@ -827,11 +948,12 @@ function clearQuestionSessionPendingCounts(instanceId: string): void { questionSessionCounts.delete(instanceId) } -function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): PermissionRequestLike | undefined { +function addPermissionToQueue(instanceId: string, permission: PermissionRequest, source: PermissionSource = "v2"): PermissionRequest | undefined { let inserted = false let updated = false - let previousPermission: PermissionRequestLike | undefined + let previousPermission: PermissionRequest | undefined let queuedPermission = permission + setPermissionSource(instanceId, permission.id, source) setPermissionQueues((prev) => { const next = new Map(prev) @@ -843,12 +965,13 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL queuedPermission = mergePermissionRequest(previousPermission, permission) const updatedQueue = queue.slice() updatedQueue[existingIndex] = queuedPermission - next.set(instanceId, updatedQueue.sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b))) + next.set(instanceId, updatedQueue.sort((a, b) => ensurePermissionEnqueuedAt(a) - ensurePermissionEnqueuedAt(b))) updated = true return next } - const updatedQueue = [...queue, queuedPermission].sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b)) + ensurePermissionEnqueuedAt(queuedPermission) + const updatedQueue = [...queue, queuedPermission].sort((a, b) => ensurePermissionEnqueuedAt(a) - ensurePermissionEnqueuedAt(b)) next.set(instanceId, updatedQueue) inserted = true return next @@ -880,12 +1003,12 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL } function removePermissionFromQueue(instanceId: string, permissionId: string): void { - let removedPermission: PermissionRequestLike | null = null + let removedPermission: PermissionRequest | null = null setPermissionQueues((prev) => { const next = new Map(prev) const queue = next.get(instanceId) ?? [] - const filtered: PermissionRequestLike[] = [] + const filtered: PermissionRequest[] = [] for (const item of queue) { if (item.id === permissionId) { @@ -904,6 +1027,8 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo }) recomputeActiveInterruption(instanceId) + permissionEnqueuedAt.delete(permissionId) + deletePermissionSource(instanceId, permissionId) const removed = removedPermission if (removed) { @@ -934,6 +1059,10 @@ function clearPermissionQueue(instanceId: string): void { clearAutoAcceptPermission(instanceId, sessionId, permission.id) } } + for (const permission of getPermissionQueue(instanceId)) { + permissionEnqueuedAt.delete(permission.id) + } + permissionSourceByInstance.delete(instanceId) setPermissionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -948,8 +1077,9 @@ function clearPermissionQueue(instanceId: string): void { recomputeActiveInterruption(instanceId) } -function addQuestionToQueue(instanceId: string, request: QuestionRequest): void { +function addQuestionToQueue(instanceId: string, request: QuestionRequest, source: QuestionSource = "v2"): void { let inserted = false + setQuestionSource(instanceId, request.id, source) setQuestionQueues((prev) => { const next = new Map(prev) @@ -999,6 +1129,7 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void { }) questionEnqueuedAt.delete(requestId) + deleteQuestionSource(instanceId, requestId) recomputeActiveInterruption(instanceId) if (removedSessionId) { @@ -1011,6 +1142,7 @@ function clearQuestionQueue(instanceId: string): void { for (const request of getQuestionQueue(instanceId)) { questionEnqueuedAt.delete(request.id) } + questionSourceByInstance.delete(instanceId) setQuestionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -1046,16 +1178,28 @@ async function sendQuestionReply( try { const client = getRootClient(instanceId) - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null - - await requestData( - client.question.reply({ - requestID: requestId, - ...(workspace ? { workspace } : {}), - answers, - }), - "question.reply", - ) + const source = getQuestionSource(instanceId, requestId) + + if (source === "legacy") { + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + await requestData( + client.question.reply({ + requestID: requestId, + ...(workspace ? { workspace } : {}), + answers, + }), + "question.reply", + ) + } else { + await requestData( + client.v2.session.question.reply({ + sessionID: sessionId, + requestID: requestId, + questionV2Reply: { answers }, + }), + "v2.session.question.reply", + ) + } removeQuestionFromQueue(instanceId, requestId) } catch (error) { @@ -1072,15 +1216,26 @@ async function sendQuestionReject(instanceId: string, sessionId: string, request try { const client = getRootClient(instanceId) - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null - - await requestData( - client.question.reject({ - requestID: requestId, - ...(workspace ? { workspace } : {}), - }), - "question.reject", - ) + const source = getQuestionSource(instanceId, requestId) + + if (source === "legacy") { + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + await requestData( + client.question.reject({ + requestID: requestId, + ...(workspace ? { workspace } : {}), + }), + "question.reject", + ) + } else { + await requestData( + client.v2.session.question.reject({ + sessionID: sessionId, + requestID: requestId, + }), + "v2.session.question.reject", + ) + } removeQuestionFromQueue(instanceId, requestId) } catch (error) { @@ -1103,17 +1258,30 @@ async function sendPermissionResponse( try { const client = getRootClient(instanceId) - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null - - await requestData( - client.permission.reply({ - requestID: requestId, - ...(workspace ? { workspace } : {}), - reply, - ...(message ? { message } : {}), - }), - "permission.reply", - ) + const source = getPermissionSource(instanceId, requestId) + + if (source === "legacy") { + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + await requestData( + client.permission.reply({ + requestID: requestId, + ...(workspace ? { workspace } : {}), + reply, + ...(message ? { message } : {}), + }), + "permission.reply", + ) + } else { + await requestData( + client.v2.session.permission.reply({ + sessionID: sessionId, + requestID: requestId, + reply, + ...(message ? { message } : {}), + }), + "v2.session.permission.reply", + ) + } markPermissionReplied(instanceId, requestId) // Remove from both local queues after successful response; the SSE replied event @@ -1214,6 +1382,7 @@ export { activePermissionId, getPermissionQueue, getPermissionQueueLength, + getPermissionEnqueuedAtForInstance, addPermissionToQueue, removePermissionFromQueue, markPermissionReplied, diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index ef62f3d7d..6ef773b74 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -1,5 +1,5 @@ -import type { PermissionRequestLike } from "../../types/permission" -import { getPermissionCallId, getPermissionMessageId } from "../../types/permission" +import type { PermissionRequest } from "../../types/permission" +import { getPermissionCallId, getPermissionMessageId, getPermissionSessionId } from "../../types/permission" import type { QuestionRequest } from "../../types/question" import { getQuestionCallId, getQuestionMessageId } from "../../types/question" import type { Message, MessageInfo, ClientPart } from "../../types/message" @@ -127,11 +127,11 @@ export function replaceMessageIdV2(instanceId: string, oldId: string, newId: str store.replaceMessageId({ oldId, newId, ...(options ?? {}) }) } -function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined { +function extractPermissionMessageId(permission: PermissionRequest): string | undefined { return getPermissionMessageId(permission) } -function extractPermissionPartId(permission: PermissionRequestLike): string | undefined { +function extractPermissionPartId(permission: PermissionRequest): string | undefined { const metadata = (permission as any).metadata || {} return ( (permission as any).partID || @@ -142,7 +142,7 @@ function extractPermissionPartId(permission: PermissionRequestLike): string | un ) } -function extractPermissionCallId(permission: PermissionRequestLike): string | undefined { +function extractPermissionCallId(permission: PermissionRequest): string | undefined { return getPermissionCallId(permission) } @@ -166,7 +166,7 @@ function resolvePartIdFromCallId(store: ReturnType { const store = createInstanceMessageStore("instance-1") store.upsertPermission({ - permission: { id: "permission-1", callID: "call-1", time: { created: 1_000 } }, + permission: { id: "permission-1", sessionID: "session-1", action: "edit", resources: ["file-a.ts"] }, enqueuedAt: 1_000, }) store.upsertPermission({ - permission: { id: "permission-1", tool: { callID: "call-1", messageID: "message-1" } }, + permission: { + id: "permission-1", + sessionID: "session-1", + action: "edit", + resources: ["file-a.ts"], + source: { type: "tool", callID: "call-1", messageID: "message-1" }, + }, messageId: "message-1", partId: "part-1", enqueuedAt: 2_000, @@ -20,15 +26,15 @@ describe("message-v2 permission state", () => { assert.equal(store.state.permissions.queue.length, 1) assert.equal(store.getPermissionState(undefined, "permission-1"), null) - assert.equal(store.getPermissionState("message-1", "part-1")?.entry.permission.callID, "call-1") + assert.equal((store.getPermissionState("message-1", "part-1")?.entry.permission as any).source?.callID, "call-1") assert.equal(store.getPermissionState("message-1", "part-1")?.active, true) }) it("recalculates the active permission after removing the first queue entry", () => { const store = createInstanceMessageStore("instance-1") - store.upsertPermission({ permission: { id: "permission-1" }, enqueuedAt: 1_000 }) - store.upsertPermission({ permission: { id: "permission-2" }, enqueuedAt: 2_000 }) + store.upsertPermission({ permission: { id: "permission-1", sessionID: "session-1", action: "edit", resources: [] }, enqueuedAt: 1_000 }) + store.upsertPermission({ permission: { id: "permission-2", sessionID: "session-1", action: "edit", resources: [] }, enqueuedAt: 2_000 }) store.removePermission("permission-1") assert.equal(store.state.permissions.active?.permission.id, "permission-2") diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 3373a4cdf..50e93cc17 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -3,7 +3,6 @@ import { createStore, produce, reconcile } from "solid-js/store" import type { SetStoreFunction } from "solid-js/store" import { getLogger } from "../../lib/logger" import type { ClientPart, MessageInfo } from "../../types/message" -import type { PermissionRequestLike } from "../../types/permission" import { mergePermissionRequest } from "../../types/permission" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" import type { diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 6208d475e..2102b2b9e 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,5 +1,5 @@ import type { ClientPart } from "../../types/message" -import type { PermissionRequestLike } from "../../types/permission" +import type { PermissionRequest } from "../../types/permission" import type { QuestionRequest } from "../../types/question" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" @@ -48,7 +48,7 @@ export interface PendingPartEntry { } export interface PermissionEntry { - permission: PermissionRequestLike + permission: PermissionRequest messageId?: string partId?: string enqueuedAt: number diff --git a/packages/ui/src/stores/permission-auto-accept.ts b/packages/ui/src/stores/permission-auto-accept.ts index 9ec0035c6..aca947936 100644 --- a/packages/ui/src/stores/permission-auto-accept.ts +++ b/packages/ui/src/stores/permission-auto-accept.ts @@ -1,5 +1,5 @@ import { createSignal } from "solid-js" -import type { PermissionReply, PermissionRequestLike } from "../types/permission" +import type { PermissionReply, PermissionRequest } from "../types/permission" import { getPermissionSessionId } from "../types/permission" import { getLogger } from "../lib/logger" @@ -121,7 +121,7 @@ export function clearAutoAcceptSession(instanceId: string, sessionId: string) { export function drainAutoAcceptPermission( instanceId: string, - permission: PermissionRequestLike, + permission: PermissionRequest, responder: AutoAcceptResponder, isPending: PendingPermissionChecker, ) { @@ -146,7 +146,7 @@ export function drainAutoAcceptPermission( export function drainAutoAcceptPermissions( instanceId: string, - permissions: PermissionRequestLike[], + permissions: PermissionRequest[], responder: AutoAcceptResponder, isPending: PendingPermissionChecker, ) { diff --git a/packages/ui/src/stores/request-locations.test.ts b/packages/ui/src/stores/request-locations.test.ts new file mode 100644 index 000000000..11e259a82 --- /dev/null +++ b/packages/ui/src/stores/request-locations.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { buildV2RequestLocations } from "./request-locations.ts" + +describe("buildV2RequestLocations", () => { + it("includes root and each workspace-backed worktree location", () => { + const locations = buildV2RequestLocations( + "/repo", + [ + { slug: "root" }, + { slug: "feature-a" }, + { slug: "feature-b" }, + { slug: "missing-workspace" }, + ], + new Map([ + ["feature-a", "workspace-a"], + ["feature-b", "workspace-b"], + ]), + ) + + assert.deepEqual(locations, [ + { directory: "/repo" }, + { directory: "/repo", workspace: "workspace-a" }, + { directory: "/repo", workspace: "workspace-b" }, + ]) + }) + + it("deduplicates repeated workspace locations", () => { + const locations = buildV2RequestLocations( + "/repo", + [{ slug: "feature-a" }, { slug: "feature-b" }], + new Map([ + ["feature-a", "workspace-shared"], + ["feature-b", "workspace-shared"], + ]), + ) + + assert.deepEqual(locations, [ + { directory: "/repo" }, + { directory: "/repo", workspace: "workspace-shared" }, + ]) + }) +}) diff --git a/packages/ui/src/stores/request-locations.ts b/packages/ui/src/stores/request-locations.ts new file mode 100644 index 000000000..0cefe5a28 --- /dev/null +++ b/packages/ui/src/stores/request-locations.ts @@ -0,0 +1,31 @@ +export type V2Location = { + directory?: string + workspace?: string +} + +export type V2RequestLocationWorktree = { + slug?: string +} + +export function buildV2RequestLocations( + directory: string | undefined, + worktrees: V2RequestLocationWorktree[], + workspaceBySlug: Map, +): V2Location[] { + const rootLocation: V2Location = directory ? { directory } : {} + const locations: V2Location[] = [rootLocation] + const seen = new Set([JSON.stringify(rootLocation)]) + + for (const worktree of worktrees) { + if (!worktree.slug || worktree.slug === "root") continue + const workspace = workspaceBySlug.get(worktree.slug) + if (!workspace) continue + const location: V2Location = { ...rootLocation, workspace } + const key = JSON.stringify(location) + if (seen.has(key)) continue + seen.add(key) + locations.push(location) + } + + return locations +} diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 7770e9e56..cc54d2682 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -7,7 +7,7 @@ import { type SessionStatus, } from "../types/session" import type { Message } from "../types/message" -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2/client" +import type { SnapshotFileDiff, SessionV2Info, V2SessionsResponse } from "@opencode-ai/sdk/v2/client" import { instances } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" @@ -15,7 +15,7 @@ import { activeSessionId, agents, clearSessionDraftPrompt, - getChildSessions, + getDescendantSessions, isBlankSession, messagesLoaded, pruneDraftPrompts, @@ -27,6 +27,7 @@ import { setSessionInfoByInstance, setSessions, sessions, + getSessionRoot, withSession, loading, setLoading, @@ -34,7 +35,7 @@ import { syncInstanceSessionIndicator, updateThreadTotalsForParent, SESSION_PAGE_SIZE, - getSessionNextStart, + getSessionNextCursor, setSessionPage, prependSessionListId, removeSessionListId, @@ -54,11 +55,11 @@ import { requestData } from "../lib/opencode-api" import { getRootClient } from "./opencode-client" import { getWorktreeSlugForSession, migrateLegacyWorktreeMapToSessionMetadata, pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, setWorktreeSlugForParentSession } from "./worktrees" import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" +import { hydrateSessionMetadataWithClient } from "./session-metadata" const log = getLogger("api") const pendingSessionDiffFetches = new Map>() -const pendingSessionChildrenFetches = new Map>() async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) @@ -127,7 +128,101 @@ interface SessionForkResponse { } } -async function fetchSessions(instanceId: string, options?: { start?: number; limit?: number }): Promise { +type V2SessionListOptions = { + directory?: string + limit?: number + search?: string + cursor?: string +} + +function getKnownParentId(session: SessionV2Info | Session): string | null | undefined { + return (session as any).parentID ?? (session as Session).parentId +} + +function hasMissingParentChain(session: SessionV2Info, loaded: Map): boolean { + let current: SessionV2Info | Session = session + const seen = new Set() + + while (getKnownParentId(current)) { + const parentId = getKnownParentId(current) + if (!parentId) return false + if (seen.has(parentId)) return false + seen.add(parentId) + const parent = loaded.get(parentId) + if (!parent) return true + current = parent + } + + return false +} + +async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions): Promise { + const client = getRootClient(instanceId) + return requestData(client.v2.session.list(options), "v2.session.list") +} + +function getV2SessionItems(response: V2SessionsResponse): SessionV2Info[] { + return response.data +} + +function getV2NextCursor(response: V2SessionsResponse): string | undefined { + const next = (response as any)?.cursor?.next + return typeof next === "string" && next.length > 0 ? next : undefined +} + +async function hydrateMissingSessionMetadata(instanceId: string, sessionIds: string[]): Promise { + const uniqueIds = Array.from(new Set(sessionIds)).filter(Boolean) + if (uniqueIds.length === 0) return + + const client = getRootClient(instanceId) + for (const sessionId of uniqueIds) { + const session = sessions().get(instanceId)?.get(sessionId) + if (!session || session.metadata !== undefined) continue + try { + await hydrateSessionMetadataWithClient(client, instanceId, sessionId) + } catch (error) { + log.warn("Failed to hydrate session metadata", { instanceId, sessionId, error }) + } + } +} + +async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SessionV2Info[], directory?: string): Promise { + const currentSessions = sessions().get(instanceId) ?? new Map() + const loaded = new Map(currentSessions) + for (const session of apiSessions) loaded.set(session.id, session) + + if (!apiSessions.some((session) => hasMissingParentChain(session, loaded))) return + + const limit = SESSION_PAGE_SIZE + let cursor: string | undefined + let remainingPages = 25 + + while (apiSessions.some((session) => hasMissingParentChain(session, loaded)) && remainingPages > 0) { + const page = await fetchV2Sessions(instanceId, { directory, limit, ...(cursor ? { cursor } : {}) }) + const items = getV2SessionItems(page) + if (items.length === 0) break + + setSessions((prev) => { + const next = new Map(prev) + const instanceSessions = new Map(next.get(instanceId) ?? new Map()) + + for (const apiSession of items) { + const existingSession = instanceSessions.get(apiSession.id) + instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession)) + loaded.set(apiSession.id, apiSession) + } + + next.set(instanceId, instanceSessions) + return next + }) + + cursor = getV2NextCursor(page) + if (!cursor) break + remainingPages -= 1 + } +} + +async function fetchSessions(instanceId: string, options?: { limit?: number; reset?: boolean; cursor?: string }): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") @@ -142,29 +237,17 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim }) try { - const projectResponse = await rootClient.project.current() - const projectId = projectResponse.data?.id - const start = options?.start ?? 0 - const limit = options?.limit ?? SESSION_PAGE_SIZE - - const sessionListOptions: { directory?: string; roots?: boolean; start?: number; limit?: number } = { - roots: true, - start, - limit, - } - if (instance.folder) sessionListOptions.directory = instance.folder - - log.info("session.list", { instanceId, projectId, start, limit, directory: sessionListOptions.directory }) - const response = await rootClient.session.list(sessionListOptions) - - const sessionMap = new Map() + const limit = Math.min(options?.limit ?? SESSION_PAGE_SIZE, 200) - if (!response.data || !Array.isArray(response.data)) { - setSessionPage(instanceId, [], start, false) - return + const sessionListOptions: { directory?: string; limit?: number; cursor?: string } = { + limit, + ...(instance.folder ? { directory: instance.folder } : {}), + ...(options?.cursor ? { cursor: options.cursor } : {}), } - const hasMore = response.data.length >= limit + log.info("v2.session.list", { instanceId, limit, directory: sessionListOptions.directory, cursor: sessionListOptions.cursor }) + const response = await fetchV2Sessions(instanceId, sessionListOptions) + const nextCursor = getV2NextCursor(response) let statusById: Record = {} try { @@ -177,8 +260,9 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim } const existingSessions = sessions().get(instanceId) + const sessionMap = new Map() - for (const apiSession of response.data) { + for (const apiSession of getV2SessionItems(response)) { const existingSession = existingSessions?.get(apiSession.id) const existingStatus = existingSession?.status @@ -194,34 +278,17 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim retry = hasType ? mapSdkSessionRetry(rawStatus) : retry } + const session = toClientSessionV2(instanceId, apiSession, existingSession) sessionMap.set(apiSession.id, { - id: apiSession.id, - instanceId, - title: apiSession.title || "Untitled", - parentId: apiSession.parentID || null, - agent: existingSession?.agent ?? "", - model: existingSession?.model ?? { providerId: "", modelId: "" }, + ...session, + agent: session.agent, + model: session.model, status, retry, idleSince: getIdleSinceForStatusTransition(existingStatus, status, existingSession?.idleSince), - version: apiSession.version, - time: { - ...apiSession.time, - }, - metadata: apiSession.metadata ?? existingSession?.metadata, - revert: apiSession.revert - ? { - messageID: apiSession.revert.messageID, - partID: apiSession.revert.partID, - snapshot: apiSession.revert.snapshot, - diff: apiSession.revert.diff, - } - : undefined, }) } - const validSessionIds = new Set(sessionMap.keys()) - setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(next.get(instanceId) ?? new Map()) @@ -232,7 +299,25 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim return next }) - setSessionPage(instanceId, Array.from(validSessionIds), start, hasMore) + const rootIds: string[] = [] + const missingRootSessionIds: string[] = [] + for (const apiSession of getV2SessionItems(response)) { + const root = getSessionRoot(instanceId, apiSession.id) + if (root) { + if (!rootIds.includes(root.id)) rootIds.push(root.id) + } else if (apiSession.parentID) { + missingRootSessionIds.push(apiSession.id) + } + } + + if (missingRootSessionIds.length > 0) { + log.warn("Some V2 session list items could not be attached to a loaded root", { + instanceId, + sessionIds: missingRootSessionIds, + }) + } + + setSessionPage(instanceId, rootIds, Boolean(nextCursor), options?.reset ?? true, nextCursor) syncInstanceSessionIndicator(instanceId) @@ -253,13 +338,8 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim pruneDraftPrompts(instanceId, new Set(sessions().get(instanceId)?.keys() ?? [])) - - const parentIds = Array.from(sessionMap.values()) - .filter((session) => session.parentId === null) - .map((session) => session.id) - - await Promise.all(parentIds.map((parentId) => fetchSessionChildren(instanceId, parentId))) void (async () => { + await hydrateMissingSessionMetadata(instanceId, rootIds) await migrateLegacyWorktreeMapToSessionMetadata(instanceId) await pruneStaleLegacyWorktreeMapEntries(instanceId) })().catch((error) => { @@ -278,7 +358,9 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim } async function loadMoreSessions(instanceId: string): Promise { - await fetchSessions(instanceId, { start: getSessionNextStart(instanceId), limit: SESSION_PAGE_SIZE }) + const cursor = getSessionNextCursor(instanceId) + if (!cursor) return + await fetchSessions(instanceId, { limit: SESSION_PAGE_SIZE, reset: false, cursor }) } async function searchSessions(instanceId: string, query: string): Promise { @@ -290,19 +372,18 @@ async function searchSessions(instanceId: string, query: string): Promise throw new Error("Instance not ready") } - const rootClient = getRootClient(instanceId) const requestId = beginSessionSearch(instanceId, trimmedQuery) try { - log.info("session.search", { instanceId, query: trimmedQuery, directory: instance.folder }) - const response = await rootClient.session.list({ + log.info("v2.session.search", { instanceId, query: trimmedQuery, directory: instance.folder }) + const response = await fetchV2Sessions(instanceId, { search: trimmedQuery, limit: SESSION_PAGE_SIZE, directory: instance.folder, }) if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return - const searchResults = response.data ?? [] + const searchResults = getV2SessionItems(response) if (searchResults.length === 0) { setSessionSearchResults(instanceId, trimmedQuery, [], requestId) @@ -315,43 +396,14 @@ async function searchSessions(instanceId: string, query: string): Promise for (const apiSession of searchResults) { const existingSession = instanceSessions.get(apiSession.id) - instanceSessions.set(apiSession.id, toClientSession(instanceId, apiSession, existingSession)) + instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession)) } next.set(instanceId, instanceSessions) return next }) - // Fetch any missing parents so child results are rendered correctly - const currentSessions = sessions().get(instanceId) - const missingParentIds = new Set() - for (const apiSession of searchResults) { - const parentId = apiSession.parentID - if (parentId && !currentSessions?.has(parentId)) { - missingParentIds.add(parentId) - } - } - - if (missingParentIds.size > 0) { - const parentFetches = Array.from(missingParentIds).map(async (parentId) => { - try { - const parentResponse = await rootClient.session.get({ sessionID: parentId }) - if (parentResponse.data) { - setSessions((prev) => { - const next = new Map(prev) - const instanceSessions = new Map(next.get(instanceId) ?? new Map()) - const existingSession = instanceSessions.get(parentId) - instanceSessions.set(parentId, toClientSession(instanceId, parentResponse.data, existingSession)) - next.set(instanceId, instanceSessions) - return next - }) - } - } catch (error) { - log.warn("Failed to fetch missing parent session:", { parentId, error }) - } - }) - await Promise.all(parentFetches) - } + await ensureV2ParentChainsLoaded(instanceId, searchResults, instance.folder) if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return @@ -377,147 +429,34 @@ async function searchSessions(instanceId: string, query: string): Promise } } -function toClientSession(instanceId: string, apiSession: any, existingSession?: Session): Session { +function toClientSessionV2(instanceId: string, apiSession: SessionV2Info, existingSession?: Session): Session { return { id: apiSession.id, instanceId, title: apiSession.title || existingSession?.title || "Untitled", parentId: apiSession.parentID || null, - agent: existingSession?.agent ?? "", - model: existingSession?.model ?? { providerId: "", modelId: "" }, + agent: apiSession.agent ?? existingSession?.agent ?? "", + model: apiSession.model + ? { + providerId: apiSession.model.providerID, + modelId: apiSession.model.id, + } + : existingSession?.model ?? { providerId: "", modelId: "" }, status: existingSession?.status ?? "idle", retry: existingSession?.retry ?? null, idleSince: existingSession?.idleSince ?? null, - version: apiSession.version, + version: existingSession?.version || "0", time: { ...apiSession.time, }, - metadata: apiSession.metadata ?? existingSession?.metadata, - revert: apiSession.revert - ? { - messageID: apiSession.revert.messageID, - partID: apiSession.revert.partID, - snapshot: apiSession.revert.snapshot, - diff: apiSession.revert.diff, - } - : existingSession?.revert, + metadata: existingSession?.metadata, + revert: existingSession?.revert, diff: existingSession?.diff, pendingPermission: existingSession?.pendingPermission, pendingQuestion: existingSession?.pendingQuestion, } } -async function fetchSessionChildren(instanceId: string, parentSessionId: string): Promise { - if (!instanceId || !parentSessionId) return [] - - const key = `${instanceId}:${parentSessionId}` - const pending = pendingSessionChildrenFetches.get(key) - if (pending) return pending - - const promise = (async () => { - const instance = instances().get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - const client = getRootClient(instanceId) - - log.info(`[HTTP] GET /session/{sessionID}/children for instance ${instanceId}`, { sessionId: parentSessionId }) - const apiChildren = await requestData( - client.session.children({ sessionID: parentSessionId, ...(await getSessionWorkspacePayload(instanceId, parentSessionId)) }), - "session.children", - ) - - if (!Array.isArray(apiChildren)) return [] - - const currentSessions = sessions().get(instanceId) - const children = apiChildren.map((apiSession) => toClientSession(instanceId, apiSession, currentSessions?.get(apiSession.id))) - const returnedChildIds = new Set(children.map((child) => child.id)) - let staleChildIds: string[] = [] - - setSessions((prev) => { - const next = new Map(prev) - const instanceSessions = new Map(next.get(instanceId)) - staleChildIds = [] - - for (const session of instanceSessions.values()) { - if (session.parentId === parentSessionId && !returnedChildIds.has(session.id)) { - staleChildIds.push(session.id) - } - } - - for (const staleChildId of staleChildIds) { - instanceSessions.delete(staleChildId) - } - - for (const child of children) { - instanceSessions.set(child.id, child) - } - next.set(instanceId, instanceSessions) - return next - }) - - if (staleChildIds.length > 0) { - const staleChildIdSet = new Set(staleChildIds) - - setMessagesLoaded((prev) => { - const loadedSet = prev.get(instanceId) - if (!loadedSet) return prev - const updated = new Set(loadedSet) - let changed = false - for (const staleChildId of staleChildIdSet) { - changed = updated.delete(staleChildId) || changed - } - if (!changed) return prev - const next = new Map(prev) - next.set(instanceId, updated) - return next - }) - - setSessionInfoByInstance((prev) => { - const instanceInfo = prev.get(instanceId) - if (!instanceInfo) return prev - const updated = new Map(instanceInfo) - let changed = false - for (const staleChildId of staleChildIdSet) { - changed = updated.delete(staleChildId) || changed - } - if (!changed) return prev - const next = new Map(prev) - next.set(instanceId, updated) - return next - }) - - for (const staleChildId of staleChildIds) { - messageStoreBus.getOrCreate(instanceId).clearSession(staleChildId) - clearCacheForSession(instanceId, staleChildId) - } - } - - syncInstanceSessionIndicator(instanceId) - updateThreadTotalsForParent(instanceId, parentSessionId) - - if (messagesLoaded().get(instanceId)?.has(parentSessionId)) { - for (const child of children) { - void loadMessages(instanceId, child.id, { skipDiff: true, skipChildren: true }).catch((error) => - log.error("Failed to load child session messages", { - instanceId, - sessionId: child.id, - parentSessionId, - error, - }), - ) - } - } - - return children - })() - - pendingSessionChildrenFetches.set(key, promise) - void promise.finally(() => pendingSessionChildrenFetches.delete(key)) - return promise -} - async function createSession(instanceId: string, agent?: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { @@ -567,7 +506,7 @@ async function createSession(instanceId: string, agent?: string): Promise log.error("Failed to load child session messages", { instanceId, @@ -1098,7 +1037,6 @@ export { fetchSessions, loadMoreSessions, searchSessions, - fetchSessionChildren, forkSession, loadMessages, } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index aa990e178..8f87e936b 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -24,10 +24,16 @@ import { getPermissionSessionId, getRequestIdFromPermissionReply, } from "../types/permission" -import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission" +import type { LegacyPermissionAskedEvent, LegacyPermissionRepliedEvent, PermissionRequest } from "../types/permission" import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question" -import type { QuestionRequest } from "../types/question" -import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2" +import type { LegacyQuestionAnsweredEvent, LegacyQuestionAskedEvent, QuestionRequest } from "../types/question" +import type { + EventPermissionV2Asked, + EventPermissionV2Replied, + EventQuestionV2Asked, + EventQuestionV2Rejected, + EventQuestionV2Replied, +} from "@opencode-ai/sdk/v2" import { showToastNotification, type ToastHandle, ToastVariant } from "../lib/notifications" import { sendOsNotification } from "../lib/os-notifications" import { preferences } from "./preferences" @@ -716,9 +722,10 @@ function handleTuiToast(_instanceId: string, event: TuiToastEvent): void { }) } -function handlePermissionUpdated(instanceId: string, event: { type: string; properties?: PermissionRequestLike } | any): void { - const permission = event?.properties as PermissionRequestLike | undefined +function handlePermissionUpdated(instanceId: string, event: EventPermissionV2Asked | LegacyPermissionAskedEvent): void { + const permission = event?.properties as PermissionRequest | undefined if (!permission) return + const source = event.type === "permission.v2.asked" ? "v2" : "legacy" const permissionId = getPermissionId(permission) if (permissionId && hasRepliedPermission(instanceId, permissionId)) { log.info(`[SSE] Ignoring stale permission request after local reply: ${permissionId}`) @@ -726,7 +733,7 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop } log.info(`[SSE] Permission request: ${permissionId} (${getPermissionKind(permission)})`) - const queuedPermission = addPermissionToQueue(instanceId, permission) ?? permission + const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission upsertPermissionV2(instanceId, queuedPermission) const sessionId = getPermissionSessionId(permission) @@ -739,8 +746,8 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop } } -function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void { - const properties = event?.properties as PermissionReplyEventPropertiesLike | undefined +function handlePermissionReplied(instanceId: string, event: EventPermissionV2Replied | LegacyPermissionRepliedEvent): void { + const properties = event?.properties const requestId = getRequestIdFromPermissionReply(properties) if (!requestId) return @@ -750,12 +757,13 @@ function handlePermissionReplied(instanceId: string, event: { type: string; prop removePermissionV2(instanceId, requestId) } -function handleQuestionAsked(instanceId: string, event: { type: string; properties?: QuestionRequest } | any): void { +function handleQuestionAsked(instanceId: string, event: EventQuestionV2Asked | LegacyQuestionAskedEvent): void { const request = event?.properties as QuestionRequest | undefined if (!request) return + const source = event.type === "question.asked" ? "legacy" : "v2" log.info(`[SSE] Question asked: ${getQuestionId(request)}`) - addQuestionToQueue(instanceId, request) + addQuestionToQueue(instanceId, request, source) upsertQuestionV2(instanceId, request) const sessionId = getQuestionSessionId(request) @@ -770,9 +778,9 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti function handleQuestionAnswered( instanceId: string, - event: { type: string; properties?: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] } | any, + event: EventQuestionV2Replied | EventQuestionV2Rejected | LegacyQuestionAnsweredEvent, ): void { - const properties = event?.properties as EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | undefined + const properties = event?.properties const requestId = getRequestIdFromQuestionReply(properties) if (!requestId) return diff --git a/packages/ui/src/stores/session-metadata.ts b/packages/ui/src/stores/session-metadata.ts index 729b8923d..15ba37b60 100644 --- a/packages/ui/src/stores/session-metadata.ts +++ b/packages/ui/src/stores/session-metadata.ts @@ -70,7 +70,7 @@ export async function updateSessionMetadataWithClient( const latest = await requestData(client.session.get({ sessionID: sessionId }), "session.get") const nextMetadata = updater(normalizeMetadata(latest?.metadata)) const updated = await requestData( - client.session.update({ sessionID: sessionId, metadata: nextMetadata }), + client.session.update({ sessionID: sessionId, metadata: nextMetadata } as any), "session.update", ) const persistedMetadata = normalizeMetadata(updated?.metadata ?? nextMetadata) @@ -82,6 +82,21 @@ export async function updateSessionMetadataWithClient( return persistedMetadata } +export async function hydrateSessionMetadataWithClient( + client: OpencodeClient, + instanceId: string, + sessionId: string, +): Promise { + const latest = await requestData(client.session.get({ sessionID: sessionId }), "session.get") + const metadata = normalizeMetadata(latest?.metadata) + + withSession(instanceId, sessionId, (session) => { + session.metadata = metadata + }) + + return metadata +} + export async function updateCodeNomadSessionMetadataWithClient( client: OpencodeClient, instanceId: string, diff --git a/packages/ui/src/stores/session-pagination-model.ts b/packages/ui/src/stores/session-pagination-model.ts new file mode 100644 index 000000000..3d5e981f7 --- /dev/null +++ b/packages/ui/src/stores/session-pagination-model.ts @@ -0,0 +1,25 @@ +export type SessionPaginationState = { + ids: string[] + hasMore: boolean + nextCursor?: string +} + +export function getDefaultSessionPaginationState(): SessionPaginationState { + return { ids: [], hasMore: true, nextCursor: undefined } +} + +export function applySessionPage( + current: SessionPaginationState | undefined, + ids: string[], + hasMore: boolean, + reset = false, + nextCursor?: string, +): SessionPaginationState { + const previous = current ?? getDefaultSessionPaginationState() + const nextIds = reset ? ids : Array.from(new Set([...previous.ids, ...ids])) + return { + ids: nextIds, + hasMore, + nextCursor, + } +} diff --git a/packages/ui/src/stores/session-pagination.test.ts b/packages/ui/src/stores/session-pagination.test.ts new file mode 100644 index 000000000..13b71a7f8 --- /dev/null +++ b/packages/ui/src/stores/session-pagination.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { applySessionPage, getDefaultSessionPaginationState } from "./session-pagination-model.ts" + +describe("session pagination cursor state", () => { + it("stores the v2 next cursor and appends loaded pages", () => { + const firstPage = applySessionPage(getDefaultSessionPaginationState(), ["root-1", "root-2"], true, true, "cursor-page-2") + + assert.deepEqual(firstPage.ids, ["root-1", "root-2"]) + assert.equal(firstPage.hasMore, true) + assert.equal(firstPage.nextCursor, "cursor-page-2") + + const secondPage = applySessionPage(firstPage, ["root-2", "root-3"], false, false, undefined) + + assert.deepEqual(secondPage.ids, ["root-1", "root-2", "root-3"]) + assert.equal(secondPage.hasMore, false) + assert.equal(secondPage.nextCursor, undefined) + }) + + it("resets ids and cursor when a fresh first page is loaded", () => { + const previous = applySessionPage(getDefaultSessionPaginationState(), ["old-root"], true, true, "old-cursor") + const next = applySessionPage(previous, ["new-root"], false, true, undefined) + + assert.deepEqual(next.ids, ["new-root"]) + assert.equal(next.hasMore, false) + assert.equal(next.nextCursor, undefined) + }) +}) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 36f0cf955..1ed9c53b1 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -12,6 +12,7 @@ import { getRootClient } from "./opencode-client" import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" import { tGlobal } from "../lib/i18n" import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals" +import { applySessionPage, getDefaultSessionPaginationState, type SessionPaginationState } from "./session-pagination-model" const log = getLogger("session") @@ -63,13 +64,7 @@ type InstanceIndicatorCounts = { const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal>(new Map()) -const SESSION_PAGE_SIZE = 100 - -type SessionPaginationState = { - ids: string[] - nextStart: number - hasMore: boolean -} +const SESSION_PAGE_SIZE = 200 type SessionSearchState = { query: string @@ -82,7 +77,7 @@ const [sessionPagination, setSessionPagination] = createSignal>(new Map()) function getSessionPaginationState(instanceId: string): SessionPaginationState { - return sessionPagination().get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true } + return sessionPagination().get(instanceId) ?? getDefaultSessionPaginationState() } function getSessionListIds(instanceId: string): string[] { @@ -93,20 +88,14 @@ function getSessionFetchLimit(instanceId: string): number { return Math.max(getSessionPaginationState(instanceId).ids.length, SESSION_PAGE_SIZE) } -function getSessionNextStart(instanceId: string): number { - return getSessionPaginationState(instanceId).nextStart +function getSessionNextCursor(instanceId: string): string | undefined { + return getSessionPaginationState(instanceId).nextCursor } -function setSessionPage(instanceId: string, ids: string[], start: number, hasMore: boolean): void { +function setSessionPage(instanceId: string, ids: string[], hasMore: boolean, reset = false, nextCursor?: string): void { setSessionPagination((prev) => { const next = new Map(prev) - const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true } - const nextIds = start <= 0 ? ids : Array.from(new Set([...current.ids, ...ids])) - next.set(instanceId, { - ids: nextIds, - nextStart: start + ids.length, - hasMore, - }) + next.set(instanceId, applySessionPage(prev.get(instanceId), ids, hasMore, reset, nextCursor)) return next }) } @@ -118,7 +107,7 @@ function getSessionHasMore(instanceId: string): boolean { function resetSessionPagination(instanceId: string): void { setSessionPagination((prev) => { const next = new Map(prev) - next.set(instanceId, { ids: [], nextStart: 0, hasMore: true }) + next.set(instanceId, getDefaultSessionPaginationState()) return next }) } @@ -126,9 +115,9 @@ function resetSessionPagination(instanceId: string): void { function prependSessionListId(instanceId: string, sessionId: string): void { setSessionPagination((prev) => { const next = new Map(prev) - const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true } + const current = prev.get(instanceId) ?? { ids: [], hasMore: true } const ids = [sessionId, ...current.ids.filter((id) => id !== sessionId)] - next.set(instanceId, { ...current, ids, nextStart: current.nextStart + (current.ids.includes(sessionId) ? 0 : 1) }) + next.set(instanceId, { ...current, ids }) return next }) } @@ -136,9 +125,9 @@ function prependSessionListId(instanceId: string, sessionId: string): void { function removeSessionListId(instanceId: string, sessionId: string): void { setSessionPagination((prev) => { const next = new Map(prev) - const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true } + const current = prev.get(instanceId) ?? { ids: [], hasMore: true } const ids = current.ids.filter((id) => id !== sessionId) - next.set(instanceId, { ...current, ids, nextStart: Math.max(0, current.nextStart - (ids.length === current.ids.length ? 0 : 1)) }) + next.set(instanceId, { ...current, ids }) return next }) } @@ -578,14 +567,66 @@ function getChildSessions(instanceId: string, parentId: string): Session[] { return allSessions.filter((s) => s.parentId === parentId) } +function getDescendantSessions(instanceId: string, parentId: string): Session[] { + const allSessions = getSessions(instanceId) + const childrenByParent = new Map() + + for (const session of allSessions) { + if (!session.parentId) continue + const children = childrenByParent.get(session.parentId) + if (children) { + children.push(session) + } else { + childrenByParent.set(session.parentId, [session]) + } + } + + const descendants: Session[] = [] + const stack = [...(childrenByParent.get(parentId) ?? [])] + const seen = new Set() + + while (stack.length > 0) { + const session = stack.shift() + if (!session || seen.has(session.id)) continue + seen.add(session.id) + descendants.push(session) + stack.push(...(childrenByParent.get(session.id) ?? [])) + } + + descendants.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0)) + return descendants +} + function getSessionFamily(instanceId: string, parentId: string): Session[] { const parent = sessions().get(instanceId)?.get(parentId) if (!parent) return [] - const children = getChildSessions(instanceId, parentId) + const children = getDescendantSessions(instanceId, parentId) return [parent, ...children] } +function getSessionRoot(instanceId: string, sessionId: string): Session | null { + const instanceSessions = sessions().get(instanceId) + if (!instanceSessions) return null + return getSessionRootFromMap(instanceSessions, sessionId) +} + +function getSessionRootFromMap(instanceSessions: Map, sessionId: string): Session | null { + let current = instanceSessions.get(sessionId) + if (!current) return null + + const seen = new Set() + while (current.parentId) { + if (seen.has(current.id)) return null + seen.add(current.id) + const parent = instanceSessions.get(current.parentId) + if (!parent) return null + current = parent + } + + return current +} + type SessionThreadCacheEntry = { signature: string thread: SessionThread @@ -616,17 +657,18 @@ function buildSessionThreads(instanceId: string, rootIds: string[], childIds?: S const cache = getOrCreateSessionThreadCache(instanceId) const seenParents = new Set() - const childrenByParent = new Map() + const childrenByRoot = new Map() for (const session of instanceSessions.values()) { - const parentId = session.parentId - if (!parentId) continue + if (!session.parentId) continue if (childIds && !childIds.has(session.id)) continue - const children = childrenByParent.get(parentId) + const root = getSessionRootFromMap(instanceSessions, session.id) + if (!root) continue + const children = childrenByRoot.get(root.id) if (children) { children.push(session) } else { - childrenByParent.set(parentId, [session]) + childrenByRoot.set(root.id, [session]) } } @@ -638,7 +680,7 @@ function buildSessionThreads(instanceId: string, rootIds: string[], childIds?: S seenParents.add(parent.id) - const children = childrenByParent.get(parent.id) ?? [] + const children = childrenByRoot.get(parent.id) ?? [] if (children.length > 1) { children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0)) } @@ -698,7 +740,8 @@ function getSessionSearchThreads(instanceId: string): SessionThread[] { rootIds.push(session.id) } else { childIds.add(session.id) - if (!rootIds.includes(session.parentId)) rootIds.push(session.parentId) + const root = getSessionRootFromMap(instanceSessions, session.id) + if (root && !rootIds.includes(root.id)) rootIds.push(root.id) } } @@ -979,6 +1022,8 @@ export { getSessions, getParentSessions, getChildSessions, + getDescendantSessions, + getSessionRoot, getSessionFamily, getSessionThreads, getSessionSearchThreads, @@ -998,7 +1043,7 @@ export { sessionSearch, getSessionListIds, getSessionFetchLimit, - getSessionNextStart, + getSessionNextCursor, setSessionPage, getSessionHasMore, resetSessionPagination, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 1430e1002..bd2513906 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -13,6 +13,8 @@ import { getActiveParentSession, getActiveSession, getChildSessions, + getDescendantSessions, + getSessionRoot, getParentSessions, getSessionDraftPrompt, getSessionFamily, @@ -55,7 +57,6 @@ import { fetchSessions, loadMoreSessions, searchSessions, - fetchSessionChildren, forkSession, loadMessages, } from "./session-api" @@ -122,11 +123,12 @@ export { fetchSessions, loadMoreSessions, searchSessions, - fetchSessionChildren, forkSession, getActiveParentSession, getActiveSession, getChildSessions, + getDescendantSessions, + getSessionRoot, getDefaultModel, getParentSessions, getSessionDraftPrompt, diff --git a/packages/ui/src/types/message.ts b/packages/ui/src/types/message.ts index 95fec10dd..faba9d9d1 100644 --- a/packages/ui/src/types/message.ts +++ b/packages/ui/src/types/message.ts @@ -9,7 +9,7 @@ import type { AssistantMessage as SDKAssistantMessageV2, } from "@opencode-ai/sdk/v2" -import type { PermissionRequestLike } from "./permission" +import type { PermissionRequest } from "./permission" // Re-export for other modules export type { @@ -44,7 +44,7 @@ export interface RenderCache { } export interface PendingPermissionState { - permission: PermissionRequestLike + permission: PermissionRequest active: boolean } diff --git a/packages/ui/src/types/permission.test.ts b/packages/ui/src/types/permission.test.ts index e82151259..91dd57e91 100644 --- a/packages/ui/src/types/permission.test.ts +++ b/packages/ui/src/types/permission.test.ts @@ -1,49 +1,42 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { mergePermissionRequest, type PermissionRequestLike } from "./permission.ts" +import { mergePermissionRequest, type PermissionRequest } from "./permission.ts" describe("mergePermissionRequest", () => { - it("preserves known routing metadata when duplicate payloads are sparse", () => { - const previous: PermissionRequestLike = { + it("preserves v2 source metadata when duplicate payload omits it", () => { + const previous: PermissionRequest = { id: "permission-1", sessionID: "session-1", - messageID: "message-1", - callID: "call-1", + action: "edit", + resources: ["file-a.ts"], metadata: { - callID: "metadata-call-1", - messageID: "metadata-message-1", + path: "file-a.ts", }, - tool: { - callID: "tool-call-1", + source: { + type: "tool", + callID: "call-1", messageID: "tool-message-1", }, - time: { created: 1_000 }, } - const next: PermissionRequestLike = { + const next: PermissionRequest = { id: "permission-1", - sessionID: undefined, - messageID: undefined, - callID: undefined, + sessionID: "session-1", + action: "edit", + resources: ["file-b.ts"], metadata: { - callID: undefined, - }, - tool: { - callID: undefined, + diff: "diff --git a/file-b.ts b/file-b.ts", }, - time: { created: undefined }, - } as PermissionRequestLike + } const merged = mergePermissionRequest(previous, next) assert.equal(merged.sessionID, "session-1") - assert.equal(merged.messageID, "message-1") - assert.equal(merged.callID, "call-1") - assert.equal(merged.metadata?.callID, "metadata-call-1") - assert.equal(merged.metadata?.messageID, "metadata-message-1") - assert.equal(merged.tool?.callID, "tool-call-1") - assert.equal(merged.tool?.messageID, "tool-message-1") - assert.equal(merged.time?.created, 1_000) + assert.deepEqual((merged as any).resources, ["file-b.ts"]) + assert.equal(merged.metadata?.path, "file-a.ts") + assert.equal(merged.metadata?.diff, "diff --git a/file-b.ts b/file-b.ts") + assert.equal((merged as any).source?.callID, "call-1") + assert.equal((merged as any).source?.messageID, "tool-message-1") }) }) diff --git a/packages/ui/src/types/permission.ts b/packages/ui/src/types/permission.ts index 5d873460d..ab1e9c1c5 100644 --- a/packages/ui/src/types/permission.ts +++ b/packages/ui/src/types/permission.ts @@ -1,23 +1,25 @@ -export type PermissionReply = "once" | "always" | "reject" +import type { PermissionV2Reply, PermissionV2Request, EventPermissionV2Replied } from "@opencode-ai/sdk/v2" -export interface PermissionToolRefLike { - messageID?: string - messageId?: string - callID?: string - callId?: string -} +export type PermissionReply = PermissionV2Reply +export type PermissionSource = "legacy" | "v2" -// Compat type that covers both the legacy Permission.Info payload and the new -// PermissionNext.Request payload. -export interface PermissionRequestLike { +export interface LegacyPermissionRequest { id: string - - // Legacy fields + sessionID?: string + sessionId?: string + permission?: string type?: string pattern?: string + patterns?: string[] + always?: string[] title?: string - sessionID?: string - sessionId?: string + metadata?: Record + tool?: { + messageID?: string + messageId?: string + callID?: string + callId?: string + } messageID?: string messageId?: string callID?: string @@ -26,141 +28,90 @@ export interface PermissionRequestLike { partId?: string toolCallID?: string toolCallId?: string - metadata?: Record time?: { created?: number } - - // New fields - permission?: string - patterns?: string[] - always?: string[] - tool?: PermissionToolRefLike } -export interface PermissionReplyEventPropertiesLike { - sessionID?: string - sessionId?: string - permissionID?: string - permissionId?: string - requestID?: string - requestId?: string - response?: PermissionReply - reply?: PermissionReply -} +export type PermissionRequest = PermissionV2Request | LegacyPermissionRequest -// Permission payloads can come from legacy/new SDK shapes. Preserve known -// top-level routing aliases when an out-of-order duplicate omits them. -const TOP_LEVEL_ROUTING_ALIAS_KEYS = [ - "sessionID", - "sessionId", - "messageID", - "messageId", - "callID", - "callId", - "partID", - "partId", - "toolCallID", - "toolCallId", -] as const satisfies ReadonlyArray +export type LegacyPermissionAskedEvent = { + type: "permission.asked" | "permission.updated" + properties?: LegacyPermissionRequest +} -function mergeRecordPreservingKnown>(previous: T | undefined, next: T | undefined): T | undefined { - if (!previous) return next - if (!next) return previous - const merged: Record = { ...previous, ...next } - for (const key of Object.keys(previous)) { - if (next[key] == null && previous[key] != null) { - merged[key] = previous[key] - } +export type LegacyPermissionRepliedEvent = { + type: "permission.replied" + properties?: { + requestID?: string + requestId?: string + permissionID?: string + permissionId?: string } - return merged as T } -export function mergePermissionRequest(previous: PermissionRequestLike | undefined, next: PermissionRequestLike): PermissionRequestLike { +function isV2Permission(permission: PermissionRequest | null | undefined): permission is PermissionV2Request { + return Boolean(permission && "action" in permission && Array.isArray((permission as PermissionV2Request).resources)) +} + +export function mergePermissionRequest(previous: PermissionRequest | undefined, next: PermissionRequest): PermissionRequest { if (!previous) return next - const merged = { + const previousMetadata = previous.metadata ?? {} + const nextMetadata = next.metadata ?? {} + return { ...previous, ...next, - metadata: mergeRecordPreservingKnown(previous.metadata, next.metadata), - time: mergeRecordPreservingKnown(previous.time as Record | undefined, next.time as Record | undefined) as PermissionRequestLike["time"], - tool: mergeRecordPreservingKnown(previous.tool as Record | undefined, next.tool as Record | undefined) as PermissionRequestLike["tool"], - } - for (const key of TOP_LEVEL_ROUTING_ALIAS_KEYS) { - if ((next as any)[key] == null && (previous as any)[key] != null) { - ;(merged as any)[key] = (previous as any)[key] - } + metadata: { + ...previousMetadata, + ...nextMetadata, + }, + source: isV2Permission(next) ? next.source ?? (isV2Permission(previous) ? previous.source : undefined) : undefined, + tool: !isV2Permission(next) ? next.tool ?? (!isV2Permission(previous) ? previous.tool : undefined) : undefined, } - return merged } -export function getPermissionId(permission: PermissionRequestLike | null | undefined): string { +export function getPermissionId(permission: PermissionRequest | null | undefined): string { return permission?.id ?? "" } -export function getPermissionSessionId(permission: PermissionRequestLike | null | undefined): string | undefined { - return ( - (permission as any)?.sessionID ?? - (permission as any)?.sessionId ?? - undefined - ) +export function getPermissionSessionId(permission: PermissionRequest | null | undefined): string | undefined { + return permission?.sessionID ?? (!isV2Permission(permission) ? permission?.sessionId : undefined) } -export function getPermissionMessageId(permission: PermissionRequestLike | null | undefined): string | undefined { - const tool = (permission as any)?.tool as PermissionToolRefLike | undefined - return ( - tool?.messageID ?? - tool?.messageId ?? - (permission as any)?.messageID ?? - (permission as any)?.messageId ?? - undefined - ) +export function getPermissionMessageId(permission: PermissionRequest | null | undefined): string | undefined { + if (isV2Permission(permission)) return permission.source?.messageID + return permission?.tool?.messageID ?? permission?.tool?.messageId ?? permission?.messageID ?? permission?.messageId } -export function getPermissionCallId(permission: PermissionRequestLike | null | undefined): string | undefined { - const tool = (permission as any)?.tool as PermissionToolRefLike | undefined - const metadata = (permission as any)?.metadata || {} +export function getPermissionCallId(permission: PermissionRequest | null | undefined): string | undefined { + if (isV2Permission(permission)) return permission.source?.callID + const metadata = permission?.metadata ?? {} return ( - tool?.callID ?? - tool?.callId ?? - (permission as any)?.callID ?? - (permission as any)?.callId ?? - (permission as any)?.toolCallID ?? - (permission as any)?.toolCallId ?? - metadata.callID ?? - metadata.callId ?? - undefined + permission?.tool?.callID ?? + permission?.tool?.callId ?? + permission?.callID ?? + permission?.callId ?? + permission?.toolCallID ?? + permission?.toolCallId ?? + (metadata.callID as string | undefined) ?? + (metadata.callId as string | undefined) ) } -export function getPermissionCreatedAt(permission: PermissionRequestLike | null | undefined): number { - const created = (permission as any)?.time?.created - return typeof created === "number" ? created : Date.now() -} - -export function getPermissionKind(permission: PermissionRequestLike | null | undefined): string { - return ( - (permission as any)?.permission ?? - (permission as any)?.type ?? - "permission" - ) +export function getPermissionKind(permission: PermissionRequest | null | undefined): string { + if (isV2Permission(permission)) return permission.action + return permission?.permission ?? permission?.type ?? "permission" } -export function getPermissionPatterns(permission: PermissionRequestLike | null | undefined): string[] { - const patterns = (permission as any)?.patterns - if (Array.isArray(patterns)) { - return patterns.filter((value) => typeof value === "string") - } - const pattern = (permission as any)?.pattern - if (typeof pattern === "string" && pattern.length > 0) { - return [pattern] - } - return [] +export function getPermissionPatterns(permission: PermissionRequest | null | undefined): string[] { + if (isV2Permission(permission)) return permission.resources.filter((value) => typeof value === "string") + const patterns = permission?.patterns + if (Array.isArray(patterns)) return patterns.filter((value) => typeof value === "string") + return typeof permission?.pattern === "string" && permission.pattern.length > 0 ? [permission.pattern] : [] } -export function getPermissionDisplayTitle(permission: PermissionRequestLike | null | undefined): string { - const title = (permission as any)?.title - if (typeof title === "string" && title.trim().length > 0) { - return title +export function getPermissionDisplayTitle(permission: PermissionRequest | null | undefined): string { + if (!isV2Permission(permission) && typeof permission?.title === "string" && permission.title.trim().length > 0) { + return permission.title } - const kind = getPermissionKind(permission) const patterns = getPermissionPatterns(permission) if (patterns.length > 0) { @@ -169,12 +120,8 @@ export function getPermissionDisplayTitle(permission: PermissionRequestLike | nu return kind } -export function getRequestIdFromPermissionReply(properties: PermissionReplyEventPropertiesLike | null | undefined): string | undefined { - return ( - (properties as any)?.requestID ?? - (properties as any)?.requestId ?? - (properties as any)?.permissionID ?? - (properties as any)?.permissionId ?? - undefined - ) +export function getRequestIdFromPermissionReply( + properties: EventPermissionV2Replied["properties"] | LegacyPermissionRepliedEvent["properties"] | null | undefined, +): string | undefined { + return properties?.requestID ?? (properties as LegacyPermissionRepliedEvent["properties"])?.requestId ?? (properties as LegacyPermissionRepliedEvent["properties"])?.permissionID ?? (properties as LegacyPermissionRepliedEvent["properties"])?.permissionId } diff --git a/packages/ui/src/types/question.ts b/packages/ui/src/types/question.ts index 02291d5dd..e2d5ad8d1 100644 --- a/packages/ui/src/types/question.ts +++ b/packages/ui/src/types/question.ts @@ -1,10 +1,24 @@ import type { - QuestionRequest, - EventQuestionReplied, - EventQuestionRejected, + QuestionV2Request, + EventQuestionV2Replied, + EventQuestionV2Rejected, } from "@opencode-ai/sdk/v2" -export type { QuestionRequest } +export type QuestionSource = "legacy" | "v2" + +export type QuestionRequest = QuestionV2Request & { + version?: string +} + +export type LegacyQuestionAskedEvent = { + type: "question.asked" + properties?: QuestionRequest +} + +export type LegacyQuestionAnsweredEvent = { + type: "question.replied" | "question.rejected" + properties?: { requestID?: string } +} export function getQuestionId(question: QuestionRequest | null | undefined): string { return question?.id ?? "" @@ -28,7 +42,7 @@ export function getQuestionCreatedAt(question: QuestionRequest | null | undefine } export function getRequestIdFromQuestionReply( - properties: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | null | undefined, + properties: EventQuestionV2Replied["properties"] | EventQuestionV2Rejected["properties"] | LegacyQuestionAnsweredEvent["properties"] | null | undefined, ): string | undefined { return properties?.requestID } From 97dcc0a692ba61ce92c1bf1f9f1349bdca97c028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 7 Jun 2026 13:02:06 +0200 Subject: [PATCH 11/11] feat(ui): show the session title in the header bar (#340) Fixes #299 ## Summary - show the active session title in the instance header, just after the menu switch. - keep the title visible whenever the left session drawer is not pinned, using a quiet two-line header treatment without active-item highlighting. - when the unpinned drawer is open as a floating overlay, keep the title in the same left header slot so the drawer covers it instead of pushing toolbar controls around. - disable the feature in mobile view ## Validation - `git diff --check` - `npm run typecheck --workspace @codenomad/ui` - `npm run build --workspace @codenomad/ui` - visually validated in the rebuilt desktop app raw executable --- packages/ui/src/App.tsx | 1 - .../components/instance/instance-shell2.tsx | 219 ++++++++++-------- .../ui/src/styles/panels/session-layout.css | 26 +++ 3 files changed, 146 insertions(+), 100 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 55acd97ee..7b8128ad8 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -103,7 +103,6 @@ const App: Component = () => { binaryPath: string instanceId: string } | null>(null) - const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index bb90f31ee..ab8cbfb36 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -514,6 +514,23 @@ const InstanceShell2: Component = (props) => {
) + const renderPreviewToggleButton = () => ( + + + {(() => { + const Icon = PreviewToggleIcon() + return + + ) + const handleCommandPaletteClick = () => { showCommandPalette(props.instance.id) } @@ -834,6 +851,50 @@ const InstanceShell2: Component = (props) => { } const showingInfoView = createMemo(() => activeSessionIdForInstance() === "info") + const activeSessionTitle = createMemo(() => { + if (showingInfoView()) return null + const title = activeSessionForInstance()?.title?.trim() + return title || t("sessionList.session.untitled") + }) + const showHeaderLeftSlot = createMemo(() => !leftPinned()) + const showHeaderSessionTitle = createMemo(() => !compactHeaderLayout() && showHeaderLeftSlot() && Boolean(activeSessionTitle())) + const headerToolbarHorizontalInset = createMemo(() => (isPhoneLayout() ? 16 : 24)) + const headerLeftSlotWidth = createMemo(() => Math.max(0, sessionSidebarWidth() - headerToolbarHorizontalInset())) + const headerLeftSlotStyle = createMemo(() => + leftDrawerState() === "floating-open" || showHeaderSessionTitle() ? { width: `${headerLeftSlotWidth()}px` } : undefined, + ) + + const renderActiveSessionHeaderTitle = () => ( + +
+ {activeSessionTitle()} +
+
+ ) + + const renderHeaderLeftSlot = () => ( + +
+ + + {leftAppBarButtonIcon()} + + + {renderActiveSessionHeaderTitle()} +
+
+ ) const isLaunching = createMemo(() => props.instance.status === "starting") @@ -933,94 +994,76 @@ const InstanceShell2: Component = (props) => { fallback={
- - - {leftAppBarButtonIcon()} - - + {renderHeaderLeftSlot()} -
- {renderSessionHeaderIndicators()} -
+
+ {renderSessionHeaderIndicators()} +
-
- +
+ + + + + + + + +
+ +
+ + + +
+ + + {renderPreviewToggleButton()} + + + - + {renderPreviewToggleButton()} + + + - {(() => { - const Icon = PreviewToggleIcon() - return - - - - -
- -
- - - -
- - - - - - - - - {rightAppBarButtonIcon()} - -
@@ -1038,18 +1081,7 @@ const InstanceShell2: Component = (props) => { } >
- - - {leftAppBarButtonIcon()} - - + {renderHeaderLeftSlot()} = (props) => { > diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index 07738886a..c6223f981 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -105,6 +105,32 @@ color: var(--text-primary); } +.session-header-left-slot { + @apply flex items-center gap-2 min-w-0; + flex: 0 0 auto; +} + +.session-header-active-title { + display: flex; + align-items: center; + flex: 1 1 auto; + min-width: 0; + align-self: stretch; + padding-inline: 0.75rem; + border-inline: 1px solid color-mix(in oklab, var(--border-base) 72%, transparent); + color: var(--text-secondary); +} + +.session-header-active-title-text { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.15; +} + .session-sidebar-shortcuts { @apply flex flex-col gap-1; }