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 @@ -107,7 +107,7 @@ function makeAssistantMessage(parts: Part[], id = 'msg-1'): StoredMessage {
describe('getChildSessionCardState', () => {
it.each([
['pending', 'Waiting for activity'],
['running', 'Waiting for activity'],
['running', 'Thinking'],
['completed', ''],
['error', ''],
] as const)(
Expand Down Expand Up @@ -324,6 +324,18 @@ describe('getChildSessionCardState', () => {
});
});

it('reads Thinking while a reasoning part streams behind an empty text placeholder', () => {
const part = makeTaskPart('running', { subagent_type: 'Thinker', description: 'Reason' });
const messages = [
makeAssistantMessage([makeReasoningPart('stepping through the problem'), makeTextPart('')]),
];
expect(getChildSessionCardState(part, messages)).toEqual({
agentName: 'Thinker',
taskName: 'Reason',
latestActivity: 'Thinking',
});
});

it('prefers a newer text part over an older completed tool part', () => {
const part = makeTaskPart('running', { subagent_type: 'Agent', description: 'Work' });
const olderTool = makeToolPart('read', {
Expand Down
36 changes: 20 additions & 16 deletions apps/mobile/src/components/agents/child-session-card-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {

import { i18n } from '@/i18n';

import { computeStatus } from './compute-status';
import { computeStatus, lastActivePart } from './compute-status';
import { isToolPart } from './part-types';
import { getFilename, truncateText } from './tool-card-utils';

Expand Down Expand Up @@ -64,16 +64,11 @@ function getToolContext(p: ToolPart): string | undefined {
return undefined;
}

function findLatestAssistantPart(messages: StoredMessage[]): Part | undefined {
function findLatestAssistantParts(messages: StoredMessage[]): readonly Part[] | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i];
if (msg?.info.role === 'assistant') {
for (let j = msg.parts.length - 1; j >= 0; j -= 1) {
const part = msg.parts[j];
if (part) {
return part;
}
}
if (msg?.info.role === 'assistant' && msg.parts.length > 0) {
return msg.parts;
}
}
return undefined;
Expand All @@ -95,14 +90,23 @@ export function getChildSessionCardState(
if (part.state.status === 'completed' || part.state.status === 'error') {
return '';
}
const latestPart = findLatestAssistantPart(childMessages);
if (!latestPart) {
return i18n.t('agentChat.childSession.waitingForActivity');
}
if (isToolPart(latestPart)) {
return { tool: latestPart.tool, context: getToolContext(latestPart) };
const assistantParts = findLatestAssistantParts(childMessages);
if (assistantParts) {
const latestPart = lastActivePart(assistantParts);
if (latestPart) {
if (isToolPart(latestPart)) {
return { tool: latestPart.tool, context: getToolContext(latestPart) };
}
return computeStatus(latestPart);
}
}
return computeStatus(latestPart);
// A running subagent without a loaded child transcript is still working, so
// the card shows the same "Thinking" label the composer spinner uses while
// it streams reasoning. A pending task has no child session yet; it is
// queued and genuinely waiting to start.
return part.state.status === 'running'
? i18n.t('agentChat.partDetail.thinking')
: i18n.t('agentChat.childSession.waitingForActivity');
})();

return { agentName, taskName, latestActivity };
Expand Down
25 changes: 22 additions & 3 deletions apps/mobile/src/components/agents/child-session-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { getChildSessionModelLabel } from './child-session-model';
import { ChildSessionModelLabel } from './child-session-model-label';
import { MessageErrorBoundary } from './message-error-boundary';
import { partRendersContent } from './message-visibility';
import { PartDetailSheetHost } from './part-detail-sheet-host';
import { getChildSessionSheetState } from './child-session-sheet-state';
import { SessionMessageList } from './session-message-list';
Expand All @@ -34,6 +35,13 @@ type ChildSessionSheetProps = {
sessionId: string;
title: string;
getChildMessages: (sessionId: string) => StoredMessage[];
/**
* Resolves the messages that derive status indicators: the footer
* working-indicator label and the nested task cards' activity label.
* Defaults to `getChildMessages`. The session page passes the raw transcript
* here so hiding thinking rows never changes "Thinking".
*/
getIndicatorMessages?: (sessionId: string) => StoredMessage[];
hydrationState: ChildSessionHydrationState;
sessionError: string | null;
isStreaming: boolean;
Expand All @@ -56,6 +64,7 @@ export function ChildSessionSheet({
sessionId,
title,
getChildMessages,
getIndicatorMessages = getChildMessages,
hydrationState,
sessionError,
isStreaming,
Expand All @@ -72,6 +81,11 @@ export function ChildSessionSheet({
modelOptions,
}: Readonly<ChildSessionSheetProps>) {
const messages = getChildMessages(sessionId);
const indicatorMessages = getIndicatorMessages(sessionId);
// A reasoning-only message keeps its place in `messages` so the sheet stays in
// the content state and the footer spinner reads "Thinking", but it renders no
// row. Drop it from the list so its padded wrapper cannot leave an empty row.
const rowMessages = messages.filter(message => message.parts.some(partRendersContent));
const state = getChildSessionSheetState(hydrationState, messages.length, sessionError);
const modelLabel = getChildSessionModelLabel(messages, modelOptions ?? []);
const { t } = useTranslation();
Expand Down Expand Up @@ -118,7 +132,7 @@ export function ChildSessionSheet({
) : null}
<SessionMessageList
sessionId={sessionId}
items={messages}
items={rowMessages}
keyExtractor={message => message.info.id}
hasOlderMessages={hasOlderMessages}
isLoadingOlderMessages={isLoadingOlderMessages}
Expand All @@ -131,15 +145,20 @@ export function ChildSessionSheet({
<ChildSessionMessage
message={item}
depth={0}
getChildMessages={getChildMessages}
// Nested task cards are status indicators too: resolve their
// activity from the raw list so a reasoning stream reads
// "Thinking" instead of a stale activity.
getChildMessages={getIndicatorMessages}
renderPart={renderPart}
onOpenChildSession={onOpenChildSession}
modelOptions={modelOptions}
/>
</View>
</MessageErrorBoundary>
)}
ListFooterComponent={<WorkingIndicator messages={messages} isStreaming={isStreaming} />}
ListFooterComponent={
<WorkingIndicator messages={indicatorMessages} isStreaming={isStreaming} />
}
contentBottomInset={sheetBottomInset}
/>
</View>
Expand Down
21 changes: 20 additions & 1 deletion apps/mobile/src/components/agents/compute-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type ReasoningPart, type TextPart, type ToolPart } from '@kilocode/cloud-agent-sdk';
import { describe, expect, it } from 'vitest';

import { computeStatus, SNAPSHOT_PROGRESS_STATUS } from './compute-status';
import { computeMessageStatus, computeStatus, SNAPSHOT_PROGRESS_STATUS } from './compute-status';

function makeTextPart(text: string, synthetic?: boolean): TextPart {
const part: TextPart = {
Expand Down Expand Up @@ -69,3 +69,22 @@ describe('computeStatus', () => {
expect(computeStatus(makeToolPart('unknown-tool'))).toBe('Considering next steps');
});
});

describe('computeMessageStatus', () => {
it('reads Thinking while the reasoning part streams behind an empty text placeholder', () => {
// opencode creates the response text part before any token arrives; its id
// sorts after the reasoning part, which used to mask the Thinking label.
expect(computeMessageStatus([makeReasoningPart(), makeTextPart('')])).toBe('Thinking');
});

it('reads Writing response once the text part has content', () => {
expect(computeMessageStatus([makeReasoningPart(), makeTextPart('Hello')])).toBe(
'Writing response'
);
});

it('falls back to Considering next steps when only empty placeholders exist', () => {
expect(computeMessageStatus([makeTextPart('')])).toBe('Considering next steps');
expect(computeMessageStatus([])).toBe('Considering next steps');
});
});
24 changes: 24 additions & 0 deletions apps/mobile/src/components/agents/compute-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,27 @@ export function computeStatus(part: Part): string {
}
return i18n.t('agentChat.computeStatus.consideringNextSteps');
}

/**
* opencode pre-creates an empty `text` part for the response block before any
* token arrives, and its id sorts after the reasoning part (a stream looks
* like `[step-start, reasoning, text(0)]`). Reading the raw last part would
* therefore label the whole reasoning stream "Writing response", and the
* spinner would never read "Thinking". Skip empty text placeholders and
* describe the last part that actually carries activity.
*/
export function lastActivePart(parts: readonly Part[]): Part | undefined {
for (let i = parts.length - 1; i >= 0; i -= 1) {
const part = parts[i];
if (part !== undefined && !(part.type === 'text' && part.text === '')) {
return part;
}
}
return undefined;
}

/** Spinner label for an assistant message's parts. */
export function computeMessageStatus(parts: readonly Part[]): string {
const part = lastActivePart(parts);
return part ? computeStatus(part) : i18n.t('agentChat.computeStatus.consideringNextSteps');
}
61 changes: 61 additions & 0 deletions apps/mobile/src/components/agents/part-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import {
type FilePart,
type PatchPart,
type ReasoningPart,
type StoredMessage,
type TextPart,
type ToolPart,
} from '@kilocode/cloud-agent-sdk';
import { describe, expect, it } from 'vitest';

import { assistantMessage } from './message-bubble-test-utils';
import {
isPartStreaming,
isPatchPart,
isSnapshotProgressPart,
shouldRenderReasoningPart,
withoutReasoningParts,
} from './part-types';

function makeReasoningPart(text: string, ended = true): ReasoningPart {
Expand Down Expand Up @@ -39,6 +43,29 @@ function makeTextPart(text: string, synthetic?: boolean): TextPart {
return part;
}

function makeToolPart(): ToolPart {
return {
id: 'tool-1',
sessionID: 's1',
messageID: 'm1',
type: 'tool',
tool: 'read',
callID: 'call-1',
state: {
status: 'completed',
input: { filePath: 'src/a.ts' },
output: 'contents',
title: 'Read',
metadata: {},
time: { start: 1, end: 2 },
},
};
}

function storedMessage(id: string, parts: StoredMessage['parts']): StoredMessage {
return { info: assistantMessage(id).info, parts };
}

describe('isSnapshotProgressPart', () => {
it('is true for a synthetic text part whose text includes Initializing snapshot', () => {
const part = makeTextPart('⠋ Initializing snapshot…', true);
Expand Down Expand Up @@ -146,3 +173,37 @@ describe('shouldRenderReasoningPart', () => {
expect(shouldRenderReasoningPart(part, false)).toBe(false);
});
});

describe('withoutReasoningParts', () => {
it('removes reasoning while keeping text and tool parts', () => {
const text = makeTextPart('answer');
const tool = makeToolPart();
const message = storedMessage('m1', [makeReasoningPart('thinking'), text, tool]);

const result = withoutReasoningParts([message]);

expect(result[0]?.parts).toEqual([text, tool]);
expect(result[0]?.parts.some(part => part.type === 'reasoning')).toBe(false);
});

it('returns the same array reference when no message has reasoning', () => {
const messages: StoredMessage[] = [
storedMessage('m1', [makeTextPart('a')]),
storedMessage('m2', [makeToolPart()]),
];

expect(withoutReasoningParts(messages)).toBe(messages);
});

it('keeps message identity for unchanged messages', () => {
const unchanged = storedMessage('m1', [makeTextPart('a')]);
const changed = storedMessage('m2', [makeReasoningPart('thinking'), makeTextPart('b')]);

const result = withoutReasoningParts([unchanged, changed]);

expect(result[0]).toBe(unchanged);
expect(result[1]).not.toBe(changed);
expect(result[1]?.info).toBe(changed.info);
expect(result[1]?.parts.map(part => part.type)).toEqual(['text']);
});
});
21 changes: 21 additions & 0 deletions apps/mobile/src/components/agents/part-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type Part,
type PatchPart,
type ReasoningPart,
type StoredMessage,
type TextPart,
type ToolPart,
} from '@kilocode/cloud-agent-sdk';
Expand Down Expand Up @@ -43,6 +44,26 @@ export function isReasoningPart(part: Part): part is ReasoningPart {
return part.type === 'reasoning';
}

/**
* Returns the messages with every reasoning part removed, for the
* "Hide thinking details" option. A message that has no reasoning part keeps
* its identity, and the input array itself is returned when nothing changed,
* so memoized consumers do not churn when thinking is already absent.
*/
export function withoutReasoningParts(messages: readonly StoredMessage[]): StoredMessage[] {
const next = messages.map(message => {
const parts = message.parts.filter(part => !isReasoningPart(part));
if (parts.length === message.parts.length) {
return message;
}
return { ...message, parts };
});
const changed = next.some((message, index) => message !== messages[index]);
// Hand back the input array itself when nothing was removed. Callers only
// read the result, so widening the readonly view is safe.
return changed ? next : (messages as StoredMessage[]);
}

export function isCompactionPart(part: Part): part is CompactionPart {
return part.type === 'compaction';
}
Expand Down
Loading