Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ const isErrorRecord = (record: JsonRecord): boolean => (
|| (record.error !== null && record.error !== undefined)
);

const isClaudeLocalCommandUserRecord = (record: JsonRecord, message: JsonRecord | null): boolean => {
if (record.isMeta === true) return true;
const content = typeof message?.content === 'string' ? message.content.trimStart() : '';
return content.startsWith('<command-name>')
|| content.startsWith('<local-command-stdout>')
|| content.startsWith('<local-command-caveat>');
};

const parseClaudeEvidence = (records: JsonRecord[]): ExternalSessionParsedActivityEvidence => {
let turnEnded = false;
for (let index = records.length - 1; index >= 0; index -= 1) {
Expand All @@ -279,8 +287,14 @@ const parseClaudeEvidence = (records: JsonRecord[]): ExternalSessionParsedActivi
continue;
}
if (type !== 'assistant' && type !== 'user') continue;
// Claude persists the completed /compact result as a synthetic user row.
// It is context for the next turn, not a newly submitted prompt.
if (type === 'user' && record.isCompactSummary === true) {
return evidence('waiting_user', 'none');
}
const role = readString(message?.role) ?? type;
if (role === 'user') {
if (isClaudeLocalCommandUserRecord(record, message)) continue;
return turnEnded
? evidence('waiting_user', 'none')
: evidence('running', 'none');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,33 @@ test('Claude turn_duration closes an interrupted input without creating a comple
].join('\n')), 'running');
});

test('Claude compact summaries leave the session ready without emitting a reply completion', () => {
assert.deepEqual(
parseExternalJsonlActivityEvidence('claude', [
line({ type: 'system', subtype: 'compact_boundary' }),
line({
type: 'user',
isCompactSummary: true,
message: { role: 'user', content: 'Compacted conversation context' },
}),
line({
type: 'user',
isMeta: true,
message: { role: 'user', content: '<local-command-caveat>internal</local-command-caveat>' },
}),
line({
type: 'user',
message: { role: 'user', content: '<command-name>/compact</command-name>' },
}),
line({
type: 'user',
message: { role: 'user', content: '<local-command-stdout>Compacted</local-command-stdout>' },
}),
].join('\n')),
{ activity: 'waiting_user', terminalOutcome: 'none' },
);
});

test('Claude API overloaded responses are promoted to ERROR evidence', () => {
const overloaded = {
type: 'error',
Expand Down
40 changes: 40 additions & 0 deletions src/components/chat/view/subcomponents/MessageComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from 'node:test';

import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer, { act } from 'react-test-renderer';

import '../../../../i18n/config';
import type { ChatMessage } from '../../types/types';
Expand Down Expand Up @@ -33,6 +34,45 @@ test('standalone conversation errors keep details collapsed and defer the full b
assert.equal((html.match(/>Error</g) || []).length, 1);
});

test('compact summaries render as a collapsed disclosure without mounting the body', () => {
const message: ChatMessage = {
type: 'assistant',
content: 'private compacted context sentinel',
timestamp: '2026-09-18T00:00:00.000Z',
isCompactSummary: true,
};
const html = renderMessage(message);

assert.match(html, /<details(?![^>]*\sopen(?:=|\s|>))[^>]*>/);
assert.match(html, /Compacted context/);
assert.doesNotMatch(html, /private compacted context sentinel/);
});

test('opening a compact summary disclosure mounts its body', async (t) => {
let renderer!: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(createElement(MessageComponent, {
message: {
type: 'assistant',
content: 'expanded compacted context sentinel',
timestamp: '2026-09-18T00:00:00.000Z',
isCompactSummary: true,
},
prevMessage: null,
createDiff: () => [],
provider: 'claude',
}));
});
t.after(() => act(() => renderer.unmount()));

assert.doesNotMatch(JSON.stringify(renderer.toJSON()), /expanded compacted context sentinel/);
const details = renderer.root.findByType('details');
await act(async () => {
details.props.onToggle({ currentTarget: { open: true } });
});
assert.match(JSON.stringify(renderer.toJSON()), /expanded compacted context sentinel/);
});

