diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 80e153723b..6f9b16f336 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,7 +1,7 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" +import { Show, createMemo, createSignal, createEffect } from "solid-js" import { useParams } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" +import { useElementHeight } from "@opencode-ai/ui/hooks" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -9,11 +9,12 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" -import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" +import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock" + +const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 } export function SessionComposerRegion(props: { state: SessionComposerState - ready: boolean centered: boolean inputRef: (el: HTMLDivElement) => void newSessionWorktree: string @@ -21,23 +22,6 @@ export function SessionComposerRegion(props: { onSubmit: () => void onResponseSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void - visualDuration?: number - bounce?: number - dockOpenVisualDuration?: number - dockOpenBounce?: number - dockCloseVisualDuration?: number - dockCloseBounce?: number - drawerExpandVisualDuration?: number - drawerExpandBounce?: number - drawerCollapseVisualDuration?: number - drawerCollapseBounce?: number - subtitleDuration?: number - subtitleTravel?: number - subtitleEdge?: number - countDuration?: number - countMask?: number - countMaskHeight?: number - countWidthDuration?: number }) { const params = useParams() const prompt = usePrompt() @@ -63,76 +47,15 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) - const [gate, setGate] = createStore({ - ready: false, - }) - let timer: number | undefined - let frame: number | undefined - - const clear = () => { - if (timer !== undefined) { - window.clearTimeout(timer) - timer = undefined - } - if (frame !== undefined) { - cancelAnimationFrame(frame) - frame = undefined - } - } - - createEffect(() => { - sessionKey() - const ready = props.ready - const delay = 140 - - clear() - setGate("ready", false) - if (!ready) return - - frame = requestAnimationFrame(() => { - frame = undefined - timer = window.setTimeout(() => { - setGate("ready", true) - timer = undefined - }, delay) - }) - }) - - onCleanup(clear) - - const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing()) - const config = createMemo(() => - open() - ? { - visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.dockOpenBounce ?? props.bounce ?? 0, - } - : { - visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.dockCloseBounce ?? props.bounce ?? 0, - }, - ) + const open = createMemo(() => props.state.dock() && !props.state.closing()) const progress = useSpring( () => (open() ? 1 : 0), - config, + DOCK_SPRING, ) - const value = createMemo(() => Math.max(0, Math.min(1, progress()))) - const [height, setHeight] = createSignal(320) - const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001) - const full = createMemo(() => Math.max(78, height())) + const dock = createMemo(() => props.state.dock() || progress() > 0.001) const [contentRef, setContentRef] = createSignal() - - createEffect(() => { - const el = contentRef() - if (!el) return - const update = () => { - setHeight(el.getBoundingClientRect().height) - } - update() - const observer = new ResizeObserver(update) - observer.observe(el) - onCleanup(() => observer.disconnect()) - }) + const height = useElementHeight(contentRef, 320) + const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height())) return (
@@ -194,20 +117,7 @@ export function SessionComposerRegion(props: { title={language.t("session.todo.title")} collapseLabel={language.t("session.todo.collapse")} expandLabel={language.t("session.todo.expand")} - dockProgress={value()} - visualDuration={props.visualDuration} - bounce={props.bounce} - expandVisualDuration={props.drawerExpandVisualDuration} - expandBounce={props.drawerExpandBounce} - collapseVisualDuration={props.drawerCollapseVisualDuration} - collapseBounce={props.drawerCollapseBounce} - subtitleDuration={props.subtitleDuration} - subtitleTravel={props.subtitleTravel} - subtitleEdge={props.subtitleEdge} - countDuration={props.countDuration} - countMask={props.countMask} - countMaskHeight={props.countMaskHeight} - countWidthDuration={props.countWidthDuration} + dockProgress={progress()} />
@@ -217,7 +127,7 @@ export function SessionComposerRegion(props: { "relative z-10": true, }} style={{ - "margin-top": `${-36 * value()}px`, + "margin-top": `${-36 * progress()}px`, }} > active()?.content ?? "") - const config = createMemo(() => - store.collapsed - ? { - visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.collapseBounce ?? props.bounce ?? 0, - } - : { - visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.expandBounce ?? props.bounce ?? 0, - }, - ) - const collapse = useSpring(() => (store.collapsed ? 1 : 0), config) - const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1))) - const shut = createMemo(() => 1 - dock()) - const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) - const hide = createMemo(() => Math.max(value(), shut())) - const [height, setHeight] = createSignal(320) - const full = createMemo(() => Math.max(78, height())) + const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING) + const shut = createMemo(() => 1 - (props.dockProgress ?? 1)) + const hide = createMemo(() => Math.max(collapse(), shut())) let contentRef: HTMLDivElement | undefined - - createEffect(() => { - const el = contentRef - if (!el) return - const update = () => { - setHeight(el.getBoundingClientRect().height) - } - update() - const observer = new ResizeObserver(update) - observer.observe(el) - onCleanup(() => observer.disconnect()) - }) + const height = useElementHeight(() => contentRef, 320) + const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height())) return (
@@ -131,12 +99,12 @@ export function SessionTodoDock(props: { class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible" aria-label={label()} style={{ - "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`, - "--tool-motion-mask": `${props.countMask ?? 18}%`, - "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`, - "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`, - opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, - filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`, + "--tool-motion-odometer-ms": `${COUNT.duration}ms`, + "--tool-motion-mask": `${COUNT.mask}%`, + "--tool-motion-mask-height": `${COUNT.maskHeight}px`, + "--tool-motion-spring-ms": `${COUNT.widthDuration}ms`, + opacity: `${1 - shut()}`, + filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none", }} > @@ -155,9 +123,9 @@ export function SessionTodoDock(props: { { event.preventDefault() event.stopPropagation() @@ -187,13 +155,14 @@ export function SessionTodoDock(props: {
0.1, }} style={{ - opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, - filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`, + opacity: `${1 - hide()}`, + filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none", visibility: hide() > 0.98 ? "hidden" : "visible", }} > @@ -279,10 +248,8 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px", - transition: - "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", - opacity: todo().status === "pending" ? "0.94" : "1", - filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", + transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", + opacity: todo().status === "pending" ? "0.5" : "1", }} > diff --git a/packages/ui/src/components/todo-panel-motion.stories.tsx b/packages/ui/src/components/todo-panel-motion.stories.tsx index 39d3421578..c7008e41bf 100644 --- a/packages/ui/src/components/todo-panel-motion.stories.tsx +++ b/packages/ui/src/components/todo-panel-motion.stories.tsx @@ -131,22 +131,7 @@ export const Playground = { const global = useGlobalSync() const [open, setOpen] = createSignal(true) const [step, setStep] = createSignal(1) - const [dockOpenDuration, setDockOpenDuration] = createSignal(0.3) - const [dockOpenBounce, setDockOpenBounce] = createSignal(0) const [dockCloseDuration, setDockCloseDuration] = createSignal(0.3) - const [dockCloseBounce, setDockCloseBounce] = createSignal(0) - const [drawerExpandDuration, setDrawerExpandDuration] = createSignal(0.3) - const [drawerExpandBounce, setDrawerExpandBounce] = createSignal(0) - const [drawerCollapseDuration, setDrawerCollapseDuration] = createSignal(0.3) - const [drawerCollapseBounce, setDrawerCollapseBounce] = createSignal(0) - const [subtitleDuration, setSubtitleDuration] = createSignal(600) - const [subtitleAuto, setSubtitleAuto] = createSignal(true) - const [subtitleTravel, setSubtitleTravel] = createSignal(25) - const [subtitleEdge, setSubtitleEdge] = createSignal(17) - const [countDuration, setCountDuration] = createSignal(600) - const [countMask, setCountMask] = createSignal(18) - const [countMaskHeight, setCountMaskHeight] = createSignal(0) - const [countWidthDuration, setCountWidthDuration] = createSignal(560) const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) let frame let composerRef @@ -256,21 +241,6 @@ export const Playground = { onSubmit={() => {}} onResponseSubmit={pin} setPromptDockRef={() => {}} - dockOpenVisualDuration={dockOpenDuration()} - dockOpenBounce={dockOpenBounce()} - dockCloseVisualDuration={dockCloseDuration()} - dockCloseBounce={dockCloseBounce()} - drawerExpandVisualDuration={drawerExpandDuration()} - drawerExpandBounce={drawerExpandBounce()} - drawerCollapseVisualDuration={drawerCollapseDuration()} - drawerCollapseBounce={drawerCollapseBounce()} - subtitleDuration={subtitleDuration()} - subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()} - subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()} - countDuration={countDuration()} - countMask={countMask()} - countMaskHeight={countMaskHeight()} - countWidthDuration={countWidthDuration()} />
@@ -279,62 +249,21 @@ export const Playground = {
- - - - {[0, 1, 2, 3].map((value) => ( - ))}
-
Dock open
- - - -
- Dock close -
+
Dock close
- - -
- Drawer expand -
- - - -
- Drawer collapse -
- - - -
- Subtitle odometer -
- - - - - -
- Count odometer -
- - - -
) diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 1c90a2e493..9637c88cb5 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from "./use-filtered-list" export * from "./create-auto-scroll" +export * from "./use-element-height" diff --git a/packages/ui/src/hooks/use-element-height.ts b/packages/ui/src/hooks/use-element-height.ts new file mode 100644 index 0000000000..a9f06ec8b8 --- /dev/null +++ b/packages/ui/src/hooks/use-element-height.ts @@ -0,0 +1,25 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +/** + * Tracks an element's height via ResizeObserver. + * Returns a reactive signal that updates whenever the element resizes. + */ +export function useElementHeight( + ref: Accessor | (() => HTMLElement | undefined), + initial = 0, +): Accessor { + const [height, setHeight] = createSignal(initial) + + createEffect(() => { + const el = ref() + if (!el) return + setHeight(el.getBoundingClientRect().height) + const observer = new ResizeObserver(() => { + setHeight(el.getBoundingClientRect().height) + }) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + + return height +}