Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
15e45ab
feat(session): implement session-scoped diff from snapshots (#174)
jeonghun-jj-lee Aug 10, 2026
ee0af8d
feat(app): replace VCS mode selector with session-scoped diffs (#174)
jeonghun-jj-lee Aug 10, 2026
2d6384b
fix(session): use step-finish snapshot for completed session diffs
jeonghun-jj-lee Aug 10, 2026
7ab3a80
fix(session): fall back to stored summary diffs when snapshots unavai…
jeonghun-jj-lee Aug 10, 2026
8c48c44
simplify: read file changes directly from message data, no server query
jeonghun-jj-lee Aug 10, 2026
3a20b13
fix(app): derive file changes from tool parts, not summary.diffs
jeonghun-jj-lee Aug 10, 2026
34d808e
fix(app): use metadata.filediff for patch content and relative paths
jeonghun-jj-lee Aug 10, 2026
03440da
fix(app): static 'Files Changed' tab label without count
jeonghun-jj-lee Aug 10, 2026
6c20a92
fix(app): default diff style to unified, fix tab labels in side panel
jeonghun-jj-lee Aug 10, 2026
aa792da
fix(app): use timeline messages() for complete file list
jeonghun-jj-lee Aug 10, 2026
001fce3
fix: extract file diffs from tool part metadata in backend + restore …
jeonghun-jj-lee Aug 10, 2026
1c426d4
fix(ui): remove redundant header, prevent toolbar overflow on narrow …
jeonghun-jj-lee Aug 10, 2026
d53442a
fix(ui): remove expand/collapse toggle (non-functional without full f…
jeonghun-jj-lee Aug 10, 2026
ba987f9
fix(ui): toolbar items flow together, prevent toggle from being pushe…
jeonghun-jj-lee Aug 10, 2026
897f502
fix(app): prevent scroll reset from review panel re-renders
jeonghun-jj-lee Aug 10, 2026
116fbae
fix(app): hasReview always true to prevent layout shift on first load
jeonghun-jj-lee Aug 10, 2026
539e7a8
fix(app): wrap reviewDiffs in createMemo to prevent scroll resets
jeonghun-jj-lee Aug 10, 2026
1181a05
rename tab: 'Files Changed' → 'Modified Files'
jeonghun-jj-lee Aug 10, 2026
b767971
fix(app): hide scroll container on cold-bottom-mount until scrollToEn…
jeonghun-jj-lee Aug 10, 2026
abb2277
fix(timeline): disable followOnAppend when user has scrolled away
jeonghun-jj-lee Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
258 changes: 58 additions & 200 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -88,18 +85,15 @@ 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"
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"
Expand All @@ -115,9 +109,6 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []

type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"

const sessionViewState = () => ({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
Expand Down Expand Up @@ -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()
Expand All @@ -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())
Expand Down Expand Up @@ -675,118 +664,69 @@ 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<ChangeMode[]>(() => {
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<VcsMode | undefined>(() => {
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<SnapshotFileDiff>)
.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<SnapshotFileDiff & { file: string }>
const seen = new Map<string, SnapshotFileDiff & { file: string }>()
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<string, unknown> | 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()
if (selected && diffs.some((diff) => diff.file === selected)) return selected
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<VcsFileDiff | undefined> => {
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(() => {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 (
<Select
options={changesOptions()}
current={reviewMode()}
label={changesLabel}
onSelect={(option) => option && view().review.setMode(option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
}

const changesTitleV2 = () => {
if (!canReview()) {
return null
}
const changesTitle = () => null

return (
<SelectV2
appearance="inline"
options={changesOptions()}
current={reviewMode()}
label={changesLabel}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && view().review.setMode(option)}
/>
)
}
const changesTitleV2 = () => null

const empty = (text: string) => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
Expand All @@ -1253,36 +1131,18 @@ export default function Page() {
)

const reviewEmptyText = createMemo(() => {
if (reviewMode() === "git") return language.t("session.review.noUncommittedChanges")
if (reviewMode() === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
})

const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (reviewMode() === "git" || reviewMode() === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}

if (reviewMode() === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText())
}

return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
</div>
)
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}

const reviewEmptyV2 = () => {
if ((reviewMode() === "git" || reviewMode() === "branch") && !reviewReady()) {
if (!reviewReady()) {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (reviewMode() === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
}
return <SessionReviewEmptyChangesV2 />
}

Expand Down Expand Up @@ -1334,7 +1194,7 @@ export default function Page() {
diffs: reviewDiffs,
diffsReady: reviewReady,
get diffVersion() {
return vcsQuery.dataUpdatedAt
return lastUserMessage()?.time.created
},
loadDiff: loadReviewDiff,
get activeFile() {
Expand Down Expand Up @@ -2086,9 +1946,7 @@ export default function Page() {
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
{"Modified Files"}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
Expand Down
Loading
Loading