Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
975f358
feat: add configurable agent execution working directory
wsrer May 8, 2026
e8d8e19
Merge branch 'furtherref:main' into main
wsrer May 8, 2026
66b69d7
Revert "feat: add configurable agent execution working directory"
wsrer May 9, 2026
3493ecf
feat: detailed agent operation tracking with diff and command output
wsrer May 9, 2026
58e4dc7
fix: add i18n keys for command output and diff viewer components
wsrer May 9, 2026
c90fc63
fix(views): use theme-aware colors for CommandOutput and DiffViewer
wsrer May 10, 2026
70ae2f6
Revert "fix(views): use theme-aware colors for CommandOutput and Diff…
wsrer May 12, 2026
4caf1c8
Revert "fix: add i18n keys for command output and diff viewer compone…
wsrer May 12, 2026
01d73af
Revert "feat: detailed agent operation tracking with diff and command…
wsrer May 12, 2026
cc35956
feat(transcript): restore cross-agent file diff viewer with unified/s…
wsrer May 12, 2026
7025fab
fix(transcript): render new-file header-only diffs as file changes
wsrer May 13, 2026
3957d4d
fix(transcript): render tool_use diffs and restore diff mode toggle
wsrer May 13, 2026
86b2738
fix(tests): align AgentTask fixture with current type
wsrer May 13, 2026
c43147b
fix(views): address transcript diff review issues
wsrer May 19, 2026
ca4b166
test(views): isolate transcript dialog avatar dependency
wsrer May 19, 2026
f2d3786
fix(views): handle trailing newline in transcript diffs
wsrer May 22, 2026
4d55198
fix(transcript): preserve safe diff rendering
wsrer May 22, 2026
58fedd9
fix(agent): preserve diff snapshots and whitespace
wsrer May 22, 2026
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
194 changes: 194 additions & 0 deletions packages/views/common/task-transcript/agent-transcript-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { type ReactNode } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enAgents from "../../locales/en/agents.json";
import { AgentTranscriptDialog } from "./agent-transcript-dialog";
import type { TimelineItem } from "./build-timeline";
import type { AgentTask } from "@multica/core/types/agent";

vi.mock("@multica/core/api", () => ({
api: {
getAgent: vi.fn().mockResolvedValue(null),
listRuntimes: vi.fn().mockResolvedValue([]),
},
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
useCurrentWorkspace: () => ({ id: "ws-1", name: "Test WS", slug: "test" }),
}));

vi.mock("../actor-avatar", () => ({
ActorAvatar: () => <span data-testid="actor-avatar" />,
}));

const TEST_RESOURCES = {
en: {
common: enCommon,
agents: enAgents,
},
};

