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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 02:02:30 +03:00
parent e26f647404
commit fb9e13a3a7
7 changed files with 182 additions and 10 deletions
@@ -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);
@@ -301,7 +301,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div>
) : (
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];
@@ -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<string, Part[]>,
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);
@@ -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')
})
})
@@ -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;
}
@@ -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);
});
});
@@ -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);
}