diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index f6a909c4b..3dcc11e19 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -702,7 +702,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, review: { - diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), + diffStyle: createMemo(() => store.review?.diffStyle ?? "unified"), setDiffStyle(diffStyle: ReviewDiffStyle) { if (!store.review) { setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 183ae8e9e..30ead384b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,7 +1,7 @@ -import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FilePart, Project, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" +import { createQuery, skipToken, useMutation } from "@tanstack/solid-query" import { batch, ErrorBoundary, @@ -22,14 +22,11 @@ import { import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" -import { debounce } from "@solid-primitives/scheduled" import { useLocal } from "@/context/local" import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore } from "solid-js/store" import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" -import { Select } from "@opencode-ai/ui/select" -import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view" import { Tabs } from "@opencode-ai/ui/tabs" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" @@ -88,10 +85,8 @@ import { import { SessionSidePanel } from "@/pages/session/session-side-panel" import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" -import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" -import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" import { useComposerCommands } from "@/pages/session/use-composer-commands" @@ -99,7 +94,6 @@ import { useSessionCommands } from "@/pages/session/use-session-commands" import { useAmicodeCommands } from "@/pages/session/use-amicode-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" -import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" @@ -115,9 +109,6 @@ type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick const emptyFollowups: FollowupItem[] = [] -type ChangeMode = "git" | "branch" | "turn" -type VcsMode = "git" | "branch" - const sessionViewState = () => ({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", @@ -363,7 +354,6 @@ export default function Page() { const local = useLocal() const file = useFile() const sync = useSync() - const queryClient = useQueryClient() const dialog = useDialog() const language = useLanguage() const sdk = useSDK() @@ -378,7 +368,6 @@ export default function Page() { const location = useLocation() const navigate = useNavigate() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() - const reviewMode = () => view().review.mode() ?? "git" const reviewFile = () => view().review.file() const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) @@ -675,63 +664,55 @@ export default function Page() { return open }, desktopReviewOpen()) - const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) - const nogit = createMemo(() => { - const project = sync().project - return !!project && project.vcs !== "git" - }) - const changesOptions = createMemo(() => { - const list: ChangeMode[] = [] - const project = sync().project - const vcs = sync().data.vcs - if (project?.vcs === "git") list.push("git") - if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) { - list.push("branch") - } - list.push("turn") - return list - }) const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const wantsReview = createMemo(() => - isDesktop() - ? desktopFileTreeOpen() || - (desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab()))) - : store.mobileTab === "changes", - ) - const vcsMode = createMemo(() => { - const mode = reviewMode() - if (mode === "git" || mode === "branch") return mode - }) - const vcsKey = createMemo( - () => - ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const, - ) - const vcsQuery = createQuery(() => { - const mode = vcsMode() - const enabled = wantsReview() && sync().project?.vcs === "git" - + const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch"]) + const sessionDiffKey = () => ["session-diff", params.id ?? ""] as const + const sessionDiffQuery = createQuery(() => { + const sessionID = params.id return { - queryKey: [...vcsKey(), mode] as const, - enabled, - queryFn: mode + queryKey: sessionDiffKey(), + enabled: !!sessionID, + placeholderData: [] as SnapshotFileDiff[], + queryFn: sessionID ? () => sdk() - .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) - .then((result) => result.data) - .catch((error) => { - console.debug("[session-review] failed to load vcs diff", { mode, error }) - return [] - }) + .client.session.diff({ sessionID, directory: sdk().directory }) + .then((result) => (result.data ?? []) as Array) + .catch(() => [] as SnapshotFileDiff[]) : skipToken, } }) - const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) - const reviewDiffs = () => { - if (reviewMode() === "git" || reviewMode() === "branch") - // avoids suspense - return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] - return turnDiffs() - } + const reviewDiffs = createMemo(() => { + // Server endpoint returns the authoritative full-session diff (queries all messages). + const serverDiffs = sessionDiffQuery.data ?? [] + if (serverDiffs.length > 0) return serverDiffs.filter((d): d is SnapshotFileDiff & { file: string } => !!d.file) + // Fallback: derive from tool parts currently loaded in the client. + // This shows immediate results for visible messages while the server query loads. + const allMessages = messages() + if (!allMessages.length) return [] as Array + const seen = new Map() + for (const msg of allMessages) { + const parts = sync().data.part[msg.id] + if (!parts) continue + for (const part of parts) { + if (part.type !== "tool" || !EDIT_TOOLS.has(part.tool)) continue + if (part.state.status !== "completed") continue + const meta = part.state.metadata as Record | undefined + const filediff = meta?.filediff as { file?: string; patch?: string; additions?: number; deletions?: number } | undefined + if (filediff?.file) { + const relPath = (part.state as { title?: string }).title || filediff.file + seen.set(filediff.file, { + file: relPath, + patch: filediff.patch, + additions: filediff.additions ?? 0, + deletions: filediff.deletions ?? 0, + status: seen.has(filediff.file) ? "modified" : "added", + }) + } + } + } + return [...seen.values()] + }) const activeReviewFile = () => { const diffs = reviewDiffs() const selected = reviewFile() @@ -739,54 +720,13 @@ export default function Page() { return diffs[0]?.file } const reviewCount = () => reviewDiffs().length - const hasReview = () => reviewCount() > 0 - const reviewReady = () => { - if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending - return true - } - const loadReviewDiff = async (file: string, version?: number): Promise => { - const mode = vcsMode() - if (!mode) return - const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory) - const directory = reviewDiffDirectory(root, file) - const source = reviewDiffs().find((diff) => diff.file === file) - const valid = (diff: VcsFileDiff | undefined) => { - if (!diff || !source) return - if (diff.additions !== source.additions || diff.deletions !== source.deletions) return - if (reviewDiffNeedsLoad(diff)) return - return diff - } - const request = (scope: string, context?: number) => - queryClient - .fetchQuery({ - queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const, - staleTime: Number.POSITIVE_INFINITY, - retry: 2, - queryFn: () => - sdk() - .api.vcs.diff({ - location: { directory: scope }, - mode: mode === "git" ? "working" : mode, - context, - }) - .then((result) => result.data), - }) - .then((diffs) => diffs.find((diff) => diff.file === file)) - - if (directory !== root) { - try { - const scoped = valid(await request(directory)) - if (scoped) return scoped - } catch (error) { - console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error }) - } - } - try { - const bounded = valid(await request(root, 3)) - if (bounded) return bounded - } catch (error) { - console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error }) - } + const hasReview = () => true + const reviewReady = () => true + const loadReviewDiff = async (file: string, _version?: number): Promise<(SnapshotFileDiff & { file: string }) | undefined> => { + const diffs = reviewDiffs() + const found = diffs.find((d) => d.file === file) + if (found && found.file) return found as SnapshotFileDiff & { file: string } + return undefined } const newSessionWorktree = createMemo(() => { @@ -984,7 +924,6 @@ export default function Page() { : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return - refreshVcs() }) onCleanup(stopVcs) @@ -1108,29 +1047,6 @@ export default function Page() { } } - createEffect(() => { - if (!layout.ready()) return - if (sync().status !== "complete") return - if (!sync().project) return - const list = changesOptions() - const mode = reviewMode() - if (list.includes(mode)) return - const next = list[0] - if (!next) return - view().review.setMode(next) - }) - - createEffect( - on( - () => sync().data.session_status[params.id ?? ""]?.type, - (next, prev) => { - if (next !== "idle" || prev === undefined || prev === "idle") return - refreshVcs() - }, - { defer: true }, - ), - ) - const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -1188,47 +1104,9 @@ export default function Page() { loadFile: file.load, }) - const changesLabel = (option: ChangeMode) => { - if (option === "git") return language.t("ui.sessionReview.title.git") - if (option === "branch") return language.t("ui.sessionReview.title.branch") - return language.t("ui.sessionReview.title.lastTurn") - } - - const changesTitle = () => { - if (!canReview()) { - return null - } - - return ( -