From b2138ace3b3600fdf3e09dbb58b6f204f48377a5 Mon Sep 17 00:00:00 2001 From: neriousy Date: Sat, 7 Mar 2026 00:01:34 +0100 Subject: [PATCH 1/5] fix: sticky accordion scroll issue --- packages/ui/src/components/message-part.tsx | 30 +++++++- packages/ui/src/components/session-review.tsx | 17 ++++- packages/ui/src/components/session-turn.tsx | 35 ++++++++- .../components/sticky-accordion-header.tsx | 18 +++-- .../ui/src/components/sticky-accordion.ts | 73 +++++++++++++++++++ 5 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/components/sticky-accordion.ts diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 9286d2a92a7..a497f5368f3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -51,6 +51,7 @@ import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" +import { accordionValue, pinSticky } from "./sticky-accordion" import { animate } from "motion" import { useLocation } from "@solidjs/router" @@ -1112,16 +1113,28 @@ 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([value()]) + let head: HTMLDivElement | undefined + + const change = (value: string | string[] | undefined) => { + const next = accordionValue(value) + if (next.length > 0 || open().length === 0) { + setOpen(next) + return + } + pinSticky(head, () => setOpen(next)) + } return ( - + (head = el)}>
@@ -1845,6 +1858,7 @@ ToolRegistry.register({ return list[0] }) const [expanded, setExpanded] = createSignal([]) + const heads = new Map() let seeded = false createEffect(() => { @@ -1881,7 +1895,15 @@ ToolRegistry.register({ data-scope="apply-patch" style={{ "--sticky-accordion-offset": "40px" }} value={expanded()} - onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + onChange={(value) => { + const next = accordionValue(value) + const key = expanded().find((item) => !next.includes(item)) + if (!key) { + setExpanded(next) + return + } + pinSticky(heads.get(key), () => setExpanded(next)) + }} > {(file) => { @@ -1902,7 +1924,7 @@ ToolRegistry.register({ return ( - + heads.set(file.filePath, el)}>
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 62c70e8647d..e8c96b111a1 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -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 { accordionValue, pinSticky } from "./sticky-accordion" import { Tooltip } from "./tooltip" import { ScrollView } from "./scroll-view" import { FileSearchBar } from "./file-search" @@ -142,6 +143,7 @@ export const SessionReview = (props: SessionReviewProps) => { const i18n = useI18n() const fileComponent = useFileComponent() const anchors = new Map() + const heads = new Map() const searchHandles = new Map() const readyFiles = new Set() const [store, setStore] = createStore<{ open: string[]; force: Record }>({ @@ -171,6 +173,16 @@ export const SessionReview = (props: SessionReviewProps) => { setStore("open", open) } + const handleAccordionChange = (value: string | string[] | undefined) => { + const next = accordionValue(value) + const key = open().find((item) => !next.includes(item)) + if (!key) { + handleChange(next) + return + } + pinSticky(heads.get(key), () => handleChange(next)) + } + const handleExpandOrCollapseAll = () => { const next = open().length > 0 ? [] : files() handleChange(next) @@ -622,7 +634,7 @@ export const SessionReview = (props: SessionReviewProps) => {
- + {(file) => { let wrapper: HTMLDivElement | undefined @@ -720,6 +732,7 @@ export const SessionReview = (props: SessionReviewProps) => { onCleanup(() => { anchors.delete(file) + heads.delete(file) readyFiles.delete(file) searchHandles.delete(file) if (highlightedFile === file) highlightedFile = undefined @@ -743,7 +756,7 @@ export const SessionReview = (props: SessionReviewProps) => { data-slot="session-review-accordion-item" data-selected={props.focusedFile === file ? "" : undefined} > - + heads.set(file, el)}>
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 3323a9fc667..46f0518477a 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -17,6 +17,7 @@ import { Icon } from "./icon" import { TextShimmer } from "./text-shimmer" import { SessionRetry } from "./session-retry" import { TextReveal } from "./text-reveal" +import { accordionValue, pinSticky } from "./sticky-accordion" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" @@ -252,6 +253,29 @@ export function SessionTurn( const edited = createMemo(() => diffs().length) const [open, setOpen] = createSignal(false) const [expanded, setExpanded] = createSignal([]) + const refs = new Map() + + const onOpenChange = (value: boolean) => { + if (value || !open()) { + setOpen(value) + return + } + + pinSticky(refs.get("root-trigger"), () => setOpen(value)) + } + + const onExpandChange = (value: string | string[] | undefined) => { + const next = accordionValue(value) + const prev = expanded() + const key = prev.find((item) => !next.includes(item)) + + if (!key) { + setExpanded(next) + return + } + + pinSticky(refs.get(`head:${key}`), () => setExpanded(next)) + } createEffect( on( @@ -431,9 +455,12 @@ export function SessionTurn( 0 && !working()}>
- + -
+
refs.set("root-trigger", el)} + data-component="session-turn-diffs-trigger" + >
{i18n.t("ui.sessionReview.change.modified")} @@ -453,7 +480,7 @@ export function SessionTurn( multiple style={{ "--sticky-accordion-offset": "40px" }} value={expanded()} - onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + onChange={onExpandChange} > {(diff) => { @@ -480,7 +507,7 @@ export function SessionTurn( return ( - + refs.set(`head:${diff.file}`, el)}>
diff --git a/packages/ui/src/components/sticky-accordion-header.tsx b/packages/ui/src/components/sticky-accordion-header.tsx index 89d73344984..82a7f939a48 100644 --- a/packages/ui/src/components/sticky-accordion-header.tsx +++ b/packages/ui/src/components/sticky-accordion-header.tsx @@ -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 }>, + props: ParentProps<{ + ref?: (el: HTMLDivElement) => void + class?: string + classList?: Record + }>, ) { + const [local, rest] = splitProps(props, ["ref", "class", "classList", "children"]) + return ( - {props.children} + {local.children} ) } diff --git a/packages/ui/src/components/sticky-accordion.ts b/packages/ui/src/components/sticky-accordion.ts new file mode 100644 index 00000000000..18a1febd239 --- /dev/null +++ b/packages/ui/src/components/sticky-accordion.ts @@ -0,0 +1,73 @@ +export function accordionValue(value: string | string[] | undefined) { + if (Array.isArray(value)) return value + if (value) return [value] + return [] +} + +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() +} From 3ad657195a9592537b7e06dfef5e6a4755d56d82 Mon Sep 17 00:00:00 2001 From: neriousy Date: Sat, 7 Mar 2026 00:20:44 +0100 Subject: [PATCH 2/5] refactor --- packages/ui/src/components/message-part.tsx | 23 ++++--------------- packages/ui/src/components/session-review.tsx | 19 ++++----------- packages/ui/src/components/session-turn.tsx | 16 +++---------- .../ui/src/components/sticky-accordion.ts | 23 +++++++++++++++++++ 4 files changed, 36 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a497f5368f3..5cf77426d6a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -51,7 +51,7 @@ import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" -import { accordionValue, pinSticky } from "./sticky-accordion" +import { pinStickyAccordionChange } from "./sticky-accordion" import { animate } from "motion" import { useLocation } from "@solidjs/router" @@ -1116,14 +1116,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre const [open, setOpen] = createSignal([value()]) let head: HTMLDivElement | undefined - const change = (value: string | string[] | undefined) => { - const next = accordionValue(value) - if (next.length > 0 || open().length === 0) { - setOpen(next) - return - } - pinSticky(head, () => setOpen(next)) - } + const change = (value: string | string[] | undefined) => pinStickyAccordionChange(open(), value, () => head, setOpen) return ( { - const next = accordionValue(value) - const key = expanded().find((item) => !next.includes(item)) - if (!key) { - setExpanded(next) - return - } - pinSticky(heads.get(key), () => setExpanded(next)) - }} + onChange={(value) => + pinStickyAccordionChange(expanded(), value, (key) => heads.get(key), setExpanded) + } > {(file) => { diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index e8c96b111a1..1c2397f702a 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -7,7 +7,7 @@ import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { IconButton } from "./icon-button" import { StickyAccordionHeader } from "./sticky-accordion-header" -import { accordionValue, pinSticky } from "./sticky-accordion" +import { pinStickyAccordionChange } from "./sticky-accordion" import { Tooltip } from "./tooltip" import { ScrollView } from "./scroll-view" import { FileSearchBar } from "./file-search" @@ -173,20 +173,11 @@ export const SessionReview = (props: SessionReviewProps) => { setStore("open", open) } - const handleAccordionChange = (value: string | string[] | undefined) => { - const next = accordionValue(value) - const key = open().find((item) => !next.includes(item)) - if (!key) { - handleChange(next) - return - } - pinSticky(heads.get(key), () => handleChange(next)) - } + const handleAccordionChange = (value: string | string[] | undefined) => + pinStickyAccordionChange(open(), value, (key) => heads.get(key), handleChange) - const handleExpandOrCollapseAll = () => { - const next = open().length > 0 ? [] : files() - handleChange(next) - } + const handleExpandOrCollapseAll = () => + pinStickyAccordionChange(open(), open().length > 0 ? [] : files(), (key) => heads.get(key), handleChange) const clearViewerSearch = () => { for (const handle of searchHandles.values()) handle.clear() diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 46f0518477a..7eb053a9971 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -17,7 +17,7 @@ import { Icon } from "./icon" import { TextShimmer } from "./text-shimmer" import { SessionRetry } from "./session-retry" import { TextReveal } from "./text-reveal" -import { accordionValue, pinSticky } from "./sticky-accordion" +import { pinSticky, pinStickyAccordionChange } from "./sticky-accordion" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" @@ -264,18 +264,8 @@ export function SessionTurn( pinSticky(refs.get("root-trigger"), () => setOpen(value)) } - const onExpandChange = (value: string | string[] | undefined) => { - const next = accordionValue(value) - const prev = expanded() - const key = prev.find((item) => !next.includes(item)) - - if (!key) { - setExpanded(next) - return - } - - pinSticky(refs.get(`head:${key}`), () => setExpanded(next)) - } + const onExpandChange = (value: string | string[] | undefined) => + pinStickyAccordionChange(expanded(), value, (key) => refs.get(`head:${key}`), setExpanded) createEffect( on( diff --git a/packages/ui/src/components/sticky-accordion.ts b/packages/ui/src/components/sticky-accordion.ts index 18a1febd239..ddb5d102535 100644 --- a/packages/ui/src/components/sticky-accordion.ts +++ b/packages/ui/src/components/sticky-accordion.ts @@ -4,6 +4,14 @@ export function accordionValue(value: string | string[] | undefined) { return [] } +function pick(list: (HTMLElement | undefined)[]) { + return list.reduce((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) { @@ -71,3 +79,18 @@ export function pinSticky(head: HTMLElement | undefined, fn: () => void) { 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)) +} From 25be33651ea155fc38bbe411dcd9c4b51dfc92ea Mon Sep 17 00:00:00 2001 From: neriousy Date: Sat, 7 Mar 2026 20:38:29 +0100 Subject: [PATCH 3/5] fix reviewer --- packages/app/src/hooks/use-providers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 9ef5272ef54..b8e87493cd1 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -13,6 +13,7 @@ export const popularProviders = [ "openrouter", "vercel", ] + const popularProviderSet = new Set(popularProviders) export function useProviders() { From 2270cb784638dc17c29ed263b5807e019094b54e Mon Sep 17 00:00:00 2001 From: neriousy Date: Sat, 7 Mar 2026 20:58:01 +0100 Subject: [PATCH 4/5] fix: copilot review comments --- packages/ui/src/components/message-part.tsx | 43 ++----------------- packages/ui/src/components/session-review.tsx | 2 +- .../components/sticky-accordion-header.tsx | 10 +---- .../ui/src/components/sticky-accordion.ts | 8 ++-- 4 files changed, 12 insertions(+), 51 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 7b97e26ea58..028755ef729 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -6,7 +6,7 @@ import { For, Match, on, - onMount, + onCleanup, Show, Switch, type JSX, @@ -47,10 +47,7 @@ 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" @@ -58,39 +55,6 @@ import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } 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 ( - - - - - {props.text} - - - - - ) -} - interface Diagnostic { range: { start: { line: number; character: number } @@ -971,7 +935,7 @@ 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([value()]) - let head: HTMLDivElement | undefined + let head: HTMLElement | undefined const change = (value: string | string[] | undefined) => pinStickyAccordionChange(open(), value, () => head, setOpen) @@ -1853,7 +1817,7 @@ ToolRegistry.register({ return list[0] }) const [expanded, setExpanded] = createSignal([]) - const heads = new Map() + const heads = new Map() let seeded = false createEffect(() => { const list = files() @@ -1916,6 +1880,7 @@ ToolRegistry.register({ {(file) => { const active = createMemo(() => expanded().includes(file.filePath)) const [visible, setVisible] = createSignal(false) + onCleanup(() => heads.delete(file.filePath)) createEffect(() => { if (!active()) { setVisible(false) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 1c2397f702a..57c24b520f0 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -143,7 +143,7 @@ export const SessionReview = (props: SessionReviewProps) => { const i18n = useI18n() const fileComponent = useFileComponent() const anchors = new Map() - const heads = new Map() + const heads = new Map() const searchHandles = new Map() const readyFiles = new Set() const [store, setStore] = createStore<{ open: string[]; force: Record }>({ diff --git a/packages/ui/src/components/sticky-accordion-header.tsx b/packages/ui/src/components/sticky-accordion-header.tsx index 82a7f939a48..2f0367c95d8 100644 --- a/packages/ui/src/components/sticky-accordion-header.tsx +++ b/packages/ui/src/components/sticky-accordion-header.tsx @@ -1,13 +1,7 @@ -import { Accordion } from "./accordion" +import { Accordion, type AccordionHeaderProps } from "./accordion" import { splitProps, type ParentProps } from "solid-js" -export function StickyAccordionHeader( - props: ParentProps<{ - ref?: (el: HTMLDivElement) => void - class?: string - classList?: Record - }>, -) { +export function StickyAccordionHeader(props: ParentProps void }>) { const [local, rest] = splitProps(props, ["ref", "class", "classList", "children"]) return ( diff --git a/packages/ui/src/components/sticky-accordion.ts b/packages/ui/src/components/sticky-accordion.ts index ddb5d102535..0fffc8b414e 100644 --- a/packages/ui/src/components/sticky-accordion.ts +++ b/packages/ui/src/components/sticky-accordion.ts @@ -33,11 +33,13 @@ export function pinSticky(head: HTMLElement | undefined, fn: () => void) { let frame: number | undefined let still = 0 + let obs: ResizeObserver | undefined + let timer: ReturnType | undefined const stop = () => { if (frame !== undefined) cancelAnimationFrame(frame) frame = undefined obs?.disconnect() - clearTimeout(timer) + if (timer !== undefined) clearTimeout(timer) } const step = () => { @@ -65,7 +67,7 @@ export function pinSticky(head: HTMLElement | undefined, fn: () => void) { }) } - const obs = + obs = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => { @@ -76,7 +78,7 @@ export function pinSticky(head: HTMLElement | undefined, fn: () => void) { obs?.observe(pane) obs?.observe(head) - const timer = setTimeout(stop, 400) + timer = setTimeout(stop, 400) step() } From 3932f5b47dc350d39f54222cf690441c47aec1ce Mon Sep 17 00:00:00 2001 From: neriousy Date: Sat, 7 Mar 2026 21:02:13 +0100 Subject: [PATCH 5/5] fix: pin scroll also for tool collapse / expand --- packages/ui/src/components/basic-tool.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 3210b487019..86f21fff269 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -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" @@ -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 @@ -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 ( - +