chore: merge dev into v2

This commit is contained in:
Aiden Cline 2026-07-06 14:31:36 -05:00
commit 6cd5812ca1
304 changed files with 24678 additions and 4507 deletions

View file

@ -230,6 +230,7 @@
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
text-transform: capitalize;
color: var(--task-agent-legacy-color, var(--text-strong));
}
[data-slot="basic-tool-tool-subtitle"] {
@ -247,6 +248,57 @@
}
}
body[data-new-layout] {
[data-component="task-tool-card"] {
padding: 8px 12px 8px 10px;
border-radius: 8px;
border: 0;
border-color: transparent;
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
box-shadow: inset 0 0 0 0.5px var(--task-agent-border, var(--v2-border-border-base));
[data-component="task-tool-spinner"],
[data-component="task-tool-title"] {
color: var(--task-agent-color, var(--text-strong));
}
[data-component="task-tool-title"] {
height: 20px;
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-style: normal;
font-weight: 530;
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
flex: none;
flex-grow: 0;
}
[data-slot="basic-tool-tool-subtitle"] {
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
color: light-dark(var(--v2-text-text-base), #fafafa);
font-variation-settings: "slnt" 0;
flex: none;
flex-grow: 0;
}
[data-component="session-progress-indicator-v2"] {
color: var(--task-agent-color, light-dark(var(--v2-text-text-base), #ffffff));
}
&:hover,
&:focus-visible {
border-color: transparent;
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
}
}
}
body:not([data-new-layout]) {
[data-component="tool-trigger"] {
[data-slot="basic-tool-tool-spinner"] {

View file

@ -37,7 +37,9 @@ export interface BasicToolProps {
animated?: boolean
onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent>
triggerHref?: string
triggerAsLink?: boolean
clickable?: boolean
}
@ -254,7 +256,7 @@ export function BasicTool(props: BasicToolProps) {
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Show
when={props.triggerHref}
when={props.triggerAsLink || props.triggerHref}
fallback={
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
@ -264,16 +266,17 @@ export function BasicTool(props: BasicToolProps) {
</Collapsible.Trigger>
}
>
{(href) => (
<Collapsible.Trigger
as="a"
href={href()}
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
)}
<Collapsible.Trigger
as="a"
href={props.triggerHref}
role={!props.triggerHref && props.clickable ? "button" : undefined}
tabIndex={!props.triggerHref && props.clickable ? 0 : undefined}
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
onKeyDown={props.onTriggerKeyDown}
>
{trigger()}
</Collapsible.Trigger>
</Show>
<Show when={props.animated && hasChildren() && !props.hideDetails}>
<div

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(() => {

View file

@ -62,6 +62,7 @@ import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
import { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
async function writeClipboard(text: string): Promise<boolean> {
const body = typeof document === "undefined" ? undefined : document.body
@ -370,6 +371,44 @@ const agentTones: Record<string, string> = {
plan: "var(--icon-agent-plan-base)",
}
const v2AgentTones: Record<string, { color: string; border: string; background: string }> = {
build: {
color: "var(--v2-agent-build-solid)",
border: "var(--v2-agent-build-border)",
background: "var(--v2-agent-build-background)",
},
explore: {
color: "var(--v2-agent-explore-solid)",
border: "var(--v2-agent-explore-border)",
background: "var(--v2-agent-explore-background)",
},
plan: {
color: "var(--v2-agent-plan-solid)",
border: "var(--v2-agent-plan-border)",
background: "var(--v2-agent-plan-background)",
},
}
const agentThemeColors: Record<string, string> = {
primary: "var(--text-interactive-base)",
secondary: "var(--text-base)",
accent: "var(--icon-info-base)",
success: "var(--icon-success-base)",
warning: "var(--icon-warning-base)",
error: "var(--icon-critical-base)",
info: "var(--icon-info-base)",
}
const v2AgentThemeColors: Record<string, string> = {
primary: "var(--v2-text-text-accent)",
secondary: "var(--v2-text-text-muted)",
accent: "var(--v2-icon-icon-accent)",
success: "var(--v2-state-fg-success)",
warning: "var(--v2-state-fg-warning)",
error: "var(--v2-state-fg-danger)",
info: "var(--v2-state-fg-info)",
}
const agentPalette = [
"var(--icon-agent-ask-base)",
"var(--icon-agent-build-base)",
@ -394,16 +433,31 @@ function tone(name: string) {
function taskAgent(
raw: unknown,
list?: readonly { name: string; color?: string }[],
): { name?: string; color?: string } {
): { name?: string; color?: string; v2Color?: string; border?: string; background?: string } {
if (typeof raw !== "string" || !raw) return {}
const key = raw.toLowerCase()
const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
const v2Tone = item?.color ? undefined : v2AgentTones[key]
const color = agentColor(item?.color, agentThemeColors) ?? agentTones[key] ?? tone(key)
const v2Color = agentColor(item?.color, v2AgentThemeColors) ?? v2Tone?.color ?? color
return {
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
color: item?.color ?? agentTones[key] ?? tone(key),
color,
v2Color,
border: v2Tone?.border ?? `color-mix(in srgb, ${v2Color} 48%, transparent)`,
background: v2Tone?.background ?? `color-mix(in srgb, ${v2Color} 12%, transparent)`,
}
}
function agentColor(value: string | undefined, themeColors: Record<string, string>) {
if (!value) return
return themeColors[value] ?? value
}
function newLayout() {
return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout")
}
function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
@ -1000,16 +1054,24 @@ export function AssistantMessageDisplay(props: {
)
}
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
export function ContextToolGroup(props: {
parts: ToolPart[]
busy?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
onSizeChange?: () => void
}) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const [localOpen, setLocalOpen] = createSignal(false)
const open = () => props.open ?? localOpen()
const pending = createMemo(
() =>
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
)
const summary = createMemo(() => contextToolSummary(props.parts))
const handleOpenChange = (value: boolean) => {
setOpen(value)
if (props.open === undefined) setLocalOpen(value)
props.onOpenChange?.(value)
props.onSizeChange?.()
}
@ -1718,7 +1780,13 @@ ToolRegistry.register({
trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
>
<Show when={props.output}>
<div data-component="tool-output" data-scrollable>
<div
data-component="tool-output"
data-scrollable
tabIndex={0}
role="region"
aria-label={i18n.t("ui.scrollView.ariaLabel")}
>
<Markdown text={props.output!} />
</div>
</Show>
@ -1742,7 +1810,13 @@ ToolRegistry.register({
}}
>
<Show when={props.output}>
<div data-component="tool-output" data-scrollable>
<div
data-component="tool-output"
data-scrollable
tabIndex={0}
role="region"
aria-label={i18n.t("ui.scrollView.ariaLabel")}
>
<Markdown text={props.output!} />
</div>
</Show>
@ -1769,7 +1843,13 @@ ToolRegistry.register({
}}
>
<Show when={props.output}>
<div data-component="tool-output" data-scrollable>
<div
data-component="tool-output"
data-scrollable
tabIndex={0}
role="region"
aria-label={i18n.t("ui.scrollView.ariaLabel")}
>
<Markdown text={props.output!} />
</div>
</Show>
@ -1864,6 +1944,9 @@ ToolRegistry.register({
const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
const tone = createMemo(() => agent().color)
const v2Tone = createMemo(() => agent().v2Color)
const border = createMemo(() => agent().border)
const background = createMemo(() => agent().background)
const subtitle = createMemo(() => {
const value =
typeof props.input.description === "string" && props.input.description
@ -1895,19 +1978,35 @@ ToolRegistry.register({
event.preventDefault()
open()
}
const navigateKey = (event: KeyboardEvent) => {
if (!clickable() || href()) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
open()
}
const trigger = () => (
<div data-component="task-tool-card">
<div
data-component="task-tool-card"
style={{
"--task-agent-color": v2Tone(),
"--task-agent-legacy-color": tone(),
"--task-agent-border": border(),
"--task-agent-background": background(),
}}
>
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<Show when={running()}>
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
<Spinner />
<Show when={newLayout()} fallback={<Spinner />}>
<SessionProgressIndicatorV2
style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }}
/>
</Show>
</span>
</Show>
<span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
{title()}
</span>
<span data-component="task-tool-title">{title()}</span>
<Show when={subtitle()}>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Show>
@ -1927,9 +2026,11 @@ ToolRegistry.register({
status={props.status}
trigger={trigger()}
hideDetails
triggerAsLink
triggerHref={href()}
clickable={clickable()}
onTriggerClick={navigate}
onTriggerKeyDown={navigateKey}
/>
)
},
@ -1987,7 +2088,13 @@ ToolRegistry.register({
/>
</TooltipV2>
</div>
<div data-slot="bash-scroll" data-scrollable>
<div
data-slot="bash-scroll"
data-scrollable
tabIndex={0}
role="region"
aria-label={i18n.t("ui.scrollView.ariaLabel")}
>
<pre data-slot="bash-pre">
<code>{text()}</code>
</pre>

View file

@ -39,10 +39,18 @@ export function createHoverCommentUtility(props: {
line = next
}
const loop = () => {
if (!button.isConnected) return
// The hovered line changes when the pointer moves or when content scrolls under
// a stationary pointer, so track both with passive listeners instead of polling
// every animation frame. There is no teardown hook for this utility; like the
// rAF loop this replaced, the listeners self-detach on the first event after
// the button leaves the DOM.
const onHoverInvalidated = () => {
if (!button.isConnected) {
document.removeEventListener("pointermove", onHoverInvalidated)
document.removeEventListener("scroll", onHoverInvalidated, true)
return
}
sync()
requestAnimationFrame(loop)
}
const open = () => {
@ -51,7 +59,8 @@ export function createHoverCommentUtility(props: {
props.onSelect(next)
}
requestAnimationFrame(loop)
document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
button.addEventListener("mouseenter", sync)
button.addEventListener("mousemove", sync)
button.addEventListener("pointerdown", (event) => {

View file

@ -0,0 +1,212 @@
import { type SelectedLineRange } from "@pierre/diffs"
import { Show, type Accessor, type JSX } from "solid-js"
import {
createLineCommentAnnotations,
createLineCommentGutterRenderer,
createLineCommentState,
createManagedLineCommentAnnotationRenderer,
type LineCommentShape,
type LineCommentStateProps,
} from "../../components/line-comment-annotations"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2"
type LineCommentControllerV2Props<T extends LineCommentShape> = {
comments: Accessor<T[]>
draftKey: Accessor<string>
label: string
state: LineCommentStateProps<string>
getSide: (range: SelectedLineRange) => "additions" | "deletions"
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
onDelete?: (comment: T) => void
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
editSubmitLabel?: string
}
type CommentProps = {
id?: string
comment: JSX.Element
selection: JSX.Element
actions?: JSX.Element
editor?: DraftProps
onClick?: JSX.EventHandlerUnion<HTMLDivElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLDivElement, MouseEvent>
}
type DraftProps = {
value: string
selection: JSX.Element
onInput: (value: string) => void
onCancel: VoidFunction
onSubmit: (value: string) => void
cancelLabel?: string
submitLabel?: string
}
function lineCommentElementV2(view: Accessor<CommentProps>) {
return (
<Show
when={view().editor}
fallback={
<div
data-prevent-autofocus=""
data-comment-id={view().id}
onMouseDown={(event) => event.stopPropagation()}
onClick={view().onClick}
onMouseEnter={view().onMouseEnter}
>
<LineCommentV2 comment={view().comment} selection={view().selection} actions={view().actions} />
</div>
}
>
<div data-prevent-autofocus="" data-comment-id={view().id} onMouseDown={(event) => event.stopPropagation()}>
<LineCommentEditorV2
value={view().editor!.value}
selection={view().editor!.selection}
onInput={view().editor!.onInput}
onCancel={view().editor!.onCancel}
onSubmit={view().editor!.onSubmit}
cancelLabel={view().editor!.cancelLabel}
submitLabel={view().editor!.submitLabel}
/>
</div>
</Show>
)
}
function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
return (
<div data-prevent-autofocus="" onMouseDown={(event) => event.stopPropagation()}>
<LineCommentEditorV2
value={view().value}
selection={view().selection}
onInput={view().onInput}
onCancel={view().onCancel}
onSubmit={view().onSubmit}
cancelLabel={view().cancelLabel}
submitLabel={view().submitLabel}
/>
</div>
)
}
export function createLineCommentControllerV2<T extends LineCommentShape>(props: LineCommentControllerV2Props<T>) {
const i18n = useI18n()
const note = createLineCommentState<string>(props.state)
const annotations = createLineCommentAnnotations({
comments: props.comments,
getCommentId: (comment) => comment.id,
getCommentSelection: (comment) => comment.selection,
draftRange: note.commenting,
draftKey: props.draftKey,
getSide: props.getSide,
})
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps>({
annotations,
commentElement: lineCommentElementV2,
draftElement: lineCommentDraftElementV2,
renderComment: (comment) => {
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
const remove = () => {
note.reset()
props.onDelete?.(comment)
}
return {
id: comment.id,
comment: comment.comment,
selection: formatSelectedLineLabel(comment.selection, i18n.t),
get actions() {
return props.renderCommentActions?.(comment, { edit, remove })
},
get editor() {
return note.isEditing(comment.id)
? {
get value() {
return note.draft()
},
selection: formatSelectedLineLabel(comment.selection, i18n.t),
onInput: note.setDraft,
onCancel: note.cancelDraft,
onSubmit: (value: string) => {
props.onUpdate?.({
id: comment.id,
comment: value,
selection: cloneSelectedLineRange(comment.selection),
})
note.cancelDraft()
},
cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: props.editSubmitLabel,
}
: undefined
},
onMouseEnter: () => note.hoverComment(comment.selection),
onClick: () => {
if (note.isEditing(comment.id)) return
note.toggleComment(comment.id, comment.selection)
},
}
},
renderDraft: (range) => ({
get value() {
return note.draft()
},
selection: formatSelectedLineLabel(range, i18n.t),
onInput: note.setDraft,
onCancel: note.cancelDraft,
onSubmit: (comment) => {
props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
note.cancelDraft()
},
cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: i18n.t("ui.lineComment.submit"),
}),
})
const renderGutterUtility = createLineCommentGutterRenderer({
label: props.label,
getSelectedRange: () => {
if (note.opened()) return null
return note.selected()
},
onOpenDraft: note.openDraft,
})
const onLineSelected = (range: SelectedLineRange | null) => {
if (!range) {
note.select(null)
note.cancelDraft()
return
}
note.select(range)
}
const onLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!range) {
note.cancelDraft()
return
}
note.openDraft(range)
}
const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
if (!range) return
note.openDraft(range)
}
return {
annotations,
renderAnnotation,
renderGutterUtility,
onLineSelected,
onLineSelectionEnd,
onLineNumberSelectionEnd,
}
}

View file

@ -0,0 +1,17 @@
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { Icon } from "@opencode-ai/ui/v2/icon"
import "./session-review-v2.css"
export function SessionReviewEmptyChangesV2() {
const i18n = useI18n()
return (
<div data-slot="session-review-v2-empty-changes">
<Icon name="review" size="large" />
<div data-slot="session-review-v2-empty-changes-title">{i18n.t("ui.sessionReviewV2.empty.changes.title")}</div>
<div data-slot="session-review-v2-empty-changes-description">
{i18n.t("ui.sessionReviewV2.empty.changes.description")}
</div>
</div>
)
}

View file

@ -0,0 +1,28 @@
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import "./session-review-v2.css"
export type SessionReviewEmptyNoGitV2Props = {
pending: boolean
onInitGit: () => void
}
export function SessionReviewEmptyNoGitV2(props: SessionReviewEmptyNoGitV2Props) {
const i18n = useI18n()
return (
<div data-slot="session-review-v2-empty-no-git">
<FileIcon node={{ path: ".gitignore", type: "file" }} mono />
<div data-slot="session-review-v2-empty-no-git-title">{i18n.t("ui.sessionReviewV2.empty.noGit.title")}</div>
<div data-slot="session-review-v2-empty-no-git-description">
{i18n.t("ui.sessionReviewV2.empty.noGit.description")}
</div>
<ButtonV2 variant="neutral" size="normal" disabled={props.pending} onClick={props.onInitGit}>
{props.pending
? i18n.t("ui.sessionReviewV2.empty.noGit.actionLoading")
: i18n.t("ui.sessionReviewV2.empty.noGit.action")}
</ButtonV2>
</div>
)
}

View file

@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize"
describe("shouldVirtualizeReviewDiff", () => {
test("renders small diffs directly", () => {
expect(shouldVirtualizeReviewDiff({ additionLines: 500, deletionLines: 500 })).toBe(false)
})
test("virtualizes large diffs", () => {
expect(shouldVirtualizeReviewDiff({ additionLines: 501, deletionLines: 1 })).toBe(true)
expect(shouldVirtualizeReviewDiff({ additionLines: 1, deletionLines: 501 })).toBe(true)
})
})

View file

@ -0,0 +1,5 @@
const lineThreshold = 500
export function shouldVirtualizeReviewDiff(input: { additionLines: number; deletionLines: number }) {
return Math.max(input.additionLines, input.deletionLines) > lineThreshold
}

View file

@ -0,0 +1,287 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { SelectedLineRange } from "@pierre/diffs"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { mediaKindFromPath } from "../../pierre/media"
import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge"
import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { normalize, text, type ViewDiff } from "../../components/session-diff"
import type {
SessionReviewComment,
SessionReviewCommentActions,
SessionReviewCommentDelete,
SessionReviewCommentUpdate,
SessionReviewDiffStyle,
SessionReviewFocus,
SessionReviewLineComment,
} from "../../components/session-review"
import type { SessionReviewExpandMode } from "./session-review-v2"
import { createLineCommentControllerV2 } from "./line-comment-annotations-v2"
import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import "./session-review-v2.css"
type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export type SessionReviewFilePreviewV2Props = {
file: string
diff: ReviewDiff
diffStyle: SessionReviewDiffStyle
expandMode?: SessionReviewExpandMode
readFile?: (path: string) => Promise<FileContent | undefined>
onLineComment?: (comment: SessionReviewLineComment) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
lineCommentActions?: SessionReviewCommentActions
comments?: SessionReviewComment[]
focusedComment?: SessionReviewFocus | null
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
}
function statusLabel(status: ViewDiff["status"]) {
if (status === "added") return "A"
if (status === "deleted") return "D"
return "M"
}
function statusType(status: ViewDiff["status"]) {
if (status === "added") return "added"
if (status === "deleted") return "deleted"
return "modified"
}
function selectionSide(range: SelectedLineRange) {
return range.endSide ?? range.side ?? "additions"
}
function selectionPreview(diff: ViewDiff, range: SelectedLineRange) {
const side = selectionSide(range)
const contents = text(diff, side)
if (contents.length === 0) return undefined
return previewSelectedLines(contents, range)
}
function ReviewCommentMenuV2(props: {
labels: SessionReviewCommentActions
onEdit: VoidFunction
onDelete: VoidFunction
}) {
return (
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<MenuV2 gutter={4}>
<MenuV2.Trigger
as="button"
type="button"
data-slot="line-comment-v2-overflow"
aria-label={props.labels.moreLabel}
>
<LineCommentV2OverflowIcon />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onEdit}>{props.labels.editLabel}</MenuV2.Item>
<MenuV2.Item onSelect={props.onDelete}>{props.labels.deleteLabel}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
)
}
export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
let scrollRef: HTMLDivElement | undefined
let focusToken = 0
const [store, setStore] = createStore({
selection: null as SelectedLineRange | null,
commenting: null as SelectedLineRange | null,
opened: null as string | null,
})
const view = createMemo(() => ({
...normalize(props.diff),
preloaded: "preloaded" in props.diff ? props.diff.preloaded : undefined,
}))
const diffCanRender = createMemo(() => view().additions !== 0 || view().deletions !== 0)
const mediaKind = createMemo(() => mediaKindFromPath(props.file))
const comments = createMemo(() => (props.comments ?? []).filter((comment) => comment.file === props.file))
const commentedLines = createMemo(() => comments().map((comment) => comment.selection))
const lineCommentsEnabled = () => props.onLineComment != null
const commentsUi = createLineCommentControllerV2<SessionReviewComment>({
comments,
label: i18n.t("ui.lineComment.submit"),
draftKey: () => props.file,
state: {
opened: () => store.opened,
setOpened: (id) => setStore("opened", id),
selected: () => store.selection,
setSelected: (range) => setStore("selection", range),
commenting: () => store.commenting,
setCommenting: (range) => setStore("commenting", range),
},
getSide: selectionSide,
onSubmit: ({ comment, selection }) => {
props.onLineComment?.({
file: props.file,
selection,
comment,
preview: selectionPreview(view(), selection),
})
},
onUpdate: ({ id, comment, selection }) => {
props.onLineCommentUpdate?.({
id,
file: props.file,
selection,
comment,
preview: selectionPreview(view(), selection),
})
},
onDelete: (comment) => {
props.onLineCommentDelete?.({
id: comment.id,
file: props.file,
})
},
editSubmitLabel: props.lineCommentActions?.saveLabel,
renderCommentActions: props.lineCommentActions
? (comment, controls) => (
<ReviewCommentMenuV2 labels={props.lineCommentActions!} onEdit={controls.edit} onDelete={controls.remove} />
)
: undefined,
})
onCleanup(() => {
focusToken++
})
createEffect(() => {
const focus = props.focusedComment
if (!focus) return
if (focus.file !== props.file) {
// The focused file has no mounted preview (e.g. not in the current diff
// set); clear the focus anyway so it cannot hijack a later diff refresh.
// V1 clears unconditionally the same way.
untrack(() => {
const token = focusToken
requestAnimationFrame(() => {
if (token !== focusToken) return
props.onFocusedCommentChange?.(null)
})
})
return
}
untrack(() => {
setStore("opened", focus.id)
const comment = (props.comments ?? []).find((item) => item.file === focus.file && item.id === focus.id)
if (comment) setStore("selection", cloneSelectedLineRange(comment.selection))
// The diff renders asynchronously, so poll for the comment anchor before
// scrolling; clear the focus once handled so revisiting the file does not
// re-open a stale comment (mirrors the v1 review behavior).
focusToken++
const token = focusToken
const scrollTo = (attempt: number) => {
if (token !== focusToken) return
const anchor = scrollRef?.querySelector(`[data-comment-id="${focus.id}"]`)
if (anchor instanceof HTMLElement) {
anchor.scrollIntoView({ block: "center" })
return
}
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
}
requestAnimationFrame(() => scrollTo(0))
requestAnimationFrame(() => {
if (token !== focusToken) return
props.onFocusedCommentChange?.(null)
})
})
})
const expandUnchanged = () => props.expandMode === "expand"
const diffViewer = () => (
<Dynamic
component={fileComponent}
mode="diff"
fileDiff={view().fileDiff}
preloadedDiff={view().preloaded}
diffStyle={props.diffStyle}
expandUnchanged={expandUnchanged()}
virtualize={shouldVirtualizeReviewDiff({
additionLines: view().fileDiff.additionLines.length,
deletionLines: view().fileDiff.deletionLines.length,
})}
hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
enableLineSelection={lineCommentsEnabled()}
enableGutterUtility={lineCommentsEnabled()}
onLineSelected={(range: SelectedLineRange | null) => {
if (!lineCommentsEnabled()) return
commentsUi.onLineSelected(range)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!lineCommentsEnabled()) return
commentsUi.onLineSelectionEnd(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderGutterUtility={lineCommentsEnabled() ? commentsUi.renderGutterUtility : undefined}
selectedLines={store.selection}
commentedLines={commentedLines()}
media={{
mode: "auto",
path: props.file,
deleted: view().status === "deleted",
readFile: view().status === "deleted" ? undefined : props.readFile,
}}
/>
)
return (
<>
<div data-slot="session-review-v2-file-header">
<div data-slot="session-review-v2-file-title">
<div data-slot="session-review-v2-file-status" data-type={statusType(view().status)}>
{statusLabel(view().status)}
</div>
<FileIcon node={{ path: props.file, type: "file" }} />
<span data-slot="session-review-v2-file-name">{getFilename(props.file)}</span>
<Show when={props.file.includes("/")}>
<span data-slot="session-review-v2-file-path">{getDirectory(props.file)}</span>
</Show>
</div>
<DiffChanges changes={view()} />
</div>
<div
ref={(el) => {
scrollRef = el
}}
data-slot="session-review-v2-diff-scroll"
>
<Show
when={diffCanRender() || mediaKind()}
fallback={
<div data-slot="session-review-v2-empty">
<span class="text-12-regular text-text-weak">{i18n.t("ui.fileMedia.binary.title")}</span>
</div>
}
>
{diffViewer()}
</Show>
</div>
</>
)
}

View file

@ -0,0 +1,432 @@
[data-component="session-review-v2"] {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--background-stronger, var(--v2-background-bg-base));
}
[data-component="session-review-v2"] [data-slot="session-review-v2-body"] {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
[data-component="session-review-v2-sidebar-root"] {
position: relative;
display: flex;
flex-shrink: 0;
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"] {
display: flex;
flex-direction: column;
flex-shrink: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
background: var(--background-stronger, var(--v2-background-bg-base));
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] {
border-right-width: 0;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"]:not([data-resizing]) {
transition:
width 200ms cubic-bezier(0.22, 1, 0.36, 1),
border-right-width 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-resize"] {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
z-index: 20;
width: 0;
overflow: visible;
}
[data-component="session-review-v2-sidebar-root"] [data-component="resize-handle"]::after {
background: var(--v2-border-border-muted, var(--border-weak-base));
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-header"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px 8px 8px;
flex-shrink: 0;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-title"] {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-strong, var(--v2-text-text-base));
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]
[data-component="select-v2-root"] {
width: fit-content;
max-width: 100%;
min-width: 0;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-filter"] {
padding: 0 8px 8px;
flex-shrink: 0;
min-width: 0;
box-sizing: border-box;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-filter"]
[data-component="text-input-v2"] {
width: 100%;
max-width: 100%;
min-width: 0;
margin-top: 2px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {
flex: 1;
min-height: 0;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport {
padding: 0 8px 12px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb {
width: 16px;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__thumb::after {
width: 6px;
background-color: var(--v2-border-border-muted, var(--border-weak-base));
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__thumb:hover::after,
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__thumb[data-dragging="true"]::after {
background-color: var(--v2-border-border-strong, var(--border-strong-base));
}
[data-component="session-review-v2"] [data-slot="session-review-v2-preview"] {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
padding-left: 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-group"] {
display: flex;
align-items: center;
gap: 0px;
}
[data-component="session-review-v2"] .session-review-v2-toolbar-group--start {
min-width: 0;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-collapsed-meta"] {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding-right: 8px;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-title"] {
display: flex;
align-items: center;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-strong, var(--v2-text-text-base));
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-title"] [data-component="select-v2-root"] {
width: fit-content;
max-width: 100%;
min-width: 0;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-position"] {
flex: none;
flex-grow: 0;
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 11px;
line-height: 100%;
letter-spacing: 0.05px;
text-transform: uppercase;
font-variant-numeric: tabular-nums lining-nums;
font-feature-settings:
"tnum" on,
"lnum" on;
font-variation-settings: "slnt" 0;
color: var(--v2-grey-600);
white-space: nowrap;
}
[data-component="session-review-v2"] .session-review-v2-file-nav-button {
width: 36px;
height: 28px;
min-width: 36px;
min-height: 28px;
padding: 0;
}
[data-component="session-review-v2"]
[data-slot="session-review-v2-toolbar-group"]
[data-component="tooltip-v2-trigger"] {
display: inline-flex;
}
[data-component="session-review-v2"] .session-review-v2-segmented-control [data-component="tooltip-v2-trigger"] {
display: flex;
flex: 0 0 auto;
min-width: 0;
}
[data-component="session-review-v2"]
.session-review-v2-segmented-control
[data-component="tooltip-v2-trigger"]
[data-slot="segmented-control-v2-item"] {
width: 100%;
}
[data-component="icon-button-v2"].session-review-v2-sidebar-toggle {
width: 36px;
height: 28px;
min-width: 36px;
min-height: 28px;
border-radius: 6px;
color: var(--v2-icon-icon-base);
}
[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"]:not(
:disabled
),
[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"]:is(
:hover,
[data-state="hover"],
:active,
[data-state="pressed"]
):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-pressed);
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-title"] {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] [data-component="diff-changes"] {
flex-shrink: 0;
margin-left: auto;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"] {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
font-size: 11px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
text-transform: uppercase;
font-variant-numeric: tabular-nums lining-nums;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"][data-type="added"] {
color: var(--icon-diff-add-base, var(--v2-state-fg-success));
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"][data-type="deleted"] {
color: var(--icon-diff-delete-base, var(--v2-state-fg-danger));
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"][data-type="modified"] {
color: var(--v2-state-fg-info);
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-name"] {
flex: none;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums lining-nums;
color: var(--v2-text-text-base);
white-space: nowrap;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-path"] {
flex: none;
font-size: 13px;
font-weight: 440;
line-height: 1;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums lining-nums;
color: var(--v2-text-text-muted);
white-space: nowrap;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-diff-scroll"] {
flex: 1;
min-height: 0;
overflow: auto;
--line-comment-z: 5;
--line-comment-popover-z: 30;
--line-comment-open-z: 6;
}
[data-component="session-review-v2"] .session-review-v2-toolbar-group--segments {
gap: 12px;
}
[data-component="session-review-v2"] .session-review-v2-toolbar-group--segments .session-review-v2-segmented-control {
width: auto;
}
[data-component="session-review-v2"]
.session-review-v2-toolbar-group--segments
.session-review-v2-segmented-control--icon
[data-slot="segmented-control-v2-item"] {
flex: 0 0 auto;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-empty"] {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
padding-bottom: 160px;
}
[data-slot="session-review-v2-empty-no-git"] {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
padding-bottom: 160px;
text-align: center;
}
[data-slot="session-review-v2-empty-no-git"] [data-component="file-icon"] {
width: 20px;
height: 20px;
color: var(--v2-icon-icon-muted);
}
[data-slot="session-review-v2-empty-no-git-title"] {
flex: none;
font-size: 13px;
font-weight: 530;
line-height: 100%;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
}
[data-slot="session-review-v2-empty-no-git-description"] {
flex: none;
margin: 0 0 4px;
max-width: 360px;
font-size: 13px;
font-weight: 440;
line-height: 20px;
text-align: center;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}
[data-slot="session-review-v2-empty-changes"] {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
padding-bottom: 160px;
text-align: center;
}
[data-slot="session-review-v2-empty-changes"] [data-slot="icon-svg"] {
flex: none;
color: var(--v2-icon-icon-muted);
}
[data-slot="session-review-v2-empty-changes-title"] {
flex: none;
margin-top: 4px;
font-size: 13px;
font-weight: 530;
line-height: 100%;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
}
[data-slot="session-review-v2-empty-changes-description"] {
flex: none;
max-width: 282px;
font-size: 13px;
font-weight: 440;
line-height: 20px;
text-align: center;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}

View file

@ -0,0 +1,331 @@
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { SegmentedControlItemV2, SegmentedControlV2 } from "@opencode-ai/ui/v2/segmented-control-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SessionReviewDiffStyle } from "../../components/session-review"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"
import { getWorkerPool } from "../../pierre/worker"
import "./session-review-v2.css"
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN = 200
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX = 480
export type SessionReviewExpandMode = "expand" | "collapse"
export type SessionReviewV2Props = {
title?: JSX.Element
stats?: JSX.Element
empty?: JSX.Element
sidebarOpen?: boolean
sidebar?: JSX.Element
sidebarToggle?: JSX.Element
activeFile?: string
files: string[]
onSelectFile: (file: string) => void
diffStyle: SessionReviewDiffStyle
onDiffStyleChange?: (style: SessionReviewDiffStyle) => void
expandMode: SessionReviewExpandMode
onExpandModeChange: (mode: SessionReviewExpandMode) => void
preview?: JSX.Element
hasDiffs: boolean
}
export type SessionReviewV2SidebarProps = {
open: boolean
title?: JSX.Element
stats?: JSX.Element
filter: string
onFilterChange: (value: string) => void
onFilterKeyDown?: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent>
width?: number
onWidthChange?: (width: number) => void
minWidth?: number
maxWidth?: number
viewportRef?: (element: HTMLDivElement) => void
children?: JSX.Element
}
export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
const i18n = useI18n()
const [resizing, setResizing] = createSignal(false)
const width = () => props.width ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT
const minWidth = () => props.minWidth ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN
const maxWidth = () => props.maxWidth ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX
createEffect(() => {
if (!resizing()) return
const stop = () => setResizing(false)
makeEventListener(document, "pointerup", stop)
makeEventListener(document, "pointercancel", stop)
})
return (
<div data-component="session-review-v2-sidebar-root">
<aside
data-slot="session-review-v2-sidebar"
data-resizing={resizing() ? "" : undefined}
aria-hidden={!props.open}
inert={!props.open}
style={{ width: props.open ? `${width()}px` : "0px" }}
>
<div data-slot="session-review-v2-sidebar-header">
<div data-slot="session-review-v2-sidebar-title">{props.title}</div>
{props.stats}
</div>
<div data-slot="session-review-v2-sidebar-filter">
<TextInputV2
type="search"
value={props.filter}
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
onKeyDown={props.onFilterKeyDown}
showClearButton={props.filter.length > 0}
clearLabel={i18n.t("ui.list.clearFilter")}
onClearClick={() => props.onFilterChange("")}
placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
leadingIcon={
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
}
/>
</div>
<ScrollView
data-slot="session-review-v2-sidebar-tree"
class="group/file-tree-v2"
thumbVisibility="scroll"
viewportRef={props.viewportRef}
>
{props.children}
</ScrollView>
</aside>
<Show when={props.open && props.onWidthChange}>
<div data-slot="session-review-v2-sidebar-resize" onPointerDown={() => setResizing(true)}>
<ResizeHandle
direction="horizontal"
size={width()}
min={minWidth()}
max={maxWidth()}
onResize={(next) => props.onWidthChange?.(next)}
/>
</div>
</Show>
</div>
)
}
export function SessionReviewV2(props: SessionReviewV2Props) {
const i18n = useI18n()
createEffect(() => {
getWorkerPool(props.diffStyle)
})
const fileIndex = () => {
const files = props.files
if (files.length === 0) return -1
const active = props.activeFile
const i = active ? files.indexOf(active) : -1
if (i >= 0) return i
return 0
}
const prev = () => {
const files = props.files
if (files.length === 0) return
return files[(fileIndex() - 1 + files.length) % files.length]
}
const next = () => {
const files = props.files
if (files.length === 0) return
return files[(fileIndex() + 1) % files.length]
}
const canCycle = () => props.files.length > 0
const showCollapsedMeta = () => props.sidebarOpen === false
// Memoize slot getters so Show conditions do not instantiate throwaway elements.
const title = createMemo(() => props.title)
const stats = createMemo(() => props.stats)
const cycle = (file: string | undefined) => {
if (!file) return
props.onSelectFile(file)
}
// The prev/next tooltips advertise < and >; keep the keys working while the
// pane is mounted, but never while typing in an input or comment editor.
makeEventListener(document, "keydown", (event) => {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return
if (event.key !== "<" && event.key !== ">") return
const target = event.target
if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
if (!props.hasDiffs || !canCycle()) return
event.preventDefault()
cycle(event.key === "<" ? prev() : next())
})
return (
<div data-component="session-review-v2">
<div data-slot="session-review-v2-body">
{props.sidebar}
<div data-slot="session-review-v2-preview">
<Show when={props.hasDiffs} fallback={props.empty}>
<div data-slot="session-review-v2-toolbar">
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--start">
{props.sidebarToggle}
<Show when={showCollapsedMeta()}>
<div data-slot="session-review-v2-toolbar-collapsed-meta">
<Show when={title()}>
<div data-slot="session-review-v2-toolbar-title">{title()}</div>
</Show>
{stats()}
<Show when={canCycle()}>
<span data-slot="session-review-v2-file-position">
{fileIndex() + 1}/{props.files.length}
</span>
</Show>
</div>
</Show>
<div data-slot="session-review-v2-toolbar-group">
<TooltipV2
openDelay={2000}
value={
<>
{i18n.t("ui.sessionReviewV2.previousFile")}
<KeybindV2 keys={["<"]} variant="neutral" />
</>
}
>
<IconButton
icon="arrow-left"
variant="ghost"
size="small"
class="session-review-v2-file-nav-button"
disabled={!canCycle()}
onClick={() => {
const file = prev()
if (!file) return
props.onSelectFile(file)
}}
aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
/>
</TooltipV2>
<TooltipV2
openDelay={2000}
value={
<>
{i18n.t("ui.sessionReviewV2.nextFile")}
<KeybindV2 keys={[">"]} variant="neutral" />
</>
}
>
<IconButton
icon="arrow-right"
variant="ghost"
size="small"
class="session-review-v2-file-nav-button"
disabled={!canCycle()}
onClick={() => {
const file = next()
if (!file) return
props.onSelectFile(file)
}}
aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
/>
</TooltipV2>
</div>
</div>
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--segments">
<SegmentedControlV2
value={props.expandMode}
onChange={(value) => {
if (value !== "expand" && value !== "collapse") return
props.onExpandModeChange(value)
}}
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
<Icon name="expand" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<Icon name="collapse" />
</SegmentedControlItemV2>
</TooltipV2>
</SegmentedControlV2>
<Show when={props.onDiffStyleChange}>
<SegmentedControlV2
value={props.diffStyle}
onChange={(value) => {
if (value !== "unified" && value !== "split") return
props.onDiffStyleChange?.(value)
}}
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<Icon name="unified" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
<Icon name="split" />
</SegmentedControlItemV2>
</TooltipV2>
</SegmentedControlV2>
</Show>
</div>
</div>
<Show when={props.activeFile} fallback={<div data-slot="session-review-v2-empty">{props.empty}</div>}>
{props.preview}
</Show>
</Show>
</div>
</div>
</div>
)
}
export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: () => void }) {
const i18n = useI18n()
return (
<TooltipV2 value={i18n.t("ui.sessionReviewV2.toggleSidebar")}>
<IconButtonV2
variant="ghost"
size="small"
class="session-review-v2-sidebar-toggle"
aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
aria-expanded={props.opened}
onClick={props.onToggle}
icon={<Icon name="filetree" />}
/>
</TooltipV2>
)
}