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
198 changes: 193 additions & 5 deletions apps/mobile/src/screens/session-transcript.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,35 +277,107 @@ test("renders an unfinished code fence while assistant text streams", () => {
expect(screen.queryByText(/```/)).toBeNull();
});

test("renders short reasoning inline with bold markdown", () => {
test("renders finished short reasoning inline with bold markdown", () => {
render(
<SessionTranscriptRow
message={{
agent: "build",
content: [{ text: "**Adding mocks to repository tests**", type: "reasoning" }],
content: [
{
text: "**Adding mocks to repository tests**",
time: { completed: 2, created: 1 },
type: "reasoning",
},
],
id: "msg_inline_reasoning",
model: { id: "model-1", providerID: "provider" },
time: { created: 1 },
time: { completed: 2, created: 1 },
type: "assistant",
}}
/>,
);

expect(screen.getByText("THOUGHT")).toBeOnTheScreen();
expect(screen.queryByText("THINKING")).toBeNull();
expect(screen.getByText("Adding mocks to repository tests")).toHaveStyle({ fontWeight: "800" });
expect(screen.queryByText(/\*\*/)).toBeNull();
expect(screen.queryByRole("button", { name: /Thought/ })).toBeNull();
});

test("labels reasoning as thinking until the part completes", () => {
const { rerender } = render(
<SessionTranscriptRow
message={{
agent: "build",
content: [{ text: "Checking the failing test", time: { created: 1 }, type: "reasoning" }],
id: "msg_streaming_reasoning",
model: { id: "model-1", providerID: "provider" },
time: { created: 1 },
type: "assistant",
}}
/>,
);

expect(screen.getByText("THINKING")).toBeOnTheScreen();
expect(screen.queryByText("THOUGHT")).toBeNull();

rerender(
<SessionTranscriptRow
message={{
agent: "build",
content: [
{
text: "Checking the failing test",
time: { completed: 2, created: 1 },
type: "reasoning",
},
],
id: "msg_streaming_reasoning",
model: { id: "model-1", providerID: "provider" },
time: { completed: 2, created: 1 },
type: "assistant",
}}
/>,
);

expect(screen.getByText("THOUGHT")).toBeOnTheScreen();
expect(screen.queryByText("THINKING")).toBeNull();
});

test("settles reasoning when the message completes without a part completion time", () => {
render(
<SessionTranscriptRow
message={{
agent: "build",
content: [{ text: "First step\nSecond step", time: { created: 1 }, type: "reasoning" }],
id: "msg_settled_message_reasoning",
model: { id: "model-1", providerID: "provider" },
time: { completed: 2, created: 1 },
type: "assistant",
}}
/>,
);

expect(screen.queryByRole("button", { name: /Thinking/ })).toBeNull();
fireEvent.press(screen.getByRole("button", { name: /Thought/ }));
expect(screen.getByText("First step\nSecond step")).toBeOnTheScreen();
});

test("keeps multiline reasoning in a disclosure", () => {
render(
<SessionTranscriptRow
message={{
agent: "build",
content: [{ text: "First step\nSecond step", type: "reasoning" }],
content: [
{
text: "First step\nSecond step",
time: { completed: 2, created: 1 },
type: "reasoning",
},
],
id: "msg_multiline_reasoning",
model: { id: "model-1", providerID: "provider" },
time: { created: 1 },
time: { completed: 2, created: 1 },
type: "assistant",
}}
/>,
Expand All @@ -316,6 +388,122 @@ test("keeps multiline reasoning in a disclosure", () => {
expect(screen.getByText("First step\nSecond step")).toBeOnTheScreen();
});

test("labels an exploration group as exploring while a search is in flight", () => {
const { rerender } = render(
<SessionTranscriptRow
message={{
agent: "build",
content: [
{
id: "tool-read",
name: "read",
state: {
content: [{ text: "source", type: "text" }],
input: { path: "src/a.ts" },
status: "completed",
},
time: { completed: 1_400, created: 1_100 },
type: "tool",
},
{
id: "tool-grep",
name: "grep",
state: { input: { pattern: "TODO" }, metadata: {}, status: "running" },
time: { created: 1_500 },
type: "tool",
},
],
id: "msg_exploring",
model: { id: "model-1", providerID: "provider" },
time: { created: 1_000 },
type: "assistant",
}}
/>,
);

expect(screen.getByText("Exploring")).toBeOnTheScreen();
expect(screen.getByText("2 searches · Running")).toBeOnTheScreen();
expect(screen.queryByText("Explored")).toBeNull();

rerender(
<SessionTranscriptRow
message={{
agent: "build",
content: [
{
id: "tool-read",
name: "read",
state: {
content: [{ text: "source", type: "text" }],
input: { path: "src/a.ts" },
status: "completed",
},
time: { completed: 1_400, created: 1_100 },
type: "tool",
},
{
id: "tool-grep",
name: "grep",
state: {
content: [{ text: "match", type: "text" }],
input: { pattern: "TODO" },
status: "completed",
},
time: { completed: 1_700, created: 1_500 },
type: "tool",
},
],
id: "msg_exploring",
model: { id: "model-1", providerID: "provider" },
time: { created: 1_000 },
type: "assistant",
}}
/>,
);

expect(screen.getByText("Explored")).toBeOnTheScreen();
expect(screen.getByText("2 searches")).toBeOnTheScreen();
expect(screen.queryByText("Exploring")).toBeNull();
});

test("labels a shell message as running until it exits", () => {
const { rerender } = render(
<SessionTranscriptRow
message={{
command: "pnpm test",
id: "msg_shell_running",
output: { cursor: 1, output: "shell output", size: 2, truncated: false },
shellID: "shell-2",
status: "running",
time: { created: 8 },
type: "shell",
}}
/>,
);

expect(screen.getByText("Running")).toBeOnTheScreen();
expect(screen.getByText("pnpm test · Running")).toBeOnTheScreen();
expect(screen.queryByText("Ran")).toBeNull();

rerender(
<SessionTranscriptRow
message={{
command: "pnpm test",
id: "msg_shell_running",
output: { cursor: 2, output: "shell output", size: 2, truncated: false },
shellID: "shell-2",
status: "exited",
time: { created: 8 },
type: "shell",
}}
/>,
);

expect(screen.getByText("Ran")).toBeOnTheScreen();
expect(screen.getByText("pnpm test")).toBeOnTheScreen();
expect(screen.queryByText("Running")).toBeNull();
});

test("groups completed assistant activity and places narrative metadata in the footer", () => {
const openDiff = jest.fn();
render(
Expand Down
20 changes: 16 additions & 4 deletions apps/mobile/src/screens/session-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,19 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({
);
}
if (part.type === "reasoning") {
const settled = isSettledReasoning(message, part);
return isInlineReasoning(part.text) ? (
<InlineMarkdownText
key={key}
prefix="THOUGHT"
prefix={settled ? "THOUGHT" : "THINKING"}
prefixStyle={styles.reasoningLabel}
style={styles.reasoningText}
text={part.text}
/>
) : (
<Disclosure
key={key}
label="Thought"
label={settled ? "Thought" : "Thinking"}
largeText={largeText}
markdown
text={part.text}
Expand Down Expand Up @@ -310,7 +311,7 @@ function ExplorationDisclosure({
canExpand
detail={detail}
expanded={expanded}
label="Explored"
label={isActivityInFlight(tools) ? "Exploring" : "Explored"}
largeText={largeText}
onPress={() => setExpanded((current) => !current)}
/>
Expand Down Expand Up @@ -538,7 +539,7 @@ function ShellDisclosure({ largeText, message }: { largeText: boolean; message:
canExpand={canExpand}
detail={detail}
expanded={expanded}
label={message.status === "exited" ? "Ran" : "Shell"}
label={message.status === "running" ? "Running" : "Ran"}
largeText={largeText}
onPress={() => setExpanded((current) => !current)}
/>
Expand Down Expand Up @@ -1206,6 +1207,10 @@ function activityGroupStatus(tools: AssistantTool[]) {
return undefined;
}

function isActivityInFlight(tools: AssistantTool[]) {
return tools.some((tool) => tool.state.status === "running" || tool.state.status === "streaming");
}

function shellStatusLabel(message: ShellMessage) {
if (message.status === "running") return "Running";
if (message.status === "timeout") return "Timed out";
Expand All @@ -1230,6 +1235,13 @@ function isInlineReasoning(text: string) {
return text.length <= maxInlineReasoning && !/[\r\n]/.test(text);
}

function isSettledReasoning(
message: AssistantMessage,
part: Extract<AssistantPart, { type: "reasoning" }>,
) {
return part.time?.completed !== undefined || message.time.completed !== undefined;
}

function basename(path: string) {
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? "Location";
}
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/screens/workspace-screen.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,16 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({
agent: "build",
content: [
{ text: "Newest answer", type: "text" },
{ text: "Private reasoning", type: "reasoning" },
{ text: "Detailed reasoning\nSecond step", type: "reasoning" },
{
text: "Private reasoning",
time: { completed: 4, created: 3 },
type: "reasoning",
},
{
text: "Detailed reasoning\nSecond step",
time: { completed: 5, created: 3 },
type: "reasoning",
},
],
id: "msg_assistant",
model: { id: "model-1", providerID: "provider" },
Expand Down