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

@ -1,5 +1,6 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
import { createEffect, createMemo, Match, on, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import {
dataUrlFromMediaValue,
@ -30,6 +31,13 @@ function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
const i18n = useI18n()
const [remote, setRemote] = createStore<{
key?: string
loading?: boolean
error?: boolean
src?: string
mime?: string
}>({})
const cfg = () => props.media
const kind = createMemo(() => {
const media = cfg()
@ -81,50 +89,66 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
}
})
const [loaded] = createResource(request, async (input) => {
return input.readFile(input.path).then(
createEffect(() => {
const input = request()
if (!input) {
setRemote({ key: undefined, loading: false, error: false, src: undefined, mime: undefined })
return
}
let active = true
// Keep the previous media visible while re-reading the same file (e.g. a vcs
// diff refresh); only a key change resets to the loading placeholder.
if (untrack(() => remote.key) === input.key) setRemote({ loading: true, error: false })
else setRemote({ key: input.key, loading: true, error: false, src: undefined, mime: undefined })
void input.readFile(input.path).then(
(result) => {
if (!active) return
const src = dataUrlFromMediaValue(result as any, input.kind)
if (!src) {
input.onError?.({ kind: input.kind })
return { key: input.key, error: true as const }
setRemote({ key: input.key, loading: false, error: true, src: undefined, mime: undefined })
return
}
return {
setRemote({
key: input.key,
loading: false,
error: false,
src,
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
}
})
},
() => {
if (!active) return
input.onError?.({ kind: input.kind })
return { key: input.key, error: true as const }
setRemote({ key: input.key, loading: false, error: true, src: undefined, mime: undefined })
},
)
})
const remote = createMemo(() => {
const input = request()
const value = loaded()
if (!input || !value || value.key !== input.key) return
return value
onCleanup(() => {
active = false
})
})
const src = createMemo(() => {
const value = remote()
return direct() ?? (value && "src" in value ? value.src : undefined)
const input = request()
if (!input || remote.key !== input.key || remote.error) return direct()
return direct() ?? remote.src
})
const status = createMemo(() => {
if (direct()) return "ready" as const
if (!request()) return "idle" as const
if (loaded.loading) return "loading" as const
if (remote()?.error) return "error" as const
const input = request()
if (!input) return "idle" as const
if (remote.key !== input.key || remote.loading) return "loading" as const
if (remote.error) return "error" as const
if (src()) return "ready" as const
return "idle" as const
})
const audioMime = createMemo(() => {
const value = remote()
return value && "mime" in value ? value.mime : undefined
const input = request()
if (!input || remote.key !== input.key) return
return remote.mime
})
const svgSource = createMemo(() => {

View file

@ -456,11 +456,18 @@ function useAnnotationRerender<A>(opts: {
current: () => AnnotationTarget<A> | undefined
annotations: () => A[]
}) {
const applied = new WeakSet<AnnotationTarget<A>>()
createEffect(() => {
opts.viewer.rendered()
const active = opts.current()
if (!active) return
active.setLineAnnotations(opts.annotations())
const annotations = opts.annotations()
// renderViewer always draws with empty annotations, so skip the extra rerender
// when this instance has nothing applied and nothing to apply.
if (annotations.length === 0 && !applied.has(active)) return
if (annotations.length === 0) applied.delete(active)
else applied.add(active)
active.setLineAnnotations(annotations)
active.rerender()
requestAnimationFrame(() => opts.viewer.find.refresh({ reset: true }))
})

View file

@ -34,7 +34,7 @@ type HoverCommentLine = {
side?: "additions" | "deletions"
}
type LineCommentStateProps<T> = {
export type LineCommentStateProps<T> = {
opened: Accessor<T | null>
setOpened: (id: T | null) => void
selected: Accessor<SelectedLineRange | null>
@ -45,7 +45,7 @@ type LineCommentStateProps<T> = {
hoverSelected?: (range: SelectedLineRange) => void
}
type LineCommentShape = {
export type LineCommentShape = {
id: string
selection: SelectedLineRange
comment: string
@ -95,9 +95,14 @@ type DraftProps = {
submitLabel?: string
}
export function createLineCommentAnnotationRenderer<T>(props: {
renderComment: (comment: T) => CommentProps
renderDraft: (range: SelectedLineRange) => DraftProps
// Generic host machinery shared by the v1 and v2 annotation renderers: each
// annotation key gets a detached DOM host with its own Solid root, updated in
// place through a signal so Pierre can reparent the host without re-rendering.
export function createLineCommentAnnotationRenderer<T, C, D>(props: {
renderComment: (comment: T) => C
renderDraft: (range: SelectedLineRange) => D
commentElement: (view: Accessor<C>) => JSX.Element
draftElement: (view: Accessor<D>) => JSX.Element
}) {
const nodes = new Map<
string,
@ -123,37 +128,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
if (next.kind !== "comment") return props.renderComment(active.comment)
return props.renderComment(next.comment)
})
return (
<Show
when={view().editor}
fallback={
<LineComment
inline
id={view().id}
open={view().open}
comment={view().comment}
selection={view().selection}
actions={view().actions}
onClick={view().onClick}
onMouseEnter={view().onMouseEnter}
/>
}
>
<LineCommentEditor
inline
id={view().id}
value={view().editor!.value}
selection={view().editor!.selection}
onInput={view().editor!.onInput}
onCancel={view().editor!.onCancel}
onSubmit={view().editor!.onSubmit}
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
cancelLabel={view().editor!.cancelLabel}
submitLabel={view().editor!.submitLabel}
mention={view().editor!.mention}
/>
</Show>
)
return props.commentElement(view)
}
const view = createMemo(() => {
@ -161,18 +136,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
if (next.kind !== "draft") return props.renderDraft(active.range)
return props.renderDraft(next.range)
})
return (
<LineCommentEditor
inline
value={view().value}
selection={view().selection}
onInput={view().onInput}
onCancel={view().onCancel}
onSubmit={view().onSubmit}
onPopoverFocusOut={view().onPopoverFocusOut}
mention={view().mention}
/>
)
return props.draftElement(view)
}, host)
const node = { host, dispose, setMeta: setCurrent }
@ -205,6 +169,55 @@ export function createLineCommentAnnotationRenderer<T>(props: {
return { render, reconcile, cleanup }
}
function lineCommentElement(view: Accessor<CommentProps>) {
return (
<Show
when={view().editor}
fallback={
<LineComment
inline
id={view().id}
open={view().open}
comment={view().comment}
selection={view().selection}
actions={view().actions}
onClick={view().onClick}
onMouseEnter={view().onMouseEnter}
/>
}
>
<LineCommentEditor
inline
id={view().id}
value={view().editor!.value}
selection={view().editor!.selection}
onInput={view().editor!.onInput}
onCancel={view().editor!.onCancel}
onSubmit={view().editor!.onSubmit}
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
cancelLabel={view().editor!.cancelLabel}
submitLabel={view().editor!.submitLabel}
mention={view().editor!.mention}
/>
</Show>
)
}
function lineCommentDraftElement(view: Accessor<DraftProps>) {
return (
<LineCommentEditor
inline
value={view().value}
selection={view().selection}
onInput={view().onInput}
onCancel={view().onCancel}
onSubmit={view().onSubmit}
onPopoverFocusOut={view().onPopoverFocusOut}
mention={view().mention}
/>
)
}
export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
const [state, setState] = createStore({
draft: "",
@ -314,7 +327,9 @@ export function createLineCommentController<T extends LineCommentShape>(
): {
note: ReturnType<typeof createLineCommentState<string>>
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
renderAnnotation: ReturnType<
typeof createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps>
>["renderAnnotation"]
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
onLineSelected: (range: SelectedLineRange | null) => void
onLineSelectionEnd: (range: SelectedLineRange | null) => void
@ -324,7 +339,9 @@ export function createLineCommentController<T extends LineCommentShape>(
): {
note: ReturnType<typeof createLineCommentState<string>>
annotations: Accessor<LineCommentAnnotation<T>[]>
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
renderAnnotation: ReturnType<
typeof createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps>
>["renderAnnotation"]
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
onLineSelected: (range: SelectedLineRange | null) => void
onLineSelectionEnd: (range: SelectedLineRange | null) => void
@ -353,8 +370,10 @@ export function createLineCommentController<T extends LineCommentShape>(
draftKey: props.draftKey,
})
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps>({
annotations,
commentElement: lineCommentElement,
draftElement: lineCommentDraftElement,
renderComment: (comment) => {
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
const remove = () => {
@ -471,77 +490,111 @@ export function createLineCommentAnnotations<T>(
const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
if ("getSide" in props) {
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(
() => {
const list = props.comments().map((comment) => {
const range = props.getCommentSelection(comment)
return {
side: props.getSide(range),
lineNumber: line(range),
metadata: {
kind: "comment",
key: `comment:${props.getCommentId(comment)}`,
comment,
} satisfies LineCommentAnnotationMeta<T>,
}
})
const range = props.draftRange()
if (!range) return list
return [
...list,
{
side: props.getSide(range),
lineNumber: line(range),
metadata: {
kind: "draft",
key: `draft:${props.draftKey()}`,
range,
} satisfies LineCommentAnnotationMeta<T>,
},
]
},
[],
// Stable identity for unchanged annotations avoids no-op diff rerenders downstream.
{ equals: sameAnnotationLists },
)
}
return createMemo<LineCommentAnnotation<T>[]>(
() => {
const list = props.comments().map((comment) => {
const range = props.getCommentSelection(comment)
return {
side: props.getSide(range),
const entry: LineCommentAnnotation<T> = {
lineNumber: line(range),
metadata: {
kind: "comment",
key: `comment:${props.getCommentId(comment)}`,
comment,
} satisfies LineCommentAnnotationMeta<T>,
},
}
return entry
})
const range = props.draftRange()
if (!range) return list
return [
...list,
{
side: props.getSide(range),
lineNumber: line(range),
metadata: {
kind: "draft",
key: `draft:${props.draftKey()}`,
range,
} satisfies LineCommentAnnotationMeta<T>,
},
]
})
}
return createMemo<LineCommentAnnotation<T>[]>(() => {
const list = props.comments().map((comment) => {
const range = props.getCommentSelection(comment)
const entry: LineCommentAnnotation<T> = {
const draft: LineCommentAnnotation<T> = {
lineNumber: line(range),
metadata: {
kind: "comment",
key: `comment:${props.getCommentId(comment)}`,
comment,
kind: "draft",
key: `draft:${props.draftKey()}`,
range,
},
}
return entry
})
return [...list, draft]
},
[],
{ equals: sameAnnotationLists },
)
}
const range = props.draftRange()
if (!range) return list
type AnnotationListItem = {
lineNumber: number
side?: unknown
metadata: { kind: string; key: string; comment?: unknown; range?: unknown }
}
const draft: LineCommentAnnotation<T> = {
lineNumber: line(range),
metadata: {
kind: "draft",
key: `draft:${props.draftKey()}`,
range,
},
}
return [...list, draft]
function sameAnnotationLists(previous: AnnotationListItem[], next: AnnotationListItem[]) {
if (previous.length !== next.length) return false
return previous.every((item, index) => {
const other = next[index]!
return (
item.lineNumber === other.lineNumber &&
item.side === other.side &&
item.metadata.kind === other.metadata.kind &&
item.metadata.key === other.metadata.key &&
item.metadata.comment === other.metadata.comment &&
item.metadata.range === other.metadata.range
)
})
}
export function createManagedLineCommentAnnotationRenderer<T>(props: {
export function createManagedLineCommentAnnotationRenderer<T, C, D>(props: {
annotations: Accessor<LineCommentAnnotation<T>[]>
renderComment: (comment: T) => CommentProps
renderDraft: (range: SelectedLineRange) => DraftProps
renderComment: (comment: T) => C
renderDraft: (range: SelectedLineRange) => D
commentElement: (view: Accessor<C>) => JSX.Element
draftElement: (view: Accessor<D>) => JSX.Element
}) {
const renderer = createLineCommentAnnotationRenderer<T>({
const renderer = createLineCommentAnnotationRenderer<T, C, D>({
renderComment: props.renderComment,
renderDraft: props.renderDraft,
commentElement: props.commentElement,
draftElement: props.draftElement,
})
createEffect(() => {