/ hooks / useTurnDiffs.ts
useTurnDiffs.ts
  1  import type { StructuredPatchHunk } from 'diff'
  2  import { useMemo, useRef } from 'react'
  3  import type { FileEditOutput } from '../tools/FileEditTool/types.js'
  4  import type { Output as FileWriteOutput } from '../tools/FileWriteTool/FileWriteTool.js'
  5  import type { Message } from '../types/message.js'
  6  
  7  export type TurnFileDiff = {
  8    filePath: string
  9    hunks: StructuredPatchHunk[]
 10    isNewFile: boolean
 11    linesAdded: number
 12    linesRemoved: number
 13  }
 14  
 15  export type TurnDiff = {
 16    turnIndex: number
 17    userPromptPreview: string
 18    timestamp: string
 19    files: Map<string, TurnFileDiff>
 20    stats: {
 21      filesChanged: number
 22      linesAdded: number
 23      linesRemoved: number
 24    }
 25  }
 26  
 27  type FileEditResult = FileEditOutput | FileWriteOutput
 28  
 29  type TurnDiffCache = {
 30    completedTurns: TurnDiff[]
 31    currentTurn: TurnDiff | null
 32    lastProcessedIndex: number
 33    lastTurnIndex: number
 34  }
 35  
 36  function isFileEditResult(result: unknown): result is FileEditResult {
 37    if (!result || typeof result !== 'object') return false
 38    const r = result as Record<string, unknown>
 39    // FileEditTool: has structuredPatch with content
 40    // FileWriteTool (update): has structuredPatch with content
 41    // FileWriteTool (create): has type='create' and content (structuredPatch is empty)
 42    const hasFilePath = typeof r.filePath === 'string'
 43    const hasStructuredPatch =
 44      Array.isArray(r.structuredPatch) && r.structuredPatch.length > 0
 45    const isNewFile = r.type === 'create' && typeof r.content === 'string'
 46    return hasFilePath && (hasStructuredPatch || isNewFile)
 47  }
 48  
 49  function isFileWriteOutput(result: FileEditResult): result is FileWriteOutput {
 50    return (
 51      'type' in result && (result.type === 'create' || result.type === 'update')
 52    )
 53  }
 54  
 55  function countHunkLines(hunks: StructuredPatchHunk[]): {
 56    added: number
 57    removed: number
 58  } {
 59    let added = 0
 60    let removed = 0
 61    for (const hunk of hunks) {
 62      for (const line of hunk.lines) {
 63        if (line.startsWith('+')) added++
 64        else if (line.startsWith('-')) removed++
 65      }
 66    }
 67    return { added, removed }
 68  }
 69  
 70  function getUserPromptPreview(message: Message): string {
 71    if (message.type !== 'user') return ''
 72    const content = message.message.content
 73    const text = typeof content === 'string' ? content : ''
 74    // Truncate to ~30 chars
 75    if (text.length <= 30) return text
 76    return text.slice(0, 29) + '…'
 77  }
 78  
 79  function computeTurnStats(turn: TurnDiff): void {
 80    let totalAdded = 0
 81    let totalRemoved = 0
 82    for (const file of turn.files.values()) {
 83      totalAdded += file.linesAdded
 84      totalRemoved += file.linesRemoved
 85    }
 86    turn.stats = {
 87      filesChanged: turn.files.size,
 88      linesAdded: totalAdded,
 89      linesRemoved: totalRemoved,
 90    }
 91  }
 92  
 93  /**
 94   * Extract turn-based diffs from messages.
 95   * A turn is defined as a user prompt followed by assistant responses and tool results.
 96   * Each turn with file edits is included in the result.
 97   *
 98   * Uses incremental accumulation - only processes new messages since last render.
 99   */
100  export function useTurnDiffs(messages: Message[]): TurnDiff[] {
101    const cache = useRef<TurnDiffCache>({
102      completedTurns: [],
103      currentTurn: null,
104      lastProcessedIndex: 0,
105      lastTurnIndex: 0,
106    })
107  
108    return useMemo(() => {
109      const c = cache.current
110  
111      // Reset if messages shrunk (user rewound conversation)
112      if (messages.length < c.lastProcessedIndex) {
113        c.completedTurns = []
114        c.currentTurn = null
115        c.lastProcessedIndex = 0
116        c.lastTurnIndex = 0
117      }
118  
119      // Process only new messages
120      for (let i = c.lastProcessedIndex; i < messages.length; i++) {
121        const message = messages[i]
122        if (!message || message.type !== 'user') continue
123  
124        // Check if this is a user prompt (not a tool result)
125        const isToolResult =
126          message.toolUseResult ||
127          (Array.isArray(message.message.content) &&
128            message.message.content[0]?.type === 'tool_result')
129  
130        if (!isToolResult && !message.isMeta) {
131          // Start a new turn on user prompt
132          if (c.currentTurn && c.currentTurn.files.size > 0) {
133            computeTurnStats(c.currentTurn)
134            c.completedTurns.push(c.currentTurn)
135          }
136  
137          c.lastTurnIndex++
138          c.currentTurn = {
139            turnIndex: c.lastTurnIndex,
140            userPromptPreview: getUserPromptPreview(message),
141            timestamp: message.timestamp,
142            files: new Map(),
143            stats: { filesChanged: 0, linesAdded: 0, linesRemoved: 0 },
144          }
145        } else if (c.currentTurn && message.toolUseResult) {
146          // Collect file edits from tool results
147          const result = message.toolUseResult
148          if (isFileEditResult(result)) {
149            const { filePath, structuredPatch } = result
150            const isNewFile = 'type' in result && result.type === 'create'
151  
152            // Get or create file entry
153            let fileEntry = c.currentTurn.files.get(filePath)
154            if (!fileEntry) {
155              fileEntry = {
156                filePath,
157                hunks: [],
158                isNewFile,
159                linesAdded: 0,
160                linesRemoved: 0,
161              }
162              c.currentTurn.files.set(filePath, fileEntry)
163            }
164  
165            // For new files, generate synthetic hunk from content
166            if (
167              isNewFile &&
168              structuredPatch.length === 0 &&
169              isFileWriteOutput(result)
170            ) {
171              const content = result.content
172              const lines = content.split('\n')
173              const syntheticHunk: StructuredPatchHunk = {
174                oldStart: 0,
175                oldLines: 0,
176                newStart: 1,
177                newLines: lines.length,
178                lines: lines.map(l => '+' + l),
179              }
180              fileEntry.hunks.push(syntheticHunk)
181              fileEntry.linesAdded += lines.length
182            } else {
183              // Append hunks (same file may be edited multiple times in a turn)
184              fileEntry.hunks.push(...structuredPatch)
185  
186              // Update line counts
187              const { added, removed } = countHunkLines(structuredPatch)
188              fileEntry.linesAdded += added
189              fileEntry.linesRemoved += removed
190            }
191  
192            // If file was created and then edited, it's still a new file
193            if (isNewFile) {
194              fileEntry.isNewFile = true
195            }
196          }
197        }
198      }
199  
200      c.lastProcessedIndex = messages.length
201  
202      // Build result: completed turns + current turn if it has files
203      const result = [...c.completedTurns]
204      if (c.currentTurn && c.currentTurn.files.size > 0) {
205        // Compute stats for current turn before including
206        computeTurnStats(c.currentTurn)
207        result.push(c.currentTurn)
208      }
209  
210      // Return in reverse order (most recent first)
211      return result.reverse()
212    }, [messages])
213  }