diff --git a/src/panel/message-renderer.ts b/src/panel/message-renderer.ts index 55fe2a9b..484fbc4f 100644 --- a/src/panel/message-renderer.ts +++ b/src/panel/message-renderer.ts @@ -3,7 +3,7 @@ import { MarkdownRenderer, type App, type Component } from "obsidian"; import type { DisplayItem } from "../display/types"; import { copyTextWithNotice } from "../ui/clipboard"; import { renderTextWithWikiLinks as renderInlineWikiLinks } from "../ui/dom"; -import { messageRenderBlocks, syncMessageRenderBlocks } from "../ui/message-stream"; +import { messageRenderBlocks, notifyMessageContentRendered, syncMessageRenderBlocks } from "../ui/message-stream"; import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../ui/scroll"; import type { TurnDiffViewState } from "../ui/turn-diff"; import { markdownFileLinkTarget } from "./markdown-file-links"; @@ -91,6 +91,7 @@ export class PanelMessageRenderer { void MarkdownRenderer.render(this.options.app, text, parent, sourcePath, this.options.owner).then(() => { this.bindRenderedWikiLinks(parent, sourcePath); this.bindRenderedMarkdownFileLinks(parent, sourcePath); + notifyMessageContentRendered(parent); this.scrollMarkdownMessageIntoPinnedBottom(parent); }); } diff --git a/src/ui/message-stream.ts b/src/ui/message-stream.ts index 8f455216..a714e690 100644 --- a/src/ui/message-stream.ts +++ b/src/ui/message-stream.ts @@ -15,6 +15,9 @@ import { } from "./work-items"; import type { TurnDiffViewState } from "./turn-diff"; +const USER_MESSAGE_COLLAPSE_HEIGHT_PX = 360; +const MESSAGE_CONTENT_RENDERED_EVENT = "codex-panel:message-content-rendered"; + export interface MessageRenderBlock { key: string; signature: string; @@ -226,12 +229,17 @@ function renderDisplayItem(parent: HTMLElement, item: DisplayItem, context: Mess if (context.canRollbackItem?.(item)) { renderMessageAction(role, "undo-2", "Rollback last turn", "codex-panel__rollback-turn", () => context.onRollbackItem?.(item)); } - const content = messageEl.createDiv({ cls: `codex-panel__message-content ${item.markdown === false ? "" : "markdown-rendered"}` }); + const collapsible = isCollapsibleUserMessage(item); + const contentParent = collapsible ? messageEl.createDiv({ cls: "codex-panel__message-collapse" }) : messageEl; + const content = contentParent.createDiv({ cls: `codex-panel__message-content ${item.markdown === false ? "" : "markdown-rendered"}` }); if (item.markdown === false) { context.renderTextWithWikiLinks(content, item.text); } else { context.renderMarkdown(content, item.text); } + if (collapsible) { + renderUserMessageCollapse(contentParent, content, item.id, context); + } if (item.kind === "message" && item.editedFiles && item.editedFiles.length > 0) { renderEditedFiles(messageEl, item, context); } @@ -251,6 +259,43 @@ function renderDisplayItem(parent: HTMLElement, item: DisplayItem, context: Mess } } +function isCollapsibleUserMessage(item: DisplayItem): boolean { + return item.kind === "message" && item.role === "user"; +} + +function renderUserMessageCollapse(parent: HTMLElement, content: HTMLElement, itemId: string, context: MessageStreamContext): void { + const key = `message:${itemId}:expanded`; + const details = parent.createEl("details", { cls: "codex-panel__message-collapse-details" }); + details.createEl("summary", { text: "Show more" }); + + const update = () => { + const overflows = content.scrollHeight > userMessageCollapseHeight(content) + 1; + const expanded = context.openDetails.has(key); + parent.classList.toggle("codex-panel__message-collapse--overflow", overflows); + parent.classList.toggle("codex-panel__message-collapse--expanded", overflows && expanded); + content.classList.toggle("codex-panel__message-content--collapsed", overflows && !expanded); + details.hidden = !overflows || expanded; + }; + + details.ontoggle = () => { + if (!details.open) return; + details.open = false; + context.openDetails.add(key); + update(); + context.onDetailsToggle?.(); + }; + + content.addEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update); + update(); + content.win.requestAnimationFrame(update); +} + +function userMessageCollapseHeight(element: HTMLElement): number { + const viewportHeight = element.win.innerHeight; + if (viewportHeight <= 0) return USER_MESSAGE_COLLAPSE_HEIGHT_PX; + return Math.min(USER_MESSAGE_COLLAPSE_HEIGHT_PX, viewportHeight * 0.45); +} + function renderMessageAction(parent: HTMLElement, icon: string, label: string, className: string, onClick: () => void): HTMLButtonElement { const button = createIconButton(parent, icon, label, `codex-panel__message-action ${className}`); button.onclick = (event) => { @@ -303,6 +348,10 @@ function renderEditedFiles(parent: HTMLElement, item: Extract, context: MessageStreamContext): void { const mentionedFiles = item.mentionedFiles ?? []; const label = mentionedFiles.length === 1 ? "Mentioned 1 file" : `Mentioned ${mentionedFiles.length} files`; diff --git a/styles.css b/styles.css index 28c9600b..25ee7f47 100644 --- a/styles.css +++ b/styles.css @@ -27,6 +27,7 @@ --codex-panel-toolbar-padding-y: var(--size-4-2, 8px); --codex-panel-toolbar-button-gap: var(--size-2-1, 2px); --codex-panel-message-padding-y: var(--size-4-3, 12px); + --codex-panel-message-collapse-height: min(360px, 45vh); --codex-panel-composer-padding-y: var(--size-4-2, 8px); --codex-panel-section-padding: var(--codex-panel-edge-padding-x); --codex-panel-section-gap: var(--size-4-2, 8px); @@ -1010,6 +1011,43 @@ white-space: pre-wrap; } +.codex-panel__message-collapse { + position: relative; +} + +.codex-panel__message-content--collapsed { + position: relative; + max-height: var(--codex-panel-message-collapse-height); + overflow: hidden; +} + +.codex-panel__message-content--collapsed::after { + content: ""; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 72px; + background: linear-gradient(to bottom, transparent, var(--background-primary)); + pointer-events: none; +} + +.codex-panel__message-collapse-details { + position: relative; + z-index: 1; + margin-top: var(--size-2-3, 6px); +} + +.codex-panel__message-collapse-details > summary { + cursor: pointer; + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.codex-panel__message-collapse-details > summary:hover { + color: var(--text-normal); +} + .codex-panel__edited-files, .codex-panel__referenced-thread, .codex-panel__mentioned-files, diff --git a/tests/ui/view-renderers.test.ts b/tests/ui/view-renderers.test.ts index 1c92d9fe..30e33ddb 100644 --- a/tests/ui/view-renderers.test.ts +++ b/tests/ui/view-renderers.test.ts @@ -21,6 +21,25 @@ import { composerSuggestionScrollFixture, installObsidianDomShims, topLevelDetai installObsidianDomShims(); +function withMessageContentScrollHeight(scrollHeight: number, fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get() { + return this.classList.contains("codex-panel__message-content") ? scrollHeight : 0; + }, + }); + try { + return fn(); + } finally { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, "scrollHeight", descriptor); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollHeight"); + } + } +} + describe("message stream block identity and message actions", () => { it("invalidates message blocks when markdown rendering mode changes", () => { const context = { busy: false, activeTurnId: null, displayItems: [] }; @@ -526,6 +545,86 @@ describe("message stream block identity and message actions", () => { expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u1" })); }); + it("collapses tall user messages without changing the copy payload", () => { + withMessageContentScrollHeight(500, () => { + const copyText = vi.fn(); + const openDetails = new Set(); + const onDetailsToggle = vi.fn(); + const block = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: null, + historyCursor: null, + loadingHistory: false, + busy: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "visible text", copyText: "full copied text", turnId: "turn-1", markdown: true }, + ], + openDetails, + onDetailsToggle, + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + copyText, + })[0]; + + const element = block.render(); + const content = element.querySelector(".codex-panel__message-content"); + const details = element.querySelector(".codex-panel__message-collapse-details"); + + expect(content?.classList.contains("codex-panel__message-content--collapsed")).toBe(true); + expect(details?.hidden).toBe(false); + expect(details?.querySelector("summary")?.textContent).toBe("Show more"); + + if (details) { + details.open = true; + details.dispatchEvent(new Event("toggle")); + } + expect(openDetails.has("message:u1:expanded")).toBe(true); + expect(content?.classList.contains("codex-panel__message-content--collapsed")).toBe(false); + expect(details?.hidden).toBe(true); + expect(onDetailsToggle).toHaveBeenCalled(); + + element.querySelector(".codex-panel__copy-message")?.click(); + expect(copyText).toHaveBeenCalledWith("full copied text"); + }); + }); + + it("does not show the collapse control for short user messages or assistant messages", () => { + withMessageContentScrollHeight(120, () => { + const shortUser = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: null, + historyCursor: null, + loadingHistory: false, + busy: false, + displayItems: [{ id: "u1", kind: "message", role: "user", text: "short", turnId: "turn-1", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0].render(); + + expect(shortUser.querySelector(".codex-panel__message-collapse-details")?.hidden).toBe(true); + }); + + withMessageContentScrollHeight(500, () => { + const assistant = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: null, + historyCursor: null, + loadingHistory: false, + busy: false, + displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "long", turnId: "turn-1", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0].render(); + + expect(assistant.querySelector(".codex-panel__message-collapse-details")).toBeNull(); + }); + }); + it("does not render rollback action when no item is eligible", () => { const block = messageRenderBlocks({ activeThreadId: "thread",