function I18nWrapper({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return (
<QueryClientProvider client={queryClient}>
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{children}
</I18nProvider>
</QueryClientProvider>
);
}

function baseTask(): AgentTask {
return {
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "issue-1",
status: "completed",
priority: 1,
created_at: "2026-05-13T00:00:00Z",
started_at: "2026-05-13T00:00:10Z",
completed_at: "2026-05-13T00:00:20Z",
dispatched_at: "2026-05-13T00:00:00Z",
result: null,
error: null,
};
}

describe("AgentTranscriptDialog tool_use diff rendering", () => {
it("redacts secrets before rendering inline edit diffs", () => {
const rawSecret = "sk-proj-oldsecret1234567890abcdef";
const items: TimelineItem[] = [
{
seq: 1,
type: "tool_use",
tool: "edit_file",
input: {
file_path: "E:/workspace/tests/.env",
old_string: `OPENAI_API_KEY=${rawSecret}`,
new_string: "OPENAI_API_KEY=sk-proj-newsecret1234567890abcdef",
},
},
];

render(
<AgentTranscriptDialog
open={true}
onOpenChange={() => {}}
task={baseTask()}
items={items}
agentName="Claude"
/>,
{ wrapper: I18nWrapper },
);

fireEvent.click(screen.getByText(".../tests/.env"));

expect(screen.queryByText(rawSecret, { exact: false })).not.toBeInTheDocument();
expect(screen.getAllByText((content) => content.includes("[REDACTED")).length).toBeGreaterThan(0);
});

it("renders diff for create-file tool_use with content + file_path", () => {
const items: TimelineItem[] = [
{
seq: 1,
type: "tool_use",
tool: "write_file",
input: {
file_path: "E:/workspace/tests/readme.txt",
content: "hello\nworld\n",
},
},
];

render(
<AgentTranscriptDialog
open={true}
onOpenChange={() => {}}
task={baseTask()}
items={items}
agentName="Claude"
/>,
{ wrapper: I18nWrapper },
);

fireEvent.click(screen.getByText(".../tests/readme.txt"));

expect(screen.getByText("File changes")).toBeInTheDocument();
expect(screen.getByText("--- E:/workspace/tests/readme.txt")).toBeInTheDocument();
expect(screen.getByText("@@ -0,0 +1,2 @@")).toBeInTheDocument();
expect(screen.getByText("+hello")).toBeInTheDocument();
expect(screen.getByText("+world")).toBeInTheDocument();
expect(screen.queryByText("+")).not.toBeInTheDocument();
expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument();
});

it("renders diff for replace tool_use with old_string + new_string", () => {
const items: TimelineItem[] = [
{
seq: 1,
type: "tool_use",
tool: "edit_file",
input: {
file_path: "E:/workspace/tests/hello.txt",
old_string: "before",
new_string: "after",
replace_all: false,
},
},
];

render(
<AgentTranscriptDialog
open={true}
onOpenChange={() => {}}
task={baseTask()}
items={items}
agentName="Claude"
/>,
{ wrapper: I18nWrapper },
);

fireEvent.click(screen.getByText(".../tests/hello.txt"));

expect(screen.getByText("File changes")).toBeInTheDocument();
expect(screen.getByText("-before")).toBeInTheDocument();
expect(screen.getByText("+after")).toBeInTheDocument();
expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument();
});

it("renders non-diff edit tool results as text", () => {
const items: TimelineItem[] = [
{
seq: 1,
type: "tool_result",
tool: "patch_apply",
output: "patched: src/app.ts",
},
];

render(
<AgentTranscriptDialog
open={true}
onOpenChange={() => {}}
task={baseTask()}
items={items}
agentName="Codex"
/>,
{ wrapper: I18nWrapper },
);

fireEvent.click(screen.getByText("patched: src/app.ts"));

expect(screen.getAllByText("patched: src/app.ts").length).toBeGreaterThan(1);
expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument();
});
});
81 changes: 73 additions & 8 deletions packages/views/common/task-transcript/agent-transcript-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ import { ActorAvatar } from "../actor-avatar";
import { api } from "@multica/core/api";
import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent";
import { redactSecrets } from "./redact";
import type { TimelineItem } from "./build-timeline";
import { isEditTool, looksLikeUnifiedDiff, type TimelineItem } from "./build-timeline";
import { DiffViewer } from "./diff-viewer";
import { useT } from "../../i18n";

interface AgentTranscriptDialogProps {
Expand Down Expand Up @@ -578,6 +579,55 @@ interface TranscriptEventRowProps {
isSelected: boolean;
}

interface ToolUseDiffPayload {
filePath?: string;
oldText?: string;
newText?: string;
}

function toolUseDiffPayload(input: Record<string, unknown> | undefined): ToolUseDiffPayload | null {
if (!input) return null;

const filePath =
(typeof input.file_path === "string" && input.file_path) ||
(typeof input.path === "string" && input.path) ||
undefined;

const oldText = typeof input.old_text === "string"
? input.old_text
: typeof input.old_string === "string"
? input.old_string
: undefined;
const newText = typeof input.new_text === "string"
? input.new_text
: typeof input.new_string === "string"
? input.new_string
: typeof input.content === "string"
? input.content
: undefined;

if (oldText == null && newText == null) return null;
return { filePath, oldText, newText };
}

function redactedToolUseDiffPayload(input: Record<string, unknown> | undefined): ToolUseDiffPayload | null {
const parsed = toolUseDiffPayload(input);
if (!parsed) return null;
return {
filePath: parsed.filePath ? redactSecrets(parsed.filePath) : parsed.filePath,
oldText: parsed.oldText == null ? parsed.oldText : redactSecrets(parsed.oldText),
newText: parsed.newText == null ? parsed.newText : redactSecrets(parsed.newText),
};
}

function redactAndTruncate(text: string): string {
const redacted = redactSecrets(text);
if (redacted.length > 4000) {
return redacted.slice(0, 4000) + "\n... (truncated)";
}
return redacted;
}

const TranscriptEventRow = ({
ref,
item,
Expand All @@ -587,9 +637,18 @@ const TranscriptEventRow = ({
const color = getEventColor(item);
const label = getEventLabel(item);
const summary = getEventSummary(item);
const parsedToolUseDiff =
item.type === "tool_use" && isEditTool(item.tool) && item.input
? toolUseDiffPayload(item.input)
: null;
const toolUseHasInlineDiff =
item.type === "tool_use" && parsedToolUseDiff != null;

const hasDetail =
(item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) ||
(item.type === "tool_use" && (
(item.input && Object.keys(item.input).length > 0) ||
toolUseHasInlineDiff
)) ||
(item.type === "tool_result" && item.output && item.output.length > 0) ||
(item.type === "thinking" && item.content && item.content.length > 0) ||
(item.type === "text" && item.content && item.content.split("\n").length > 1) ||
Expand Down Expand Up @@ -664,20 +723,26 @@ const TranscriptEventRow = ({

function EventDetailContent({ item }: { item: TimelineItem }) {
switch (item.type) {
case "tool_use":
case "tool_use": {
if (isEditTool(item.tool) && item.input) {
const parsed = redactedToolUseDiffPayload(item.input);
if (parsed) {
return <DiffViewer oldText={parsed.oldText} newText={parsed.newText} filePath={parsed.filePath} />;
}
Comment thread
wsrer marked this conversation as resolved.
}
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
</pre>
);
}
case "tool_result":
if (item.output && looksLikeUnifiedDiff(item.output)) {
return <DiffViewer output={redactSecrets(item.output)} />;
}
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{item.output
? item.output.length > 4000
? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)"
: redactSecrets(item.output)
: ""}
{item.output ? redactAndTruncate(item.output) : ""}
</pre>
);
case "thinking":
Expand Down
47 changes: 47 additions & 0 deletions packages/views/common/task-transcript/build-timeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { isEditTool, looksLikeUnifiedDiff } from "./build-timeline";

describe("isEditTool", () => {
it("recognizes common edit tool names across backends", () => {
expect(isEditTool("patch_apply")).toBe(true);
expect(isEditTool("edit_file")).toBe(true);
expect(isEditTool("file_edit")).toBe(true);
expect(isEditTool("MultiEdit")).toBe(true);
expect(isEditTool("Write File")).toBe(true);
});

it("does not classify non-edit tools as edit tools", () => {
expect(isEditTool("exec_command")).toBe(false);
expect(isEditTool("terminal")).toBe(false);
expect(isEditTool("search_files")).toBe(false);
expect(isEditTool(undefined)).toBe(false);
});
});

describe("looksLikeUnifiedDiff", () => {
it("returns true for valid unified diff text", () => {
const diff = [
"--- a/file.txt",
"+++ b/file.txt",
"@@ -1 +1 @@",
"-old line",
"+new line",
].join("\n");
expect(looksLikeUnifiedDiff(diff)).toBe(true);
});

it("returns true for new-file style diff headers without hunks", () => {
const headerOnly = [
"--- src/new-file.ts",
"+++ src/new-file.ts",
"(new file, 42 bytes)",
].join("\n");
expect(looksLikeUnifiedDiff(headerOnly)).toBe(true);
});

it("returns false for non-diff text", () => {
expect(looksLikeUnifiedDiff("plain output")).toBe(false);
expect(looksLikeUnifiedDiff("")).toBe(false);
expect(looksLikeUnifiedDiff(undefined)).toBe(false);
});
});
Loading
Loading