Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 62 additions & 5 deletions packages/ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js"
import {
Component,
createEffect,
createMemo,
createSignal,
For,
Match,
on,
onMount,
Show,
Switch,
type JSX,
} from "solid-js"
import stripAnsi from "strip-ansi"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
Expand Down Expand Up @@ -35,13 +47,50 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
import { pinStickyAccordionChange } from "./sticky-accordion"
import { animate } from "motion"
import { list } from "./text-utils"
import { GrowBox } from "./grow-box"
import { COLLAPSIBLE_SPRING } from "./motion"
import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
import { ShellRollingResults } from "./shell-rolling-results"

function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
let valueRef: HTMLSpanElement | undefined

onMount(() => {
if (!props.animate) return
requestAnimationFrame(() => {
if (widthRef) {
animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
}
if (valueRef) {
animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
}
})
})

return (
<span data-component="shell-submessage">
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}>
<span data-slot="basic-tool-tool-subtitle">
<span
ref={valueRef}
data-slot="shell-submessage-value"
style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined}
>
{props.text}
</span>
</span>
</span>
</span>
)
}

interface Diagnostic {
range: {
start: { line: number; character: number }
Expand Down Expand Up @@ -921,16 +970,21 @@ export const ToolRegistry = {

function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) {
const value = createMemo(() => props.path || "tool-file")
const [open, setOpen] = createSignal<string[]>([value()])
let head: HTMLDivElement | undefined

const change = (value: string | string[] | undefined) => pinStickyAccordionChange(open(), value, () => head, setOpen)

return (
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "37px" }}
defaultValue={[value()]}
value={open()}
onChange={change}
>
<Accordion.Item value={value()}>
<StickyAccordionHeader>
<StickyAccordionHeader ref={(el) => (head = el)}>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
Expand Down Expand Up @@ -1799,6 +1853,7 @@ ToolRegistry.register({
return list[0]
})
const [expanded, setExpanded] = createSignal<string[]>([])
const heads = new Map<string, HTMLDivElement>()
let seeded = false
createEffect(() => {
const list = files()
Expand Down Expand Up @@ -1853,7 +1908,9 @@ ToolRegistry.register({
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "37px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
onChange={(value) =>
pinStickyAccordionChange(expanded(), value, (key) => heads.get(key), setExpanded)
}
>
<For each={files()}>
{(file) => {
Expand All @@ -1872,7 +1929,7 @@ ToolRegistry.register({

return (
<Accordion.Item value={file.filePath} data-type={file.type}>
<StickyAccordionHeader>
<StickyAccordionHeader ref={(el) => heads.set(file.filePath, el)}>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
Expand Down
16 changes: 10 additions & 6 deletions packages/ui/src/components/session-review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { pinStickyAccordionChange } from "./sticky-accordion"
import { Tooltip } from "./tooltip"
import { ScrollView } from "./scroll-view"
import { FileSearchBar } from "./file-search"
Expand Down Expand Up @@ -142,6 +143,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const i18n = useI18n()
const fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>()
const heads = new Map<string, HTMLDivElement>()
const searchHandles = new Map<string, FileSearchHandle>()
const readyFiles = new Set<string>()
const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
Expand Down Expand Up @@ -171,10 +173,11 @@ export const SessionReview = (props: SessionReviewProps) => {
setStore("open", open)
}

const handleExpandOrCollapseAll = () => {
const next = open().length > 0 ? [] : files()
handleChange(next)
}
const handleAccordionChange = (value: string | string[] | undefined) =>
pinStickyAccordionChange(open(), value, (key) => heads.get(key), handleChange)

const handleExpandOrCollapseAll = () =>
pinStickyAccordionChange(open(), open().length > 0 ? [] : files(), (key) => heads.get(key), handleChange)

const clearViewerSearch = () => {
for (const handle of searchHandles.values()) handle.clear()
Expand Down Expand Up @@ -622,7 +625,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<div data-slot="session-review-container" class={props.classes?.container}>
<Show when={hasDiffs()} fallback={props.empty}>
<div class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}>
<Accordion multiple value={open()} onChange={handleAccordionChange}>
<For each={files()}>
{(file) => {
let wrapper: HTMLDivElement | undefined
Expand Down Expand Up @@ -720,6 +723,7 @@ export const SessionReview = (props: SessionReviewProps) => {

onCleanup(() => {
anchors.delete(file)
heads.delete(file)
readyFiles.delete(file)
searchHandles.delete(file)
if (highlightedFile === file) highlightedFile = undefined
Expand All @@ -743,7 +747,7 @@ export const SessionReview = (props: SessionReviewProps) => {
data-slot="session-review-accordion-item"
data-selected={props.focusedFile === file ? "" : undefined}
>
<StickyAccordionHeader>
<StickyAccordionHeader ref={(el) => heads.set(file, el)}>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info">
Expand Down
18 changes: 13 additions & 5 deletions packages/ui/src/components/sticky-accordion-header.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { Accordion } from "./accordion"
import { ParentProps } from "solid-js"
import { splitProps, type ParentProps } from "solid-js"

export function StickyAccordionHeader(
props: ParentProps<{ class?: string; classList?: Record<string, boolean | undefined> }>,
props: ParentProps<{
ref?: (el: HTMLDivElement) => void
class?: string
classList?: Record<string, boolean | undefined>
}>,
) {
const [local, rest] = splitProps(props, ["ref", "class", "classList", "children"])

return (
<Accordion.Header
ref={local.ref}
data-component="sticky-accordion-header"
classList={{
...(props.classList ?? {}),
[props.class ?? ""]: !!props.class,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
{...rest}
>
{props.children}
{local.children}
</Accordion.Header>
)
}
96 changes: 96 additions & 0 deletions packages/ui/src/components/sticky-accordion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
export function accordionValue(value: string | string[] | undefined) {
if (Array.isArray(value)) return value
if (value) return [value]
return []
}

function pick(list: (HTMLElement | undefined)[]) {
return list.reduce<HTMLElement | undefined>((best, el) => {
if (!el) return best
if (!best) return el
return el.getBoundingClientRect().top < best.getBoundingClientRect().top ? el : best
}, undefined)
}

function root(el: HTMLElement) {
let node = el.parentElement
while (node) {
const style = getComputedStyle(node)
if ((style.overflowY === "auto" || style.overflowY === "scroll") && node.scrollHeight > node.clientHeight + 1) {
return node
}
node = node.parentElement
}
}

export function pinSticky(head: HTMLElement | undefined, fn: () => void) {
const pane = head ? root(head) : undefined
const top = head?.getBoundingClientRect().top

fn()

if (!pane || !head || top === undefined) return

let frame: number | undefined
let still = 0
const stop = () => {
if (frame !== undefined) cancelAnimationFrame(frame)
frame = undefined
obs?.disconnect()
clearTimeout(timer)
}

const step = () => {
frame = requestAnimationFrame(() => {
frame = undefined
if (!head.isConnected) {
stop()
return
}

const delta = top - head.getBoundingClientRect().top
if (Math.abs(delta) >= 1) {
pane.scrollTop -= delta
still = 0
} else {
still += 1
}

if (still >= 2) {
stop()
return
}

step()
})
}

const obs =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver(() => {
still = 0
if (frame === undefined) step()
})

obs?.observe(pane)
obs?.observe(head)

const timer = setTimeout(stop, 400)
step()
}

export function pinStickyAccordionChange(
prev: string[],
value: string | string[] | undefined,
get: (key: string) => HTMLElement | undefined,
update: (next: string[]) => void,
) {
const next = accordionValue(value)
const head = pick(prev.filter((item) => !next.includes(item)).map(get))
if (!head) {
update(next)
return
}
pinSticky(head, () => update(next))
}
Loading