refactor(ui): extract shared collapsible, scroll mask, and hold utilities
- Replace manual autoOpen signal+effect with hold(pending, 2000) - Extract updateScrollMask() shared by ShellExpanded and ContextToolExpandedList - Extract useCollapsible() hook for the shared expand/collapse animation pattern Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4e46d98156
commit
0ac8f06521
3 changed files with 292 additions and 171 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, onMount } from "solid-js"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
|
@ -9,15 +9,13 @@ import { AnimatedCountList } from "./tool-count-summary"
|
|||
import { RollingResults } from "./rolling-results"
|
||||
import {
|
||||
animate,
|
||||
type AnimationPlaybackControls,
|
||||
clearFadeStyles,
|
||||
clearMaskStyles,
|
||||
COLLAPSIBLE_SPRING,
|
||||
GROW_SPRING,
|
||||
WIPE_MASK,
|
||||
} from "./motion"
|
||||
import { useSpring } from "./motion-spring"
|
||||
import { busy } from "./tool-utils"
|
||||
import { busy, updateScrollMask, useCollapsible } from "./tool-utils"
|
||||
|
||||
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
|
||||
const state = part.state
|
||||
|
|
@ -124,74 +122,15 @@ export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: bo
|
|||
let contentRef: HTMLDivElement | undefined
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
let heightAnim: AnimationPlaybackControls | undefined
|
||||
let fadeAnim: AnimationPlaybackControls | undefined
|
||||
|
||||
const FADE = 12
|
||||
|
||||
const updateMask = () => {
|
||||
if (!scrollRef) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef
|
||||
const overflow = scrollHeight - clientHeight
|
||||
if (overflow <= 1) {
|
||||
scrollRef.style.maskImage = ""
|
||||
scrollRef.style.webkitMaskImage = ""
|
||||
return
|
||||
}
|
||||
const top = scrollTop > 1
|
||||
const bottom = scrollTop < overflow - 1
|
||||
const mask =
|
||||
top && bottom
|
||||
? `linear-gradient(to bottom, transparent 0, black ${FADE}px, black calc(100% - ${FADE}px), transparent 100%)`
|
||||
: top
|
||||
? `linear-gradient(to bottom, transparent 0, black ${FADE}px)`
|
||||
: bottom
|
||||
? `linear-gradient(to bottom, black calc(100% - ${FADE}px), transparent 100%)`
|
||||
: ""
|
||||
scrollRef.style.maskImage = mask
|
||||
scrollRef.style.webkitMaskImage = mask
|
||||
if (scrollRef) updateScrollMask(scrollRef)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.expanded,
|
||||
(isOpen) => {
|
||||
if (!contentRef || !bodyRef) return
|
||||
heightAnim?.stop()
|
||||
fadeAnim?.stop()
|
||||
if (isOpen) {
|
||||
contentRef.style.display = ""
|
||||
bodyRef.style.opacity = "0"
|
||||
bodyRef.style.filter = "blur(2px)"
|
||||
const h = bodyRef.getBoundingClientRect().height
|
||||
heightAnim = animate(contentRef, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
|
||||
fadeAnim = animate(bodyRef, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
|
||||
heightAnim.finished
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (!contentRef || !props.expanded) return
|
||||
contentRef.style.height = "auto"
|
||||
updateMask()
|
||||
})
|
||||
} else {
|
||||
const h = contentRef.getBoundingClientRect().height
|
||||
heightAnim = animate(contentRef, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
|
||||
fadeAnim = animate(bodyRef, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
|
||||
heightAnim.finished
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (!contentRef || props.expanded) return
|
||||
contentRef.style.display = "none"
|
||||
})
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
heightAnim?.stop()
|
||||
fadeAnim?.stop()
|
||||
useCollapsible({
|
||||
content: () => contentRef,
|
||||
body: () => bodyRef,
|
||||
open: () => props.expanded,
|
||||
onOpen: updateMask,
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
|
|
@ -8,9 +8,15 @@ import { Icon } from "./icon"
|
|||
import { IconButton } from "./icon-button"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { animate, clearFadeStyles, clearMaskStyles, FAST_SPRING, GROW_SPRING, WIPE_MASK } from "./motion"
|
||||
import {
|
||||
animate,
|
||||
clearFadeStyles,
|
||||
clearMaskStyles,
|
||||
GROW_SPRING,
|
||||
WIPE_MASK,
|
||||
} from "./motion"
|
||||
import { useSpring } from "./motion-spring"
|
||||
import { busy, createThrottledValue, useToolFade } from "./tool-utils"
|
||||
import { busy, createThrottledValue, hold, updateScrollMask, useCollapsible, useToolFade } from "./tool-utils"
|
||||
|
||||
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
|
|
@ -57,6 +63,118 @@ function ShellRollingCommand(props: { text: string; animate?: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const fade = 12
|
||||
const rows = 10
|
||||
const rowHeight = 22
|
||||
const max = rows * rowHeight
|
||||
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
let topRef: HTMLDivElement | undefined
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [cap, setCap] = createSignal(max)
|
||||
|
||||
const updateMask = () => {
|
||||
if (scrollRef) updateScrollMask(scrollRef, fade)
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0)
|
||||
setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0)))
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
resize()
|
||||
return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resize()
|
||||
if (!topRef) return
|
||||
const obs = new ResizeObserver(resize)
|
||||
obs.observe(topRef)
|
||||
onCleanup(() => obs.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.cmd
|
||||
props.out
|
||||
queueMicrotask(() => {
|
||||
resize()
|
||||
updateMask()
|
||||
})
|
||||
})
|
||||
|
||||
useCollapsible({
|
||||
content: () => contentRef,
|
||||
body: () => bodyRef,
|
||||
open: () => props.open,
|
||||
measure,
|
||||
onOpen: updateMask,
|
||||
})
|
||||
|
||||
const handleCopy = async (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const cmd = props.cmd ? `$ ${props.cmd}` : ""
|
||||
const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}`
|
||||
if (!text) return
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
|
||||
<div ref={bodyRef} data-component="shell-expanded-shell">
|
||||
<div data-slot="shell-expanded-body">
|
||||
<div ref={topRef} data-slot="shell-expanded-top">
|
||||
<div data-slot="shell-expanded-command">
|
||||
<span data-slot="shell-expanded-prompt">$</span>
|
||||
<span data-slot="shell-expanded-input">{props.cmd}</span>
|
||||
</div>
|
||||
<div data-slot="shell-expanded-actions">
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="shell-expanded-copy"
|
||||
onMouseDown={(e: MouseEvent) => e.preventDefault()}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.out}>
|
||||
<>
|
||||
<div data-slot="shell-expanded-divider" />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-component="shell-expanded-output"
|
||||
data-scrollable
|
||||
onScroll={updateMask}
|
||||
style={{ "max-height": `${cap()}px` }}
|
||||
>
|
||||
<pre data-slot="shell-expanded-pre">
|
||||
<code>{props.out}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const wiped = new Set<string>()
|
||||
|
|
@ -66,36 +184,24 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
|||
onMount(() => setMounted(true))
|
||||
const state = createMemo(() => props.part.state as Record<string, any>)
|
||||
const pending = createMemo(() => busy(props.part.state.status))
|
||||
// autoOpen starts true if pending, false if already complete (e.g. scrolling back in history).
|
||||
// When pending transitions false, autoOpen is still true — no intermediate state — then
|
||||
// a 2s timer sets it false to trigger the collapse. This avoids the flash caused by
|
||||
// holdOpen being set in a late-running effect.
|
||||
const [autoOpen, setAutoOpen] = createSignal(pending())
|
||||
createEffect(
|
||||
on(pending, (isPending, wasPending) => {
|
||||
if (isPending) {
|
||||
setAutoOpen(true)
|
||||
} else if (wasPending && !userToggled()) {
|
||||
const timer = setTimeout(() => setAutoOpen(false), 2000)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
}
|
||||
}),
|
||||
)
|
||||
const autoOpen = hold(pending, 2000)
|
||||
const effectiveOpen = createMemo(() => {
|
||||
if (pending()) return true
|
||||
if (userToggled()) return userOpen()
|
||||
return autoOpen()
|
||||
})
|
||||
const subtitle = createMemo(() => {
|
||||
const value = state().input?.description ?? state().metadata?.description
|
||||
if (typeof value === "string") return value
|
||||
return ""
|
||||
})
|
||||
const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen())
|
||||
const previewOpen = createMemo(() => effectiveOpen() && !expanded())
|
||||
const command = createMemo(() => {
|
||||
const value = state().input?.command ?? state().metadata?.command
|
||||
if (typeof value === "string") return value
|
||||
return ""
|
||||
})
|
||||
const subtitle = createMemo(() => {
|
||||
const value = state().input?.description ?? state().metadata?.description
|
||||
if (typeof value === "string" && value.trim().length > 0) return value
|
||||
return firstLine(command()) ?? ""
|
||||
})
|
||||
const output = createMemo(() => {
|
||||
const value = state().output ?? state().metadata?.output
|
||||
if (typeof value === "string") return value
|
||||
|
|
@ -105,9 +211,10 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
|||
const skip = () => reduce() || props.animate === false
|
||||
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
|
||||
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
|
||||
const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING)
|
||||
const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING)
|
||||
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
|
||||
let headerClipRef: HTMLDivElement | undefined
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const handleHeaderClick = () => {
|
||||
if (pending()) return
|
||||
const el = headerClipRef
|
||||
|
|
@ -123,16 +230,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
|||
})
|
||||
}
|
||||
}
|
||||
const handleCopy = async (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const cmd = command()
|
||||
const out = stripAnsi(output())
|
||||
const content = `$ ${cmd}${out ? "\n" + out : ""}`
|
||||
if (!content) return
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
const line = createMemo(() => firstLine(command()))
|
||||
const fixed = createMemo(() => {
|
||||
const value = line()
|
||||
|
|
@ -162,21 +259,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
|||
<Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="shell-rolling-actions">
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="shell-rolling-copy"
|
||||
onMouseDown={(e: MouseEvent) => e.preventDefault()}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
|
|
@ -184,59 +266,74 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
|||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<RollingResults
|
||||
class="shell-rolling-output"
|
||||
items={rows()}
|
||||
fixed={fixed()}
|
||||
fixedHeight={22}
|
||||
rows={userToggled() && userOpen() ? 10 : 5}
|
||||
rowHeight={22}
|
||||
rowGap={0}
|
||||
open={effectiveOpen()}
|
||||
scrollable={!pending() && !autoOpen() && userToggled() && userOpen()}
|
||||
spring={userToggled() ? FAST_SPRING : undefined}
|
||||
animate={props.animate !== false}
|
||||
getKey={(row) => row.id}
|
||||
render={(row) => {
|
||||
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
|
||||
createEffect(() => {
|
||||
const el = textRef()
|
||||
if (!el || !row.text) return
|
||||
if (wiped.has(row.id)) return
|
||||
wiped.add(row.id)
|
||||
if (reduce()) return
|
||||
el.style.maskImage = WIPE_MASK
|
||||
el.style.webkitMaskImage = WIPE_MASK
|
||||
el.style.maskSize = "240% 100%"
|
||||
el.style.webkitMaskSize = "240% 100%"
|
||||
el.style.maskRepeat = "no-repeat"
|
||||
el.style.webkitMaskRepeat = "no-repeat"
|
||||
el.style.maskPosition = "100% 0%"
|
||||
el.style.webkitMaskPosition = "100% 0%"
|
||||
animate(
|
||||
el,
|
||||
{
|
||||
opacity: [0, 1],
|
||||
filter: ["blur(2px)", "blur(0px)"],
|
||||
transform: ["translateX(-0.06em)", "translateX(0)"],
|
||||
maskPosition: "0% 0%",
|
||||
},
|
||||
GROW_SPRING,
|
||||
).finished.then(() => {
|
||||
if (!el) return
|
||||
clearFadeStyles(el)
|
||||
clearMaskStyles(el)
|
||||
})
|
||||
})
|
||||
return (
|
||||
<div data-component="shell-rolling-row">
|
||||
<span ref={setTextRef} data-slot="shell-rolling-text">
|
||||
{row.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
<div
|
||||
data-slot="shell-rolling-preview"
|
||||
style={{
|
||||
opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(),
|
||||
filter: `blur(${skip() ? 0 : previewBlur()}px)`,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<RollingResults
|
||||
class="shell-rolling-output"
|
||||
items={rows()}
|
||||
fixed={fixed()}
|
||||
fixedHeight={22}
|
||||
rows={5}
|
||||
rowHeight={22}
|
||||
rowGap={0}
|
||||
open={previewOpen()}
|
||||
animate={props.animate !== false}
|
||||
getKey={(row) => row.id}
|
||||
render={(row) => {
|
||||
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
|
||||
createEffect(() => {
|
||||
const el = textRef()
|
||||
if (!el || !row.text) return
|
||||
if (wiped.has(row.id)) return
|
||||
wiped.add(row.id)
|
||||
if (reduce()) return
|
||||
el.style.maskImage = WIPE_MASK
|
||||
el.style.webkitMaskImage = WIPE_MASK
|
||||
el.style.maskSize = "240% 100%"
|
||||
el.style.webkitMaskSize = "240% 100%"
|
||||
el.style.maskRepeat = "no-repeat"
|
||||
el.style.webkitMaskRepeat = "no-repeat"
|
||||
el.style.maskPosition = "100% 0%"
|
||||
el.style.webkitMaskPosition = "100% 0%"
|
||||
let done = false
|
||||
const clear = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearFadeStyles(el)
|
||||
clearMaskStyles(el)
|
||||
}
|
||||
const anim = animate(
|
||||
el,
|
||||
{
|
||||
opacity: [0, 1],
|
||||
filter: ["blur(2px)", "blur(0px)"],
|
||||
transform: ["translateX(-0.06em)", "translateX(0)"],
|
||||
maskPosition: "0% 0%",
|
||||
},
|
||||
GROW_SPRING,
|
||||
)
|
||||
anim.finished.catch(() => {}).finally(clear)
|
||||
onCleanup(() => {
|
||||
anim.stop()
|
||||
clear()
|
||||
})
|
||||
})
|
||||
return (
|
||||
<div data-component="shell-rolling-row">
|
||||
<span ref={setTextRef} data-slot="shell-rolling-text">
|
||||
{row.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ShellExpanded cmd={command()} out={text()} open={expanded()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||
import {
|
||||
animate,
|
||||
type AnimationPlaybackControls,
|
||||
clearFadeStyles,
|
||||
clearMaskStyles,
|
||||
COLLAPSIBLE_SPRING,
|
||||
GROW_SPRING,
|
||||
WIPE_MASK,
|
||||
} from "./motion"
|
||||
|
|
@ -76,6 +77,90 @@ export function hold(state: () => boolean, wait = 2000) {
|
|||
return live
|
||||
}
|
||||
|
||||
export function updateScrollMask(el: HTMLElement, fade = 12) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = el
|
||||
const overflow = scrollHeight - clientHeight
|
||||
if (overflow <= 1) {
|
||||
el.style.maskImage = ""
|
||||
el.style.webkitMaskImage = ""
|
||||
return
|
||||
}
|
||||
const top = scrollTop > 1
|
||||
const bottom = scrollTop < overflow - 1
|
||||
const mask =
|
||||
top && bottom
|
||||
? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
|
||||
: top
|
||||
? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
|
||||
: bottom
|
||||
? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
|
||||
: ""
|
||||
el.style.maskImage = mask
|
||||
el.style.webkitMaskImage = mask
|
||||
}
|
||||
|
||||
export function useCollapsible(options: {
|
||||
content: () => HTMLElement | undefined
|
||||
body: () => HTMLElement | undefined
|
||||
open: () => boolean
|
||||
measure?: () => number
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
let heightAnim: AnimationPlaybackControls | undefined
|
||||
let fadeAnim: AnimationPlaybackControls | undefined
|
||||
|
||||
createEffect(
|
||||
on(options.open, (isOpen) => {
|
||||
const content = options.content()
|
||||
const body = options.body()
|
||||
if (!content || !body) return
|
||||
heightAnim?.stop()
|
||||
fadeAnim?.stop()
|
||||
if (isOpen) {
|
||||
content.style.display = ""
|
||||
content.style.height = "0px"
|
||||
body.style.opacity = "0"
|
||||
body.style.filter = "blur(2px)"
|
||||
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
|
||||
queueMicrotask(() => {
|
||||
if (!options.open()) return
|
||||
const c = options.content()
|
||||
if (!c) return
|
||||
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
|
||||
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
|
||||
heightAnim.finished
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (!options.open()) return
|
||||
const el = options.content()
|
||||
if (!el) return
|
||||
el.style.height = "auto"
|
||||
options.onOpen?.()
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const h = content.getBoundingClientRect().height
|
||||
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
|
||||
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
|
||||
heightAnim.finished
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (options.open()) return
|
||||
const el = options.content()
|
||||
if (!el) return
|
||||
el.style.display = "none"
|
||||
})
|
||||
}, { defer: true }),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
heightAnim?.stop()
|
||||
fadeAnim?.stop()
|
||||
})
|
||||
}
|
||||
|
||||
export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
|
||||
const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
|
||||
const [settled, setSettled] = createSignal(false)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue