diff --git a/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts b/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts index e9dc7992..bb655811 100644 --- a/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts +++ b/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts @@ -481,7 +481,7 @@ describe("chatPaneSelectors", () => { expect(selectActiveSubAgentParentItemIds(state, "t1")).toEqual(["sub:run-1"]); }); - it("groups edits only with edits to the same file", () => { + it("groups adjacent edits with the rest of the tool-call run", () => { const state = { runtimeItemIdsByThread: { t1: [ @@ -572,20 +572,18 @@ describe("chatPaneSelectors", () => { { kind: "tool_call_group", id: "tool-call-group:edit-1", - itemIds: ["edit-1", "edit-2"], + itemIds: ["edit-1", "edit-2", "command-1", "command-2"], }, + { kind: "item", id: "assistant-2" }, { kind: "tool_call_group", - id: "tool-call-group:command-1", - itemIds: ["command-1", "command-2"], + id: "tool-call-group:edit-3", + itemIds: ["edit-3", "edit-4"], }, - { kind: "item", id: "assistant-2" }, - { kind: "item", id: "edit-3" }, - { kind: "item", id: "edit-4" }, ]); }); - it("applies the same edit grouping rule to generic edit tool calls", () => { + it("groups generic edit tool calls with adjacent tools", () => { const state = { runtimeItemIdsByThread: { t1: ["tool-edit-1", "tool-edit-2", "tool-read-1", "tool-edit-3"], @@ -643,10 +641,8 @@ describe("chatPaneSelectors", () => { { kind: "tool_call_group", id: "tool-call-group:tool-edit-1", - itemIds: ["tool-edit-1", "tool-edit-2"], + itemIds: ["tool-edit-1", "tool-edit-2", "tool-read-1", "tool-edit-3"], }, - { kind: "item", id: "tool-read-1" }, - { kind: "item", id: "tool-edit-3" }, ]); }); }); diff --git a/src/renderer/components/thread/ChatPane/chatPaneSelectors.ts b/src/renderer/components/thread/ChatPane/chatPaneSelectors.ts index df840907..87524bde 100644 --- a/src/renderer/components/thread/ChatPane/chatPaneSelectors.ts +++ b/src/renderer/components/thread/ChatPane/chatPaneSelectors.ts @@ -4,7 +4,6 @@ import type { } from "@/renderer/state/slices/runtimeEventSlice"; import type { AppStoreState } from "@/renderer/state/slices/shared"; import type { ToolCallPayload } from "@/shared/contracts"; -import { canShareRuntimeToolGroup } from "@/renderer/state/runtimeToolGrouping"; import { imageViewRendersInline } from "./parts/items/imageViewSource"; import { isToolGroupItem as isGroupableItemType, @@ -159,9 +158,6 @@ function buildTimelineEntries( if (!next || !isToolGroupItem(next) || childParentIds.has(nextId)) { break; } - if (!canShareRuntimeToolGroup(item, next)) { - break; - } groupIds.push(nextId); idx += 1; } diff --git a/src/renderer/state/chatRuntimePersister.test.ts b/src/renderer/state/chatRuntimePersister.test.ts index beae66ac..be1cd0fa 100644 --- a/src/renderer/state/chatRuntimePersister.test.ts +++ b/src/renderer/state/chatRuntimePersister.test.ts @@ -97,7 +97,7 @@ describe("prepareRuntimeSnapshotForPersistence", () => { ]); }); - it("does not compact edits together with other tool calls", () => { + it("compacts edits together with the rest of the tool-call run", () => { const snapshot = prepareRuntimeSnapshotForPersistence( [ makeItem({ id: "assistant-1", type: "assistant_message" }), @@ -130,19 +130,13 @@ describe("prepareRuntimeSnapshotForPersistence", () => { [makeTurn("edit-1"), makeTurn("edit-2"), makeTurn("command-1"), makeTurn("edit-3")], ); - const editSummaryId = "tool-call-summary:edit-1:edit-2:2"; - const commandSummaryId = "tool-call-summary:command-1:command-2:2"; - expect(snapshot.items.map((item) => item.id)).toEqual([ - "assistant-1", - editSummaryId, - commandSummaryId, - "edit-3", - ]); + const summaryId = "tool-call-summary:edit-1:edit-3:5"; + expect(snapshot.items.map((item) => item.id)).toEqual(["assistant-1", summaryId]); expect(snapshot.turns.map((turn) => turn.anchorItemId)).toEqual([ - editSummaryId, - editSummaryId, - commandSummaryId, - "edit-3", + summaryId, + summaryId, + summaryId, + summaryId, ]); }); diff --git a/src/renderer/state/chatRuntimePersister.ts b/src/renderer/state/chatRuntimePersister.ts index b934fbf7..e5f27ffd 100644 --- a/src/renderer/state/chatRuntimePersister.ts +++ b/src/renderer/state/chatRuntimePersister.ts @@ -4,7 +4,6 @@ import { imageViewRendersInline } from "../components/thread/ChatPane/parts/item import { isSubAgentTool } from "../components/thread/ChatPane/parts/items/toolDisplay"; import { readBridge } from "../bridge"; import { useAppStore } from "./appStore"; -import { canShareRuntimeToolGroup } from "./runtimeToolGrouping"; import { subscribeRuntimePersistenceDirtyThreads, type CompletedTurnRecord, @@ -272,7 +271,6 @@ function compactRuntimeItemsForPersistence( while (idx < items.length) { const next = items[idx]!; if (!isToolGroupItem(next) || next.state !== "completed") break; - if (!canShareRuntimeToolGroup(run[0]!, next)) break; run.push(next); idx += 1; } diff --git a/src/renderer/state/runtimeToolGrouping.ts b/src/renderer/state/runtimeToolGrouping.ts deleted file mode 100644 index bed5fb47..00000000 --- a/src/renderer/state/runtimeToolGrouping.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { FileChangePayload, ToolCallPayload } from "@/shared/contracts"; -import { extractLeadingPath } from "@/shared/extractLeadingPath"; -import type { RuntimeChatItem } from "./slices/runtimeEventSlice"; - -const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "Patch"]); - -/** - * Tool rows may be grouped together unless either side is an edit. Edits only - * collapse with other edits that target the exact same normalized path. - */ -export function canShareRuntimeToolGroup(first: RuntimeChatItem, next: RuntimeChatItem): boolean { - const firstEditTarget = getRuntimeToolEditTarget(first); - const nextEditTarget = getRuntimeToolEditTarget(next); - if (firstEditTarget !== undefined || nextEditTarget !== undefined) { - return ( - firstEditTarget !== undefined && - nextEditTarget !== undefined && - firstEditTarget !== null && - firstEditTarget === nextEditTarget - ); - } - return true; -} - -function getRuntimeToolEditTarget(item: RuntimeChatItem): string | null | undefined { - if (item.type === "file_change") { - const payload = item.payload as Partial | undefined; - return normalizeEditPath(payload?.path) ?? null; - } - if (!isToolPayloadItem(item)) return undefined; - - const payload = item.payload as Partial | undefined; - if (!payload || !isEditToolPayload(payload)) return undefined; - return readEditToolPath(payload) ?? null; -} - -function isToolPayloadItem(item: RuntimeChatItem): boolean { - return ( - item.type === "tool_call" || - item.type === "mcp_tool_call" || - item.type === "image_view" || - item.type === "dynamic_tool_call" - ); -} - -function isEditToolPayload(payload: Partial): boolean { - switch (payload.kind) { - case "edit": - case "delete": - case "move": - return true; - } - if (payload.name && EDIT_TOOL_NAMES.has(payload.name)) return true; - if (isPersistedEditSummaryName(payload.name)) return true; - const title = payload.title?.trim() || payload.name?.trim() || ""; - return isEditVerbTitle(title); -} - -function readEditToolPath(payload: Partial): string | undefined { - const locationPath = payload.locations?.find((location) => location.path.length > 0)?.path; - const argsPath = readArgsPath(payload.args); - const titlePath = - extractPathFromEditTitle(payload.title) ?? extractPathFromEditTitle(payload.name); - return normalizeEditPath(locationPath ?? argsPath ?? titlePath); -} - -function readArgsPath(args: unknown): string | undefined { - if (!args) return undefined; - if (typeof args === "string") return extractPathFromPatchText(args); - if (typeof args !== "object" || Array.isArray(args)) return undefined; - const record = args as Record; - for (const key of ["file_path", "filePath", "notebook_path", "path"]) { - const value = record[key]; - if (typeof value === "string" && value.trim().length > 0) return value; - } - return undefined; -} - -function extractPathFromPatchText(text: string): string | undefined { - const paths = [...text.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)] - .map((match) => normalizeEditPath(match[1]?.trim())) - .filter((path): path is string => path !== undefined); - const uniquePaths = new Set(paths); - return uniquePaths.size === 1 ? paths[0] : undefined; -} - -function extractPathFromEditTitle(value: string | undefined): string | undefined { - const leading = extractLeadingPath(value); - if (leading) return leading; - const trimmed = value?.trim(); - if (!trimmed) return undefined; - const match = - /^(?:edit(?:ing)?|writ(?:e|ing)|patch(?:ing)?|creat(?:e|ing)|delet(?:e|ing)|remov(?:e|ing)|mov(?:e|ing))\s*:?\s+(.+)$/i.exec( - trimmed, - ); - return extractLeadingPath(match?.[1]); -} - -function isEditVerbTitle(value: string): boolean { - const normalized = value.toLowerCase().trim(); - return ( - normalized.startsWith("editing") || - normalized.startsWith("writing") || - normalized.startsWith("patching") || - normalized.startsWith("creating") || - normalized.startsWith("deleting") || - normalized.startsWith("removing") - ); -} - -function isPersistedEditSummaryName(name: string | undefined): boolean { - const parts = name - ?.split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0); - if (!parts?.length) return false; - return parts.some((part) => /^\d+\s+edits?$/i.test(part)); -} - -function normalizeEditPath(path: string | undefined): string | undefined { - const normalized = path?.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/"); - return normalized && normalized.length > 0 ? normalized : undefined; -}