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 ` / `. 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 }), + }); +};