feat(app): v2 review panel overhaul (#31882)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
Aarav Sareen 2026-07-02 13:11:58 +05:30 committed by GitHub
commit 7d2618637f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3438 additions and 214 deletions

View file

@ -0,0 +1,421 @@
import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import {
createEffect,
createMemo,
For,
Match,
on,
Show,
splitProps,
Switch,
untrack,
type ComponentProps,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import {
dirsToExpand,
pathToFileUrl,
shouldListRoot,
visibleKind,
withFileDragImage,
type Filter,
type Kind,
} from "@/components/file-tree"
export type { Kind } from "@/components/file-tree"
const MAX_DEPTH = 128
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
const nodes = children(path)
if (!current) return nodes
const parent = (item: string) => {
const idx = item.lastIndexOf("/")
if (idx === -1) return ""
return item.slice(0, idx)
}
const leaf = (item: string) => {
const idx = item.lastIndexOf("/")
return idx === -1 ? item : item.slice(idx + 1)
}
const out = nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
const seen = new Set(out.map((node) => node.path))
for (const dir of current.dirs) {
if (parent(dir) !== path) continue
if (seen.has(dir)) continue
out.push({
name: leaf(dir),
path: dir,
absolute: dir,
type: "directory",
ignored: false,
})
seen.add(dir)
}
for (const item of current.files) {
if (parent(item) !== path) continue
if (seen.has(item)) continue
out.push({
name: leaf(item),
path: item,
absolute: item,
type: "file",
ignored: false,
})
seen.add(item)
}
out.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
return out
}
const INDENT_STEP = 16
function rowPaddingLeft(level: number, type: FileNode["type"]) {
if (type === "directory") return 8 + level * INDENT_STEP
if (level === 0) return 8
return 8 + level * INDENT_STEP - INDENT_STEP
}
function guideLineLeft(level: number) {
return rowPaddingLeft(level, "directory") + 8
}
export const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return ""
}
export const kindChange = (kind: Kind) => {
if (kind === "add") return "added"
if (kind === "del") return "deleted"
return "modified"
}
const FileTreeNodeV2 = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
level: number
active?: string
draggable: boolean
kinds?: ReadonlyMap<string, Kind>
marks?: Set<string>
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, [
"node",
"level",
"active",
"draggable",
"kinds",
"marks",
"as",
"children",
"class",
"classList",
])
const kind = () => visibleKind(local.node, local.kinds, local.marks)
return (
<Dynamic
component={local.as ?? "div"}
data-slot="file-tree-v2-row"
data-selected={local.node.path === local.active ? "" : undefined}
data-ignored={local.node.ignored ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
withFileDragImage(event)
}}
{...rest}
>
{local.children}
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
{(() => {
const value = kind()
if (!value || local.node.type !== "file") return null
return (
<span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
{kindLabel(value)}
</span>
)
})()}
</Dynamic>
)
}
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
// synthesized from that list) for nested content to appear.
export default function FileTreeV2(props: {
path: string
active?: string
level?: number
allowed?: readonly string[]
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) {
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const key = (p: string) =>
file
.normalize(p)
.replace(/[\\/]+$/, "")
.replaceAll("\\", "/")
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
const filter = createMemo(() => {
if (props._filter) return props._filter
const allowed = props.allowed
if (!allowed) return
const files = new Set(allowed)
const dirs = new Set<string>()
for (const item of allowed) {
const parts = item.split("/")
const parents = parts.slice(0, -1)
for (const [idx] of parents.entries()) {
const dir = parents.slice(0, idx + 1).join("/")
if (dir) dirs.add(dir)
}
}
return { files, dirs }
})
const marks = createMemo(() => {
if (props._marks) return props._marks
const out = new Set<string>(props.kinds?.keys() ?? [])
if (out.size === 0) return
return out
})
const kinds = createMemo(() => {
if (props._kinds) return props._kinds
return props.kinds
})
const deeps = createMemo(() => {
if (props._deeps) return props._deeps
const out = new Map<string, number>()
const root = props.path
if (!(file.tree.state(root)?.expanded ?? false)) return out
const seen = new Set<string>()
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
const push = (dir: string, lvl: number) => {
const id = key(dir)
if (seen.has(id)) return
seen.add(id)
const kids = file.tree
.children(dir)
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
.map((node) => node.path)
stack.push({ dir, lvl, i: 0, kids, max: lvl })
}
push(root, level - 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]!
if (top.i < top.kids.length) {
const next = top.kids[top.i]!
top.i++
push(next, top.lvl + 1)
continue
}
out.set(top.dir, top.max)
stack.pop()
const parent = stack[stack.length - 1]
if (!parent) continue
parent.max = Math.max(parent.max, top.max)
}
return out
})
createEffect(() => {
const current = filter()
const dirs = dirsToExpand({
level,
filter: current,
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
})
// Nodes come from the `allowed` filter; skip listing so directories that only
// exist on the diff's base branch do not each fail with an error toast.
for (const dir of dirs) file.tree.expand(dir, { list: false })
})
createEffect(
on(
() => props.path,
(path) => {
const dir = untrack(() => file.tree.state(path))
if (!shouldListRoot({ level, dir })) return
void file.tree.list(path)
},
{ defer: false },
),
)
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
return (
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
// an outer group with the same name to widen the hover area.
<div data-component="file-tree-v2" class="group/file-tree-v2">
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
return (
<Switch>
<Match when={node.type === "directory"}>
<Collapsible
variant="ghost"
class="w-full"
data-scope="file-tree-v2"
forceMount={false}
open={expanded()}
onOpenChange={(open) =>
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
}
>
<Collapsible.Trigger>
<FileTreeNodeV2
node={node}
level={level}
active={props.active}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
>
<div
data-slot="file-tree-v2-chevron"
data-expanded={expanded() ? "" : undefined}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
</div>
</FileTreeNodeV2>
</Collapsible.Trigger>
<Show when={hasChildren()}>
<Collapsible.Content class="relative">
<div
classList={{
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
}}
style={`left: ${guideLineLeft(level)}px`}
/>
<Show
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
>
<FileTreeV2
path={node.path}
level={level + 1}
allowed={props.allowed}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
/>
</Show>
</Collapsible.Content>
</Show>
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<FileTreeNodeV2
node={node}
level={level}
active={props.active}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
>
<Show when={level > 0}>
<div class="w-4 shrink-0" />
</Show>
<Show
when={!node.ignored}
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
>
<span class="filetree-iconpair size-4">
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</Show>
</FileTreeNodeV2>
</Match>
</Switch>
)
}}
</For>
</div>
)
}

