mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
438 lines
17 KiB
TypeScript
438 lines
17 KiB
TypeScript
import { displayBlocksForItems } from "../display/blocks";
|
|
import { displayItemSignature, isMessageCopyActionVisible } from "../display/signature";
|
|
import { executionState } from "../display/state";
|
|
import type { DisplayBlock, DisplayDetailSection, DisplayItem } from "../display/types";
|
|
import { createIconButton, createMetaPair, createRememberedDetails } from "./components";
|
|
import { shortSignature } from "./dom";
|
|
import { applyExecutionStateClass } from "./execution-state";
|
|
import { renderToolResult } from "./tool-result";
|
|
import {
|
|
activeAgentRunSummaryBlock,
|
|
createAgentRunSummaryElement,
|
|
renderAgentItem,
|
|
renderReasoningItem,
|
|
renderTaskProgressItem,
|
|
} 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;
|
|
render: () => HTMLElement;
|
|
}
|
|
|
|
export interface MessageStreamContext {
|
|
activeThreadId: string | null;
|
|
activeTurnId: string | null;
|
|
historyCursor: string | null;
|
|
loadingHistory: boolean;
|
|
busy: boolean;
|
|
displayItems: DisplayItem[];
|
|
turnDiffs?: ReadonlyMap<string, string>;
|
|
workspaceRoot?: string | null;
|
|
openDetails: Set<string>;
|
|
onDetailsToggle?: () => void;
|
|
loadOlderTurns: () => void;
|
|
renderMarkdown: (parent: HTMLElement, text: string) => void;
|
|
renderTextWithWikiLinks: (parent: HTMLElement, text: string) => void;
|
|
copyText?: (text: string) => void;
|
|
canImplementPlanItem?: (item: DisplayItem) => boolean;
|
|
onImplementPlanItem?: (item: DisplayItem) => void;
|
|
canRollbackItem?: (item: DisplayItem) => boolean;
|
|
onRollbackItem?: (item: DisplayItem) => void;
|
|
openTurnDiff?: (state: TurnDiffViewState) => void;
|
|
pendingRequestsSignature?: string;
|
|
renderPendingRequests?: () => HTMLElement | null;
|
|
}
|
|
|
|
export function messageRenderBlocks(context: MessageStreamContext): MessageRenderBlock[] {
|
|
const blocks: MessageRenderBlock[] = [];
|
|
|
|
if (context.activeThreadId && context.historyCursor) {
|
|
blocks.push({
|
|
key: "history-bar",
|
|
signature: `${context.activeThreadId}:${context.historyCursor}:${context.loadingHistory}`,
|
|
render: () => createHistoryBarElement(context.loadingHistory, context.loadOlderTurns),
|
|
});
|
|
}
|
|
|
|
if (context.displayItems.length === 0) {
|
|
blocks.push({
|
|
key: "empty",
|
|
signature: "empty",
|
|
render: () =>
|
|
createDiv({
|
|
cls: "codex-panel__message codex-panel__message--system",
|
|
text: "Start a new thread or send a message.",
|
|
}),
|
|
});
|
|
return blocks;
|
|
}
|
|
|
|
for (const block of displayBlocksForItems(context.displayItems, context.activeTurnId, context.workspaceRoot, context.turnDiffs)) {
|
|
if (block.type === "item") {
|
|
blocks.push({
|
|
key: `item:${block.item.id}`,
|
|
signature: displayItemSignature(block.item, context),
|
|
render: () => createDisplayItemElement(block.item, context),
|
|
});
|
|
} else {
|
|
blocks.push({
|
|
key: `activity:${block.id}`,
|
|
signature: `${block.summary}\n${block.items.map((item) => displayItemSignature(item, context)).join("\n")}`,
|
|
render: () => createActivityGroupElement(block, context),
|
|
});
|
|
}
|
|
}
|
|
|
|
const agentSummary = activeAgentRunSummaryBlock(context);
|
|
if (agentSummary) {
|
|
blocks.push({
|
|
key: `active-agents:${context.activeTurnId}`,
|
|
signature: JSON.stringify(agentSummary),
|
|
render: () => createAgentRunSummaryElement(agentSummary),
|
|
});
|
|
}
|
|
|
|
if (context.renderPendingRequests && context.pendingRequestsSignature) {
|
|
blocks.push({
|
|
key: "pending-requests",
|
|
signature: context.pendingRequestsSignature,
|
|
render: () => context.renderPendingRequests?.() ?? createDiv(),
|
|
});
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
export function syncMessageRenderBlocks(parent: HTMLElement, blocks: MessageRenderBlock[], signatures: Map<string, string>): void {
|
|
const existing = new Map<string, HTMLElement>();
|
|
parent.querySelectorAll<HTMLElement>(":scope > [data-codex-panel-block-key]").forEach((element) => {
|
|
const key = element.dataset.codexPanelBlockKey;
|
|
if (key) existing.set(key, element);
|
|
});
|
|
|
|
const seen = new Set<string>();
|
|
let nextPosition: ChildNode | null = parent.firstChild;
|
|
for (const block of blocks) {
|
|
const current = existing.get(block.key);
|
|
let element = current;
|
|
const currentWasNext = current === nextPosition;
|
|
if (!element || signatures.get(block.key) !== block.signature) {
|
|
element = block.render();
|
|
element.dataset.codexPanelBlockKey = block.key;
|
|
element.dataset.codexPanelBlockSignature = shortSignature(block.signature);
|
|
signatures.set(block.key, block.signature);
|
|
if (current) {
|
|
current.replaceWith(element);
|
|
if (currentWasNext) nextPosition = element;
|
|
}
|
|
}
|
|
if (element !== nextPosition) {
|
|
parent.insertBefore(element, nextPosition);
|
|
}
|
|
nextPosition = element.nextSibling;
|
|
seen.add(block.key);
|
|
}
|
|
|
|
for (const [key, element] of existing) {
|
|
if (!seen.has(key)) {
|
|
signatures.delete(key);
|
|
element.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
function createHistoryBarElement(loadingHistory: boolean, loadOlderTurns: () => void): HTMLElement {
|
|
const historyBar = createDiv({ cls: "codex-panel__history-bar" });
|
|
const loadOlder = historyBar.createEl("button", {
|
|
text: loadingHistory ? "Loading..." : "Load older",
|
|
});
|
|
loadOlder.disabled = loadingHistory;
|
|
loadOlder.onclick = loadOlderTurns;
|
|
return historyBar;
|
|
}
|
|
|
|
function createDisplayItemElement(item: DisplayItem, context: MessageStreamContext): HTMLElement {
|
|
const container = createDiv();
|
|
renderDisplayItem(container, item, context);
|
|
return container.firstElementChild as HTMLElement;
|
|
}
|
|
|
|
function createActivityGroupElement(group: Extract<DisplayBlock, { type: "activityGroup" }>, context: MessageStreamContext): HTMLElement {
|
|
const container = createDiv();
|
|
const details = createRememberedDetails(
|
|
container,
|
|
context.openDetails,
|
|
`turn:${group.turnId}:activity`,
|
|
"codex-panel__activity-group",
|
|
group.summary,
|
|
false,
|
|
context.onDetailsToggle,
|
|
);
|
|
for (const item of group.items) {
|
|
renderDisplayItem(details, item, context);
|
|
}
|
|
return container.firstElementChild as HTMLElement;
|
|
}
|
|
|
|
function renderDisplayItem(parent: HTMLElement, item: DisplayItem, context: MessageStreamContext): void {
|
|
if (item.kind === "command") {
|
|
renderToolResult(parent, item, context);
|
|
return;
|
|
}
|
|
if (item.kind === "fileChange") {
|
|
renderToolResult(parent, item, context);
|
|
return;
|
|
}
|
|
if (item.kind === "taskProgress") {
|
|
renderTaskProgressItem(parent, item);
|
|
return;
|
|
}
|
|
if (item.kind === "agent") {
|
|
renderAgentItem(parent, item, context);
|
|
return;
|
|
}
|
|
if (item.kind === "reasoning") {
|
|
renderReasoningItem(parent, item, context);
|
|
return;
|
|
}
|
|
if (item.kind === "tool" || item.kind === "hook") {
|
|
renderToolResult(parent, item, context);
|
|
return;
|
|
}
|
|
if (item.kind === "reviewResult") {
|
|
renderToolResult(parent, item, context);
|
|
return;
|
|
}
|
|
if (item.kind === "approvalResult") {
|
|
renderToolResult(parent, item, context);
|
|
return;
|
|
}
|
|
if (item.kind !== "message" && item.kind !== "system" && item.kind !== "userInputResult") {
|
|
return;
|
|
}
|
|
|
|
const messageEl = parent.createDiv({ cls: messageClass(item) });
|
|
applyExecutionStateClass(messageEl, executionState(item));
|
|
const role = messageEl.createDiv({ cls: "codex-panel__message-role" });
|
|
role.createSpan({ text: displayRoleLabel(item) });
|
|
if (item.kind === "message" && context.copyText && isMessageCopyActionVisible(item, context)) {
|
|
renderMessageAction(role, "copy", "Copy message", "codex-panel__copy-message", () => context.copyText?.(item.copyText ?? item.text));
|
|
}
|
|
if (context.canImplementPlanItem?.(item)) {
|
|
renderMessageAction(role, "play", "Implement plan", "codex-panel__implement-plan", () => context.onImplementPlanItem?.(item));
|
|
}
|
|
if (context.canRollbackItem?.(item)) {
|
|
renderMessageAction(role, "undo-2", "Rollback last turn", "codex-panel__rollback-turn", () => context.onRollbackItem?.(item));
|
|
}
|
|
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);
|
|
}
|
|
if (item.kind === "message" && item.referencedThread) {
|
|
renderReferencedThread(messageEl, item);
|
|
}
|
|
if (item.kind === "message" && item.mentionedFiles && item.mentionedFiles.length > 0) {
|
|
renderMentionedFiles(messageEl, item, context);
|
|
}
|
|
if (item.kind === "message" && item.autoReviewSummaries && item.autoReviewSummaries.length > 0) {
|
|
renderAutoReviewSummaries(messageEl, item.autoReviewSummaries);
|
|
}
|
|
if (item.kind === "system" && item.details && item.details.length > 0) {
|
|
renderSystemDetails(messageEl, item.details);
|
|
} else if ("details" in item && item.details && item.details.length > 0) {
|
|
renderMessageDetails(messageEl, item.id, item.details, context);
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onClick();
|
|
};
|
|
return button;
|
|
}
|
|
|
|
function renderReferencedThread(parent: HTMLElement, item: Extract<DisplayItem, { kind: "message" }>): void {
|
|
const reference = item.referencedThread;
|
|
if (!reference) return;
|
|
const wrapper = parent.createDiv({ cls: "codex-panel__referenced-thread" });
|
|
const label = wrapper.createSpan({ cls: "codex-panel__referenced-thread-label" });
|
|
label.createSpan({ text: "Referenced " });
|
|
label.createSpan({ text: reference.title });
|
|
label.createSpan({ cls: "codex-panel__edited-files-separator", text: "·" });
|
|
label.createSpan({ text: `${reference.includedTurns}/${reference.turnLimit} turns` });
|
|
wrapper.title = reference.threadId;
|
|
}
|
|
|
|
function renderEditedFiles(parent: HTMLElement, item: Extract<DisplayItem, { kind: "message" }>, context: MessageStreamContext): void {
|
|
const editedFiles = item.editedFiles ?? [];
|
|
const label = editedFiles.length === 1 ? "Edited 1 file" : `Edited ${editedFiles.length} files`;
|
|
const wrapper = parent.createDiv({ cls: "codex-panel__edited-files" });
|
|
const details = wrapper.createEl("details", { cls: "codex-panel__edited-files-details" });
|
|
const summary = details.createEl("summary");
|
|
const summaryContent = summary.createSpan({ cls: "codex-panel__edited-files-summary" });
|
|
summaryContent.createSpan({ text: label });
|
|
if (item.turnDiff && item.turnId && context.activeThreadId && context.openTurnDiff) {
|
|
summaryContent.createSpan({ cls: "codex-panel__edited-files-separator", text: "·" });
|
|
const button = createIconButton(summaryContent, "file-diff", "View diff", "codex-panel__open-turn-diff");
|
|
button.createSpan({ cls: "codex-panel__open-turn-diff-label", text: "View diff" });
|
|
button.onclick = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
context.openTurnDiff?.({
|
|
threadId: context.activeThreadId ?? "",
|
|
turnId: item.turnId ?? "",
|
|
cwd: context.workspaceRoot ?? null,
|
|
files: editedFiles,
|
|
diff: item.turnDiff?.diff ?? "",
|
|
});
|
|
};
|
|
}
|
|
const list = details.createEl("ul");
|
|
for (const file of editedFiles) {
|
|
list.createEl("li", { text: file });
|
|
}
|
|
}
|
|
|
|
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`;
|
|
const wrapper = parent.createDiv({ cls: "codex-panel__mentioned-files" });
|
|
const details = createRememberedDetails(
|
|
wrapper,
|
|
context.openDetails,
|
|
`${item.id}:mentioned-files`,
|
|
"codex-panel__mentioned-files-details",
|
|
label,
|
|
false,
|
|
context.onDetailsToggle,
|
|
);
|
|
const list = details.createEl("ul");
|
|
for (const file of mentionedFiles) {
|
|
const row = list.createEl("li");
|
|
row.createSpan({ text: file.name });
|
|
row.createSpan({ cls: "codex-panel__edited-files-separator", text: " · " });
|
|
row.createSpan({ text: file.path });
|
|
}
|
|
}
|
|
|
|
function renderAutoReviewSummaries(parent: HTMLElement, summaries: string[]): void {
|
|
const label = summaries.length === 1 ? "Auto-reviewed 1 request" : `Auto-reviewed ${summaries.length} requests`;
|
|
const details = parent.createEl("details", { cls: "codex-panel__auto-reviews" });
|
|
details.createEl("summary", { text: label });
|
|
const list = details.createEl("ul");
|
|
for (const summary of summaries) {
|
|
list.createEl("li", { text: summary });
|
|
}
|
|
}
|
|
|
|
function displayRoleLabel(item: DisplayItem): string {
|
|
if (item.kind === "approvalResult") return "Approval";
|
|
if (item.kind === "userInputResult") return "Input";
|
|
if (item.kind === "reviewResult") return "Review";
|
|
if (item.role === "user") return "You";
|
|
if (item.role === "assistant") return "Codex";
|
|
return "System";
|
|
}
|
|
|
|
function messageClass(item: DisplayItem): string {
|
|
const classes = ["codex-panel__message", `codex-panel__message--${item.role}`];
|
|
if (item.kind === "approvalResult") classes.push("codex-panel__message--approval-result");
|
|
if (item.kind === "userInputResult") classes.push("codex-panel__message--user-input-result");
|
|
if (item.kind === "reviewResult") classes.push("codex-panel__message--review-result");
|
|
return classes.join(" ");
|
|
}
|
|
|
|
function renderMessageDetails(parent: HTMLElement, itemId: string, details: DisplayDetailSection[], context: MessageStreamContext): void {
|
|
for (const [index, section] of details.entries()) {
|
|
const summary = section.title ?? "Details";
|
|
const detailsEl = createRememberedDetails(
|
|
parent,
|
|
context.openDetails,
|
|
`${itemId}:message-detail:${index}`,
|
|
"codex-panel__output",
|
|
summary,
|
|
false,
|
|
context.onDetailsToggle,
|
|
);
|
|
if (section.rows && section.rows.length > 0) {
|
|
const rows = detailsEl.createEl("dl", { cls: "codex-panel__meta-grid" });
|
|
for (const row of section.rows) {
|
|
createMetaPair(rows, row.key, row.value);
|
|
}
|
|
}
|
|
if (section.body) detailsEl.createEl("pre", { text: section.body });
|
|
}
|
|
}
|
|
|
|
function renderSystemDetails(parent: HTMLElement, details: DisplayDetailSection[]): void {
|
|
for (const section of details) {
|
|
const sectionEl = parent.createDiv({ cls: "codex-panel__output codex-panel__system-result-section" });
|
|
if (section.title) sectionEl.createDiv({ cls: "codex-panel__output-title", text: section.title });
|
|
if (section.rows && section.rows.length > 0) {
|
|
const rows = sectionEl.createEl("dl", { cls: "codex-panel__meta-grid" });
|
|
for (const row of section.rows) {
|
|
createMetaPair(rows, row.key, row.value);
|
|
}
|
|
}
|
|
if (section.body) sectionEl.createEl("pre", { text: section.body });
|
|
}
|
|
}
|