chore: merge dev into v2 (#35591)

Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
This commit is contained in:
Aiden Cline
2026-07-06 16:05:29 -05:00
committed by GitHub
co-authored by Frank Aarav Sareen Brendan Allan opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Jack Brendan Allan Shoubhit Dash opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> James Long Dustin Deus starptech Luke Parker 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 Dax usrnk1 Jay runvip opencode Julian Coy Vladimir Glafirov Adam Kit Langton Simon Klee Jay David Hill
parent f87998f37f
commit 9e0d3976e1
332 changed files with 24650 additions and 4497 deletions
@@ -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"] {
@@ -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
@@ -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(() => {
+8 -1
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 }))
})
@@ -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(() => {
@@ -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>
@@ -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) => {
@@ -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,
}
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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)
})
})
@@ -0,0 +1,5 @@
const lineThreshold = 500
export function shouldVirtualizeReviewDiff(input: { additionLines: number; deletionLines: number }) {
return Math.max(input.additionLines, input.deletionLines) > lineThreshold
}
@@ -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>
</>
)
}
@@ -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);
}
@@ -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>
)
}