test('non-Bash tool failures defer full output behind a collapsed disclosure', () => {
const html = renderMessage({
type: 'assistant',
Expand Down
35 changes: 34 additions & 1 deletion src/components/chat/view/subcomponents/MessageComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,16 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
const { fullToolResult, isLoadingFullToolResult, fullToolResultError, loadFullToolResult } = useFullToolResult(message.sessionId, message.toolId);
const [isToolErrorOpen, setIsToolErrorOpen] = useState(false);
const [isConversationErrorOpen, setIsConversationErrorOpen] = useState(false);
const [isCompactSummaryOpen, setIsCompactSummaryOpen] = useState(false);
useEffect(() => {
setIsToolErrorOpen(false);
}, [message.sessionId, message.toolId]);
useEffect(() => {
setIsConversationErrorOpen(false);
}, [message.sessionId, message.timestamp, message.content]);
useEffect(() => {
setIsCompactSummaryOpen(false);
}, [message.sessionId, message.timestamp, message.content]);
const effectiveToolResult = message.toolResult;
const errorContent = String(message.content || '');
const errorSummary = compactErrorSummary(errorContent, t('messageTypes.error'));
Expand All @@ -90,7 +94,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
assistantCopyContent.trim().length > 0 &&
!isCommandOrFileEditToolResponse &&
!message.isThinking;
!message.isThinking &&
!message.isCompactSummary;


const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
Expand Down Expand Up @@ -372,6 +377,34 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
</div>
</div>
</div>
) : message.isCompactSummary ? (
<details
className="rounded-lg border border-border/70 bg-muted/30 px-3 py-2 text-sm"
onToggle={(event) => setIsCompactSummaryOpen(event.currentTarget.open)}
>
<summary className="flex cursor-pointer list-none items-center gap-2 text-muted-foreground [&::-webkit-details-marker]:hidden">
<svg
aria-hidden="true"
className="details-chevron h-4 w-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="font-medium">{t('messageTypes.compactSummary')}</span>
</summary>
{isCompactSummaryOpen && (
<div className="mt-3 border-t border-border/60 pt-3">
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
{formattedMessageContent}
</Markdown>
<div className="mt-3 flex items-center text-[11px]">
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
</div>
</div>
)}
</details>
) : message.isThinking ? (
/* Thinking messages — Reasoning component (ai-elements pattern) */
<Reasoning defaultOpen={false}>
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/de/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"tool": "Werkzeug",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"compactSummary": "Komprimierter Kontext"
},
"tools": {
"settings": "Werkzeugeinstellungen",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"opencode": "OpenCode"
"opencode": "OpenCode",
"compactSummary": "Compacted context"
},
"tools": {
"settings": "Tool Settings",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/fr/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"opencode": "OpenCode"
"opencode": "OpenCode",
"compactSummary": "Contexte compacté"
},
"tools": {
"settings": "Paramètres de l'outil",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/it/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"tool": "Strumento",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"compactSummary": "Contesto compattato"
},
"tools": {
"settings": "Impostazioni strumento",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ja/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"tool": "ツール",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"compactSummary": "圧縮されたコンテキスト"
},
"tools": {
"settings": "ツール設定",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ko/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"tool": "도구",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"compactSummary": "압축된 대화 내용"
},
"tools": {
"settings": "도구 설정",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ru/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"tool": "Инструмент",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"compactSummary": "Сжатый контекст"
},
"tools": {
"settings": "Настройки инструмента",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/tr/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"tool": "Araç",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"compactSummary": "Sıkıştırılmış bağlam"
},
"tools": {
"settings": "Araç Ayarları",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/zh-CN/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"tool": "工具",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"compactSummary": "已压缩的上下文"
},
"tools": {
"settings": "工具设置",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/zh-TW/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"tool": "工具",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"compactSummary": "已壓縮的上下文"
},
"tools": {
"settings": "工具設定",
Expand Down
Loading