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 { describe, expect, it } from "vitest";
import { chatQueuedMessageSections } from "./ChatView"; import { chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
describe("chatQueuedMessageSections", () => { describe("chatQueuedMessageSections", () => {
it("labels client-side pending-start sends separately from server queued messages", () => { 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 "./FormattedText";
import "./ToolExecutionView"; import "./ToolExecutionView";
const shortTimestampFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
const fullTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
const partialStreamNoticeBodies = [ const partialStreamNoticeBodies = [
"You opened this chat while the assistant was already replying. The complete answer will appear shortly.", "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); ].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") @customElement("chat-view")
export class ChatView extends LitElement { export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = []; @property({ attribute: false }) messages: ChatLine[] = [];
@@ -84,7 +105,7 @@ export class ChatView extends LitElement {
private groupedMessagesInput?: ChatLine[]; private groupedMessagesInput?: ChatLine[];
private groupedMessagesStart = 0; private groupedMessagesStart = 0;
private groupedMessagesCache: ChatGroup[] = []; 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 readonly messageCopyTextCache = new WeakMap<ChatLine, string>();
private partialStreamNoticeBody: string | undefined; private partialStreamNoticeBody: string | undefined;
private lastScrollTop = 0; private lastScrollTop = 0;
@@ -397,7 +418,7 @@ export class ChatView extends LitElement {
<b class="label">${message.role}</b> <b class="label">${message.role}</b>
<div class="msg-header-trailing"> <div class="msg-header-trailing">
${this.renderMessageActions(message, key)} ${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>
</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); const cached = this.messageMetaCache.get(message);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
const timestamp = message.meta?.timestamp; const label = chatMessageMetadataLabel(message);
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(" · ") };
this.messageMetaCache.set(message, label); this.messageMetaCache.set(message, label);
return 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) { 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>`;
+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.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); } .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); } .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-header-trailing { min-width: 0; flex: 1 1 auto; 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-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 { 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-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; } .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; } .label { display: block; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
.msg-header .label { margin: 0; } .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: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; } .msg-meta:focus { outline: 1px solid var(--pi-border); outline-offset: 3px; border-radius: 4px; }
@media (hover: none) { @media (hover: none) {
.msg-actions { opacity: 1; } .msg-actions { opacity: 1; }
.msg-meta { opacity: .75; max-width: 26px; } .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::before { content: "ⓘ"; font-size: 13px; }
.msg-meta:focus, .msg-meta.expanded { opacity: 1; max-width: 75%; } .msg-meta.expanded { opacity: 1; max-width: 100%; }
.msg-meta:focus::before, .msg-meta.expanded::before { content: ""; } .msg-meta.expanded::before { content: ""; }
} }
formatted-text.part { display: block; } formatted-text.part { display: block; }
formatted-text.part, .queued-message formatted-text { text-align: start; unicode-bidi: plaintext; } formatted-text.part, .queued-message formatted-text { text-align: start; unicode-bidi: plaintext; }