diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx
index f15e8467..149c4c13 100644
--- a/packages/ui/src/components/layout/ContextSidebarTab.tsx
+++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx
@@ -7,13 +7,21 @@ import { Icon } from "@/components/icon/Icon";
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { useConfigStore } from '@/stores/useConfigStore';
+import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
import { copyTextToClipboard } from '@/lib/clipboard';
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 { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
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 = (
providers: ProviderLike[],
providerID: string,
@@ -545,10 +538,33 @@ export const ContextPanelContent: React.FC = () => {
{t('contextSidebar.section.rawMessages')}
{[...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 isCopied = copiedRawMessageId === message.info.id;
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:**
`. 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
? JSON.stringify({ info: message.info, parts: message.parts }, null, 2)
@@ -570,12 +586,49 @@ export const ContextPanelContent: React.FC = () => {
}));
}}
>
-
-
- {capitalizeRole(role)}
- {message.info.id}
-
-
{formatMessageDateMeta(messageCreatedAt, timeFormatPreference)}
+ {/*
+ 4-column grid: cols 1-2 = role+content area, col 3 = id,
+ col 4 = time. User rows fuse cols 1-2 into a single
+ inline `**user:**
` block via grid-column:
+ span 2; assistant/other rows keep them split (label |
+ value) so the I/O tokens line up vertically across rows.
+ */}
+
+ {isUser ? (
+
+ user:{' '}
+ {userSnippet}
+
+ ) : (
+ <>
+
+ {isAssistant ? assistantLeft : otherLeft}
+
+
+ {isAssistant ? assistantMiddle : otherMiddle}
+
+ >
+ )}
+ {shortId}
+ {previewTime}
diff --git a/packages/ui/src/components/layout/__tests__/rawMessagePreview.test.ts b/packages/ui/src/components/layout/__tests__/rawMessagePreview.test.ts
new file mode 100644
index 00000000..1ae595b2
--- /dev/null
+++ b/packages/ui/src/components/layout/__tests__/rawMessagePreview.test.ts
@@ -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): 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');
+ });
+});
diff --git a/packages/ui/src/components/layout/rawMessagePreview.ts b/packages/ui/src/components/layout/rawMessagePreview.ts
new file mode 100644
index 00000000..e8d43e1d
--- /dev/null
+++ b/packages/ui/src/components/layout/rawMessagePreview.ts
@@ -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 => part as unknown as Record;
+
+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 `` 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 ` /