mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Collapse long user messages
This commit is contained in:
parent
db51718474
commit
d4d8022b64
4 changed files with 189 additions and 2 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
38
styles.css
38
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue