Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 4 additions & 2 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,9 @@ export function ThreadWorkLog(props: {
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const viewedImagePath = workEntryViewedImagePath(row.workEntry);
const displayText = row.detail ?? row.summary;
const previewText = row.detail ?? row.summary;
const displayText =
expanded && !row.live && row.workEntry.command?.trim() ? "Command" : previewText;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
const failed = row.status === "failure";
const showIcon = !row.groupedToolDetail || iconIsDestructive || failed;
Expand All @@ -352,7 +354,7 @@ export function ThreadWorkLog(props: {
>
<Pressable
accessibilityRole={canExpand ? "button" : undefined}
accessibilityLabel={failed ? `${displayText}, tool call failed` : displayText}
accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText}
accessibilityHint={
canExpand
? "Double tap to show full details. Long press to copy."
Expand Down
216 changes: 216 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,222 @@ function makeThread(
}

describe("buildThreadFeed", () => {
it("keeps long Claude commands expandable without repeating them in full detail", () => {
const command = `printf 'first line\nsecond line'\n&& printf done`;
const thread = makeThread({
id: ThreadId.make("thread-long-command"),
projectId: ProjectId.make("project-1"),
title: "Long command",
activities: [
makeActivity({
id: EventId.make("long-command"),
kind: "tool.completed",
tone: "tool",
summary: "Command run",
createdAt: "2026-09-01T00:00:00.000Z",
payload: {
itemType: "command_execution",
title: "Command run",
detail: `Bash: ${command}`,
data: { toolName: "Bash", command },
},
}),
],
});

const [group] = buildThreadFeed(thread);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
const [row] = group.activities;
expect(row).toMatchObject({ detail: command, canExpand: true });
expect(row?.getFullDetail()).toBe(command);
expect(row?.getCopyText()).toBe(`Command run\n${command}`);
});

it("keeps command output when it equals the displayed command", () => {
const command = "printf hello";
const thread = makeThread({
id: ThreadId.make("thread-matching-command-output"),
projectId: ProjectId.make("project-1"),
title: "Matching output",
activities: [
makeActivity({
id: EventId.make("matching-command-output"),
kind: "tool.completed",
tone: "tool",
summary: "Command run",
createdAt: "2026-09-01T00:00:00.000Z",
payload: {
itemType: "command_execution",
title: "Command run",
detail: `Bash: ${command}`,
data: { toolName: "Bash", command, rawOutput: { content: command } },
},
}),
],
});

const [group] = buildThreadFeed(thread);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
const [row] = group.activities;
expect(row?.detail).toBe(command);
expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
expect(row?.getCopyText()).toBe(`Command run\n${command}\n\n${command}`);
});

it("keeps OpenCode detail-only output when it equals the command", () => {
const command = "printf hello";
const thread = makeThread({
id: ThreadId.make("thread-opencode-detail-output"),
projectId: ProjectId.make("project-1"),
title: "OpenCode detail output",
activities: [
makeActivity({
id: EventId.make("opencode-detail-output"),
kind: "tool.completed",
tone: "tool",
summary: "bash",
createdAt: "2026-09-01T00:00:00.000Z",
payload: {
itemType: "command_execution",
title: "bash",
detail: command,
data: { command },
},
}),
],
});

const [group] = buildThreadFeed(thread);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
const [row] = group.activities;
expect(row?.workEntry.detail).toBe(command);
expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
});

it("drops a truncated Claude echo of a long command", () => {
const command = `git add -A && git commit -m "${"x".repeat(200)}"`;
const thread = makeThread({
id: ThreadId.make("thread-truncated-echo"),
projectId: ProjectId.make("project-1"),
title: "Truncated echo",
activities: [
makeActivity({
id: EventId.make("truncated-echo"),
kind: "tool.completed",
tone: "tool",
summary: "Command run",
createdAt: "2026-09-01T00:00:00.000Z",
payload: {
itemType: "command_execution",
title: "Command run",
detail: `Bash: ${command}`.slice(0, 177) + "...",
data: { toolName: "Bash", command },
},
}),
],
});

const [group] = buildThreadFeed(thread);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
const [row] = group.activities;
expect(row?.workEntry.detail).toBeUndefined();
expect(row?.getFullDetail()).toBe(command);
});

it("drops an ACP command echo when the update omits the tool kind", () => {
const command = "pnpm test";
const thread = makeThread({
id: ThreadId.make("thread-acp-no-kind"),
projectId: ProjectId.make("project-1"),
title: "ACP no kind",
activities: [
makeActivity({
id: EventId.make("acp-no-kind"),
kind: "tool.completed",
tone: "tool",
summary: "Terminal",
createdAt: "2026-09-01T00:00:00.000Z",
payload: {
itemType: "command_execution",
title: "Terminal",
detail: command,
data: { toolCallId: "tool-1", command },
},
}),
],
});

const [group] = buildThreadFeed(thread);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
const [row] = group.activities;
expect(row?.workEntry.detail).toBeUndefined();
expect(row?.getFullDetail()).toBe(command);
});

it("drops ACP command metadata when detail only repeats the command", () => {
const command = "printf hello";
const thread = makeThread({
id: ThreadId.make("thread-acp-command-detail"),
projectId: ProjectId.make("project-1"),
title: "ACP command detail",
activities: [
makeActivity({
id: EventId.make("acp-command-detail"),
kind: "tool.completed",
tone: "tool",
summary: "Terminal",
createdAt: "2026-09-01T00:00:00.000Z",
payload: {
itemType: "command_execution",
title: "Terminal",
detail: command,
data: { kind: "execute", command },
},
}),
],
});

const [group] = buildThreadFeed(thread);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
const [row] = group.activities;
expect(row?.workEntry.detail).toBeUndefined();
expect(row?.getFullDetail()).toBe(command);
});

it("does not show command output when the command input is missing", () => {
const thread = makeThread({
id: ThreadId.make("thread-command-without-input"),
projectId: ProjectId.make("project-1"),
title: "Missing command input",
activities: [
makeActivity({
id: EventId.make("command-without-input"),
kind: "tool.completed",
tone: "tool",
summary: "Command run",
createdAt: "2026-09-01T00:00:00.000Z",
payload: {
itemType: "command_execution",
title: "Command run",
data: { rawOutput: { content: "output without command metadata" } },
},
}),
],
});

const [group] = buildThreadFeed(thread);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
expect(group.activities[0]?.detail).toBeNull();
expect(group.activities[0]?.getFullDetail()).toBeNull();
});

it("keeps setup failures visible without routine setup notices before or after a turn", () => {
const thread = makeThread({
id: ThreadId.make("thread-worktree-setup"),
Expand Down
57 changes: 36 additions & 21 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import type {
} from "@t3tools/contracts";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import {
commandDetailRepeatsCommand,
extractCommandOutputText,
isWorktreeSetupActivity,
normalizeCompactToolLabel,
omitSupersededLifecycleMarkers,
Expand Down Expand Up @@ -438,16 +440,23 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
}
const itemType = extractWorkLogItemType(payload);
const requestKind = extractWorkLogRequestKind(payload);
if (
!taskDetailAsLabel &&
payload &&
typeof payload.detail === "string" &&
payload.detail.length > 0
) {
const commandOutput = commandPreview.command ? extractCommandOutputText(payload?.data) : null;
const output = commandOutput ? stripTrailingExitCode(commandOutput).output : null;
if (!taskDetailAsLabel && output) {
entry.detail = output;
} else if (!taskDetailAsLabel && typeof payload?.detail === "string") {
const detail = stripTrailingExitCode(payload.detail).output;
if (detail) {
entry.detail = detail;
}
const data = asRecord(payload.data);
const repeatsCommand =
detail !== null &&
commandDetailRepeatsCommand({
detail,
command: commandPreview.command,
rawCommand: commandPreview.rawCommand,
toolName: data?.toolName,
data,
});
if (detail && !repeatsCommand) entry.detail = detail;
}
if (commandPreview.command) {
entry.command = commandPreview.command;
Expand Down Expand Up @@ -750,20 +759,18 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {

function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
const blocks: string[] = [];
const appendUniqueBlock = (value: string | null | undefined) => {
const appendBlock = (value: string | null | undefined) => {
const trimmed = value?.trim();
if (trimmed && !blocks.includes(trimmed)) {
blocks.push(trimmed);
}
if (trimmed && (entry.command || !blocks.includes(trimmed))) blocks.push(trimmed);
};

if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) {
appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);
appendBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);
}
appendUniqueBlock(entry.rawCommand ?? entry.command);
appendUniqueBlock(entry.detail);
appendBlock(entry.rawCommand ?? entry.command);
appendBlock(entry.detail);
if ((entry.changedFiles?.length ?? 0) > 0) {
appendUniqueBlock(entry.changedFiles!.join("\n"));
appendBlock(entry.changedFiles!.join("\n"));
}

return blocks.length > 0 ? blocks.join("\n\n") : null;
Expand Down Expand Up @@ -1758,13 +1765,21 @@ export function buildThreadFeed(
const summary = workEntryHeading(entry);
const detail = workEntryPreview(entry);
const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));
const getCopyText = memoizeValue(() =>
[summary, detail, getFullDetail()]
const getCopyText = memoizeValue(() => {
const fullDetail = getFullDetail();
if (entry.command) {
const normalizedCommand =
entry.rawCommand && summary.trim() !== entry.command.trim() ? entry.command : null;
return [summary, normalizedCommand, fullDetail ?? entry.command]
.filter((value): value is string => Boolean(value))
.join("\n");
}
return [summary, detail, fullDetail]
.filter((value, index, values): value is string => {
return Boolean(value) && values.indexOf(value) === index;
})
.join("\n"),
);
.join("\n");
});
return {
type: "activity",
id: entry.id,
Expand Down
18 changes: 14 additions & 4 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,21 @@ describe("projectActivityPayload", () => {
expect(JSON.stringify(acp.payload).length).toBeLessThan(500);
});

it("normalizes Claude and OpenCode command inputs before slimming provider data", () => {
it("keeps bounded Claude command input and result summaries", () => {
const claude = projectActivityPayload(
activity({
itemType: "command_execution",
toolCallId: "claude-call-1",
data: {
toolName: "Bash",
input: { command: "vp test run" },
result: { content: "x".repeat(5_000) },
result: {
type: "tool_result",
content: [
{ type: "text", text: "tests passed" },
{ type: "text", text: "x".repeat(5_000) },
],
},
},
}),
);
Expand All @@ -148,13 +154,17 @@ describe("projectActivityPayload", () => {

expect(claude.payload).toMatchObject({
toolCallId: "claude-call-1",
data: { command: "vp test run" },
data: {
toolName: "Bash",
command: "vp test run",
rawOutput: { content: "tests passed" },
},
});
expect(openCode.payload).toMatchObject({
toolCallId: "opencode-call-1",
data: { command: "vp lint" },
});
expect(JSON.stringify(claude.payload).length).toBeLessThan(200);
expect(JSON.stringify(claude.payload).length).toBeLessThan(250);
expect(JSON.stringify(openCode.payload).length).toBeLessThan(200);
});

Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/orchestration/ActivityPayloadProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,14 @@ export function projectActivityPayload(
if ("kind" in data) {
projectedData.kind = data.kind;
}
if ("toolName" in data) {
projectedData.toolName = data.toolName;
}

const rawOutput = projectRawOutput(data.rawOutput) ?? projectAcpContent(data.content);
const rawOutput =
projectRawOutput(data.rawOutput) ??
projectAcpContent(data.content) ??
(payload.itemType === "command_execution" ? summarizeMcpResult(data.result) : undefined);
if (rawOutput) {
projectedData.rawOutput = rawOutput;
}
Expand Down
Loading
Loading