Files
openchamber/packages/ui/src/components/layout/rawMessagePreview.ts
T

136 lines
4.5 KiB
TypeScript

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 }),
});
};