"use client" import { useEffect, useMemo, useRef, useState } from "react" import { ChevronRight, FileIcon } from "lucide-react" import { useTranslations } from "next-intl" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" import type { LiveMessage } from "@/contexts/acp-connections-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useConnection } from "@/hooks/use-connection" import { useDbMessageDetail } from "@/hooks/use-db-message-detail" import { extractSessionFilesGrouped } from "@/lib/session-files" import { getPendingPromptText } from "@/lib/pending-prompt-text" import { inferLiveToolName, normalizeToolName, } from "@/lib/tool-call-normalization" import type { ConnectionStatus, MessageTurn } from "@/lib/types" import { CommitFileAdditions, CommitFileDeletions, } from "@/components/ai-elements/commit" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" import { cn } from "@/lib/utils" const LIVE_FILE_WRITE_OPS = new Set(["edit", "write", "apply_patch"]) function isRemovedFileDiff(diff: string | null): boolean { if (!diff) return false return ( /^\*\*\* Delete File:\s+/m.test(diff) || /^deleted file mode\b/m.test(diff) || /^\+\+\+\s+\/dev\/null$/m.test(diff) ) } function normalizeSlashPath(path: string): string { return path.replace(/\\/g, "/") } function toFolderRelativePath(filePath: string, folderPath?: string): string { const normalizedFilePath = normalizeSlashPath(filePath) if (!folderPath) return normalizedFilePath const normalizedFolderPath = normalizeSlashPath(folderPath).replace( /\/+$/, "" ) if (!normalizedFolderPath) return normalizedFilePath const folderPrefix = `${normalizedFolderPath}/` if (normalizedFilePath.startsWith(folderPrefix)) { return normalizedFilePath.slice(folderPrefix.length) } return normalizedFilePath } function extractTurnText(turn: MessageTurn | null): string | null { if (!turn || turn.role !== "user") return null for (const block of turn.blocks) { if (block.type !== "text") continue const text = block.text.trim() if (text) return text } return null } function mergeLiveTurns(params: { turns: MessageTurn[] liveMessage: LiveMessage | null connStatus: ConnectionStatus | null pendingPromptText: string | null fallbackPromptText: string }): MessageTurn[] { const { turns, liveMessage, connStatus, pendingPromptText, fallbackPromptText, } = params if (!liveMessage || connStatus !== "prompting") return turns const liveBlocks = liveMessage.content.flatMap((block) => { if (block.type !== "tool_call") return [] const toolName = inferLiveToolName({ title: block.info.title, kind: block.info.kind, rawInput: block.info.raw_input, }) const normalizedToolName = normalizeToolName(toolName) if (!LIVE_FILE_WRITE_OPS.has(normalizedToolName)) return [] return [ { type: "tool_use" as const, tool_use_id: block.info.tool_call_id, tool_name: toolName, input_preview: block.info.raw_input, }, ] }) if (liveBlocks.length === 0) return turns const now = new Date().toISOString() const mergedTurns = [...turns] const lastTurn = mergedTurns[mergedTurns.length - 1] const lastUserTurn = [...mergedTurns].reverse().find((turn) => turn.role === "user") ?? null const pendingText = pendingPromptText?.trim() ?? "" const shouldReuseExistingUserTurn = pendingText.length > 0 && extractTurnText(lastUserTurn) === pendingText if ((!lastTurn || lastTurn.role !== "user") && !shouldReuseExistingUserTurn) { mergedTurns.push({ id: `live-user-${liveMessage.id}`, role: "user", blocks: [ { type: "text", text: pendingPromptText?.trim() || fallbackPromptText }, ], timestamp: now, }) } mergedTurns.push({ id: `live-assistant-${liveMessage.id}`, role: "assistant", blocks: liveBlocks, timestamp: now, }) return mergedTurns } function SessionFilesContent({ conversationId, liveMessage, connStatus, pendingPromptText, }: { conversationId: number liveMessage: LiveMessage | null connStatus: ConnectionStatus | null pendingPromptText: string | null }) { const t = useTranslations("Folder.sessionFiles") const { detail, loading, refetch } = useDbMessageDetail(conversationId) const { openSessionFileDiff } = useWorkspaceContext() const { folder } = useFolderContext() const [openGroups, setOpenGroups] = useState>({}) const prevStatusRef = useRef(connStatus) useEffect(() => { const prev = prevStatusRef.current prevStatusRef.current = connStatus if (prev === "prompting" && connStatus && connStatus !== "prompting") { refetch() } }, [connStatus, refetch]) const turns = useMemo( () => mergeLiveTurns({ turns: detail?.turns ?? [], liveMessage, connStatus, pendingPromptText, fallbackPromptText: t("currentResponse"), }), [detail?.turns, liveMessage, connStatus, pendingPromptText, t] ) const groups = useMemo( () => (turns.length > 0 ? extractSessionFilesGrouped(turns) : []), [turns] ) const handleFileClick = ( filePath: string, diffContent: string | null, groupIndex: number, changeIndex: number ) => { openSessionFileDiff( filePath, diffContent ?? t("noDiffDataAvailable", { filePath }), `msg-${groupIndex + 1}-chg-${changeIndex + 1}` ) } if (loading) { return (

{t("loading")}

) } if (groups.length === 0) { return (

{t("noFileChangesInConversation")}

) } return (
{groups.map((group, groupIndex) => { const groupKey = `${group.userTurnId}-${group.timestamp}-${groupIndex}` const isOpen = openGroups[groupKey] ?? false const totalAdditions = group.files.reduce( (sum, f) => sum + f.additions, 0 ) const totalDeletions = group.files.reduce( (sum, f) => sum + f.deletions, 0 ) const uniqueFileCount = new Set( group.files.map((file) => file.path.replace(/\\/g, "/")) ).size return ( setOpenGroups((prev) => ({ ...prev, [groupKey]: open, })) } >
    {group.files.map((file, fileIndex) => { const normalizedDisplayPath = toFolderRelativePath( file.path, folder?.path ) const lastSlash = normalizedDisplayPath.lastIndexOf("/") const fileName = lastSlash >= 0 ? normalizedDisplayPath.slice(lastSlash + 1) : normalizedDisplayPath const isRemoved = isRemovedFileDiff(file.diff) return (
  • ) })}
) })}
) } export function SessionFilesTab() { const t = useTranslations("Folder.sessionFiles") const { tabs, activeTabId } = useTabContext() const activeTab = tabs.find((t) => t.id === activeTabId) const conversationId = activeTab?.conversationId const contextKey = activeTab?.id ?? "__session-files-tab__" const conn = useConnection(contextKey) const pendingPromptText = getPendingPromptText(contextKey) if (!activeTab) { return (

{t("openConversationToSeeChanges")}

) } if (!conversationId) { return (

{t("noFileChangesInConversation")}

) } return (
) }