diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
index b9efa1580..63b470781 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@@ -498,6 +498,7 @@ function runtimeEventToActivities(
payload: {
itemType: event.payload.itemType,
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
+ ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
diff --git a/apps/web/src/components/chat/InlineDiffBlock.tsx b/apps/web/src/components/chat/InlineDiffBlock.tsx
new file mode 100644
index 000000000..9598ab3ee
--- /dev/null
+++ b/apps/web/src/components/chat/InlineDiffBlock.tsx
@@ -0,0 +1,261 @@
+import { memo, useMemo, useState } from "react";
+import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
+import { cn } from "~/lib/utils";
+import { VscodeEntryIcon } from "./VscodeEntryIcon";
+import type { InlineDiffData } from "../../session-logic";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+type DiffLineKind = "addition" | "deletion" | "context";
+
+interface DiffLine {
+ kind: DiffLineKind;
+ text: string;
+}
+
+/* ------------------------------------------------------------------ */
+/* Diff computation */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Simple line-level diff between two strings.
+ * Uses a basic LCS approach for short inputs, falls back to
+ * showing all old lines as deletions and all new lines as additions
+ * for very large inputs.
+ */
+function computeLineDiff(oldStr: string, newStr: string): DiffLine[] {
+ const oldLines = oldStr.split("\n");
+ const newLines = newStr.split("\n");
+
+ // For very large diffs, skip LCS and just show removed + added
+ if (oldLines.length + newLines.length > 400) {
+ return [
+ ...oldLines.map((text): DiffLine => ({ kind: "deletion", text })),
+ ...newLines.map((text): DiffLine => ({ kind: "addition", text })),
+ ];
+ }
+
+ // Simple LCS for line-level diff
+ const m = oldLines.length;
+ const n = newLines.length;
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0) as number[]);
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (oldLines[i - 1] === newLines[j - 1]) {
+ dp[i]![j] = dp[i - 1]![j - 1]! + 1;
+ } else {
+ dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
+ }
+ }
+ }
+
+ // Backtrack to build diff
+ const result: DiffLine[] = [];
+ let i = m;
+ let j = n;
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
+ result.push({ kind: "context", text: oldLines[i - 1]! });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
+ result.push({ kind: "addition", text: newLines[j - 1]! });
+ j--;
+ } else {
+ result.push({ kind: "deletion", text: oldLines[i - 1]! });
+ i--;
+ }
+ }
+
+ return result.reverse();
+}
+
+/**
+ * Build diff lines from InlineDiffData.
+ * For edits (old_string + new_string): computes line-level diff.
+ * For writes (content only): all lines are additions.
+ */
+function buildDiffLines(data: InlineDiffData): DiffLine[] {
+ if (data.oldString != null && data.newString != null) {
+ return computeLineDiff(data.oldString, data.newString);
+ }
+ if (data.content != null) {
+ return data.content.split("\n").map((text): DiffLine => ({ kind: "addition", text }));
+ }
+ return [];
+}
+
+function countStats(lines: DiffLine[]): { additions: number; deletions: number } {
+ let additions = 0;
+ let deletions = 0;
+ for (const line of lines) {
+ if (line.kind === "addition") additions++;
+ else if (line.kind === "deletion") deletions++;
+ }
+ return { additions, deletions };
+}
+
+/* ------------------------------------------------------------------ */
+/* Styling */
+/* ------------------------------------------------------------------ */
+
+function lineKindStyle(kind: DiffLineKind): string {
+ switch (kind) {
+ case "addition":
+ return "bg-emerald-500/10 border-l-emerald-500/60";
+ case "deletion":
+ return "bg-red-500/8 border-l-red-500/50";
+ case "context":
+ default:
+ return "border-l-transparent";
+ }
+}
+
+function lineTextStyle(kind: DiffLineKind): string {
+ switch (kind) {
+ case "addition":
+ return "text-emerald-300";
+ case "deletion":
+ return "text-red-400/90";
+ case "context":
+ default:
+ return "text-foreground/70";
+ }
+}
+
+function linePrefixChar(kind: DiffLineKind): string {
+ switch (kind) {
+ case "addition":
+ return "+";
+ case "deletion":
+ return "\u2212";
+ case "context":
+ return " ";
+ }
+}
+
+function linePrefixStyle(kind: DiffLineKind): string {
+ switch (kind) {
+ case "addition":
+ return "text-emerald-500/60";
+ case "deletion":
+ return "text-red-500/50";
+ case "context":
+ default:
+ return "text-transparent";
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+function basename(filePath: string): string {
+ const parts = filePath.split("/");
+ return parts[parts.length - 1] ?? filePath;
+}
+
+/** Max lines to show before collapsing with a "show more" toggle. */
+const MAX_VISIBLE_LINES = 18;
+
+/* ------------------------------------------------------------------ */
+/* Component */
+/* ------------------------------------------------------------------ */
+
+export const InlineDiffBlock = memo(function InlineDiffBlock(props: {
+ diffData: InlineDiffData;
+ resolvedTheme: "light" | "dark";
+}) {
+ const { diffData, resolvedTheme } = props;
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const allLines = useMemo(() => buildDiffLines(diffData), [diffData]);
+ const stats = useMemo(() => countStats(allLines), [allLines]);
+
+ if (allLines.length === 0) return null;
+
+ const needsTruncation = allLines.length > MAX_VISIBLE_LINES;
+ const visibleLines =
+ needsTruncation && !isExpanded ? allLines.slice(0, MAX_VISIBLE_LINES) : allLines;
+ const hiddenCount = allLines.length - visibleLines.length;
+ const fileName = basename(diffData.filePath);
+
+ return (
+
+ {/* File header */}
+
+
+
+ {fileName}
+
+
+ {stats.additions > 0 && (
+ +{stats.additions}
+ )}
+ {stats.deletions > 0 && (
+ −{stats.deletions}
+ )}
+
+
+
+ {/* Diff lines */}
+
+ {visibleLines.map((line, idx) => (
+
+
+ {linePrefixChar(line.kind)}
+
+
+ {line.text}
+
+
+ ))}
+
+ {/* Truncation toggle */}
+ {needsTruncation && (
+
+ )}
+
+
+ );
+});
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 407873895..887774191 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -43,6 +43,7 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage
import { ProposedPlanCard } from "./ProposedPlanCard";
import { ChangedFilesTree } from "./ChangedFilesTree";
import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
+import { InlineDiffBlock } from "./InlineDiffBlock";
import { MessageCopyButton } from "./MessageCopyButton";
import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
import type { ChatShortcutGuide } from "~/lib/chatShortcutGuidance";
@@ -369,12 +370,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
) : (
),
)}
@@ -1071,8 +1074,9 @@ function groupConsecutiveWorkEntries(entries: TimelineWorkEntry[]): ConsecutiveW
const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
workEntry: TimelineWorkEntry;
+ resolvedTheme: "light" | "dark";
}) {
- const { workEntry } = props;
+ const { workEntry, resolvedTheme } = props;
const iconConfig = workToneIcon(workEntry.tone);
const EntryIcon = workEntryIcon(workEntry);
const heading = toolWorkEntryHeading(workEntry);
@@ -1080,6 +1084,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
const displayText = preview ? `${heading} - ${preview}` : heading;
const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail;
+ const hasDiffData = workEntry.diffData != null && workEntry.itemType === "file_change";
return (
@@ -1104,25 +1109,32 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
- {hasChangedFiles && !previewIsChangedFiles && (
-
- {workEntry.changedFiles?.slice(0, 4).map((filePath) => {
- const basename = filePath.split("/").pop() ?? filePath;
- return (
-
- {basename}
-
- );
- })}
- {(workEntry.changedFiles?.length ?? 0) > 4 && (
-
- +{(workEntry.changedFiles?.length ?? 0) - 4}
-
- )}
+ {hasDiffData ? (
+
+
+ ) : (
+ hasChangedFiles &&
+ !previewIsChangedFiles && (
+
+ {workEntry.changedFiles?.slice(0, 4).map((filePath) => {
+ const basename = filePath.split("/").pop() ?? filePath;
+ return (
+
+ {basename}
+
+ );
+ })}
+ {(workEntry.changedFiles?.length ?? 0) > 4 && (
+
+ +{(workEntry.changedFiles?.length ?? 0) - 4}
+
+ )}
+
+ )
)}
);
@@ -1131,8 +1143,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
const CollapsedWorkEntryGroup = memo(function CollapsedWorkEntryGroup(props: {
heading: string;
entries: TimelineWorkEntry[];
+ resolvedTheme: "light" | "dark";
}) {
- const { heading, entries } = props;
+ const { heading, entries, resolvedTheme } = props;
const [isExpanded, setIsExpanded] = useState(false);
const firstEntry = entries[0]!;
const EntryIcon = workEntryIcon(firstEntry);
@@ -1164,16 +1177,21 @@ const CollapsedWorkEntryGroup = memo(function CollapsedWorkEntryGroup(props: {
/>
{isExpanded && (
-
+
{entries.map((entry) => {
const preview = workEntryPreview(entry);
+ const hasDiff = entry.diffData != null && entry.itemType === "file_change";
return (
-
- {preview ?? heading}
-
+
+
+ {preview ?? heading}
+
+ {hasDiff && (
+
+
+
+ )}
+
);
})}
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index c09928b2f..9150d5fc6 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -32,6 +32,14 @@ export const PROVIDER_OPTIONS: Array<{
{ value: "cursor", label: "Cursor", available: false },
];
+export interface InlineDiffData {
+ filePath: string;
+ oldString?: string;
+ newString?: string;
+ content?: string;
+ toolName?: string;
+}
+
export interface WorkLogEntry {
id: string;
createdAt: string;
@@ -43,6 +51,7 @@ export interface WorkLogEntry {
toolTitle?: string;
itemType?: ToolLifecycleItemType;
requestKind?: PendingApproval["requestKind"];
+ diffData?: InlineDiffData;
}
interface DerivedWorkLogEntry extends WorkLogEntry {
@@ -536,6 +545,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
if (requestKind) {
entry.requestKind = requestKind;
}
+ const diffData = extractDiffData(payload);
+ if (diffData) {
+ entry.diffData = diffData;
+ }
const collapseKey = deriveToolLifecycleCollapseKey(entry);
if (collapseKey) {
entry.collapseKey = collapseKey;
@@ -585,6 +598,7 @@ function mergeDerivedWorkLogEntries(
const itemType = next.itemType ?? previous.itemType;
const requestKind = next.requestKind ?? previous.requestKind;
const collapseKey = next.collapseKey ?? previous.collapseKey;
+ const diffData = next.diffData ?? previous.diffData;
return {
...previous,
...next,
@@ -595,6 +609,7 @@ function mergeDerivedWorkLogEntries(
...(itemType ? { itemType } : {}),
...(requestKind ? { requestKind } : {}),
...(collapseKey ? { collapseKey } : {}),
+ ...(diffData ? { diffData } : {}),
};
}
@@ -664,6 +679,57 @@ function normalizeCommandValue(value: unknown): string | null {
return parts.length > 0 ? parts.join(" ") : null;
}
+/** Max chars to keep for inline diff preview data (prevents bloating frontend state). */
+const MAX_INLINE_DIFF_STRING_LENGTH = 8000;
+
+function clampDiffString(value: string): string {
+ return value.length > MAX_INLINE_DIFF_STRING_LENGTH
+ ? value.slice(0, MAX_INLINE_DIFF_STRING_LENGTH)
+ : value;
+}
+
+function extractDiffData(payload: Record
| null): InlineDiffData | undefined {
+ const data = asRecord(payload?.data);
+ if (!data) return undefined;
+
+ // Navigate to tool input — adapters emit data.input or data.item.input
+ const item = asRecord(data.item);
+ const input = asRecord(data.input) ?? asRecord(item?.input);
+ if (!input) return undefined;
+
+ // Extract file path
+ const filePath =
+ asTrimmedString(input.file_path) ??
+ asTrimmedString(input.filePath) ??
+ asTrimmedString(input.path);
+ if (!filePath) return undefined;
+
+ const toolName = asTrimmedString(data.toolName) ?? asTrimmedString(item?.toolName) ?? undefined;
+
+ // Edit-style tool: old_string → new_string
+ const oldString = asTrimmedString(input.old_string) ?? asTrimmedString(input.oldString);
+ const newString = asTrimmedString(input.new_string) ?? asTrimmedString(input.newString);
+ if (oldString && newString) {
+ const result: InlineDiffData = {
+ filePath,
+ oldString: clampDiffString(oldString),
+ newString: clampDiffString(newString),
+ };
+ if (toolName) result.toolName = toolName;
+ return result;
+ }
+
+ // Write-style tool: full file content
+ const content = asTrimmedString(input.content);
+ if (content) {
+ const result: InlineDiffData = { filePath, content: clampDiffString(content) };
+ if (toolName) result.toolName = toolName;
+ return result;
+ }
+
+ return undefined;
+}
+
function extractToolCommand(payload: Record | null): string | null {
const data = asRecord(payload?.data);
const item = asRecord(data?.item);