浏览代码

#1 Files changes panel fix

paviko 2 月之前
父节点
当前提交
b1dcbe49fb

+ 0 - 4
packages/opencode/webgui/src/App.tsx

@@ -9,7 +9,6 @@ import { MessageList } from "./components/MessageList"
 import { MessagesProvider } from "./state/MessagesContext"
 import { ThemeProvider } from "./state/ThemeContext"
 import { CompactHeader } from "./components/CompactHeader"
-import { FileChangesPanel } from "./components/FileChangesPanel"
 import { OfflineBanner } from "./components/OfflineBanner"
 import { CommandPalette } from "./components/CommandPalette"
 import { KeyboardShortcutsHelp } from "./components/KeyboardShortcutsHelp"
@@ -202,9 +201,6 @@ function AppInner({ connectionState }: { connectionState: ConnectionState }) {
         />
       </main>
 
-      {/* File Changes Panel (placeholder for now) */}
-      <FileChangesPanel />
-
       {/* Input Area */}
       <MessageInput
         ref={messageInputRef}

+ 130 - 51
packages/opencode/webgui/src/components/FileChangesPanel.tsx

@@ -1,66 +1,145 @@
-import { useState } from "react"
-
-interface FileChange {
-  path: string
-  linesAdded: number
-  linesRemoved: number
-}
+import { useMemo } from "react"
+import type { FileDiff } from "@opencode-ai/sdk/client"
+import { useOpenFile } from "../hooks/useOpenFile"
+import { useProject } from "../state/ProjectContext"
+import { normalizePath, toDisplayPath } from "../utils/path"
 
 interface FileChangesPanelProps {
-  changes?: FileChange[]
+  diffs?: FileDiff[]
+  fallbackFiles?: string[]
 }
 
-export function FileChangesPanel({ changes = [] }: FileChangesPanelProps) {
-  const [isExpanded, setIsExpanded] = useState(false)
+export function FileChangesPanel({ diffs = [], fallbackFiles = [] }: FileChangesPanelProps) {
+  const openFile = useOpenFile()
+  const { worktree } = useProject()
+
+  const mergedDiffs = useMemo(() => {
+    if (fallbackFiles.length === 0) return diffs
+    // sessionDiff entries (diffs) are primary
+    // Use toDisplayPath to normalize both absolute and relative paths to a consistent format
+    const sessionPaths = new Set(diffs.map((d) => toDisplayPath(d.file, worktree)))
+    const fallbackOnly = fallbackFiles
+      .filter((f) => !sessionPaths.has(toDisplayPath(f, worktree)))
+      .map((file) => ({
+        file,
+        before: "",
+        after: "",
+        additions: 0,
+        deletions: 0,
+      }))
+    return [...diffs, ...fallbackOnly]
+  }, [diffs, fallbackFiles, worktree])
 
-  const totalLines = changes.reduce((sum, c) => sum + c.linesAdded + c.linesRemoved, 0)
+  const { modified, deleted, totalAdditions, totalDeletions, netChange } = useMemo(() => {
+    const sortByBasename = (a: FileDiff, b: FileDiff) => {
+      const aPath = normalizePath(a.file)
+      const bPath = normalizePath(b.file)
+      const aBasename = (aPath.split("/").pop() || aPath).toLowerCase()
+      const bBasename = (bPath.split("/").pop() || bPath).toLowerCase()
+      const nameCompare = aBasename.localeCompare(bBasename)
+      if (nameCompare !== 0) return nameCompare
+      return aPath.localeCompare(bPath)
+    }
 
-  if (changes.length === 0) {
+    const modifiedEntries = mergedDiffs.filter((diff) => diff.after != null).sort(sortByBasename)
+    const deletedEntries = mergedDiffs.filter((diff) => diff.after == null).sort(sortByBasename)
+    const totals = mergedDiffs.reduce(
+      (sum, diff) => {
+        sum.additions += diff.additions
+        sum.deletions += diff.deletions
+        return sum
+      },
+      { additions: 0, deletions: 0 }
+    )
+    return {
+      modified: modifiedEntries,
+      deleted: deletedEntries,
+      totalAdditions: totals.additions,
+      totalDeletions: totals.deletions,
+      netChange: totals.additions - totals.deletions,
+    }
+  }, [mergedDiffs])
+
+  if (mergedDiffs.length === 0) {
     return null
   }
 
   return (
-    <div className="border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900">
-      {/* Collapsed header */}
-      <button
-        onClick={() => setIsExpanded(!isExpanded)}
-        className="w-full px-4 py-2 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
-      >
-        <span>
-          {changes.length} file{changes.length !== 1 ? "s" : ""} changed • {totalLines} line
-          {totalLines !== 1 ? "s" : ""}
-        </span>
-        <svg
-          className={`w-3 h-3 transition-transform ${isExpanded ? "rotate-180" : ""}`}
-          fill="none"
-          stroke="currentColor"
-          viewBox="0 0 24 24"
-        >
-          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
-        </svg>
-      </button>
+    <div className="bg-gray-50 dark:bg-gray-900">
+      {/* File list */}
+      <div className="max-h-40 overflow-y-auto">
+          <div className="px-3 py-1.5 text-xs text-gray-600 dark:text-gray-400 flex flex-wrap items-center gap-x-3 gap-y-1">
+            <span>
+              {mergedDiffs.length} file{mergedDiffs.length !== 1 ? "s" : ""}
+            </span>
+            <span>
+              {modified.length} modified • {deleted.length} deleted
+            </span>
+            <span className="text-green-600 dark:text-green-400">+{totalAdditions}</span>
+            <span className="text-red-600 dark:text-red-400">-{totalDeletions}</span>
+            <span className="text-gray-500 dark:text-gray-500">
+              net {netChange >= 0 ? "+" : ""}
+              {netChange}
+            </span>
+          </div>
+          {modified.length > 0 && (
+            <div className="px-3 py-1.5 flex flex-wrap items-center gap-1.5">
+              {modified.map((diff) => {
+                const displayPath = toDisplayPath(diff.file, worktree) || normalizePath(diff.file)
+                const baseName = displayPath.split("/").pop() || displayPath
+                return (
+                  <span
+                    key={diff.file}
+                    role="button"
+                    tabIndex={0}
+                    onClick={() => openFile({ path: diff.file, display: displayPath || diff.file })}
+                    onKeyDown={(e) => {
+                      if (e.key === "Enter" || e.key === " ") {
+                        e.preventDefault()
+                        openFile({ path: diff.file, display: displayPath || diff.file })
+                      }
+                    }}
+                    className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60"
+                    title={displayPath || diff.file}
+                  >
+                    {baseName}
+                    {(diff.additions > 0 || diff.deletions > 0) && (
+                      <>
+                        <span className="text-green-600 dark:text-green-400 text-[10px]">+{diff.additions}</span>
+                        <span className="text-red-600 dark:text-red-400 text-[10px]">-{diff.deletions}</span>
+                      </>
+                    )}
+                  </span>
+                )
+              })}
+            </div>
+          )}
 
-      {/* Expanded file list */}
-      {isExpanded && (
-        <div className="border-t border-gray-200 dark:border-gray-700 max-h-40 overflow-y-auto">
-          {changes.map((change, idx) => (
-            <div
-              key={idx}
-              className="px-4 py-2 text-xs flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-800"
-            >
-              <span className="text-gray-700 dark:text-gray-300 font-mono truncate flex-1">{change.path}</span>
-              <div className="flex items-center gap-2 ml-2">
-                {change.linesAdded > 0 && (
-                  <span className="text-green-600 dark:text-green-400">+{change.linesAdded}</span>
-                )}
-                {change.linesRemoved > 0 && (
-                  <span className="text-red-600 dark:text-red-400">-{change.linesRemoved}</span>
-                )}
-              </div>
+          {deleted.length > 0 && (
+            <div className="border-t border-gray-200 dark:border-gray-800 px-3 py-1.5 flex flex-wrap items-center gap-1.5">
+              {deleted.map((diff) => {
+                const displayPath = toDisplayPath(diff.file, worktree) || normalizePath(diff.file)
+                const baseName = displayPath.split("/").pop() || displayPath
+                return (
+                  <span
+                    key={diff.file}
+                    className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-gray-100 dark:bg-gray-800/50 text-gray-500 dark:text-gray-500 rounded line-through"
+                    title={displayPath || diff.file}
+                  >
+                    {baseName}
+                    {(diff.additions > 0 || diff.deletions > 0) && (
+                      <>
+                        <span className="text-green-600 dark:text-green-400 text-[10px] no-underline">+{diff.additions}</span>
+                        <span className="text-red-600 dark:text-red-400 text-[10px] no-underline">-{diff.deletions}</span>
+                      </>
+                    )}
+                  </span>
+                )
+              })}
             </div>
-          ))}
-        </div>
-      )}
+          )}
+
+      </div>
     </div>
   )
 }

+ 22 - 14
packages/opencode/webgui/src/components/MessageInput/FooterPanels.tsx

@@ -1,28 +1,33 @@
 import { useState, useMemo } from "react"
 import { useMessages } from "../../state/MessagesContext"
-import { ModifiedFilesList } from "./ModifiedFilesPanel"
+import { useSession } from "../../state/SessionContext"
 import { TodosList } from "./TodosPanel"
+import { FileChangesPanel } from "../FileChangesPanel"
 
 interface FooterPanelsProps {
   sessionID: string | null
 }
 
 export function FooterPanels({ sessionID }: FooterPanelsProps) {
-  const [filesExpanded, setFilesExpanded] = useState(false)
   const [todosExpanded, setTodosExpanded] = useState(false)
+  const [filesExpanded, setFilesExpanded] = useState(false)
   const { getMessagesBySession } = useMessages()
+  const { sessionDiff } = useSession()
 
-  const { modifiedFiles, todos } = useMemo(() => {
-    if (!sessionID) return { modifiedFiles: [] as string[], todos: null }
+  const { todos, modifiedFiles } = useMemo(() => {
+    if (!sessionID) return { todos: null, modifiedFiles: [] as string[] }
     const messages = getMessagesBySession(sessionID)
+    let todoOutput: string | null = null
     const files: string[] = []
     const seen = new Set<string>()
-    let todoOutput: string | null = null
 
     for (const msg of messages) {
       for (const part of msg.parts) {
         if (part.type !== "tool") continue
         const toolPart = part as { tool?: string; state?: { input?: { filePath?: string }; output?: string } }
+        if (toolPart.tool === "todowrite" && toolPart.state?.output) {
+          todoOutput = toolPart.state.output
+        }
         if ((toolPart.tool === "write" || toolPart.tool === "edit") && toolPart.state?.input?.filePath) {
           const path = toolPart.state.input.filePath
           if (!seen.has(path)) {
@@ -30,9 +35,6 @@ export function FooterPanels({ sessionID }: FooterPanelsProps) {
             files.push(path)
           }
         }
-        if (toolPart.tool === "todowrite" && toolPart.state?.output) {
-          todoOutput = toolPart.state.output
-        }
       }
     }
 
@@ -44,21 +46,27 @@ export function FooterPanels({ sessionID }: FooterPanelsProps) {
       } catch {}
     }
 
-    return { modifiedFiles: files, todos }
+    return { todos, modifiedFiles: files }
   }, [sessionID, getMessagesBySession])
 
-  const hasFiles = modifiedFiles.length > 0
+  const diffs = sessionID ? sessionDiff[sessionID] : undefined
   const hasTodos = todos && todos.length > 0
+  const hasFiles = (diffs && diffs.length > 0) || modifiedFiles.length > 0
 
-  if (!hasFiles && !hasTodos) return null
+  if (!hasTodos && !hasFiles) return null
 
   const completedTodos = todos?.filter((t: any) => t.status === "completed").length ?? 0
+  const fileCount = diffs?.length ?? modifiedFiles.length
 
   return (
     <div className="px-2 py-1 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50 flex flex-col gap-1">
-      {/* Expanded content - TODOs above files */}
+      {/* Expanded content - Files */}
+      {filesExpanded && hasFiles && (
+        <FileChangesPanel diffs={diffs} fallbackFiles={modifiedFiles} />
+      )}
+
+      {/* Expanded content - TODOs */}
       {todosExpanded && hasTodos && <TodosList todos={todos} />}
-      {filesExpanded && hasFiles && <ModifiedFilesList files={modifiedFiles} />}
 
       {/* Labels row */}
       <div className="flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
@@ -70,7 +78,7 @@ export function FooterPanels({ sessionID }: FooterPanelsProps) {
             <svg className={`w-3 h-3 transition-transform ${filesExpanded ? "-rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
               <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
             </svg>
-            <span>{modifiedFiles.length} file{modifiedFiles.length !== 1 ? "s" : ""} changed</span>
+            <span>{fileCount} file{fileCount !== 1 ? "s" : ""} changed</span>
           </button>
         )}
         {hasTodos && (

+ 0 - 46
packages/opencode/webgui/src/components/MessageInput/ModifiedFilesPanel.tsx

@@ -1,46 +0,0 @@
-import { useCallback, type KeyboardEvent } from "react"
-import { useOpenFile } from "../../hooks/useOpenFile"
-import { normalizePath } from "../../utils/path"
-
-interface ModifiedFilesListProps {
-  files: string[]
-}
-
-export function ModifiedFilesList({ files }: ModifiedFilesListProps) {
-  const openFile = useOpenFile()
-
-  const handleFileClick = useCallback(
-    (path: string) => {
-      openFile({ path })
-    },
-    [openFile],
-  )
-
-  const handleKeyDown = useCallback(
-    (path: string) => (e: KeyboardEvent) => {
-      if (e.key === "Enter" || e.key === " ") {
-        e.preventDefault()
-        handleFileClick(path)
-      }
-    },
-    [handleFileClick],
-  )
-
-  return (
-    <div className="flex flex-wrap gap-1">
-      {files.map((path) => (
-        <span
-          key={path}
-          role="button"
-          tabIndex={0}
-          onClick={() => handleFileClick(path)}
-          onKeyDown={handleKeyDown(path)}
-          className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60"
-          title={path}
-        >
-          {normalizePath(path).split("/").pop()}
-        </span>
-      ))}
-    </div>
-  )
-}

+ 6 - 23
packages/opencode/webgui/src/components/MessageList/MessagePart.tsx

@@ -2,7 +2,6 @@ import type { Part, WebguiPart, QuestionRequestPart as QuestionRequestPartType }
 import { TextPart } from "./TextPart"
 import { ReasoningPart } from "./ReasoningPart"
 import { ToolPart } from "../parts/ToolPart"
-import { PatchPart } from "../parts/PatchPart"
 import { SnapshotPart } from "../parts/SnapshotPart"
 import { RetryPart } from "../parts/RetryPart"
 import { SessionErrorPart } from "./SessionErrorPart"
@@ -37,6 +36,11 @@ export function MessagePart({
     return null
   }
 
+  // Patch parts are internal server messages and should not be rendered
+  if (part.type === "patch") {
+    return null
+  }
+
   // Text parts (user and assistant messages)
   if (part.type === "text") {
     // Collect following file/agent parts to group together (they have position info)
@@ -122,28 +126,7 @@ export function MessagePart({
     return null
   }
 
-  // Patches (file edits) - only show standalone ones (not associated with write/edit)
-  if (part.type === "patch") {
-    // Check if there's a write/edit tool before this patch
-    const currentIndex = allParts.findIndex((p) => p.id === part.id)
-    if (currentIndex > 0) {
-      // Look backwards for a write/edit tool
-      for (let i = currentIndex - 1; i >= 0; i--) {
-        const prevPart = allParts[i]
-        if (prevPart.type === "tool" && (prevPart.tool === "write" || prevPart.tool === "edit")) {
-          // This patch is associated with a write/edit tool, skip it
-          return null
-        }
-        // Stop if we hit another patch or non-tool part
-        if (prevPart.type === "patch" || prevPart.type === "text" || prevPart.type === "reasoning") {
-          break
-        }
-      }
-    }
-
-    // Standalone patch (e.g., from patch tool)
-    return <PatchPart key={part.id} part={part as any} sessionID={sessionID || ""} messageID={messageID || ""} />
-  }
+  // Patches are suppressed above
 
   // Snapshots (file state snapshots)
   if (part.type === "snapshot") {

+ 2 - 0
packages/opencode/webgui/src/lib/api/events.ts

@@ -1,4 +1,5 @@
 import { useEffect, useRef, useCallback, useState } from "react"
+import type { FileDiff } from "@opencode-ai/sdk/client"
 
 // Event type definitions based on server Bus events
 export type ServerEvent =
@@ -21,6 +22,7 @@ export type ServerEvent =
     }
   | { type: "session.idle"; properties: { sessionID: string } }
   | { type: "session.compacted"; properties: { sessionID: string } }
+  | { type: "session.diff"; properties: { sessionID: string; diff: FileDiff[] } }
   | { type: "message.updated"; properties: { info: any } }
   | { type: "message.removed"; properties: { sessionID: string; messageID: string } }
   | { type: "message.part.updated"; properties: { part: any; delta?: string } }

+ 45 - 1
packages/opencode/webgui/src/state/SessionContext.tsx

@@ -1,6 +1,6 @@
 import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react"
 import { sdk } from "../lib/api/sdkClient"
-import type { Session } from "@opencode-ai/sdk/client"
+import type { Session, FileDiff } from "@opencode-ai/sdk/client"
 import { eventEmitter } from "../lib/api/events"
 
 /**
@@ -35,6 +35,9 @@ interface SessionContextState {
   isReasoning: boolean
   setReasoning: (sessionId: string, active: boolean) => void
 
+  // Session diff data (per session)
+  sessionDiff: Record<string, FileDiff[]>
+
   // Session status for current session
   currentStatus: SessionStatusInfo
 
@@ -125,6 +128,7 @@ export function SessionProvider({ children }: SessionProviderProps) {
   const [isIdle, setIsIdle] = useState(true)
   const [reasoningMap, setReasoningMap] = useState<Record<string, boolean>>({})
   const [statusMap, setStatusMap] = useState<Record<string, SessionStatusInfo>>({})
+  const [sessionDiffMap, setSessionDiffMap] = useState<Record<string, FileDiff[]>>({})
 
   // Model and Agent selection state (synced with server state + localStorage fallback)
   const [selectedProviderId, setSelectedProviderId] = useState<string | undefined>()
@@ -844,6 +848,30 @@ export function SessionProvider({ children }: SessionProviderProps) {
     loadSessions()
   }, [loadSessions])
 
+  // Load session diff when current session changes
+  useEffect(() => {
+    const sessionId = currentSession?.id
+    if (!sessionId || isVirtualSession) return
+
+    const controller = new AbortController()
+    const fetchDiff = async () => {
+      try {
+        const response = await sdk.session.diff({ path: { id: sessionId } })
+        if (controller.signal.aborted) return
+        if (response.data) {
+          setSessionDiffMap((prev) => ({ ...prev, [sessionId]: response.data }))
+        }
+      } catch (err) {
+        if (!controller.signal.aborted) {
+          console.error("[SessionContext] Failed to load session diff:", err)
+        }
+      }
+    }
+
+    fetchDiff()
+    return () => controller.abort()
+  }, [currentSession?.id, isVirtualSession])
+
   // Listen for session events from SSE
   useEffect(() => {
     const handleSessionCreated = (event: any) => {
@@ -884,6 +912,12 @@ export function SessionProvider({ children }: SessionProviderProps) {
         const deletedId = event.properties.info.id
         console.log("[SessionContext] Session deleted event:", deletedId)
         setSessions((prev) => prev.filter((s) => s.id !== deletedId))
+        setSessionDiffMap((prev) => {
+          if (!prev[deletedId]) return prev
+          const next = { ...prev }
+          delete next[deletedId]
+          return next
+        })
         const isCurrent = currentSession?.id === deletedId
         if (isCurrent) {
           newVirtual()
@@ -908,16 +942,25 @@ export function SessionProvider({ children }: SessionProviderProps) {
       })
     }
 
+    const handleSessionDiff = (event: any) => {
+      if (event.type !== "session.diff" || !event.properties) return
+      const { sessionID, diff } = event.properties as { sessionID: string; diff: FileDiff[] }
+      if (!sessionID) return
+      setSessionDiffMap((prev) => ({ ...prev, [sessionID]: Array.isArray(diff) ? diff : [] }))
+    }
+
     const unsubscribeCreated = eventEmitter.on("session.created", handleSessionCreated)
     const unsubscribeUpdated = eventEmitter.on("session.updated", handleSessionUpdated)
     const unsubscribeDeleted = eventEmitter.on("session.deleted", handleSessionDeleted)
     const unsubscribeStatus = eventEmitter.on("session.status", handleSessionStatus)
+    const unsubscribeDiff = eventEmitter.on("session.diff", handleSessionDiff)
 
     return () => {
       unsubscribeCreated()
       unsubscribeUpdated()
       unsubscribeDeleted()
       unsubscribeStatus()
+      unsubscribeDiff()
     }
   }, [currentSession?.id, setReasoning, newVirtual])
 
@@ -933,6 +976,7 @@ export function SessionProvider({ children }: SessionProviderProps) {
     setIsIdle,
     isReasoning,
     setReasoning,
+    sessionDiff: sessionDiffMap,
     currentStatus,
     selectedProviderId,
     selectedModelId,