chore: merge dev into v2
This commit is contained in:
commit
6cd5812ca1
304 changed files with 24678 additions and 4507 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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
432
packages/session-ui/src/v2/components/session-review-v2.css
Normal file
432
packages/session-ui/src/v2/components/session-review-v2.css
Normal 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);
|
||||
}
|
||||
331
packages/session-ui/src/v2/components/session-review-v2.tsx
Normal file
331
packages/session-ui/src/v2/components/session-review-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue