diff --git a/apps/web/package.json b/apps/web/package.json index 8cccaddfeb31962ad93a398d81fab175255d6d8f..517a6bba0b52274109d9610ab80ad1272f60af69 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@pierre/diffs": "^1.0.11", "@t3tools/contracts": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 85fc77c00a7d7832778dc3d510fbdcfc32856681..6a3d09363d20dc77c7d1211e3f7b0d922d9c6c54 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,7 @@ import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; +import { Suspense, lazy, useEffect, useRef } from "react"; import ChatView from "./components/ChatView"; -import DiffPanel from "./components/DiffPanel"; import Sidebar from "./components/Sidebar"; import { isElectron } from "./env"; import { DEFAULT_MODEL } from "./model-logic"; @@ -13,6 +12,8 @@ import { useNativeApi } from "./hooks/useNativeApi"; import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; import { invalidateGitQueries } from "./lib/gitReactQuery"; +const DiffPanel = lazy(() => import("./components/DiffPanel")); + function EventRouter() { const api = useNativeApi(); const { dispatch } = useStore(); @@ -195,7 +196,17 @@ function Layout() { - {state.diffOpen && } + {state.diffOpen && ( + + Loading diff viewer... + + } + > + + + )} ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2fdccc071529599b4ff4795f96695f021c7cee52..dda7d6a5ae78b3b4db589b17fab8d4b335c49d47 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -52,7 +52,9 @@ import { import { derivePendingApprovals, derivePhase, + deriveTurnDiffSummaries, deriveTimelineEntries, + type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, @@ -406,6 +408,18 @@ export default function ChatView() { () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], ); + const turnDiffSummaries = useMemo( + () => deriveTurnDiffSummaries(activeThread?.events ?? []), + [activeThread?.events], + ); + const turnDiffSummaryByAssistantMessageId = useMemo(() => { + const byMessageId = new Map(); + for (const summary of turnDiffSummaries) { + if (!summary.assistantMessageId) continue; + byMessageId.set(summary.assistantMessageId, summary); + } + return byMessageId; + }, [turnDiffSummaries]); const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; @@ -1685,8 +1699,36 @@ export default function ChatView() { setExpandedImage(image); }, []); const onToggleDiff = useCallback(() => { - dispatch({ type: "TOGGLE_DIFF" }); - }, [dispatch]); + if (state.diffOpen) { + dispatch({ type: "CLOSE_DIFF" }); + return; + } + + const latestSummary = turnDiffSummaries[0]; + if (!activeThread || !latestSummary) { + dispatch({ type: "TOGGLE_DIFF" }); + return; + } + + dispatch({ + type: "OPEN_DIFF", + threadId: activeThread.id, + turnId: latestSummary.turnId, + ...(latestSummary.files[0]?.path ? { filePath: latestSummary.files[0].path } : {}), + }); + }, [activeThread, dispatch, state.diffOpen, turnDiffSummaries]); + const onOpenTurnDiff = useCallback( + (turnId: string, filePath?: string) => { + if (!activeThread) return; + dispatch({ + type: "OPEN_DIFF", + threadId: activeThread.id, + turnId, + ...(filePath ? { filePath } : {}), + }); + }, + [activeThread, dispatch], + ); // Empty state: no active thread if (!activeThread) { @@ -1761,9 +1803,11 @@ export default function ChatView() { completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} assistantCompletionByItemId={assistantCompletionByItemId} + turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} nowIso={nowIso} expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} + onOpenTurnDiff={onOpenTurnDiff} onImageExpand={onExpandTimelineImage} messagesEndRef={messagesEndRef} /> @@ -2268,9 +2312,11 @@ interface MessagesTimelineProps { completionDividerBeforeEntryId: string | null; completionSummary: string | null; assistantCompletionByItemId: Map; + turnDiffSummaryByAssistantMessageId: Map; nowIso: string; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; + onOpenTurnDiff: (turnId: string, filePath?: string) => void; onImageExpand: (image: ExpandedImagePreview) => void; messagesEndRef: RefObject; } @@ -2282,9 +2328,11 @@ const MessagesTimeline = memo(function MessagesTimeline({ completionDividerBeforeEntryId, completionSummary, assistantCompletionByItemId, + turnDiffSummaryByAssistantMessageId, nowIso, expandedWorkGroups, onToggleWorkGroup, + onOpenTurnDiff, onImageExpand, messagesEndRef, }: MessagesTimelineProps) { @@ -2446,6 +2494,39 @@ const MessagesTimeline = memo(function MessagesTimeline({ (timelineEntry.message.streaming ? "" : "(empty response)") } /> + {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id); + if (!turnSummary) return null; + return ( +
+
+

+ Changed files ({turnSummary.files.length}) +

+ +
+
+ {turnSummary.files.map((file) => ( + + ))} +
+
+ ); + })()}

{formatMessageMeta( timelineEntry.message.createdAt, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 84a6cad50831d99a6d14174207309cecd3298b03..8cecdedd5d932b08009e59a8ecfe4090b73ebc8c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,58 +1,145 @@ +import { PatchDiff } from "@pierre/diffs/react"; import { XIcon } from "lucide-react"; +import { useMemo } from "react"; import { isElectron } from "../env"; +import { deriveTurnDiffSummaries, formatTimestamp } from "../session-logic"; import { useStore } from "../store"; import { Button } from "./ui/button"; +import { cn } from "~/lib/utils"; export default function DiffPanel() { - const { dispatch } = useStore(); + const { state, dispatch } = useStore(); + const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); + const turnDiffSummaries = useMemo( + () => deriveTurnDiffSummaries(activeThread?.events ?? []), + [activeThread?.events], + ); + + const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); + const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; + const selectedFilePath = canApplyStoredTarget ? state.diffFilePath : null; + const selectedTurn = + turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? turnDiffSummaries[0]; + const selectedFile = + selectedTurn?.files.find((file) => file.path === selectedFilePath) ?? selectedTurn?.files[0]; + const selectedPatch = selectedFile?.diff ?? selectedTurn?.unifiedDiff; + + const selectTurn = (turnId: string) => { + if (!activeThread) return; + const turn = turnDiffSummaries.find((summary) => summary.turnId === turnId); + dispatch({ + type: "SET_DIFF_TARGET", + threadId: activeThread.id, + turnId, + ...(turn?.files[0]?.path ? { filePath: turn.files[0].path } : {}), + }); + }; + + const selectFile = (filePath: string) => { + if (!activeThread || !selectedTurn) return; + dispatch({ + type: "SET_DIFF_TARGET", + threadId: activeThread.id, + turnId: selectedTurn.turnId, + filePath, + }); + }; return ( -