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);