View file

@ -21,13 +21,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
function pathToFileUrl(filepath: string): string {
export function pathToFileUrl(filepath: string): string {
return `file://${encodeFilePath(filepath)}`
}
type Kind = "add" | "del" | "mix"
export type Kind = "add" | "del" | "mix"
type Filter = {
export type Filter = {
files: Set<string>
dirs: Set<string>
}
@ -78,7 +78,7 @@ const kindDotColor = (kind: Kind) => {
return "background-color: var(--icon-diff-modified-base)"
}
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
export const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
const kind = kinds?.get(node.path)
if (!kind) return
if (!marks?.has(node.path)) return
@ -99,7 +99,7 @@ const buildDragImage = (target: HTMLElement) => {
return image
}
const withFileDragImage = (event: DragEvent) => {
export const withFileDragImage = (event: DragEvent) => {
const image = buildDragImage(event.currentTarget as HTMLElement)
if (!image) return
document.body.appendChild(image)

View file

@ -85,7 +85,15 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
active: null as CommentFocus | null,
})
const all = () => aggregate(store.comments)
// Reuse the previous array when contents are unchanged so consumers keep a stable
// identity; a fresh array per call cascaded into diff annotation re-renders.
let lastAll: LineComment[] = []
const all = () => {
const next = aggregate(store.comments)
if (next.length === lastAll.length && next.every((item, index) => item === lastAll[index])) return lastAll
lastAll = next
return next
}
const setRef = (
key: "focus" | "active",

View file

@ -127,10 +127,14 @@ export function createFileTreeStore(options: TreeStoreOptions) {
return promise
}
const expandDir = (input: string) => {
// `list: false` marks a directory expanded without fetching its children, for
// trees whose nodes are synthesized from a filter; listing directories that
// only exist on a diff's base branch fails and surfaces error toasts.
const expandDir = (input: string, behavior?: { list?: boolean }) => {
const dir = options.normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", true)
if (behavior?.list === false) return
void listDir(dir)
}

View file

@ -24,8 +24,10 @@ 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"
@ -77,6 +79,10 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/
import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
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 { TerminalPanel } from "@/pages/session/terminal-panel"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { useSessionCommands } from "@/pages/session/use-session-commands"
@ -1052,22 +1058,22 @@ 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
}
const label = (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")
}
return (
<Select
options={changesOptions()}
current={store.changes}
label={label}
label={changesLabel}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
@ -1076,6 +1082,24 @@ export default function Page() {
)
}
const changesTitleV2 = () => {
if (!canReview()) {
return null
}
return (
<SelectV2
appearance="inline"
options={changesOptions()}
current={store.changes}
label={changesLabel}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && setStore("changes", option)}
/>
)
}
const empty = (text: string) => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
@ -1122,6 +1146,16 @@ export default function Page() {
)
}
const reviewEmptyV2 = () => {
if ((store.changes === "git" || store.changes === "branch") && !reviewReady()) {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (store.changes === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
}
return <SessionReviewEmptyChangesV2 />
}
const reviewContent = (input: {
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void
@ -1155,6 +1189,63 @@ export default function Page() {
</Show>
)
const reviewV2State = createReviewPanelV2State()
// Getters defer reactive reads to the consuming scope. Eager reads here ran inside
// the side panel's Show children and remounted the whole review panel on unrelated
// updates such as session switches.
const reviewPanelV2Props = () => ({
get title() {
return changesTitleV2()
},
get empty() {
return reviewEmptyV2()
},
diffs: reviewDiffs,
diffsReady: reviewReady,
get activeFile() {
return tree.activeDiff
},
onSelectFile: focusReviewDiff,
get diffStyle() {
return layout.review.diffStyle()
},
onDiffStyleChange: layout.review.setDiffStyle,
state: reviewV2State,
onLineComment: (comment: SessionReviewLineComment) => addCommentToContext({ ...comment, origin: "review" }),
onLineCommentUpdate: updateCommentInContext,
onLineCommentDelete: removeCommentFromContext,
get lineCommentActions() {
return reviewCommentActions()
},
get comments() {
return comments.all()
},
get focusedComment() {
return comments.focus()
},
onFocusedCommentChange: (focus: { file: string; id: string } | null) => {
// The preview clears the focus once it has opened the comment; persist the
// focused file as the active selection so the preview stays on it. Skip
// files outside the current diff set (their focus is cleared unhandled).
if (!focus) {
const current = comments.focus()
if (current && reviewDiffs().some((diff) => diff.file === current.file)) focusReviewDiff(current.file)
}
comments.setFocus(focus)
},
})
const reviewPanelV2 = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
{/* The route remounts per session; defer the diff render off the switch critical path
like the legacy review tab does. */}
<Show when={!store.deferRender}>
<ReviewPanelV2 {...reviewPanelV2Props()} />
</Show>
</div>
)
const reviewPanel = () => (
<div
classList={{
@ -2078,7 +2169,7 @@ export default function Page() {
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
reviewPanel={() => (newSessionDesign() ? reviewPanelV2() : reviewPanel())}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { filterReviewFiles, reviewDiffKinds } from "./review-diff-kinds"
describe("reviewDiffKinds", () => {
test("maps file and directory kinds", () => {
const kinds = reviewDiffKinds([
{ file: "src/a.ts", additions: 1, deletions: 0, status: "added" },
{ file: "src/b.ts", additions: 0, deletions: 2, status: "deleted" },
])
expect(kinds.get("src/a.ts")).toBe("add")
expect(kinds.get("src/b.ts")).toBe("del")
expect(kinds.get("src")).toBe("mix")
})
})
describe("filterReviewFiles", () => {
test("filters by path substring", () => {
const files = ["src/a.ts", "src/b.ts", "lib/c.ts"]
expect(filterReviewFiles(files, "b.ts")).toEqual(["src/b.ts"])
expect(filterReviewFiles(files, "")).toEqual(files)
})
})

View file

@ -0,0 +1,42 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Kind } from "@/components/file-tree-v2"
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) {
return p.replaceAll("\\", "/").replace(/\/+$/, "")
}
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function reviewDiffKinds(diffs: RenderDiff[]) {
const merge = (a: Kind | undefined, b: Kind) => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const out = new Map<string, Kind>()
for (const diff of diffs) {
const file = normalizePath(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
parts.slice(0, -1).forEach((_, idx) => {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) return
out.set(dir, merge(out.get(dir), kind))
})
}
return out
}
export function filterReviewFiles(files: string[], query: string) {
const value = query.trim().toLowerCase()
if (!value) return files
return files.filter((file) => file.toLowerCase().includes(value))
}

View file

@ -0,0 +1,40 @@
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
type SessionReviewExpandMode,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
export function createReviewPanelV2State() {
const [store, setStore] = persisted(
Persist.global("review-panel-v2"),
createStore({
sidebarOpened: true,
sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
expandMode: "collapse" as SessionReviewExpandMode,
}),
)
// The filter is transient by design: a persisted filter would silently hide
// files after a reload.
const [filter, setFilter] = createSignal("")
return {
sidebarOpened: () => store.sidebarOpened,
sidebarWidth: () => store.sidebarWidth,
filter,
setFilter,
expandMode: () => store.expandMode,
setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode),
resizeSidebar: (width: number) =>
setStore(
"sidebarWidth",
Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)),
),
toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened),
}
}
export type ReviewPanelV2State = ReturnType<typeof createReviewPanelV2State>

View file

@ -0,0 +1,234 @@
import { createMemo, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
import type {
SessionReviewComment,
SessionReviewCommentActions,
SessionReviewCommentDelete,
SessionReviewCommentUpdate,
SessionReviewDiffStyle,
SessionReviewFocus,
SessionReviewLineComment,
} from "@opencode-ai/session-ui/session-review"
import FileTreeV2 from "@/components/file-tree-v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import {
filterRenderableDiff,
filterReviewFiles,
reviewDiffKinds,
type RenderDiff,
} from "@/pages/session/v2/review-diff-kinds"
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element
empty?: JSX.Element
diffs: () => ReviewDiff[]
diffsReady: () => boolean
activeFile?: string
onSelectFile: (path: string) => void
diffStyle: SessionReviewDiffStyle
onDiffStyleChange?: (style: SessionReviewDiffStyle) => void
state: ReviewPanelV2State
onLineComment?: (comment: SessionReviewLineComment) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
lineCommentActions?: SessionReviewCommentActions
comments?: SessionReviewComment[]
focusedComment?: SessionReviewFocus | null
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
}
export function ReviewPanelV2(props: ReviewPanelV2Props) {
const sdk = useSDK()
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
const filteredFiles = createMemo(() =>
filterReviewFiles(
diffs().map((diff) => diff.file),
props.state.filter(),
),
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and
// clears the focus; the owner then persists the file as the active selection.
const focus = props.focusedComment
if (focus && diffs().some((diff) => diff.file === focus.file)) return focus.file
const active = props.activeFile
if (searching()) return active
const files = filteredFiles()
if (active && files.includes(active)) return active
return files[0]
})
const activeItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
const readFile = async (path: string) =>
sdk()
.client.file.read({ path })
.then((x) => x.data)
.catch((error) => {
console.debug("[session-review-v2] failed to read file", { path, error })
return undefined
})
return (
<SessionReviewV2
title={props.title}
stats={<DiffChanges changes={diffs()} />}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
}
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
<ReviewPanelV2Sidebar
title={props.title}
state={props.state}
diffsReady={props.diffsReady}
onSelectFile={props.onSelectFile}
diffs={diffs}
filteredFiles={filteredFiles}
searching={searching}
kinds={kinds}
activeDiff={activeDiff}
/>
}
activeFile={activeDiff()}
files={filteredFiles()}
onSelectFile={props.onSelectFile}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
expandMode={props.state.expandMode()}
onExpandModeChange={props.state.setExpandMode}
hasDiffs={diffs().length > 0}
preview={
// Key on the file path, not the diff object identity, so refreshed diff data
// updates the mounted preview instead of remounting the whole viewer.
<Show when={activeDiff()} keyed>
{(file) => (
<Show when={activeItem()}>
{(diff) => (
<SessionReviewFilePreviewV2
file={file}
diff={diff()}
diffStyle={props.diffStyle}
expandMode={props.state.expandMode()}
readFile={readFile}
onLineComment={props.onLineComment}
onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete}
lineCommentActions={props.lineCommentActions}
comments={props.comments}
focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange}
/>
)}
</Show>
)}
</Show>
}
/>
)
}
function ReviewPanelV2Sidebar(props: {
title?: JSX.Element
state: ReviewPanelV2State
diffsReady: () => boolean
onSelectFile: (path: string) => void
diffs: () => RenderDiff[]
filteredFiles: () => string[]
searching: () => boolean
kinds: () => ReturnType<typeof reviewDiffKinds>
activeDiff: () => string | undefined
}) {
const language = useLanguage()
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
const highlightedPath = createMemo(() => {
if (!props.searching()) return undefined
const files = props.filteredFiles()
if (files.length === 0) return undefined
const explicit = explicitHighlight()
if (explicit && files.includes(explicit)) return explicit
return files[0]
})
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
if (!props.searching()) return
applyFileListKeyDown(event, props.filteredFiles(), highlightedPath(), {
onHighlight: setExplicitHighlight,
onSelect: props.onSelectFile,
})
}
return (
<SessionReviewV2Sidebar
open={props.state.sidebarOpened()}
title={props.title}
stats={<DiffChanges changes={props.diffs()} />}
filter={props.state.filter()}
onFilterChange={props.state.setFilter}
onFilterKeyDown={onFilterKeyDown}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
minWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN}
maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX}
>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={props.searching()}
fallback={
<FileTreeV2
path=""
allowed={props.filteredFiles()}
kinds={props.kinds()}
draggable={false}
active={props.activeDiff()}
onFileClick={(node) => props.onSelectFile(node.path)}
/>
}
>
<Show
when={props.filteredFiles().length > 0}
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{language.t("palette.empty")}</div>}
>
<SessionFileListV2
files={props.filteredFiles()}
kinds={props.kinds()}
active={props.activeDiff()}
highlighted={highlightedPath()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelectFile(path)
}}
/>
</Show>
</Show>
</Show>
</SessionReviewV2Sidebar>
)
}

