Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
return { ios: "eye", android: "visibility" };
case "globe":
return { ios: "globe", android: "public" };
case "search":
return "magnifyingglass";
case "hammer":
return { ios: "hammer", android: "construction" };
case "lock":
Expand Down
74 changes: 74 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation";
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";

Expand Down Expand Up @@ -59,6 +60,64 @@ it("keeps historical plan detail accessible from its paged turn item", () => {
expect(activity?.getFullDetail()).toContain("Full historical plan text");
});

it("shows only the structured path in expanded mobile read details", () => {
const item: OrchestrationV2TurnItem = {
...base("read-detail", "2026-06-20T00:00:03.000Z", 2),
type: "dynamic_tool",
toolName: "Read",
title: "Read src/env.ts",
input: { path: "src/env.ts" },
output: "---\nname: env\n---\nsecret content",
};
const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
)[0];

expect(activity?.getFullDetail()).toBe("src/env.ts");
expect(activity?.canExpand).toBe(true);
expect(activity?.getCopyText()).not.toContain("secret content");
expect(activity?.getFullDetail()).not.toContain("sourceThreadId");

const withoutPath = buildThreadFeed([
projected({ ...item, id: TurnItemId.make("read-without-path"), input: {} }, 0),
]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0];
expect(withoutPath?.getFullDetail()).toBeNull();
expect(withoutPath?.canExpand).toBe(false);
});

it("keeps approval prompts rather than presenting them as tool work", () => {
const approval = (
id: string,
requestKind: "file-read" | "command" | "file-change",
ordinal: number,
) =>
({
...base(id, `2026-06-20T00:00:0${ordinal}.000Z`, ordinal),
type: "approval_request",
requestId: RuntimeRequestId.make(`request-${id}`),
requestKind,
prompt: `Allow ${requestKind}?`,
}) satisfies OrchestrationV2TurnItem;
const feed = buildThreadFeed([
projected(approval("approve-read", "file-read", 1), 0),
projected(approval("approve-command", "command", 2), 1),
projected(approval("approve-edit", "file-change", 3), 2),
]);
const activities = feed.flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);

expect(activities.map((activity) => workEntryRowLabel(activity.workEntry))).toEqual([
"Allow file-read?",
"Allow command?",
"Allow file-change?",
]);
expect(activities[0]?.canExpand).toBe(true);
expect(
summarizeToolGroup(activities.slice(1).map((activity) => activity.workEntry)).summary,
).not.toMatch(/Ran|changed/);
});

