2026-07-02 07:41:58 +00:00
|
|
|
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"
|
2026-07-06 06:26:03 +00:00
|
|
|
import { getWorkerPool } from "../../pierre/worker"
|
2026-07-08 23:09:21 +00:00
|
|
|
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "./session-file-panel-v2"
|
2026-07-02 07:41:58 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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
|
2026-07-14 12:50:16 +00:00
|
|
|
transition: boolean
|
2026-07-02 07:41:58 +00:00
|
|
|
title?: JSX.Element
|
|
|
|
|
stats?: JSX.Element
|
|
|
|
|
filter: string
|
|
|
|
|
onFilterChange: (value: string) => void
|
|
|
|
|
onFilterKeyDown?: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent>
|
2026-07-08 23:09:21 +00:00
|
|
|
filterAutofocus?: boolean
|
|
|
|
|
filterRef?: (element: HTMLInputElement) => void
|
|
|
|
|
filterControls?: string
|
|
|
|
|
filterActiveDescendant?: string
|
|
|
|
|
filterExpanded?: boolean
|
2026-07-02 07:41:58 +00:00
|
|
|
width?: number
|
|
|
|
|
onWidthChange?: (width: number) => void
|
|
|
|
|
minWidth?: number
|
|
|
|
|
maxWidth?: number
|
2026-07-06 06:26:03 +00:00
|
|
|
viewportRef?: (element: HTMLDivElement) => void
|
2026-07-02 07:41:58 +00:00
|
|
|
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">
|
2026-07-08 23:44:02 +00:00
|
|
|
<Show when={props.open}>
|
|
|
|
|
<aside
|
|
|
|
|
data-slot="session-review-v2-sidebar"
|
2026-07-14 12:50:16 +00:00
|
|
|
data-transition={props.transition ? "" : undefined}
|
2026-07-08 23:44:02 +00:00
|
|
|
data-resizing={resizing() ? "" : undefined}
|
|
|
|
|
style={{ width: `${width()}px` }}
|
2026-07-06 06:26:03 +00:00
|
|
|
>
|
2026-07-08 23:44:02 +00:00
|
|
|
<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}
|
|
|
|
|
autofocus={props.filterAutofocus}
|
|
|
|
|
ref={props.filterRef}
|
|
|
|
|
role={props.filterControls ? "combobox" : undefined}
|
|
|
|
|
aria-autocomplete={props.filterControls ? "list" : undefined}
|
|
|
|
|
aria-controls={props.filterControls}
|
|
|
|
|
aria-activedescendant={props.filterActiveDescendant}
|
|
|
|
|
aria-expanded={props.filterControls ? props.filterExpanded : undefined}
|
|
|
|
|
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>
|
2026-07-02 07:41:58 +00:00
|
|
|
<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)}
|
2026-07-17 09:40:42 +00:00
|
|
|
onDblClick={() => props.onWidthChange?.(SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT)}
|
2026-07-02 07:41:58 +00:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SessionReviewV2(props: SessionReviewV2Props) {
|
|
|
|
|
const i18n = useI18n()
|
|
|
|
|
|
2026-07-06 06:26:03 +00:00
|
|
|
createEffect(() => {
|
|
|
|
|
getWorkerPool(props.diffStyle)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-02 07:41:58 +00:00
|
|
|
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 = () => {
|
2026-07-30 08:56:16 +00:00
|
|
|
if (!canCycle()) return
|
|
|
|
|
return props.files[(fileIndex() - 1 + props.files.length) % props.files.length]
|
2026-07-02 07:41:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const next = () => {
|
2026-07-30 08:56:16 +00:00
|
|
|
if (!canCycle()) return
|
|
|
|
|
return props.files[(fileIndex() + 1) % props.files.length]
|
2026-07-02 07:41:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 08:56:16 +00:00
|
|
|
// Keep the advertised arrow keys working while the
|
2026-07-02 07:41:58 +00:00
|
|
|
// 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
|
2026-07-30 08:56:16 +00:00
|
|
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
|
2026-07-02 07:41:58 +00:00
|
|
|
const target = event.target
|
|
|
|
|
if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
|
|
|
|
|
if (!props.hasDiffs || !canCycle()) return
|
2026-07-30 08:56:16 +00:00
|
|
|
const file = event.key === "ArrowLeft" ? prev() : next()
|
2026-07-18 05:39:15 +00:00
|
|
|
if (!file) return
|
2026-07-02 07:41:58 +00:00
|
|
|
event.preventDefault()
|
2026-07-18 05:39:15 +00:00
|
|
|
cycle(file)
|
2026-07-02 07:41:58 +00:00
|
|
|
})
|
|
|
|
|
|
2026-07-08 23:09:21 +00:00
|
|
|
const toolbarStart = () => (
|
|
|
|
|
<>
|
|
|
|
|
<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>
|
2026-07-02 07:41:58 +00:00
|
|
|
</Show>
|
|
|
|
|
</div>
|
2026-07-08 23:09:21 +00:00
|
|
|
</Show>
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
<TooltipV2
|
2026-07-18 05:39:15 +00:00
|
|
|
inactive={!prev()}
|
2026-07-08 23:09:21 +00:00
|
|
|
value={
|
|
|
|
|
<>
|
|
|
|
|
{i18n.t("ui.sessionReviewV2.previousFile")}
|
2026-07-30 08:56:16 +00:00
|
|
|
<KeybindV2 keys={["←"]} variant="neutral" />
|
2026-07-08 23:09:21 +00:00
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<IconButton
|
|
|
|
|
icon="arrow-left"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="small"
|
|
|
|
|
class="session-review-v2-file-nav-button"
|
2026-07-18 05:39:15 +00:00
|
|
|
disabled={!prev()}
|
2026-07-08 23:09:21 +00:00
|
|
|
onClick={() => cycle(prev())}
|
|
|
|
|
aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
|
|
|
|
|
/>
|
|
|
|
|
</TooltipV2>
|
|
|
|
|
<TooltipV2
|
2026-07-18 05:39:15 +00:00
|
|
|
inactive={!next()}
|
2026-07-08 23:09:21 +00:00
|
|
|
value={
|
|
|
|
|
<>
|
|
|
|
|
{i18n.t("ui.sessionReviewV2.nextFile")}
|
2026-07-30 08:56:16 +00:00
|
|
|
<KeybindV2 keys={["→"]} variant="neutral" />
|
2026-07-08 23:09:21 +00:00
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<IconButton
|
|
|
|
|
icon="arrow-right"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="small"
|
|
|
|
|
class="session-review-v2-file-nav-button"
|
2026-07-18 05:39:15 +00:00
|
|
|
disabled={!next()}
|
2026-07-08 23:09:21 +00:00
|
|
|
onClick={() => cycle(next())}
|
|
|
|
|
aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
|
|
|
|
|
/>
|
|
|
|
|
</TooltipV2>
|
2026-07-02 07:41:58 +00:00
|
|
|
</div>
|
2026-07-08 23:09:21 +00:00
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const toolbarEnd = () => (
|
|
|
|
|
<>
|
|
|
|
|
<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")}
|
|
|
|
|
>
|
2026-07-22 23:34:36 +00:00
|
|
|
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
2026-07-08 23:09:21 +00:00
|
|
|
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
|
|
|
|
<Icon name="expand" />
|
|
|
|
|
</SegmentedControlItemV2>
|
|
|
|
|
</TooltipV2>
|
2026-07-22 23:34:36 +00:00
|
|
|
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
2026-07-08 23:09:21 +00:00
|
|
|
<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")}
|
|
|
|
|
>
|
2026-07-22 23:34:36 +00:00
|
|
|
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
2026-07-08 23:09:21 +00:00
|
|
|
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
|
|
|
|
<Icon name="unified" />
|
|
|
|
|
</SegmentedControlItemV2>
|
|
|
|
|
</TooltipV2>
|
2026-07-22 23:34:36 +00:00
|
|
|
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
2026-07-08 23:09:21 +00:00
|
|
|
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
|
|
|
|
<Icon name="split" />
|
|
|
|
|
</SegmentedControlItemV2>
|
|
|
|
|
</TooltipV2>
|
|
|
|
|
</SegmentedControlV2>
|
|
|
|
|
</Show>
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<SessionFilePanelV2
|
|
|
|
|
sidebar={props.sidebar}
|
|
|
|
|
toolbar={props.hasDiffs}
|
|
|
|
|
toolbarStart={toolbarStart()}
|
|
|
|
|
toolbarEnd={toolbarEnd()}
|
|
|
|
|
>
|
|
|
|
|
<Show when={props.hasDiffs} fallback={props.empty}>
|
|
|
|
|
<Show when={props.activeFile} fallback={<SessionFilePanelV2Empty>{props.empty}</SessionFilePanelV2Empty>}>
|
|
|
|
|
{props.preview}
|
|
|
|
|
</Show>
|
|
|
|
|
</Show>
|
|
|
|
|
</SessionFilePanelV2>
|
2026-07-02 07:41:58 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 09:32:25 +00:00
|
|
|
export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) {
|
2026-07-02 07:41:58 +00:00
|
|
|
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}
|
2026-07-30 09:03:23 +00:00
|
|
|
data-expanded={props.opened ? "" : undefined}
|
2026-07-13 09:32:25 +00:00
|
|
|
disabled={props.disabled}
|
2026-07-02 07:41:58 +00:00
|
|
|
onClick={props.onToggle}
|
|
|
|
|
icon={<Icon name="filetree" />}
|
|
|
|
|
/>
|
|
|
|
|
</TooltipV2>
|
|
|
|
|
)
|
|
|
|
|
}
|