Skip to content

Commit 908250a

Browse files
therefclaude
authored andcommitted
perf(web): cache message date/time and sort key at parse time
Addresses review feedback on GoogleCloudPlatform#300. - rebuildMessages sorted by re-parsing each timestamp into a Date on every comparison; it runs per streamed message, so that is hot. Derive an epoch-ms sortKey once at parse time and sort numerically. (Numeric key rather than a lexicographic string compare so ordering stays correct across the hub-store and Cloud-Logging sources even if their RFC3339 offsets ever differ.) - renderMessages created a Date and ran Intl date/time formatting for every message on every render; because composeText is @State, each keystroke re-rendered the whole list and re-formatted every row. Pre-format dateStr/timeStr once at parse time and just reference them in the loop. deriveTimeFields centralises this parse-time derivation for both the hub and Cloud-Logging parse paths. No cast is added in shared/markdown.ts: marked's overload for { async: false } already returns string (confirmed by tsc and typed lint), so `as string` would be flagged as an unnecessary assertion here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2f4d3cf commit 908250a

2 files changed

Lines changed: 34 additions & 14 deletions

File tree

web/src/components/shared/agent-message-viewer.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,36 @@ interface ParsedMessage {
6464
urgent: boolean;
6565
broadcasted: boolean;
6666
timestamp: string;
67+
/** Epoch ms of `timestamp`, derived once at parse time. Used for sorting so
68+
* `rebuildMessages` (called per streamed message) never re-parses dates. */
69+
sortKey: number;
70+
/** Pre-formatted date/time strings, derived once at parse time so the message
71+
* list does not re-run Intl formatting on every render (e.g. each keystroke
72+
* in the compose box, which re-renders the component). */
73+
dateStr: string;
74+
timeStr: string;
6775
insertId: string;
6876
raw: MessageLogEntry | null;
6977
}
7078

79+
/**
80+
* Derive the cached sort key and display strings from a message timestamp.
81+
* Computed once per message at parse time rather than on every render/compare.
82+
*/
83+
function deriveTimeFields(timestamp: string): {
84+
sortKey: number;
85+
dateStr: string;
86+
timeStr: string;
87+
} {
88+
const d = new Date(timestamp);
89+
const ms = d.getTime();
90+
return {
91+
sortKey: Number.isNaN(ms) ? 0 : ms,
92+
dateStr: d.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' }),
93+
timeStr: d.toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit' }),
94+
};
95+
}
96+
7197
const MAX_BUFFER = 500;
7298

7399
/** Message types that are part of normal conversational flow; their type
@@ -637,6 +663,7 @@ export class ScionAgentMessageViewer extends LitElement {
637663
urgent: msg.urgent ?? false,
638664
broadcasted: msg.broadcasted ?? false,
639665
timestamp: msg.createdAt,
666+
...deriveTimeFields(msg.createdAt),
640667
insertId: `hub:${msg.id}`,
641668
raw: null,
642669
};
@@ -699,6 +726,7 @@ export class ScionAgentMessageViewer extends LitElement {
699726
urgent,
700727
broadcasted,
701728
timestamp: entry.timestamp,
729+
...deriveTimeFields(entry.timestamp),
702730
insertId: entry.insertId,
703731
raw: entry,
704732
};
@@ -717,9 +745,7 @@ export class ScionAgentMessageViewer extends LitElement {
717745

718746
/** Sort buffered messages oldest-first and evict the oldest beyond MAX_BUFFER. */
719747
private rebuildMessages(): void {
720-
const sorted = Array.from(this.entryMap.values()).sort(
721-
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
722-
);
748+
const sorted = Array.from(this.entryMap.values()).sort((a, b) => a.sortKey - b.sortKey);
723749

724750
if (sorted.length > MAX_BUFFER) {
725751
// Drop the oldest (front) entries.
@@ -1074,23 +1100,15 @@ export class ScionAgentMessageViewer extends LitElement {
10741100
let lastDate = '';
10751101

10761102
for (const msg of this.messages) {
1077-
const d = new Date(msg.timestamp);
1078-
const dateStr = d.toLocaleDateString('en', {
1079-
year: 'numeric',
1080-
month: 'short',
1081-
day: 'numeric',
1082-
});
1103+
// dateStr/timeStr are pre-computed at parse time (see deriveTimeFields)
1104+
// so this loop stays allocation-free on re-render.
1105+
const { dateStr, timeStr } = msg;
10831106

10841107
if (dateStr !== lastDate) {
10851108
lastDate = dateStr;
10861109
rows.push(html`<div class="date-divider">${dateStr}</div>`);
10871110
}
10881111

1089-
const timeStr = d.toLocaleTimeString('en', {
1090-
hour12: false,
1091-
hour: '2-digit',
1092-
minute: '2-digit',
1093-
});
10941112
const isExpanded = this.expandedIds.has(msg.insertId);
10951113
const isProjectView = !this.agentId;
10961114
const isUser = !isProjectView && msg.direction === 'received';

web/src/shared/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ async function loadRenderer(): Promise<MarkdownRenderer> {
3939

4040
return {
4141
render(markdown: string): string {
42+
// marked's overload for { async: false } already returns string, so
43+
// no cast is needed here (verified by tsc + typed lint).
4244
const rawHtml = marked.parse(markdown, { async: false });
4345
return purify.sanitize(rawHtml);
4446
},

0 commit comments

Comments
 (0)