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
4 changes: 3 additions & 1 deletion .github/fork-features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1203,7 +1203,7 @@ features:
- id: inspectable-tool-activity
title: Keep tool activity concise and inspectable
status: maintained
prs: [4, 11, 36, 58, 60, 66, 117, 139, 158, 168, 190, 204, 230, 238, 278, 282, 301]
prs: [4, 11, 36, 58, 60, 66, 117, 139, 158, 168, 190, 204, 230, 238, 278, 282, 301, 582]
invariants:
- Web and mobile name supported tool calls by their action with a concise useful argument, while agent panels and approval rows use the same vocabulary instead of raw provider names or serialized input.
- Clarifying-question calls name what was asked and expand into those questions with their options, rather than restating the provider's serialized input.
Expand All @@ -1213,6 +1213,7 @@ features:
- Claude, Codex, and OpenCode file edits retain structured paths, line statistics, lifecycle state, and provider-independent presentation without eagerly sending full diffs to clients.
- Folded completed turns summarize the categories of tools used with recognizable icons.
- Claude project memory writes and index updates form a distinct work-log category on web and mobile; similarly named repository files remain ordinary edits.
- Page fetches read as "Fetched page" with their URL for Claude and ACP providers, and ACP workspace searches read and count as code searches rather than web lookups, including in historical threads.
implementation_paths:
- apps/mobile/src/features/threads/thread-work-log.tsx
- apps/mobile/src/lib/threadActivity.ts
Expand Down Expand Up @@ -1266,6 +1267,7 @@ features:
upstream:
status: partial
tracking:
- https://github.com/pingdotgg/t3code/pull/13375
- https://github.com/pingdotgg/t3code/pull/7152
- https://github.com/pingdotgg/t3code/pull/8395
- https://github.com/pingdotgg/t3code/pull/9106
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projec
const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
// makes pre-pagination clients discard the record instead of decoding a
// partial thread as complete (rollback safety).
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;
// partial thread as complete (rollback safety). v4 refreshes cached activity
// payloads after ACP tool arguments joined the compact server projection.
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 4;
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;

Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,8 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
}
if (entry.itemType === "command_execution" || entry.command) return "command";
if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit";
// Matches Claude's Grep rows, which name their tool above.
if (toolGroupAction(entry) === "code-search") return "wrench";
if (entry.itemType === "web_search") return "globe";
if (entry.itemType === "image_view") return "eye";
if (entry.itemType === "mcp_tool_call") return "wrench";
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,24 @@ describe("projectActivityPayload tool identity", () => {
expect((projected.payload as Record<string, unknown>).status).toBe("failed");
});

it("keeps identifying ACP arguments from rawInput", () => {
const project = (rawInput: Record<string, unknown>) =>
(
(
projectActivityPayload(
activity({ itemType: "web_search", data: { kind: "search", rawInput } }),
).payload as Record<string, unknown>
).data as Record<string, unknown>
).input;

expect(project({ url: "https://example.com/docs", title: "", content_path: "" })).toEqual({
url: "https://example.com/docs",
});
expect(
project({ directory_path: "/repo", query: "posthog", num_results: 0, content: "x" }),
).toEqual({ directory_path: "/repo", query: "posthog" });
});

