fix: harden and de-slop the merged contribution batch
Follow-ups promised on merge, plus review findings on the batch itself: - chat: task-tool output now respects the 512KiB render cap; quick-open icon is visible at rest on coarse pointers and reachable by keyboard (row keydown no longer swallows inner-button Enter/Space); composer inline-code decoration drops the metric-shifting padding; a btw fork send carries only the boundary instruction, never the promotion notice - sync: cascade revert/unrevert aborts busy descendants, busy state is read from every child store at the moment of use; rule 9 documents redo clearing all descendant revert markers - electron: renderer recovery keeps memory-eviction (a valid render-process-gone reason) and both windows share one attachRendererRecovery helper - vscode: process registry is a thin re-export of the web module (provider-env-aliases precedent) with ordered register/unregister writes and an awaited close - server/cli: managed-process registry takes injectable deps (fixes the unreaped-orphans ReferenceError), corrupt settings errors name the file, getWorktrees test restores console.warn - tests: module-mock harnesses removed (AgentsSidebar, SettingsView mobile focus — behaviors stay live but uncovered, accepted trade), QuestionMarkdown asserts rendered DOM - i18n: German gains the debug-panel request keys, Japanese/German drop removed worktree keys, Ukrainian unit spacing fixed - changelog: Copilot AI Credits entries (main + VS Code)
This commit is contained in:
@@ -36,7 +36,7 @@ import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session
|
||||
import { BtwPanel } from './btw/BtwPanel';
|
||||
import { useBtwPanelState } from './btw/useBtwPanelState';
|
||||
import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
import { BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import { buildBtwSyntheticTexts, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
@@ -1134,12 +1134,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null,
|
||||
composerAttachments: attachedFiles,
|
||||
inlineComments: drafts,
|
||||
// btw mode: the boundary rides with every send, not just the
|
||||
// first one, so the inherited transcript stays reference material
|
||||
// for the whole side conversation.
|
||||
syntheticTexts: [
|
||||
...(isBtwActive ? [BTW_BOUNDARY_INSTRUCTION] : []),
|
||||
...(isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : []),
|
||||
...buildBtwSyntheticTexts({ isBtwActive, isPromotedBtwSession }),
|
||||
...(syntheticParts?.map((part) => part.text) ?? []),
|
||||
],
|
||||
linkedIssue: linkedIssue
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
|
||||
import { QuestionMarkdown } from './QuestionMarkdown';
|
||||
|
||||
// The markdown renderer is lazy, so a synchronous server render always emits the
|
||||
// Suspense fallback QuestionMarkdown supplies. That fallback is the surface that
|
||||
// has to keep the exact question text and the question typography classes.
|
||||
describe('QuestionMarkdown', () => {
|
||||
test('delegates exact content to the tool markdown renderer', () => {
|
||||
test('renders the question content verbatim', () => {
|
||||
const content = 'Choose **one** from `mode`: [details](https://example.com)';
|
||||
const element = QuestionMarkdown({ content, size: 'meta' });
|
||||
|
||||
expect(element.type).toBe(SimpleMarkdownRenderer);
|
||||
expect(element.props.content).toBe(content);
|
||||
expect(element.props.variant).toBe('tool');
|
||||
expect(element.props.fallbackContent.props.children).toBe(content);
|
||||
expect(element.props.fallbackContent.props.className).toContain('whitespace-pre-wrap');
|
||||
const html = renderToStaticMarkup(<QuestionMarkdown content={content} size="meta" />);
|
||||
|
||||
expect(html).toBe(
|
||||
`<div class="question-markdown typography-meta whitespace-pre-wrap">${content}</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves question typography size and caller classes', () => {
|
||||
const meta = QuestionMarkdown({ content: 'Meta', size: 'meta', className: 'font-medium text-foreground' });
|
||||
const micro = QuestionMarkdown({ content: 'Micro', size: 'micro', className: 'text-muted-foreground' });
|
||||
test('applies meta typography and caller classes', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QuestionMarkdown content="Meta" size="meta" className="font-medium text-foreground" />,
|
||||
);
|
||||
|
||||
expect(meta.props.className).toBe('question-markdown typography-meta font-medium text-foreground');
|
||||
expect(micro.props.className).toBe('question-markdown typography-micro text-muted-foreground');
|
||||
expect(html).toContain('class="question-markdown typography-meta font-medium text-foreground whitespace-pre-wrap"');
|
||||
});
|
||||
|
||||
test('applies micro typography and caller classes', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QuestionMarkdown content="Micro" size="micro" className="text-muted-foreground" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('class="question-markdown typography-micro text-muted-foreground whitespace-pre-wrap"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
|
||||
mentionAgent: 'text-[var(--status-success)]',
|
||||
mentionCommand: 'text-[var(--primary)]',
|
||||
mentionSnippet: 'text-[var(--status-warning)]',
|
||||
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)] px-[0.3125rem] py-0.5',
|
||||
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
|
||||
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
|
||||
// A `~path` is written for the reader's benefit, not to attach anything —
|
||||
// it takes the same colour as a file mention, since it names the same kind
|
||||
|
||||
@@ -46,9 +46,9 @@ import {
|
||||
buildTaskSummaryEntriesFromSession,
|
||||
normalizeTaskSummaryEntries,
|
||||
parseTaskMetadataBlock,
|
||||
prepareTaskToolOutput,
|
||||
readTaskSessionIdFromOutput,
|
||||
readTaskSessionIdFromRecord,
|
||||
stripTaskMetadataFromOutput,
|
||||
type TaskToolSummaryEntry,
|
||||
} from './taskToolModel';
|
||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||
@@ -1004,9 +1004,7 @@ const TaskToolSummary: React.FC<{
|
||||
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
|
||||
const trimmedOutput = typeof output === 'string'
|
||||
? stripTaskMetadataFromOutput(output)
|
||||
: '';
|
||||
const trimmedOutput = prepareTaskToolOutput(output);
|
||||
const hasOutput = trimmedOutput.length > 0;
|
||||
const [isOutputExpanded, setIsOutputExpanded] = React.useState(false);
|
||||
|
||||
@@ -2037,6 +2035,9 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
};
|
||||
|
||||
const handleMainKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
// Nested buttons (quick-open, copy) handle their own Enter/Space; the row
|
||||
// must not swallow the key and toggle instead.
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
@@ -2092,13 +2093,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
openQuickTarget();
|
||||
};
|
||||
|
||||
const handleQuickOpenKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openQuickTarget();
|
||||
};
|
||||
|
||||
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
|
||||
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
|
||||
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
|
||||
@@ -2192,7 +2186,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div className={cn('flex items-center min-w-0 flex-1', quickOpenTarget ? 'gap-1' : 'gap-2')}>
|
||||
<MinDurationShineText
|
||||
active={Boolean(isActive && !isError)}
|
||||
minDurationMs={300}
|
||||
@@ -2206,10 +2200,11 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickOpen}
|
||||
onKeyDown={handleQuickOpenKeyDown}
|
||||
className={cn(
|
||||
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
|
||||
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100',
|
||||
// Coarse pointers never hover, so the icon has to rest visible
|
||||
// there or it stays invisible while remaining tappable.
|
||||
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-60',
|
||||
)}
|
||||
style={{ color: 'var(--tools-icon)' }}
|
||||
title={t('chat.toolPart.openFile')}
|
||||
|
||||
@@ -4,9 +4,11 @@ import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
buildTaskSummaryEntriesFromSession,
|
||||
parseTaskMetadataBlock,
|
||||
prepareTaskToolOutput,
|
||||
readTaskSessionIdFromRecord,
|
||||
readTaskSessionIdFromOutput,
|
||||
} from './taskToolModel';
|
||||
import { TOOL_OUTPUT_MAX_CHARS } from '../toolRenderers';
|
||||
|
||||
describe('taskToolModel', () => {
|
||||
test('reads the current OpenCode running-state identity contract', () => {
|
||||
@@ -39,4 +41,19 @@ describe('taskToolModel', () => {
|
||||
state: { status: 'completed', title: undefined, input: { filePath: 'a.ts' } },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('strips task metadata and caps oversized task output before markdown rendering', () => {
|
||||
const oversized = 'x'.repeat(TOOL_OUTPUT_MAX_CHARS + 5_000);
|
||||
const output = `${oversized}\n<task_metadata>{"sessionID":"child-1"}</task_metadata>`;
|
||||
const prepared = prepareTaskToolOutput(output);
|
||||
|
||||
expect(prepared.length).toBeLessThan(oversized.length);
|
||||
expect(prepared).toContain('output truncated');
|
||||
expect(prepared).not.toContain('task_metadata');
|
||||
});
|
||||
|
||||
test('leaves normal task output untouched', () => {
|
||||
expect(prepareTaskToolOutput('done\n<task_metadata>{"sessionID":"child-1"}</task_metadata>')).toBe('done');
|
||||
expect(prepareTaskToolOutput(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MessageRecord } from '@/lib/messageCompletion';
|
||||
|
||||
import { capToolOutputText } from '../toolRenderers';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
|
||||
export type TaskToolSummaryEntry = {
|
||||
@@ -131,3 +132,12 @@ export const buildTaskSummaryEntriesFromSession = (messages: MessageRecord[]): T
|
||||
export const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
||||
};
|
||||
|
||||
// The task tool renders its output through the markdown parser instead of the
|
||||
// shared tool-output path, so it needs the same size guard as
|
||||
// `getToolOutputText` (issue #2265): an unbounded single string reaching the
|
||||
// parser can exhaust V8's Zone allocator and crash the renderer.
|
||||
export const prepareTaskToolOutput = (output: string | undefined): string => {
|
||||
if (!output) return '';
|
||||
return capToolOutputText(stripTaskMetadataFromOutput(output));
|
||||
};
|
||||
|
||||
@@ -2,8 +2,18 @@ import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { buildChildrenIndex, computeSubtreeCost, formatCost } from './subagentCost';
|
||||
|
||||
function makeSession(id: string, cost: number, parentID?: string): Session {
|
||||
return { id, cost, parentID } as unknown as Session;
|
||||
function makeSession(id: string, cost: number | undefined, parentID?: string): Session {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
directory: '/project',
|
||||
title: id,
|
||||
version: '1',
|
||||
time: { created: 0, updated: 0 },
|
||||
cost,
|
||||
parentID,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildChildrenIndex', () => {
|
||||
@@ -57,7 +67,7 @@ describe('computeSubtreeCost', () => {
|
||||
|
||||
test('treats zero and undefined cost as zero, not a break', () => {
|
||||
const root = makeSession('root', 0);
|
||||
const child = { id: 'child', parentID: 'root' } as unknown as Session;
|
||||
const child = makeSession('child', undefined, 'root');
|
||||
const sessions = [root, child];
|
||||
const sessionsById = new Map(sessions.map((s) => [s.id, s]));
|
||||
const childrenByParent = buildChildrenIndex(sessions);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4
|
||||
export function buildChildrenIndex(sessions: Session[]): Map<string, Session[]> {
|
||||
const index = new Map<string, Session[]>();
|
||||
for (const session of sessions) {
|
||||
const parentID = (session as unknown as { parentID?: string }).parentID;
|
||||
const parentID = session.parentID;
|
||||
if (!parentID) continue;
|
||||
const existing = index.get(parentID);
|
||||
if (existing) {
|
||||
@@ -30,8 +30,7 @@ export function buildChildrenIndex(sessions: Session[]): Map<string, Session[]>
|
||||
}
|
||||
|
||||
function sessionCost(session: Session | undefined): number {
|
||||
const cost = (session as unknown as { cost?: number } | undefined)?.cost;
|
||||
return typeof cost === 'number' ? cost : 0;
|
||||
return session?.cost ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,17 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { computeRollup } from './useSubagentCostRollup';
|
||||
|
||||
function makeSession(id: string, cost: number, parentID?: string): Session {
|
||||
return { id, cost, parentID } as unknown as Session;
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
directory: '/project',
|
||||
title: id,
|
||||
version: '1',
|
||||
time: { created: 0, updated: 0 },
|
||||
cost,
|
||||
parentID,
|
||||
};
|
||||
}
|
||||
|
||||
const sessions: Session[] = [
|
||||
|
||||
Reference in New Issue
Block a user