📄 File detail

hooks/useTurnDiffs.ts

🧩 .ts📏 214 lines💾 6,686 bytes📝 text
← Back to All Files

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

  • TurnFileDiff
  • TurnDiff
  • useTurnDiffs

📚 External import roots

Package roots from from "…" (relative paths omitted).

  • diff
  • react

🖥️ 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])
}