From 258d109adca488b7c4d7f118eb46459f92b7f8e9 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 9 May 2026 14:06:09 +0200 Subject: [PATCH] Show Pi message metadata in chat --- src/client/src/chatGroups.test.ts | 9 +++++ src/client/src/chatGroups.ts | 5 ++- src/client/src/chatMessages.ts | 40 +++++++++++++++++-- src/client/src/chatTranscript.ts | 18 ++++++++- src/client/src/components/ChatView.ts | 51 +++++++++++++++++++++++-- src/client/src/components/shared.ts | 17 ++++++++- src/server/sessions/piSessionService.ts | 5 ++- src/shared/apiTypes.ts | 2 +- 8 files changed, 134 insertions(+), 13 deletions(-) diff --git a/src/client/src/chatGroups.test.ts b/src/client/src/chatGroups.test.ts index bf43384..d80762f 100644 --- a/src/client/src/chatGroups.test.ts +++ b/src/client/src/chatGroups.test.ts @@ -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", () => { const messages: ChatLine[] = [ { ...text("assistant", "summary"), source: "compaction" }, diff --git a/src/client/src/chatGroups.ts b/src/client/src/chatGroups.ts index e08a1ef..9c42c4f 100644 --- a/src/client/src/chatGroups.ts +++ b/src/client/src/chatGroups.ts @@ -24,10 +24,11 @@ export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGr const technicalParts = message.parts.filter((part) => !isReadablePart(message, part)); 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) { 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(); diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 88e86f9..a052ada 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -8,6 +8,11 @@ export function textMessage(role: ChatLine["role"], text: string): ChatLine { 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[] { const last = messages.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[] { - 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 parts = normalizeContent(getProperty(message, "content"), message); 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); - 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"); - 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 { @@ -61,6 +66,33 @@ function normalizeSource(message: unknown): ChatLine["source"] | 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["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 { const command = getString(message, "command") ?? ""; const lines = getBoolean(message, "excludeFromContext") === true ? ["excluded from context", "", `$ ${command}`] : [`$ ${command}`]; diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index 16c83c2..7d5980f 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -1,4 +1,4 @@ -import { appendText, normalizeMessage, textMessage } from "./chatMessages"; +import { appendText, normalizeMessage, textMessage, withMessageMeta } from "./chatMessages"; import type { ChatLine } from "./components/shared"; import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages"; 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 === "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 === "message.end") return event.message === undefined ? undefined : applyMessageEndMeta(messages, event.message); 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[] { return normalizeMessage(rawMessage).reduce(appendLine, messages); } diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index c260ed7..344148c 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -38,6 +38,7 @@ export class ChatView extends LitElement { @state() private pinnedToBottom = true; @state() private openGroupKeys = new Set(); @state() private loadedScrollPercent = 100; + @state() private expandedMetaKey: string | undefined; private suppressScrollSave = false; private saveScrollTimer?: number; private lastScrollTop = 0; @@ -155,7 +156,7 @@ export class ChatView extends LitElement { private renderMessage(message: ChatLine, index: number) { return html`
- ${message.role} + ${this.renderMessageHeader(message, String(index))} ${message.parts.map((part) => this.renderPart(part, message))}
`; @@ -170,9 +171,9 @@ export class ChatView extends LitElement { ${summarizeChatGroup(messages)}
- ${messages.map((message) => html` + ${messages.map((message, offset) => html`
- ${message.role} + ${this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)} ${message.parts.map((part) => this.renderPart(part, message))}
`)} @@ -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` +
+ ${message.role} + { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short} +
+ `; + } + + 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) { if (part.type === "text" && message?.role === "bash") return html`
${part.text}
`; if (part.type === "text") return html``; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 3e0fd83..9866e8b 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -12,6 +12,10 @@ export interface ChatLine { role: "user" | "assistant" | "tool" | "system" | "bash"; parts: ChatPart[]; source?: "compaction" | "branch_summary"; + meta?: { + timestamp?: string; + model?: { provider?: string; id?: string; responseId?: string }; + }; } export interface CompletionItem { @@ -146,7 +150,18 @@ export const chatStyles = css` .session-activity.receiving strong { color: #3fb950; } .session-activity span, .session-activity small { color: #8b949e; } .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; } .part + .part { margin-top: 10px; } .tool-line { color: #d29922; } diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 7a147f5..288ef7b 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -450,7 +450,10 @@ function toClientEvent(event: unknown): SessionUiEvent { } if (eventType === "agent_start") return { type: "agent.start" }; 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" }; } diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index bf29cc6..1183810 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -150,7 +150,7 @@ export type SessionUiEvent = | { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean } | { type: "agent.start" } | { type: "agent.end" } - | { type: "message.end" } + | { type: "message.end"; message?: unknown } | { type: "status.update"; status: SessionStatus } | { type: "activity.update"; activity: SessionActivity } | { type: "command.output"; level: "info" | "success" | "error"; message: string }