fix: show complete chat message metadata

This commit is contained in:
Federico Jaramillo Martinez
2026-07-10 22:10:40 +02:00
parent 3b2a225f87
commit abcf44b962
4 changed files with 53 additions and 36 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show complete chat message dates and model identifiers in one consistent label, wrap rather than truncate expanded metadata, and let the clean touch info control collapse while it retains focus.
+14 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { chatQueuedMessageSections } from "./ChatView";
import { chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
describe("chatQueuedMessageSections", () => {
it("labels client-side pending-start sends separately from server queued messages", () => {
@@ -22,3 +22,16 @@ describe("chatQueuedMessageSections", () => {
]);
});
});
describe("chatMessageMetadataLabel", () => {
it("uses one full date and model label without a model prefix", () => {
const timestamp = "2026-07-10T19:15:30.000Z";
const formattedTimestamp = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(timestamp));
expect(chatMessageMetadataLabel({
role: "assistant",
parts: [],
meta: { timestamp, model: { provider: "provider", id: "model" } },
})).toBe(`${formattedTimestamp} · provider/model`);
});
});
+27 -30
View File
@@ -14,8 +14,7 @@ import "./ConversationMeter";
import "./FormattedText";
import "./ToolExecutionView";
const shortTimestampFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
const fullTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
const partialStreamNoticeBodies = [
"You opened this chat while the assistant was already replying. The complete answer will appear shortly.",
@@ -52,6 +51,28 @@ export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[],
].filter((section): section is QueuedMessageSection => section !== undefined);
}
export function chatMessageMetadataLabel(message: ChatLine): string {
const timestamp = message.meta?.timestamp;
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
const model = chatMessageModelLabel(message);
const parts = [time, model].filter((part): part is string => part !== undefined && part !== "");
return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · ");
}
function formatMessageTimestamp(timestamp: string): string | undefined {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return undefined;
return messageTimestampFormatter.format(date);
}
function chatMessageModelLabel(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;
}
@customElement("chat-view")
export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = [];
@@ -84,7 +105,7 @@ export class ChatView extends LitElement {
private groupedMessagesInput?: ChatLine[];
private groupedMessagesStart = 0;
private groupedMessagesCache: ChatGroup[] = [];
private readonly messageMetaCache = new WeakMap<ChatLine, { short: string; full: string }>();
private readonly messageMetaCache = new WeakMap<ChatLine, string>();
private readonly messageCopyTextCache = new WeakMap<ChatLine, string>();
private partialStreamNoticeBody: string | undefined;
private lastScrollTop = 0;
@@ -397,7 +418,7 @@ export class ChatView extends LitElement {
<b class="label">${message.role}</b>
<div class="msg-header-trailing">
${this.renderMessageActions(message, key)}
<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>
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta} aria-label=${meta} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta}</span>
</div>
</div>
`;
@@ -448,38 +469,14 @@ export class ChatView extends LitElement {
}
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
private messageMetaLabel(message: ChatLine): string {
const cached = this.messageMetaCache.get(message);
if (cached !== undefined) return cached;
const timestamp = message.meta?.timestamp;
const model = this.modelLabel(message);
if (timestamp === undefined && model === undefined) {
const empty = { short: "no info", full: "No Pi message metadata available" };
this.messageMetaCache.set(message, empty);
return empty;
}
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 !== "");
const label = { short: parts.join(" · "), full: fullParts.join(" · ") };
const label = chatMessageMetadataLabel(message);
this.messageMetaCache.set(message, label);
return label;
}
private formatTimestamp(timestamp: string): { short: string; full: string } | undefined {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return undefined;
return { short: shortTimestampFormatter.format(date), full: fullTimestampFormatter.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`<pre class="part shell-output">${part.text}</pre>`;
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
+7 -5
View File
@@ -327,22 +327,24 @@ export const chatStyles = css`
.msg.bash > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-success) 35%, transparent); background: var(--pi-success-bg); }
.msg.skill > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-purple-border) 35%, transparent); background: var(--pi-purple-surface); }
.group-msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -10px 0 8px; padding: 7px 0 6px; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-bg); }
.msg-header-trailing { min-width: 0; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; }
.msg-actions { display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; }
.msg-header-trailing { min-width: 0; flex: 1 1 auto; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; }
.msg-actions { flex: 0 0 auto; display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; }
.msg-action { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; }
.msg-action:hover, .msg-action:focus { color: var(--pi-text); border-color: var(--pi-accent); }
.msg:hover > .msg-header .msg-actions, .msg:focus-within > .msg-header .msg-actions, .group-msg:hover > .msg-header .msg-actions, .group-msg:focus-within > .msg-header .msg-actions { opacity: 1; }
.label { display: block; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
.msg-header .label { margin: 0; }
.msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: var(--pi-dim); 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-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: var(--pi-dim); padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; }
.msg:hover > .msg-header .msg-meta, .msg:focus-within > .msg-header .msg-meta, .group-msg:hover > .msg-header .msg-meta, .group-msg:focus-within > .msg-header .msg-meta, .msg-meta:focus, .msg-meta.expanded { opacity: 1; }
.msg-meta.expanded { flex: 1 1 auto; max-width: 100%; white-space: normal; overflow: visible; overflow-wrap: anywhere; text-overflow: clip; }
.msg-meta:focus { outline: 1px solid var(--pi-border); outline-offset: 3px; border-radius: 4px; }
@media (hover: none) {
.msg-actions { opacity: 1; }
.msg-meta { opacity: .75; max-width: 26px; }
.msg-meta:not(.expanded) { display: inline-grid; width: 26px; height: 26px; place-items: center; font-size: 0; text-overflow: clip; }
.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: ""; }
.msg-meta.expanded { opacity: 1; max-width: 100%; }
.msg-meta.expanded::before { content: ""; }
}
formatted-text.part { display: block; }
formatted-text.part, .queued-message formatted-text { text-align: start; unicode-bidi: plaintext; }