Collapse long user messages

This commit is contained in:
murashit 2026-05-21 14:28:36 +09:00
parent db51718474
commit d4d8022b64
4 changed files with 189 additions and 2 deletions

View file

@ -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);
});
}

View file

@ -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<DisplayItem, { kin
}
}
export function notifyMessageContentRendered(element: HTMLElement): void {
element.dispatchEvent(new Event(MESSAGE_CONTENT_RENDERED_EVENT));
}
function renderMentionedFiles(parent: HTMLElement, item: Extract<DisplayItem, { kind: "message" }>, context: MessageStreamContext): void {
const mentionedFiles = item.mentionedFiles ?? [];
const label = mentionedFiles.length === 1 ? "Mentioned 1 file" : `Mentioned ${mentionedFiles.length} files`;

View file

@ -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,

View file

@ -21,6 +21,25 @@ import { composerSuggestionScrollFixture, installObsidianDomShims, topLevelDetai
installObsidianDomShims();
function withMessageContentScrollHeight<T>(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<string>();
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<HTMLElement>(".codex-panel__message-content");
const details = element.querySelector<HTMLDetailsElement>(".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<HTMLButtonElement>(".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<HTMLDetailsElement>(".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",