🎯 Use case
This file lives under “hooks/”, which covers reusable UI or integration hooks. On the API surface it exposes TurnFileDiff, TurnDiff, and useTurnDiffs — mainly types, interfaces, or factory objects. Dependencies touch text diffing and React UI. It composes internal code from tools and types (relative imports).
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
import type { StructuredPatchHunk } from 'diff' import { useMemo, useRef } from 'react' import type { FileEditOutput } from '../tools/FileEditTool/types.js' import type { Output as FileWriteOutput } from '../tools/FileWriteTool/FileWriteTool.js' import type { Message } from '../types/message.js'
📤 Exports (heuristic)
TurnFileDiffTurnDiffuseTurnDiffs
📚 External import roots
Package roots from from "…" (relative paths omitted).
diffreact
🖥️ Source preview
import type { StructuredPatchHunk } from 'diff'
import { useMemo, useRef } from 'react'
import type { FileEditOutput } from '../tools/FileEditTool/types.js'
import type { Output as FileWriteOutput } from '../tools/FileWriteTool/FileWriteTool.js'
import type { Message } from '../types/message.js'
export type TurnFileDiff = {
filePath: string
hunks: StructuredPatchHunk[]
isNewFile: boolean
linesAdded: number
linesRemoved: number
}
export type TurnDiff = {
turnIndex: number
userPromptPreview: string
timestamp: string
files: Map<string, TurnFileDiff>
stats: {
filesChanged: number
linesAdded: number
linesRemoved: number
}
}
type FileEditResult = FileEditOutput | FileWriteOutput
type TurnDiffCache = {
completedTurns: TurnDiff[]
currentTurn: TurnDiff | null
lastProcessedIndex: number
lastTurnIndex: number
}
function isFileEditResult(result: unknown): result is FileEditResult {
if (!result || typeof result !== 'object') return false
const r = result as Record<string, unknown>
// FileEditTool: has structuredPatch with content
// FileWriteTool (update): has structuredPatch with content
// FileWriteTool (create): has type='create' and content (structuredPatch is empty)
const hasFilePath = typeof r.filePath === 'string'
const hasStructuredPatch =
Array.isArray(r.structuredPatch) && r.structuredPatch.length > 0
const isNewFile = r.type === 'create' && typeof r.content === 'string'
return hasFilePath && (hasStructuredPatch || isNewFile)
}
function isFileWriteOutput(result: FileEditResult): result is FileWriteOutput {
return (
'type' in result && (result.type === 'create' || result.type === 'update')
)
}
function countHunkLines(hunks: StructuredPatchHunk[]): {
added: number
removed: number
} {
let added = 0
let removed = 0
for (const hunk of hunks) {
for (const line of hunk.lines) {
if (line.startsWith('+')) added++
else if (line.startsWith('-')) removed++
}
}
return { added, removed }
}
function getUserPromptPreview(message: Message): string {
if (message.type !== 'user') return ''
const content = message.message.content
const text = typeof content === 'string' ? content : ''
// Truncate to ~30 chars
if (text.length <= 30) return text
return text.slice(0, 29) + '…'
}
function computeTurnStats(turn: TurnDiff): void {
let totalAdded = 0
let totalRemoved = 0
for (const file of turn.files.values()) {
totalAdded += file.linesAdded
totalRemoved += file.linesRemoved
}
turn.stats = {
filesChanged: turn.files.size,
linesAdded: totalAdded,
linesRemoved: totalRemoved,
}
}
/**
* Extract turn-based diffs from messages.
* A turn is defined as a user prompt followed by assistant responses and tool results.
* Each turn with file edits is included in the result.
*
* Uses incremental accumulation - only processes new messages since last render.
*/
export function useTurnDiffs(messages: Message[]): TurnDiff[] {
const cache = useRef<TurnDiffCache>({
completedTurns: [],
currentTurn: null,
lastProcessedIndex: 0,
lastTurnIndex: 0,
})
return useMemo(() => {
const c = cache.current
// Reset if messages shrunk (user rewound conversation)
if (messages.length < c.lastProcessedIndex) {
c.completedTurns = []
c.currentTurn = null
c.lastProcessedIndex = 0
c.lastTurnIndex = 0
}
// Process only new messages
for (let i = c.lastProcessedIndex; i < messages.length; i++) {
const message = messages[i]
if (!message || message.type !== 'user') continue
// Check if this is a user prompt (not a tool result)
const isToolResult =
message.toolUseResult ||
(Array.isArray(message.message.content) &&
message.message.content[0]?.type === 'tool_result')
if (!isToolResult && !message.isMeta) {
// Start a new turn on user prompt
if (c.currentTurn && c.currentTurn.files.size > 0) {
computeTurnStats(c.currentTurn)
c.completedTurns.push(c.currentTurn)
}
c.lastTurnIndex++
c.currentTurn = {
turnIndex: c.lastTurnIndex,
userPromptPreview: getUserPromptPreview(message),
timestamp: message.timestamp,
files: new Map(),
stats: { filesChanged: 0, linesAdded: 0, linesRemoved: 0 },
}
} else if (c.currentTurn && message.toolUseResult) {
// Collect file edits from tool results
const result = message.toolUseResult
if (isFileEditResult(result)) {
const { filePath, structuredPatch } = result
const isNewFile = 'type' in result && result.type === 'create'
// Get or create file entry
let fileEntry = c.currentTurn.files.get(filePath)
if (!fileEntry) {
fileEntry = {
filePath,
hunks: [],
isNewFile,
linesAdded: 0,
linesRemoved: 0,
}
c.currentTurn.files.set(filePath, fileEntry)
}
// For new files, generate synthetic hunk from content
if (
isNewFile &&
structuredPatch.length === 0 &&
isFileWriteOutput(result)
) {
const content = result.content
const lines = content.split('\n')
const syntheticHunk: StructuredPatchHunk = {
oldStart: 0,
oldLines: 0,
newStart: 1,
newLines: lines.length,
lines: lines.map(l => '+' + l),
}
fileEntry.hunks.push(syntheticHunk)
fileEntry.linesAdded += lines.length
} else {
// Append hunks (same file may be edited multiple times in a turn)
fileEntry.hunks.push(...structuredPatch)
// Update line counts
const { added, removed } = countHunkLines(structuredPatch)
fileEntry.linesAdded += added
fileEntry.linesRemoved += removed
}
// If file was created and then edited, it's still a new file
if (isNewFile) {
fileEntry.isNewFile = true
}
}
}
}
c.lastProcessedIndex = messages.length
// Build result: completed turns + current turn if it has files
const result = [...c.completedTurns]
if (c.currentTurn && c.currentTurn.files.size > 0) {
// Compute stats for current turn before including
computeTurnStats(c.currentTurn)
result.push(c.currentTurn)
}
// Return in reverse order (most recent first)
return result.reverse()
}, [messages])
}