From fb9e13a3a73c3666ac84428a9d9b09dfcc9b4b92 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 29 Aug 2026 02:02:30 +0300 Subject: [PATCH] fix(chat): list quote-only user messages in the prompt navigator Context parts are marked synthetic, so a message made only of quoted fragments was skipped as an injected message and could not be navigated to. Such turns now appear with the same caption the bubble shows. --- .../ui/src/components/chat/ChatContainer.tsx | 6 +- .../ui/src/components/chat/TimelineDialog.tsx | 2 +- .../chat/components/PromptNavigatorRail.tsx | 9 +- .../chat/lib/messagePreview.test.ts | 40 +++++- .../src/components/chat/lib/messagePreview.ts | 122 +++++++++++++++++- .../ui/src/lib/messages/contextParts.test.ts | 8 ++ packages/ui/src/lib/messages/contextParts.ts | 5 + 7 files changed, 182 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index d3c71b23..43aa97dd 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -56,6 +56,7 @@ import { WorkStatusPanel } from './work-status/WorkStatusPanel'; import { useWorkStatusVisibility } from './work-status/useWorkStatusVisibility'; import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat'; import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; +import { hasContextParts } from '@/lib/messages/contextParts'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge'; import { resolveChatPromptReadOnly } from './chatPromptReadOnly'; @@ -262,7 +263,10 @@ const ChatViewport = React.memo(({ // Other fully synthetic user messages (loop continuations, // plan-mode injections) are not prompts the user typed — keep // them out of the navigator entirely. - if (isFullySyntheticMessage(message.parts)) { + // Attached context (a quoted message, a terminal selection) is + // synthetic transport-wise but is a turn the user sent, so a + // context-only message stays navigable. + if (isFullySyntheticMessage(message.parts) && !hasContextParts(message.parts)) { continue; } let displayParts = normalizedPromptPartsCache.current.get(message.parts); diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx index 45e0f9f4..a96588df 100644 --- a/packages/ui/src/components/chat/TimelineDialog.tsx +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -301,7 +301,7 @@ export const TimelineDialog: React.FC = ({ ) : ( filteredMessages.map(({ message }, index) => { - const preview = getMessagePreview(message.parts); + const preview = getMessagePreview(message.parts, undefined, t); const timestamp = message.info.time.created; const dateGroup = formatDateGroup(timestamp); const previous = filteredMessages[index - 1]; diff --git a/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx b/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx index d61ec9eb..ac34b5d0 100644 --- a/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx +++ b/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import { Icon } from '@/components/icon/Icon'; -import { useI18n } from '@/lib/i18n'; +import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { cn } from '@/lib/utils'; import { getMessagePreview } from '../lib/messagePreview'; @@ -58,12 +58,13 @@ const PANEL_HIDE_DELAY_MS = 160; const buildPromptEntries = ( turnIds: string[], previewsByTurnId: Map, + t: (key: I18nKey, params?: I18nParams) => string, ): PromptEntry[] => { return turnIds.map((turnId) => { const parts = previewsByTurnId.get(turnId) ?? []; return { turnId, - preview: getMessagePreview(parts, PREVIEW_MAX_CHARS), + preview: getMessagePreview(parts, PREVIEW_MAX_CHARS, t), }; }); }; @@ -128,8 +129,8 @@ export function PromptNavigatorRail({ }, []); const prompts = React.useMemo( - () => buildPromptEntries(turnIds, previewsByTurnId), - [previewsByTurnId, turnIds], + () => buildPromptEntries(turnIds, previewsByTurnId, t), + [previewsByTurnId, t, turnIds], ); const visibleCount = Math.min(prompts.length, MAX_VISIBLE_TICKS); diff --git a/packages/ui/src/components/chat/lib/messagePreview.test.ts b/packages/ui/src/components/chat/lib/messagePreview.test.ts index e53fd42b..83bea83e 100644 --- a/packages/ui/src/components/chat/lib/messagePreview.test.ts +++ b/packages/ui/src/components/chat/lib/messagePreview.test.ts @@ -1,9 +1,26 @@ import { describe, expect, test } from 'bun:test' import type { Part } from '@opencode-ai/sdk/v2' -import { getFullText, getMessagePreview } from './messagePreview' +import { CONTEXT_METADATA_KEY, type ContextPartPayload } from '@/lib/messages/contextParts' +import { getFullText, getMessagePreview, getPromptPreviewText } from './messagePreview' const textPart = (text: string): Part => ({ type: 'text', text } as Part) +// SAFETY: a synthetic context part as the composer builds it; the preview +// helpers read only type, text, and metadata. +const contextPart = (payload: ContextPartPayload, text: string): Part => ({ + id: 'prt_1', + sessionID: 'ses_1', + messageID: 'msg_1', + type: 'text', + text, + synthetic: true, + metadata: { [CONTEXT_METADATA_KEY]: payload }, +} as Part) + +const chatQuote = (quote: string, text = ''): ContextPartPayload => ({ kind: 'chat-quote', quote, text }) + +const t = (key: string): string => (key === 'chat.message.context.chatQuote' ? 'Quoted from an earlier message' : key) + describe('messagePreview', () => { test('joins text parts for full text', () => { expect(getFullText([textPart('hello'), textPart('world')])).toBe('hello\nworld') @@ -18,4 +35,25 @@ describe('messagePreview', () => { expect(getMessagePreview([])).toBe('') expect(getFullText([{ type: 'file' } as Part])).toBe('') }) + + test('labels a quote-only message from its context part', () => { + const parts = [contextPart(chatQuote('the anchored scroll bit'), 'Comment on this fragment...')] + expect(getPromptPreviewText(parts, t)).toBe('Quoted from an earlier message: the anchored scroll bit') + expect(getMessagePreview(parts, 160, t)).toBe('Quoted from an earlier message: the anchored scroll bit') + }) + + test('prefers the quote comment over the quote itself', () => { + const parts = [contextPart(chatQuote('the anchored scroll bit', 'why this?'), 'raw model text')] + expect(getPromptPreviewText(parts, t)).toBe('Quoted from an earlier message: why this?') + }) + + test('keeps the typed text when a message has both text and quotes', () => { + const parts = [contextPart(chatQuote('quoted bit'), 'raw model text'), textPart('please explain')] + expect(getPromptPreviewText(parts, t)).toBe('please explain') + }) + + test('falls back to raw text without a translator', () => { + const parts = [contextPart(chatQuote('quoted bit'), 'raw model text')] + expect(getPromptPreviewText(parts)).toBe('raw model text') + }) }) diff --git a/packages/ui/src/components/chat/lib/messagePreview.ts b/packages/ui/src/components/chat/lib/messagePreview.ts index 811275ec..4cd89f7f 100644 --- a/packages/ui/src/components/chat/lib/messagePreview.ts +++ b/packages/ui/src/components/chat/lib/messagePreview.ts @@ -1,14 +1,130 @@ import type { Part } from '@opencode-ai/sdk/v2'; +import type { I18nKey, I18nParams } from '@/lib/i18n'; +import { readContextPart, type ContextPartPayload } from '@/lib/messages/contextParts'; + +type Translate = (key: I18nKey, params?: I18nParams) => string; + +type TextPartLike = Part & { type: 'text'; text: string }; + +const isTextPart = (part: Part): part is TextPartLike => part.type === 'text' && typeof part.text === 'string'; + export function getFullText(parts: Part[]): string { return parts - .filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string') + .filter(isTextPart) .map((p) => p.text) .join('\n'); } -export function getMessagePreview(parts: Part[], maxLength = 80): string { - const full = getFullText(parts); +const basename = (path: string): string => { + const segments = path.split('/').filter(Boolean); + return segments[segments.length - 1] ?? path; +}; + +/** The caption a context attachment shows in the bubble, reused as a preview prefix. */ +const contextSummary = (payload: ContextPartPayload, t: Translate): string => { + switch (payload.kind) { + case 'code-comment': { + const file = basename(payload.fileLabel); + return payload.startLine === payload.endLine + ? t('chat.message.context.codeCommentLine', { file, line: payload.startLine }) + : t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine }); + } + case 'terminal': + return t('chat.message.terminalContext', { + terminal: payload.terminalLabel, + start: payload.startLine, + end: payload.endLine, + }); + case 'browser-annotation': + return t('chat.message.context.browserAnnotation', { page: payload.pageUrl }); + case 'pr-comment': + return t('chat.message.context.prComment', { label: payload.label }); + case 'pr-check': + return t('chat.message.context.prCheck', { label: payload.label }); + case 'file-quote': { + const file = basename(payload.fileLabel); + if (payload.startLine == null || payload.endLine == null) { + return t('chat.message.context.fileQuote', { file }); + } + return payload.startLine === payload.endLine + ? t('chat.message.context.codeCommentLine', { file, line: payload.startLine }) + : t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine }); + } + case 'chat-quote': + return t('chat.message.context.chatQuote'); + case 'github-issue': + return `#${payload.number} ${payload.title}`; + case 'github-pr': + return `#${payload.number} ${payload.title}`; + } +}; + +/** The quoted material behind a context attachment. */ +const contextBody = (payload: ContextPartPayload): string => { + switch (payload.kind) { + case 'code-comment': + return payload.code; + case 'terminal': + return payload.output; + case 'browser-annotation': + return payload.prompt; + case 'pr-comment': + return payload.body; + case 'pr-check': + return payload.output; + case 'file-quote': + case 'chat-quote': + return payload.quote; + case 'github-issue': + case 'github-pr': + return ''; + } +}; + +/** + * One preview line for a context attachment, mirroring the collapsed bubble: + * the caption, then the user's comment when there is one, otherwise the quote. + */ +const contextPreview = (payload: ContextPartPayload, t: Translate): string => { + const summary = contextSummary(payload, t); + const comment = 'text' in payload ? payload.text.trim() : ''; + const detail = comment.length > 0 ? comment : contextBody(payload).trim(); + return detail.length > 0 ? `${summary}: ${detail}` : summary; +}; + +/** + * The text a user prompt shows in navigators: what the user typed, and — for + * messages that are only attached context (a quoted message, a terminal + * selection) — a label derived from that context, so such turns are never + * label-less. Without a translator it falls back to the raw part text. + */ +export function getPromptPreviewText(parts: Part[], t?: Translate): string { + const typed = parts + .filter(isTextPart) + .filter((p) => readContextPart(p) === null) + .map((p) => p.text.trim()) + .filter((text) => text.length > 0); + if (typed.length > 0) { + return typed.join('\n'); + } + + if (t) { + const contextLines = parts + .map((part) => readContextPart(part)) + .filter((payload): payload is ContextPartPayload => payload !== null) + .map((payload) => contextPreview(payload, t)) + .filter((line) => line.length > 0); + if (contextLines.length > 0) { + return contextLines.join(' · '); + } + } + + return getFullText(parts); +} + +export function getMessagePreview(parts: Part[], maxLength = 80, t?: Translate): string { + const full = getPromptPreviewText(parts, t); const singleLine = full.replace(/\n/g, ' '); return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength)}…` : singleLine; } diff --git a/packages/ui/src/lib/messages/contextParts.test.ts b/packages/ui/src/lib/messages/contextParts.test.ts index eb980435..52caca5f 100644 --- a/packages/ui/src/lib/messages/contextParts.test.ts +++ b/packages/ui/src/lib/messages/contextParts.test.ts @@ -6,6 +6,7 @@ import { contextPayloadFromDraft, createContextPart, formatContextText, + hasContextParts, readContextPart, type ContextPartPayload, } from './contextParts'; @@ -126,4 +127,11 @@ describe('round-trip through part metadata', () => { metadata: { [CONTEXT_METADATA_KEY]: { kind: 'github-issue', number: 0, title: 't', url: 'u' } }, })).toBeNull(); }); + + test('hasContextParts detects user-attached context in a message', () => { + const quote = asPart(contextPayloadFromDraft(draft({ source: 'chat-quote', fileLabel: 'msg_1' }))); + expect(hasContextParts([quote])).toBe(true); + expect(hasContextParts([{ type: 'text' }])).toBe(false); + expect(hasContextParts([])).toBe(false); + }); }); diff --git a/packages/ui/src/lib/messages/contextParts.ts b/packages/ui/src/lib/messages/contextParts.ts index d31e0ee0..f875f8a7 100644 --- a/packages/ui/src/lib/messages/contextParts.ts +++ b/packages/ui/src/lib/messages/contextParts.ts @@ -312,3 +312,8 @@ export function readContextPart(part: ContextCarrierPart): ContextPartPayload | const parsed = contextPayloadSchema.safeParse(part.metadata?.[CONTEXT_METADATA_KEY]); return parsed.success ? parsed.data : null; } + +/** Whether a message carries any user-attached context part. */ +export function hasContextParts(parts: ContextCarrierPart[]): boolean { + return parts.some((part) => readContextPart(part) !== null); +}