feat: show previews in context messages breakdown. (#1563)
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
15813b61c3
commit
8fbaa5df9c
@@ -7,13 +7,21 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
|
import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
|
||||||
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
|
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
derivePartsLabel,
|
||||||
|
deriveUserSnippet,
|
||||||
|
formatAssistantTokens,
|
||||||
|
formatMessagePreviewTime,
|
||||||
|
truncateMessageId,
|
||||||
|
} from './rawMessagePreview';
|
||||||
|
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
||||||
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
||||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
|
||||||
|
|
||||||
type SessionMessage = { info: Message; parts: Part[] };
|
type SessionMessage = { info: Message; parts: Part[] };
|
||||||
|
|
||||||
@@ -247,21 +255,6 @@ const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeForm
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMessageDateMeta = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => {
|
|
||||||
if (!timestamp || !Number.isFinite(timestamp)) return '-';
|
|
||||||
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const capitalizeRole = (role: string): string => {
|
|
||||||
if (!role) return role;
|
|
||||||
return `${role[0].toUpperCase()}${role.slice(1)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveProviderAndModel = (
|
const resolveProviderAndModel = (
|
||||||
providers: ProviderLike[],
|
providers: ProviderLike[],
|
||||||
providerID: string,
|
providerID: string,
|
||||||
@@ -545,10 +538,33 @@ export const ContextPanelContent: React.FC = () => {
|
|||||||
<div className="typography-micro text-muted-foreground">{t('contextSidebar.section.rawMessages')}</div>
|
<div className="typography-micro text-muted-foreground">{t('contextSidebar.section.rawMessages')}</div>
|
||||||
<div className="mt-2.5 space-y-1">
|
<div className="mt-2.5 space-y-1">
|
||||||
{[...sessionMessages].reverse().map((message) => {
|
{[...sessionMessages].reverse().map((message) => {
|
||||||
const role = deriveMessageRole(message.info).role;
|
const roleInfo = deriveMessageRole(message.info);
|
||||||
|
const role = roleInfo.role;
|
||||||
|
const isAssistant = role === 'assistant';
|
||||||
|
const isUser = role === 'user';
|
||||||
const isExpanded = expandedRawMessages[message.info.id] === true;
|
const isExpanded = expandedRawMessages[message.info.id] === true;
|
||||||
const isCopied = copiedRawMessageId === message.info.id;
|
const isCopied = copiedRawMessageId === message.info.id;
|
||||||
const messageCreatedAt = (message.info.time?.created ?? null) as number | null;
|
const messageCreatedAt = (message.info.time?.created ?? null) as number | null;
|
||||||
|
const partsLabel = derivePartsLabel(message.parts);
|
||||||
|
const tokens = isAssistant ? extractTokenBreakdown({ info: message.info, parts: message.parts }) : null;
|
||||||
|
const userSnippet = isUser ? deriveUserSnippet(message.parts) : '';
|
||||||
|
const shortId = truncateMessageId(message.info.id);
|
||||||
|
const previewTime = formatMessagePreviewTime(messageCreatedAt, timeFormatPreference);
|
||||||
|
// User rows merge the first two columns into a single inline
|
||||||
|
// block: `**user:** <snippet>`. The bold prefix anchors the eye
|
||||||
|
// to the start of the block; the snippet flows inline until the
|
||||||
|
// truncation point chosen by CSS.
|
||||||
|
//
|
||||||
|
// Assistant rows keep two cells: parts label on the left, I/O
|
||||||
|
// tokens right-aligned in a fixed middle column. Other roles
|
||||||
|
// (tool/system) reuse the assistant layout with an empty tokens
|
||||||
|
// cell so columns still align across rows.
|
||||||
|
const assistantLeft = partsLabel || '\u2014';
|
||||||
|
const assistantMiddle = tokens
|
||||||
|
? formatAssistantTokens(tokens.input, tokens.output, formatNumber)
|
||||||
|
: '';
|
||||||
|
const otherLeft = role || 'unknown';
|
||||||
|
const otherMiddle = partsLabel;
|
||||||
|
|
||||||
const jsonValue = isExpanded
|
const jsonValue = isExpanded
|
||||||
? JSON.stringify({ info: message.info, parts: message.parts }, null, 2)
|
? JSON.stringify({ info: message.info, parts: message.parts }, null, 2)
|
||||||
@@ -570,12 +586,49 @@ export const ContextPanelContent: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 whitespace-nowrap overflow-hidden">
|
{/*
|
||||||
<span className="min-w-0 inline-flex items-center gap-1.5">
|
4-column grid: cols 1-2 = role+content area, col 3 = id,
|
||||||
<span className="typography-ui-label text-foreground shrink-0">{capitalizeRole(role)}</span>
|
col 4 = time. User rows fuse cols 1-2 into a single
|
||||||
<span className="min-w-0 truncate typography-micro text-muted-foreground">{message.info.id}</span>
|
inline `**user:** <snippet>` block via grid-column:
|
||||||
</span>
|
span 2; assistant/other rows keep them split (label |
|
||||||
<span className="typography-micro text-muted-foreground shrink-0">{formatMessageDateMeta(messageCreatedAt, timeFormatPreference)}</span>
|
value) so the I/O tokens line up vertically across rows.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
className="grid items-center gap-x-2 whitespace-nowrap typography-micro"
|
||||||
|
style={{ gridTemplateColumns: 'auto minmax(0, 1fr) 5rem 4.5rem' }}
|
||||||
|
>
|
||||||
|
{isUser ? (
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate text-muted-foreground"
|
||||||
|
style={{ gridColumn: 'span 2' }}
|
||||||
|
>
|
||||||
|
<span className="typography-ui-label text-foreground">user:</span>{' '}
|
||||||
|
{userSnippet}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isAssistant
|
||||||
|
? 'min-w-0 truncate text-muted-foreground'
|
||||||
|
: 'typography-ui-label text-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isAssistant ? assistantLeft : otherLeft}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isAssistant
|
||||||
|
? 'text-right text-muted-foreground tabular-nums'
|
||||||
|
: 'min-w-0 truncate text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isAssistant ? assistantMiddle : otherMiddle}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-right font-mono text-muted-foreground">{shortId}</span>
|
||||||
|
<span className="text-right text-muted-foreground">{previewTime}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
|
import {
|
||||||
|
derivePartsLabel,
|
||||||
|
deriveUserSnippet,
|
||||||
|
formatAssistantTokens,
|
||||||
|
formatMessagePreviewTime,
|
||||||
|
truncateMessageId,
|
||||||
|
} from '../rawMessagePreview';
|
||||||
|
|
||||||
|
const part = (data: Record<string, unknown>): Part => data as unknown as Part;
|
||||||
|
|
||||||
|
describe('truncateMessageId', () => {
|
||||||
|
test('returns trailing 8 chars (suffix, not prefix)', () => {
|
||||||
|
// OpenCode ids share a long common prefix (msg_e39e98d…); the suffix is
|
||||||
|
// the only distinguishing region, so we surface the tail.
|
||||||
|
const id = 'msg_e39e98d86001xA2wMRcvRuL5HT';
|
||||||
|
expect(truncateMessageId(id)).toBe(id.slice(-8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinguishes two ids that differ only in suffix', () => {
|
||||||
|
const a = 'msg_e39e98d86001xA2wMRcvRuL5HT';
|
||||||
|
const b = 'msg_e39e98d0e001kmHn6dH5r3IHfs';
|
||||||
|
expect(truncateMessageId(a)).not.toBe(truncateMessageId(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns last 8 chars when longer', () => {
|
||||||
|
expect(truncateMessageId('abcdefghij')).toBe('cdefghij');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns id as-is when shorter than or equal to limit', () => {
|
||||||
|
expect(truncateMessageId('abc')).toBe('abc');
|
||||||
|
expect(truncateMessageId('12345678')).toBe('12345678');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty string', () => {
|
||||||
|
expect(truncateMessageId('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects custom length', () => {
|
||||||
|
expect(truncateMessageId('abcdefghij', 4)).toBe('ghij');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('derivePartsLabel', () => {
|
||||||
|
test('returns empty for no parts', () => {
|
||||||
|
expect(derivePartsLabel([])).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses tool name for tool parts', () => {
|
||||||
|
expect(derivePartsLabel([part({ type: 'tool', tool: 'bash' })])).toBe('bash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joins multiple distinct parts with " + "', () => {
|
||||||
|
expect(
|
||||||
|
derivePartsLabel([
|
||||||
|
part({ type: 'text', text: 'hi' }),
|
||||||
|
part({ type: 'tool', tool: 'bash' }),
|
||||||
|
]),
|
||||||
|
).toBe('text + bash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates labels', () => {
|
||||||
|
expect(
|
||||||
|
derivePartsLabel([
|
||||||
|
part({ type: 'text', text: 'a' }),
|
||||||
|
part({ type: 'text', text: 'b' }),
|
||||||
|
part({ type: 'tool', tool: 'bash' }),
|
||||||
|
]),
|
||||||
|
).toBe('text + bash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lowercases tool names', () => {
|
||||||
|
expect(derivePartsLabel([part({ type: 'tool', tool: 'Bash' })])).toBe('bash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to "tool" for tool parts without a tool name', () => {
|
||||||
|
expect(derivePartsLabel([part({ type: 'tool' })])).toBe('tool');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to "unknown" for parts without a type', () => {
|
||||||
|
expect(derivePartsLabel([part({})])).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatMessagePreviewTime', () => {
|
||||||
|
// Fixed timestamp: 2024-01-15 14:35:00 UTC. Local rendering will vary; we
|
||||||
|
// only assert structural properties (no AM/PM in 24h mode, presence in 12h).
|
||||||
|
const ts = Date.UTC(2024, 0, 15, 14, 35, 0);
|
||||||
|
|
||||||
|
test('returns "-" for null', () => {
|
||||||
|
expect(formatMessagePreviewTime(null, '24h')).toBe('-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "-" for non-finite', () => {
|
||||||
|
expect(formatMessagePreviewTime(Number.NaN, '24h')).toBe('-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('24h mode omits AM/PM markers', () => {
|
||||||
|
const result = formatMessagePreviewTime(ts, '24h');
|
||||||
|
expect(/AM|PM/i.test(result)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('12h mode includes AM or PM marker', () => {
|
||||||
|
const result = formatMessagePreviewTime(ts, '12h');
|
||||||
|
expect(/AM|PM/i.test(result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto mode is non-empty', () => {
|
||||||
|
expect(formatMessagePreviewTime(ts, 'auto').length > 0).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveUserSnippet', () => {
|
||||||
|
test('returns the first text part verbatim (no length cap)', () => {
|
||||||
|
// CSS handles the visual truncation at column width; the helper just
|
||||||
|
// returns the cleaned full string so consumers can decide what to do.
|
||||||
|
expect(
|
||||||
|
deriveUserSnippet([part({ type: 'text', text: 'hello world this is long' })]),
|
||||||
|
).toBe('hello world this is long');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves punctuation, accents, and unicode (React escapes at render)', () => {
|
||||||
|
expect(
|
||||||
|
deriveUserSnippet([part({ type: 'text', text: 'olá, mundo! 123' })]),
|
||||||
|
).toBe('olá, mundo! 123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses whitespace runs and trims ends', () => {
|
||||||
|
expect(
|
||||||
|
deriveUserSnippet([part({ type: 'text', text: ' a\n\n b\t\tc ' })]),
|
||||||
|
).toBe('a b c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses attachment count fallback when no text part exists', () => {
|
||||||
|
expect(deriveUserSnippet([part({ type: 'file' })])).toBe('1 attachment');
|
||||||
|
expect(
|
||||||
|
deriveUserSnippet([part({ type: 'file' }), part({ type: 'file' })]),
|
||||||
|
).toBe('2 attachments');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips empty text parts and falls through to next text part', () => {
|
||||||
|
expect(
|
||||||
|
deriveUserSnippet([
|
||||||
|
part({ type: 'text', text: ' ' }),
|
||||||
|
part({ type: 'text', text: 'next' }),
|
||||||
|
]),
|
||||||
|
).toBe('next');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty string when there are no parts at all', () => {
|
||||||
|
expect(deriveUserSnippet([])).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty string when all parts are whitespace-only text (not attachments)', () => {
|
||||||
|
expect(
|
||||||
|
deriveUserSnippet([
|
||||||
|
part({ type: 'text', text: ' ' }),
|
||||||
|
part({ type: 'text', text: '' }),
|
||||||
|
]),
|
||||||
|
).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAssistantTokens', () => {
|
||||||
|
const fmt = (n: number) => n.toLocaleString('en-US');
|
||||||
|
|
||||||
|
test('renders input and output separated by " / "', () => {
|
||||||
|
expect(formatAssistantTokens(340, 1205, fmt)).toBe('340 / 1,205');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders both zeros explicitly (does not hide 0/0)', () => {
|
||||||
|
expect(formatAssistantTokens(0, 0, fmt)).toBe('0 / 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors the caller-provided number formatter', () => {
|
||||||
|
expect(formatAssistantTokens(1234, 5678, (n) => String(n))).toBe('1234 / 5678');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers for the Raw Messages preview row in the context sidebar.
|
||||||
|
*
|
||||||
|
* Each collapsed entry shows a role label, parts summary (e.g. "bash",
|
||||||
|
* "text + todowrite"), I/O token counters (assistant only), a content
|
||||||
|
* snippet, an 8-char message id suffix, and a locale-aware timestamp.
|
||||||
|
*
|
||||||
|
* Note: we surface the **suffix** of the message id (last 8 chars), not the
|
||||||
|
* prefix. OpenCode ids share a long common prefix (e.g. `msg_e39e98d…`); the
|
||||||
|
* tail is what actually differentiates them.
|
||||||
|
*
|
||||||
|
* Helpers here are pure and DOM-free so they can be unit tested.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PREVIEW_ID_LENGTH = 8;
|
||||||
|
|
||||||
|
const partRecord = (part: Part): Record<string, unknown> => part as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
const partTypeOf = (part: Part): string => {
|
||||||
|
const value = partRecord(part).type;
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const partToolOf = (part: Part): string => {
|
||||||
|
const value = partRecord(part).tool;
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const partTextOf = (part: Part): string => {
|
||||||
|
const value = partRecord(part).text;
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelForPart = (part: Part): string => {
|
||||||
|
const type = partTypeOf(part);
|
||||||
|
if (type === 'tool') {
|
||||||
|
const tool = partToolOf(part).trim().toLowerCase();
|
||||||
|
return tool || 'tool';
|
||||||
|
}
|
||||||
|
return type || 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const derivePartsLabel = (parts: Part[]): string => {
|
||||||
|
if (parts.length === 0) return '';
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const label = labelForPart(part);
|
||||||
|
if (!seen.includes(label)) {
|
||||||
|
seen.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen.join(' + ');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the trailing `length` characters of a message id. Used because all
|
||||||
|
* ids share the same long prefix and only the suffix is distinguishable.
|
||||||
|
*/
|
||||||
|
export const truncateMessageId = (id: string, length: number = PREVIEW_ID_LENGTH): string => {
|
||||||
|
if (typeof id !== 'string') return '';
|
||||||
|
return id.length <= length ? id : id.slice(-length);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse whitespace runs into single spaces and trim ends. Keeps the
|
||||||
|
* snippet on a single line in the preview row; CSS handles truncation
|
||||||
|
* with an ellipsis at whatever width the column ends up rendering at.
|
||||||
|
*
|
||||||
|
* Punctuation, accents, and unicode are preserved — React escapes the
|
||||||
|
* value at render time so there is no injection risk, and the row is
|
||||||
|
* visually anchored by the bold `user:` prefix anyway.
|
||||||
|
*/
|
||||||
|
const collapseWhitespace = (text: string): string =>
|
||||||
|
text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the inline snippet shown after `user:` on a user-row in the
|
||||||
|
* Raw Messages preview. Returns the cleaned text of the first non-empty
|
||||||
|
* text part, or `<N attachment[s]>` when the message carries no text
|
||||||
|
* (e.g. file-only messages). Returns empty string when the message has
|
||||||
|
* no parts at all.
|
||||||
|
*/
|
||||||
|
export const deriveUserSnippet = (parts: Part[]): string => {
|
||||||
|
for (const part of parts) {
|
||||||
|
if (partTypeOf(part) === 'text') {
|
||||||
|
const cleaned = collapseWhitespace(partTextOf(part));
|
||||||
|
if (cleaned.length === 0) continue;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nonTextCount = parts.filter((part) => partTypeOf(part) !== 'text').length;
|
||||||
|
if (nonTextCount === 0) return '';
|
||||||
|
return `${nonTextCount} attachment${nonTextCount === 1 ? '' : 's'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the assistant token counters as `<input> / <output>`. Both zero
|
||||||
|
* still renders as `0 / 0` so streaming-not-started messages stay visible
|
||||||
|
* in the column instead of disappearing.
|
||||||
|
*/
|
||||||
|
export const formatAssistantTokens = (
|
||||||
|
input: number,
|
||||||
|
output: number,
|
||||||
|
formatNumber: (value: number) => string,
|
||||||
|
): string => `${formatNumber(input)} / ${formatNumber(output)}`;
|
||||||
|
|
||||||
|
const resolveHour12 = (preference: TimeFormatPreference): boolean | undefined => {
|
||||||
|
if (preference === '12h') return true;
|
||||||
|
if (preference === '24h') return false;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a message timestamp for the Raw Messages preview row.
|
||||||
|
*
|
||||||
|
* Mirrors the original short "MM/DD HH:MM" shape but honors the user's
|
||||||
|
* `timeFormatPreference` setting. In 24h mode no AM/PM is rendered.
|
||||||
|
*/
|
||||||
|
export const formatMessagePreviewTime = (
|
||||||
|
timestamp: number | null,
|
||||||
|
preference: TimeFormatPreference,
|
||||||
|
): string => {
|
||||||
|
if (!timestamp || !Number.isFinite(timestamp)) return '-';
|
||||||
|
const hour12 = resolveHour12(preference);
|
||||||
|
return new Date(timestamp).toLocaleString(undefined, {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: hour12 === false ? '2-digit' : 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
...(hour12 === undefined ? {} : { hour12 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user