it("never ships file contents or message bodies", () => {
const projected = projectActivityPayload(
activity({
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 @@ -186,6 +186,8 @@ const TOOL_INPUT_KEPT_FIELDS = [
"file_path",
"notebook_path",
"path",
// ACP searches name the directory they ran in; web searches have none.
"directory_path",
"pattern",
"query",
"skill",
Expand Down Expand Up @@ -346,7 +348,11 @@ export function projectToolInput(

function projectToolIdentity(data: Record<string, unknown>): Record<string, unknown> {
const toolName = asTrimmedString(data.toolName) ?? asTrimmedString(data.tool);
const input = projectToolInput(data.input ?? asRecord(data.state)?.input, toolName);
// ACP adapters keep the agent's arguments as `rawInput`.
const input = projectToolInput(
data.input ?? asRecord(data.state)?.input ?? data.rawInput,
toolName,
);
return {
...(toolName ? { toolName } : {}),
...(input ? { input } : {}),
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,12 @@ describe("deriveMessagesTimelineRows", () => {
{ id: "edit-1", itemType: "file_change" as const },
{ id: "read-1", requestKind: "file-read" as const },
{ id: "web-1", itemType: "web_search" as const },
// An ACP workspace grep counts like Grep, not as a web lookup.
{
id: "grep-1",
itemType: "web_search" as const,
toolInput: { directory_path: "/repo", query: "needle" },
},
{ id: "image-1", itemType: "image_view" as const },
{ id: "tool-1", itemType: "mcp_tool_call" as const },
{
Expand Down Expand Up @@ -1927,7 +1933,7 @@ describe("deriveMessagesTimelineRows", () => {
{ kind: "file-change", count: 1 },
{ kind: "file-read", count: 1 },
{ kind: "web", count: 2 },
{ kind: "tool", count: 1 },
{ kind: "tool", count: 2 },
{ kind: "image", count: 1 },
],
});
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3to
import {
deriveToolRowPresentation,
isPreviewToolName,
isWorkspaceSearchInput,
memoryFileToolKind,
memoryFileToolPath,
} from "@t3tools/shared/toolRowPresentation";
Expand Down Expand Up @@ -615,7 +616,10 @@ function turnFoldActivityKind(entry: WorkLogEntry): TurnFoldActivityKind | null
if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) {
return "file-change";
}
if (entry.itemType === "web_search") return "web";
// ACP workspace greps arrive typed as web searches; count them like Grep.
if (entry.itemType === "web_search") {
return isWorkspaceSearchInput(entry.toolInput) ? "tool" : "web";
}
if (
entry.itemType === "mcp_tool_call" ||
entry.itemType === "dynamic_tool_call" ||
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/connection/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const SERVER_CONFIG_STORE_NAME = "server-config";
const VCS_REFS_STORE_NAME = "vcs-refs";
const CATALOG_KEY = "document";
const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 4;
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 5;

const StoredShellSnapshot = Schema.Struct({
schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
Expand All @@ -66,8 +66,9 @@ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
// exists for rollback safety: a pre-pagination client would decode a windowed
// v2 record, silently drop the unknown `page` field, and treat the partial
// thread as complete forever. v4 refreshes cached activity payloads after
// command exit codes were added to the compact server projection. Older entries
// fail to decode and are treated as a cold cache.
// command exit codes were added to the compact server projection, and v5 after
// ACP tool arguments joined it. Older entries fail to decode and are treated as
// a cold cache.
const StoredThreadSnapshot = Schema.Struct({
schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
environmentId: EnvironmentId,
Expand Down
15 changes: 15 additions & 0 deletions packages/client-runtime/src/work-log/presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,21 @@ describe("browser group summaries", () => {
).toBe("Used browser 1 time, used 1 tool, and searched the web 1 time");
});

it("counts ACP workspace searches as code searches and fetches as web lookups", () => {
const acpSearch = {
label: "Searched files",
tone: "tool" as const,
itemType: "web_search" as const,
};
expect(
summarizeToolGroup([
{ ...acpSearch, toolInput: { directory_path: "/repo", query: "posthog" } },
{ ...acpSearch, toolInput: { url: "https://example.com/docs" } },
{ ...acpSearch, toolInput: { query: "release notes" } },
]),
).toBe("Searched code 1 time and searched the web 2 times");
});

it("keeps browser screenshots in the browser count while preserving their image path", () => {
const entry = { ...browserEntry, viewedImagePath: "/workspace/page.png" };
expect(summarizeToolGroup([entry])).toBe("Used browser 1 time");
Expand Down
9 changes: 7 additions & 2 deletions packages/client-runtime/src/work-log/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
readReviewFindingsReport,
type ReviewFindingsReport,
} from "@t3tools/shared/reviewFindings";
import { memoryFileToolKind, memoryFileToolPath } from "@t3tools/shared/toolRowPresentation";
import {
isWorkspaceSearchInput,
memoryFileToolKind,
memoryFileToolPath,
} from "@t3tools/shared/toolRowPresentation";

/**
* Activity kinds clients show as a row of their own: never merged into a
Expand Down Expand Up @@ -619,7 +623,8 @@ export function workEntryIndicatesToolSuccess(entry: WorkLogPresentationEntry):
function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean {
return (
entry.itemType === "web_search" &&
/\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
(/\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) ||
isWorkspaceSearchInput(entry.toolInput))
);
}

Expand Down
37 changes: 37 additions & 0 deletions packages/shared/src/toolRowPresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,43 @@ describe("deriveToolRowPresentation", () => {
});
});

it("tells ACP page fetches and workspace searches apart from web searches", () => {
const acpSearch = { itemType: "web_search" as const, label: "Searched files" };
expect(
deriveToolRowPresentation({ ...acpSearch, input: { url: "https://example.com/docs" } }),
).toEqual({
heading: "Fetched page",
argument: { kind: "text", value: "https://example.com/docs" },
});
expect(
deriveToolRowPresentation({
...acpSearch,
detail: "posthog",
input: { directory_path: "/repo", query: "posthog" },
}),
).toEqual({ heading: "Searched code", argument: { kind: "text", value: "posthog" } });
expect(
deriveToolRowPresentation({
...acpSearch,
detail: "release notes",
input: { query: "release notes" },
}),
).toEqual({ heading: "Searched web", argument: { kind: "text", value: "release notes" } });

// Claude's fetches read the same way.
expect(
deriveToolRowPresentation({
toolName: "WebFetch",
itemType: "dynamic_tool_call",
label: "Tool call",
input: { url: "https://example.com/docs" },
}),
).toEqual({
heading: "Fetched page",
argument: { kind: "text", value: "https://example.com/docs" },
});
});

it("distinguishes memory writes from ordinary file writes", () => {
expect(
deriveToolRowPresentation({
Expand Down
23 changes: 23 additions & 0 deletions packages/shared/src/toolRowPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,14 @@ const ITEM_TYPE_BY_TOOL_NAME: Readonly<Record<string, ToolLifecycleItemType>> =
WebSearch: "web_search",
};

/**
* ACP adapters type every search as `web_search`, including greps of the
* workspace; only those name the directory they searched.
*/
export function isWorkspaceSearchInput(input: Record<string, unknown> | undefined): boolean {
return asTrimmedString(input?.directory_path) !== undefined;
}

/**
* Tools whose own name would mislead. Deliberately tiny: everything else names
* itself, so tools added later (including MCP and user-defined ones) get a
Expand All @@ -543,6 +551,9 @@ function overrideHeading(toolName: string, filePath: string | undefined): string
return "Updated task";
case "TaskList":
return "Listed tasks";
// Named like ACP's page fetches rather than by the tool's identifier.
case "WebFetch":
return "Fetched page";
case "Write":
case "Edit": {
const slug = memorySlug(filePath);
Expand Down Expand Up @@ -750,6 +761,18 @@ function deriveBaseToolRowPresentation(
}
}

// ACP rows name no tool and arrive as `web_search` for page fetches and
// workspace greps too, titled "Searched files"; the input says which ran.
if (!toolName && itemType === "web_search") {
const url = asTrimmedString(toolInput?.url);
if (url) {
return { heading: "Fetched page", argument: { kind: "text", value: url } };
}
if (isWorkspaceSearchInput(toolInput)) {
return { heading: "Searched code", ...(argument ? { argument } : {}) };
}
}

if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
return askUserQuestionPresentation(toolInput, input.failed === true);
}
Expand Down
Loading