function base(id: string, updatedAt: string, ordinal: number) {
const timestamp = DateTime.makeUnsafe(updatedAt);
return {
Expand Down Expand Up @@ -2049,6 +2108,21 @@ it("uses a compact reasoning preview and a short expanded heading", () => {
expect(workEntryRowLabel({ ...entry, toolLifecycleStatus: "completed" }, true)).toBe("Thought");
});

it("keeps search output in expanded details rather than the compact label", () => {
const entry = {
id: "search",
label: "Grep",
toolTitle: "Grep",
createdAt: "2026-09-17T12:00:00Z",
itemType: "dynamic_tool" as const,
tone: "tool" as const,
detail: "---\nfile body",
toolData: {},
};
expect(workEntryRowLabel(entry)).toBe("Grep");
expect(workEntryRowLabel(entry, true)).toBe("---\nfile body");
});

it.each(["First paragraph.\n\nSecond paragraph.", ""])(
"previews live reasoning text %j",
(text) => {
Expand Down
77 changes: 67 additions & 10 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,15 @@ import type {
ScheduledTaskId,
} from "@t3tools/contracts";
import { RunId, ThreadId } from "@t3tools/contracts";
import {
classifyToolActivity,
collectToolFilePaths,
computerUseToolTitle,
formatReadToolLabel,
formatSearchToolLabel,
} from "@t3tools/shared/toolActivity";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
import { computerUseToolTitle } from "@t3tools/shared/toolActivity";
import * as DateTime from "effect/DateTime";

export type PendingApproval = ThreadPendingApproval;
Expand Down Expand Up @@ -80,6 +86,7 @@ export interface ThreadFeedActivity {
| "edit"
| "eye"
| "globe"
| "search"
| "hammer"
| "lock"
| "message"
Expand Down Expand Up @@ -206,9 +213,30 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa
const presentation = resolveWorkEntryToolPresentation(entry);
if (presentation) return presentation.displayName;
if (entry.command?.trim()) return compactWorkEntryText(commandDisplayText(entry.command));
const action = toolGroupAction(entry);
const isToolRead = action === "read" && entry.itemType === "dynamic_tool";
if (action === "code-search" || action === "search") {
const toolData =
entry.toolData !== null &&
typeof entry.toolData === "object" &&
!Array.isArray(entry.toolData)
? (entry.toolData as Record<string, unknown>)
: undefined;
const searchLabel = formatSearchToolLabel(toolData);
if (searchLabel) return searchLabel;
}
if (isToolRead) {
const [firstPath] = entry.changedFiles ?? collectToolFilePaths(entry.toolData);
if (firstPath) {
return formatReadToolLabel(firstPath, Math.max(0, (entry.changedFiles?.length ?? 1) - 1));
}
if (!expanded) return "Read file";
}
const preview =
entry.command ??
entry.detail ??
(isToolRead || (!expanded && (action === "code-search" || action === "search"))
? null
: entry.detail) ??
(entry.changedFiles?.length
? entry.changedFiles.length === 1
? entry.changedFiles[0]!
Expand Down Expand Up @@ -414,6 +442,16 @@ function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntr

function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
if (item.type === "notification") return "zap";
if (item.type === "dynamic_tool") {
const classified = classifyToolActivity({
itemType: "dynamic_tool_call",
data: { toolName: item.toolName ?? undefined, input: item.input },
});
if (classified === "read") {
return "eye";
}
if (classified === "search") return "search";
}
switch (item.type) {
case "reasoning":
return "agent";
Expand All @@ -422,7 +460,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
case "file_change":
return "edit";
case "file_search":
return "eye";
return "search";
case "web_search":
return "globe";
case "approval_request":
Expand Down Expand Up @@ -486,7 +524,7 @@ function itemSummary(
? `Changed ${item.changes.length} files`
: `Changed ${item.fileName}`;
case "file_search":
return "Searched files";
return formatSearchToolLabel(item) ?? "Searched files";
case "web_search":
return "Searched the web";
case "approval_request":
Expand All @@ -507,8 +545,20 @@ function itemSummary(
return "Thread forked";
case "thread_created":
return "Thread created";
case "dynamic_tool":
case "dynamic_tool": {
const classified = classifyToolActivity({
itemType: "dynamic_tool_call",
data: { toolName: item.toolName ?? undefined, input: item.input },
});
if (classified === "read") {
const [path] = collectToolFilePaths({ input: item.input });
return formatReadToolLabel(path ?? "");
}
if (classified === "search") {
return formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call";
}
return toolPresentation?.displayName ?? item.toolName ?? "Tool call";
}
case "proposed_plan":
return "Proposed plan";
case "todo_list":
Expand Down Expand Up @@ -651,8 +701,15 @@ function toFeedActivity(
const detail = item.type === "notification" ? null : itemPreview(item);
const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt);
const workEntry = toWorkLogEntry(item, createdAt, summary, detail);
const getFullDetail = memoizeValue(() =>
JSON.stringify(
const readPaths =
item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read"
? collectToolFilePaths(item)
: null;
const getFullDetail = memoizeValue(() => {
if (readPaths) {
return readPaths.join("\n") || null;
}
return JSON.stringify(
{
visibility: row.visibility,
sourceThreadId: row.sourceThreadId,
Expand All @@ -661,8 +718,8 @@ function toFeedActivity(
},
null,
2,
),
);
);
});
const getCopyText = memoizeValue(() =>
[summary, detail, getFullDetail()]
.filter(
Expand All @@ -678,7 +735,7 @@ function toFeedActivity(
attemptId,
summary,
detail,
canExpand: !(item.type === "error" && item.status === "failed"),
canExpand: !(item.type === "error" && item.status === "failed") && (readPaths?.length ?? 1) > 0,
getFullDetail,
getCopyText,
icon: workEntry.toolSurface ?? itemIcon(item),
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import * as EffectAcpErrors from "effect-acp/errors";
import type * as EffectAcpProtocol from "effect-acp/protocol";
import type * as EffectAcpSchema from "effect-acp/compat";

import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import {
Expand Down Expand Up @@ -3251,9 +3252,28 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV
} else {
switch (toolCall.kind) {
case "read":
turnItem = {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
...base,
title: path ? formatReadToolLabel(path) : (title ?? "Read file"),
type: "dynamic_tool",
toolName: "Read",
input:
path === undefined ||
["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path)
? (rawInputRecord ?? {})
: { ...rawInputRecord, path },
...(rawOutput === undefined ? {} : { output: rawOutput }),
};
break;
case "search":
turnItem = {
...base,
title:
formatSearchToolLabel({
rawInput: rawInputRecord,
input: rawInputRecord,
...(path === undefined ? {} : { pattern: path }),
}) ?? title,
type: "file_search",
...(path === undefined ? {} : { pattern: path }),
...(path === undefined
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2762,6 +2762,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
const tools = [
{ id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } },
{ id: "text", name: "Read", input: { file_path: "/workspace/README.md" } },
{ id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } },
{
id: "write",
name: "Write",
Expand Down Expand Up @@ -2823,6 +2824,15 @@ describe("ClaudeAdapterV2 background wake turns", () => {
assert.equal(image?.type, "dynamic_tool");
if (image?.type === "dynamic_tool")
assert.equal(image.viewedImagePath, "/workspace/reference.png");
assert.equal(image?.title, "Read /workspace/reference.png");
assert.equal(
items.find((item) => item.nativeItemRef?.nativeId === "text")?.title,
"Read /workspace/README.md",
);
assert.equal(
items.find((item) => item.nativeItemRef?.nativeId === "search")?.title,
"Searched TODO in src",
);
for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image"))
assert.notProperty(item, "viewedImagePath");
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, idAllocatorLayer))),
Expand Down
20 changes: 16 additions & 4 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as NodeCrypto from "node:crypto";

import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";
import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";
import {
Expand Down Expand Up @@ -3367,6 +3368,20 @@ export function makeClaudeAdapterV2(
startedAt: input.startedAt,
completedAt,
};
const readPath = ["read", "read file"].includes(input.classification.normalizedName)
? firstStringInputField(input.toolInput, ["file_path", "path"])?.trim()
: undefined;
const nativeToolInput = claudeNativeToolInputValue(input.toolInput);
const searchTitle = ["grep", "glob", "ls"].includes(input.classification.normalizedName)
? formatSearchToolLabel({
input:
nativeToolInput !== null &&
typeof nativeToolInput === "object" &&
!Array.isArray(nativeToolInput)
? (nativeToolInput as Record<string, unknown>)
: undefined,
})
: undefined;
const itemBase = {
id: turnItemId,
threadId: input.threadId,
Expand All @@ -3378,7 +3393,7 @@ export function makeClaudeAdapterV2(
parentItemId: null,
ordinal: input.ordinal,
status: input.status,
title: null,
title: readPath !== undefined ? formatReadToolLabel(readPath) : (searchTitle ?? null),
startedAt: input.startedAt,
completedAt,
updatedAt: input.updatedAt,
Expand All @@ -3399,9 +3414,6 @@ export function makeClaudeAdapterV2(
| "completedAt"
| "updatedAt"
>;
const readPath = ["read", "read file"].includes(input.classification.normalizedName)
? firstStringInputField(input.toolInput, ["file_path", "path"])?.trim()
: undefined;
const viewedImagePath =
readPath &&
readPath.length <= 4096 &&
Expand Down
Loading
Loading