View file

@ -0,0 +1,109 @@
import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createEffect, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
// Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events.
export function applyFileListKeyDown(
event: KeyboardEvent,
files: readonly string[],
highlighted: string | undefined,
options: { onHighlight: (path: string) => void; onSelect: (path: string) => void },
) {
if (files.length === 0) return
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const currentIndex = highlighted ? files.indexOf(highlighted) : -1
const delta = event.key === "ArrowDown" ? 1 : -1
const start = currentIndex === -1 ? (delta > 0 ? 0 : files.length - 1) : currentIndex + delta
const index = Math.max(0, Math.min(files.length - 1, start))
options.onHighlight(files[index]!)
event.preventDefault()
return
}
if (event.key !== "Enter") return
const target = highlighted ?? files[0]
if (!target) return
options.onSelect(target)
event.preventDefault()
}
// Flat variant of FileTreeV2 for filtered results: reuses its data-component and
// row data-slots on purpose so file-tree-v2.css styles both. data-highlighted has
// no CSS of its own — it folds into data-selected below and only exists as the
// scrollIntoView query hook.
export function SessionFileListV2(props: {
files: readonly string[]
active?: string
highlighted?: string
kinds?: ReadonlyMap<string, Kind>
onFileClick: (path: string) => void
}) {
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
let rootRef: HTMLDivElement | undefined
createEffect(() => {
highlighted()
if (!rootRef) return
queueMicrotask(() => {
const row = rootRef?.querySelector<HTMLElement>('[data-slot="file-tree-v2-row"][data-highlighted]')
row?.scrollIntoView({ block: "nearest" })
})
})
return (
<div
ref={(el) => {
rootRef = el
}}
data-component="file-tree-v2"
>
<For each={props.files}>
{(path) => {
const normalized = normalizePath(path)
const selected = () => {
if (highlighted()) return highlighted() === normalized
return active() === normalized
}
const highlightedRow = () => highlighted() === normalized
const kind = () => props.kinds?.get(normalized)
const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined)
const filename = () => getFilename(normalized)
return (
<button
type="button"
data-slot="file-tree-v2-row"
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-left: 8px"
onClick={() => props.onFileClick(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
{(value) => <span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>}
</Show>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
)
}}
</For>
</div>
)
}