From abcf44b962cfe9a45bc3673150896b4a7cb6553e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 10 Jul 2026 22:08:12 +0200 Subject: [PATCH] fix: show complete chat message metadata --- .changeset/show-complete-message-metadata.md | 5 ++ src/client/src/components/ChatView.test.ts | 15 +++++- src/client/src/components/ChatView.ts | 57 ++++++++++---------- src/client/src/components/shared.ts | 12 +++-- 4 files changed, 53 insertions(+), 36 deletions(-) create mode 100644 .changeset/show-complete-message-metadata.md diff --git a/.changeset/show-complete-message-metadata.md b/.changeset/show-complete-message-metadata.md new file mode 100644 index 0000000..2eb3305 --- /dev/null +++ b/.changeset/show-complete-message-metadata.md @@ -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. diff --git a/src/client/src/components/ChatView.test.ts b/src/client/src/components/ChatView.test.ts index 4322c54..3aa8484 100644 --- a/src/client/src/components/ChatView.test.ts +++ b/src/client/src/components/ChatView.test.ts @@ -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`); + }); +}); diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index da47d3f..0e67e7d 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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(); + private readonly messageMetaCache = new WeakMap(); private readonly messageCopyTextCache = new WeakMap(); private partialStreamNoticeBody: string | undefined; private lastScrollTop = 0; @@ -397,7 +418,7 @@ export class ChatView extends LitElement { ${message.role}
${this.renderMessageActions(message, key)} - { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short} + { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta}
`; @@ -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`
${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 4422620..b0b4045 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; }