Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/app/src/hooks/use-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const popularProviders = [
"openrouter",
"vercel",
]

const popularProviderSet = new Set(popularProviders)

export function useProviders() {
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "solid-js"
import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion"
import { Collapsible } from "./collapsible"
import { pinSticky } from "./sticky-accordion"
import { TextShimmer } from "./text-shimmer"
import { hold } from "./tool-utils"

Expand Down Expand Up @@ -165,6 +166,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
// Animated content height — single springValue drives all height changes
let contentRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let triggerRef: HTMLElement | undefined
let fadeAnim: AnimationPlaybackControls | undefined
let observer: ResizeObserver | undefined
let resizeFrame: number | undefined
Expand Down Expand Up @@ -271,12 +273,17 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
if (value === open()) return
if (triggerRef) {
pinSticky(triggerRef, () => setOpen(value))
return
}
setOpen(value)
}

return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<Collapsible.Trigger ref={triggerRef}>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
Expand Down
32 changes: 27 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,
onCleanup,
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,6 +47,7 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { pinStickyAccordionChange } from "./sticky-accordion"
import { list } from "./text-utils"
import { GrowBox } from "./grow-box"
import { COLLAPSIBLE_SPRING } from "./motion"
Expand Down Expand Up @@ -921,16 +934,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: HTMLElement | 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 +1817,7 @@ ToolRegistry.register({
return list[0]
})
const [expanded, setExpanded] = createSignal<string[]>([])
const heads = new Map<string, HTMLElement>()
let seeded = false
createEffect(() => {
const list = files()
Expand Down Expand Up @@ -1853,12 +1872,15 @@ 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) => {
const active = createMemo(() => expanded().includes(file.filePath))
const [visible, setVisible] = createSignal(false)
onCleanup(() => heads.delete(file.filePath))
createEffect(() => {
if (!active()) {
setVisible(false)
Expand All @@ -1872,7 +1894,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, HTMLElement>()
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: 10 additions & 8 deletions packages/ui/src/components/sticky-accordion-header.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { Accordion } from "./accordion"
import { ParentProps } from "solid-js"
import { Accordion, type AccordionHeaderProps } from "./accordion"
import { splitProps, type ParentProps } from "solid-js"

export function StickyAccordionHeader(props: ParentProps<AccordionHeaderProps & { ref?: (el: HTMLElement) => void }>) {
const [local, rest] = splitProps(props, ["ref", "class", "classList", "children"])

export function StickyAccordionHeader(
props: ParentProps<{ class?: string; classList?: Record<string, boolean | undefined> }>,
) {
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>
)
}
98 changes: 98 additions & 0 deletions packages/ui/src/components/sticky-accordion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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
let obs: ResizeObserver | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const stop = () => {
if (frame !== undefined) cancelAnimationFrame(frame)
frame = undefined
obs?.disconnect()
if (timer !== undefined) 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()
})
}

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

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

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