Archived
Show Pi message metadata in chat
This commit is contained in:
@@ -30,6 +30,15 @@ describe("groupChatMessages", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves message metadata when grouping", () => {
|
||||||
|
const message: ChatLine = { role: "assistant", parts: [{ type: "thinking", text: "hidden" }, { type: "text", text: "shown" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "test", id: "model" } } };
|
||||||
|
|
||||||
|
expect(groupChatMessages([message])).toEqual([
|
||||||
|
{ kind: "group", startIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "hidden" }], meta: message.meta }] },
|
||||||
|
{ kind: "message", index: 0, message: { role: "assistant", parts: [{ type: "text", text: "shown" }], meta: message.meta } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("treats compaction and branch summaries as grouped events", () => {
|
it("treats compaction and branch summaries as grouped events", () => {
|
||||||
const messages: ChatLine[] = [
|
const messages: ChatLine[] = [
|
||||||
{ ...text("assistant", "summary"), source: "compaction" },
|
{ ...text("assistant", "summary"), source: "compaction" },
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGr
|
|||||||
const technicalParts = message.parts.filter((part) => !isReadablePart(message, part));
|
const technicalParts = message.parts.filter((part) => !isReadablePart(message, part));
|
||||||
|
|
||||||
const absoluteIndex = indexOffset + index;
|
const absoluteIndex = indexOffset + index;
|
||||||
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts }, absoluteIndex);
|
const metadata = { ...(message.source === undefined ? {} : { source: message.source }), ...(message.meta === undefined ? {} : { meta: message.meta }) };
|
||||||
|
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts, ...metadata }, absoluteIndex);
|
||||||
if (readableParts.length) {
|
if (readableParts.length) {
|
||||||
flushEvents();
|
flushEvents();
|
||||||
groups.push({ kind: "message", message: { role: message.role, parts: readableParts }, index: absoluteIndex });
|
groups.push({ kind: "message", message: { role: message.role, parts: readableParts, ...metadata }, index: absoluteIndex });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
flushEvents();
|
flushEvents();
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export function textMessage(role: ChatLine["role"], text: string): ChatLine {
|
|||||||
return { role, parts: [{ type: "text", text }] };
|
return { role, parts: [{ type: "text", text }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withMessageMeta(line: ChatLine, rawMessage: unknown): ChatLine {
|
||||||
|
const meta = normalizeMeta(rawMessage);
|
||||||
|
return meta === undefined ? line : { ...line, meta };
|
||||||
|
}
|
||||||
|
|
||||||
export function appendText(messages: ChatLine[], role: ChatLine["role"], text: string): ChatLine[] {
|
export function appendText(messages: ChatLine[], role: ChatLine["role"], text: string): ChatLine[] {
|
||||||
const last = messages.at(-1);
|
const last = messages.at(-1);
|
||||||
const lastPart = last?.parts.at(-1);
|
const lastPart = last?.parts.at(-1);
|
||||||
@@ -21,16 +26,16 @@ export function appendText(messages: ChatLine[], role: ChatLine["role"], text: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeMessage(message: unknown): ChatLine[] {
|
export function normalizeMessage(message: unknown): ChatLine[] {
|
||||||
if (getString(message, "role") === "bashExecution") return [normalizeBashExecution(message)];
|
if (getString(message, "role") === "bashExecution") return [withMessageMeta(normalizeBashExecution(message), message)];
|
||||||
const role = normalizeRole(getString(message, "role"));
|
const role = normalizeRole(getString(message, "role"));
|
||||||
const parts = normalizeContent(getProperty(message, "content"), message);
|
const parts = normalizeContent(getProperty(message, "content"), message);
|
||||||
const skillLines = role === "user" ? normalizeSkillInvocation(parts) : undefined;
|
const skillLines = role === "user" ? normalizeSkillInvocation(parts) : undefined;
|
||||||
if (skillLines !== undefined) return skillLines;
|
if (skillLines !== undefined) return skillLines.map((line) => withMessageMeta(line, message));
|
||||||
const source = normalizeSource(message);
|
const source = normalizeSource(message);
|
||||||
if (role === "tool") return [{ role, parts, ...(source === undefined ? {} : { source }) }];
|
if (role === "tool") return [withMessageMeta({ role, parts, ...(source === undefined ? {} : { source }) }, message)];
|
||||||
|
|
||||||
const visible = parts.filter((part) => part.type !== "empty");
|
const visible = parts.filter((part) => part.type !== "empty");
|
||||||
return visible.length > 0 ? [{ role, parts: visible, ...(source === undefined ? {} : { source }) }] : [];
|
return visible.length > 0 ? [withMessageMeta({ role, parts: visible, ...(source === undefined ? {} : { source }) }, message)] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSkillInvocation(parts: ChatPart[]): ChatLine[] | undefined {
|
function normalizeSkillInvocation(parts: ChatPart[]): ChatLine[] | undefined {
|
||||||
@@ -61,6 +66,33 @@ function normalizeSource(message: unknown): ChatLine["source"] | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMeta(message: unknown): ChatLine["meta"] | undefined {
|
||||||
|
const timestamp = normalizeTimestamp(getProperty(message, "timestamp"));
|
||||||
|
const model = normalizeModel(message);
|
||||||
|
if (timestamp === undefined && model === undefined) return undefined;
|
||||||
|
return { ...(timestamp === undefined ? {} : { timestamp }), ...(model === undefined ? {} : { model }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestamp(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
|
||||||
|
if (typeof value !== "string" || value === "") return undefined;
|
||||||
|
const time = Date.parse(value);
|
||||||
|
return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModel(message: unknown): NonNullable<ChatLine["meta"]>["model"] | undefined {
|
||||||
|
if (getString(message, "role") !== "assistant") return undefined;
|
||||||
|
const provider = getString(message, "provider");
|
||||||
|
const id = getString(message, "model");
|
||||||
|
const responseId = getString(message, "responseModel");
|
||||||
|
if ((provider === undefined || provider === "") && (id === undefined || id === "") && (responseId === undefined || responseId === "")) return undefined;
|
||||||
|
return {
|
||||||
|
...(provider === undefined || provider === "" ? {} : { provider }),
|
||||||
|
...(id === undefined || id === "" ? {} : { id }),
|
||||||
|
...(responseId === undefined || responseId === "" ? {} : { responseId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBashExecution(message: unknown): ChatLine {
|
function normalizeBashExecution(message: unknown): ChatLine {
|
||||||
const command = getString(message, "command") ?? "";
|
const command = getString(message, "command") ?? "";
|
||||||
const lines = getBoolean(message, "excludeFromContext") === true ? ["excluded from context", "", `$ ${command}`] : [`$ ${command}`];
|
const lines = getBoolean(message, "excludeFromContext") === true ? ["excluded from context", "", `$ ${command}`] : [`$ ${command}`];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { appendText, normalizeMessage, textMessage } from "./chatMessages";
|
import { appendText, normalizeMessage, textMessage, withMessageMeta } from "./chatMessages";
|
||||||
import type { ChatLine } from "./components/shared";
|
import type { ChatLine } from "./components/shared";
|
||||||
import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages";
|
import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages";
|
||||||
import type { SessionUiEvent } from "./sessionSocket";
|
import type { SessionUiEvent } from "./sessionSocket";
|
||||||
@@ -13,9 +13,25 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent
|
|||||||
if (event.type === "shell.end") return finalizeShellMessage(messages, event);
|
if (event.type === "shell.end") return finalizeShellMessage(messages, event);
|
||||||
if (event.type === "command.output") return [...messages, textMessage(event.level === "error" ? "system" : "tool", event.message)];
|
if (event.type === "command.output") return [...messages, textMessage(event.level === "error" ? "system" : "tool", event.message)];
|
||||||
if (event.type === "session.error") return [...messages, textMessage("system", event.message)];
|
if (event.type === "session.error") return [...messages, textMessage("system", event.message)];
|
||||||
|
if (event.type === "message.end") return event.message === undefined ? undefined : applyMessageEndMeta(messages, event.message);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMessageEndMeta(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined {
|
||||||
|
const ended = normalizeMessage(rawMessage)[0];
|
||||||
|
if (ended === undefined || ended.meta === undefined) return undefined;
|
||||||
|
const index = findLastMatchingRole(messages, ended.role);
|
||||||
|
if (index < 0) return undefined;
|
||||||
|
return messages.map((message, i) => i === index ? withMessageMeta(message, rawMessage) : message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastMatchingRole(messages: ChatLine[], role: ChatLine["role"]): number {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (messages[i]?.role === role) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] {
|
function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] {
|
||||||
return normalizeMessage(rawMessage).reduce(appendLine, messages);
|
return normalizeMessage(rawMessage).reduce(appendLine, messages);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export class ChatView extends LitElement {
|
|||||||
@state() private pinnedToBottom = true;
|
@state() private pinnedToBottom = true;
|
||||||
@state() private openGroupKeys = new Set<string>();
|
@state() private openGroupKeys = new Set<string>();
|
||||||
@state() private loadedScrollPercent = 100;
|
@state() private loadedScrollPercent = 100;
|
||||||
|
@state() private expandedMetaKey: string | undefined;
|
||||||
private suppressScrollSave = false;
|
private suppressScrollSave = false;
|
||||||
private saveScrollTimer?: number;
|
private saveScrollTimer?: number;
|
||||||
private lastScrollTop = 0;
|
private lastScrollTop = 0;
|
||||||
@@ -155,7 +156,7 @@ export class ChatView extends LitElement {
|
|||||||
private renderMessage(message: ChatLine, index: number) {
|
private renderMessage(message: ChatLine, index: number) {
|
||||||
return html`
|
return html`
|
||||||
<article class="msg ${message.role}" data-index=${index}>
|
<article class="msg ${message.role}" data-index=${index}>
|
||||||
<b class="label">${message.role}</b>
|
${this.renderMessageHeader(message, String(index))}
|
||||||
${message.parts.map((part) => this.renderPart(part, message))}
|
${message.parts.map((part) => this.renderPart(part, message))}
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
@@ -170,9 +171,9 @@ export class ChatView extends LitElement {
|
|||||||
<span>${summarizeChatGroup(messages)}</span>
|
<span>${summarizeChatGroup(messages)}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="group-body">
|
<div class="group-body">
|
||||||
${messages.map((message) => html`
|
${messages.map((message, offset) => html`
|
||||||
<section class="group-msg ${message.role}">
|
<section class="group-msg ${message.role}">
|
||||||
<b class="label">${message.role}</b>
|
${this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
||||||
${message.parts.map((part) => this.renderPart(part, message))}
|
${message.parts.map((part) => this.renderPart(part, message))}
|
||||||
</section>
|
</section>
|
||||||
`)}
|
`)}
|
||||||
@@ -181,6 +182,50 @@ export class ChatView extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderMessageHeader(message: ChatLine, key: string) {
|
||||||
|
const meta = this.messageMetaLabel(message);
|
||||||
|
const expanded = this.expandedMetaKey === key;
|
||||||
|
return html`
|
||||||
|
<div class="msg-header">
|
||||||
|
<b class="label">${message.role}</b>
|
||||||
|
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta.full} aria-label=${meta.full} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMetaKeydown(event: KeyboardEvent, key: string, expanded: boolean) {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.expandedMetaKey = expanded ? undefined : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
|
||||||
|
const timestamp = message.meta?.timestamp;
|
||||||
|
const model = this.modelLabel(message);
|
||||||
|
if (timestamp === undefined && model === undefined) return { short: "no info", full: "No Pi message metadata available" };
|
||||||
|
const time = timestamp === undefined ? undefined : this.formatTimestamp(timestamp);
|
||||||
|
const parts = [time?.short, model].filter((part): part is string => part !== undefined && part !== "");
|
||||||
|
const fullParts = [time?.full, model === undefined ? undefined : `Model: ${model}`].filter((part): part is string => part !== undefined && part !== "");
|
||||||
|
return { short: parts.join(" · "), full: fullParts.join(" · ") };
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatTimestamp(timestamp: string): { short: string; full: string } | undefined {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (!Number.isFinite(date.getTime())) return undefined;
|
||||||
|
return {
|
||||||
|
short: new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(date),
|
||||||
|
full: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(date),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private modelLabel(message: ChatLine): string | undefined {
|
||||||
|
const model = message.meta?.model;
|
||||||
|
if (model === undefined) return undefined;
|
||||||
|
const id = model.responseId ?? model.id;
|
||||||
|
if (id === undefined || id === "") return model.provider;
|
||||||
|
return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id;
|
||||||
|
}
|
||||||
|
|
||||||
private renderPart(part: ChatPart, message?: ChatLine) {
|
private renderPart(part: ChatPart, message?: ChatLine) {
|
||||||
if (part.type === "text" && message?.role === "bash") return html`<pre class="part shell-output">${part.text}</pre>`;
|
if (part.type === "text" && message?.role === "bash") return html`<pre class="part shell-output">${part.text}</pre>`;
|
||||||
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
|
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export interface ChatLine {
|
|||||||
role: "user" | "assistant" | "tool" | "system" | "bash";
|
role: "user" | "assistant" | "tool" | "system" | "bash";
|
||||||
parts: ChatPart[];
|
parts: ChatPart[];
|
||||||
source?: "compaction" | "branch_summary";
|
source?: "compaction" | "branch_summary";
|
||||||
|
meta?: {
|
||||||
|
timestamp?: string;
|
||||||
|
model?: { provider?: string; id?: string; responseId?: string };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompletionItem {
|
export interface CompletionItem {
|
||||||
@@ -146,7 +150,18 @@ export const chatStyles = css`
|
|||||||
.session-activity.receiving strong { color: #3fb950; }
|
.session-activity.receiving strong { color: #3fb950; }
|
||||||
.session-activity span, .session-activity small { color: #8b949e; }
|
.session-activity span, .session-activity small { color: #8b949e; }
|
||||||
.history-boundary small { color: #6e7681; }
|
.history-boundary small { color: #6e7681; }
|
||||||
.label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
|
.msg-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.label { display: block; color: #8b949e; font-size: 12px; text-transform: uppercase; }
|
||||||
|
.msg-header .label { margin: 0; }
|
||||||
|
.msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: #6e7681; padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease, max-width .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; }
|
||||||
|
.msg:hover > .msg-header .msg-meta, .msg:focus-within > .msg-header .msg-meta, .msg-meta:focus, .msg-meta.expanded { opacity: 1; }
|
||||||
|
.msg-meta:focus { outline: 1px solid #30363d; outline-offset: 3px; border-radius: 4px; }
|
||||||
|
@media (hover: none) {
|
||||||
|
.msg-meta { opacity: .75; max-width: 26px; }
|
||||||
|
.msg-meta::before { content: "ⓘ"; font-size: 13px; }
|
||||||
|
.msg-meta:focus, .msg-meta.expanded { opacity: 1; max-width: 75%; }
|
||||||
|
.msg-meta:focus::before, .msg-meta.expanded::before { content: ""; }
|
||||||
|
}
|
||||||
formatted-text.part { display: block; }
|
formatted-text.part { display: block; }
|
||||||
.part + .part { margin-top: 10px; }
|
.part + .part { margin-top: 10px; }
|
||||||
.tool-line { color: #d29922; }
|
.tool-line { color: #d29922; }
|
||||||
|
|||||||
@@ -450,7 +450,10 @@ function toClientEvent(event: unknown): SessionUiEvent {
|
|||||||
}
|
}
|
||||||
if (eventType === "agent_start") return { type: "agent.start" };
|
if (eventType === "agent_start") return { type: "agent.start" };
|
||||||
if (eventType === "agent_end") return { type: "agent.end" };
|
if (eventType === "agent_end") return { type: "agent.end" };
|
||||||
if (eventType === "message_end") return { type: "message.end" };
|
if (eventType === "message_end") {
|
||||||
|
const message = getProperty(event, "message");
|
||||||
|
return message === undefined ? { type: "message.end" } : { type: "message.end", message };
|
||||||
|
}
|
||||||
return { type: "pi.event", eventType: eventType ?? "unknown" };
|
return { type: "pi.event", eventType: eventType ?? "unknown" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export type SessionUiEvent =
|
|||||||
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }
|
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }
|
||||||
| { type: "agent.start" }
|
| { type: "agent.start" }
|
||||||
| { type: "agent.end" }
|
| { type: "agent.end" }
|
||||||
| { type: "message.end" }
|
| { type: "message.end"; message?: unknown }
|
||||||
| { type: "status.update"; status: SessionStatus }
|
| { type: "status.update"; status: SessionStatus }
|
||||||
| { type: "activity.update"; activity: SessionActivity }
|
| { type: "activity.update"; activity: SessionActivity }
|
||||||
| { type: "command.output"; level: "info" | "success" | "error"; message: string }
|
| { type: "command.output"; level: "info" | "success" | "error"; message: string }
|
||||||
|
|||||||
Reference in New Issue
Block a user