diff --git a/packages/app/src/session/composer/composer-docks.stories.tsx b/packages/app/src/session/composer/composer-docks.stories.tsx new file mode 100644 index 00000000000..4872b00cb79 --- /dev/null +++ b/packages/app/src/session/composer/composer-docks.stories.tsx @@ -0,0 +1,246 @@ +import { + activePermissionRequest, + activeQuestionRequest, + CurrentSessionProviders, + editThenTestDocument, +} from "@opencode/session-ui/storybook" +import { SessionTimeline } from "@opencode/session-ui/timeline" +import { motionTuning } from "@opencode/ui/motion-tuning" +import { mountMotionPanel } from "@opencode/ui/motion-tuning-panel" +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { createEffect, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import type { FormInfo } from "@opencode/client/promise" +import { Composer } from "@/composer/composer" +import { createPromptController } from "@/session/story-model" +import type { WebSearchRequestModel } from "@/session/requests/websearch" +import type { SessionQueueView } from "./queue" +import { SessionQueuePanel } from "./queue-panel" +import { SessionComposerRegion, type SessionComposerRegionViewController } from "./session-composer-region" +import { SubagentBar } from "./subagent-bar" + +type DockArgs = { + question: boolean + permission: boolean + websearch: boolean + queue: number + subagent: boolean +} + +const prompts = [ + "Rename the Alpha export", + "Add a Beta regression test", + "Summarize the Gamma changes", + "Check the Delta fixture", +] + +const websearchRequest: FormInfo = { + id: "frm_docks_websearch", + sessionID: activeQuestionRequest.sessionID, + title: "Web Search", + metadata: { kind: "websearch.provider" }, + fields: [{ key: "choice", type: "string", required: true, custom: false }], +} + +// The real composer region with its docks, queue and subagent bar, driven by the story's args or by the replay +// buttons in the Motion panel beside it. Answering, dismissing and Esc close docks the way they do in the app. +function ComposerDocksPreview(props: DockArgs) { + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + const [state, setState] = createStore({ + ...props, + rows: prompts.slice(0, props.queue).map((_, index) => index), + revision: 0, + searchProvider: "exa", + }) + // Grows or trims the queue to `count`, adding the prompts not queued yet. + const queueTo = (count: number) => + setState("rows", (rows) => + [...rows, ...prompts.map((_, index) => index).filter((index) => !rows.includes(index))].slice( + 0, + Math.max(0, Math.min(prompts.length, count)), + ), + ) + const dequeue = (id: string) => setState("rows", (rows) => rows.filter((index) => `queued_${index}` !== id)) + + createEffect( + on( + () => [props.question, props.permission, props.websearch, props.queue, props.subagent] as const, + ([question, permission, websearch, queue, subagent]) => { + setState({ question, permission, websearch, subagent }) + queueTo(queue) + }, + { defer: true }, + ), + ) + + const replay = (id: string, label: string, run: () => void) => + onCleanup(motionTuning.action({ id: `docks.${id}`, label, group: "Composer docks", run })) + replay("question", "Question", () => setState({ question: true, revision: state.revision + 1 })) + replay("permission", "Permission", () => setState("permission", true)) + replay("websearch", "Web search", () => setState("websearch", true)) + replay("stacked", "Question + permission", () => + setState({ question: true, permission: true, revision: state.revision + 1 }), + ) + replay("answer", "Answer", () => setState({ question: false, permission: false, websearch: false })) + replay("esc", "Esc", () => + document + .querySelector('[data-component="dock-prompt"][data-kind="question"]') + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })), + ) + replay("queue-add", "Queue +1", () => queueTo(state.rows.length + 1)) + replay("queue-remove", "Queue −1", () => queueTo(state.rows.length - 1)) + replay("subagent", "Subagent", () => setState("subagent", (value) => !value)) + replay("queue-reverse", "Reverse queue", () => setState("rows", (rows) => rows.toReversed())) + + const prompt = createPromptController({ + initial: "", + placeholder: "Ask anything", + status: () => ({ type: "busy" }), + onActivity: () => undefined, + onSubmit: () => queueTo(state.rows.length + 1), + onStop: () => undefined, + }) + + const blocked = () => state.question || state.permission || state.websearch + // A permission decision also reports a response submit, so only a submit without a decision answers the question. + let deciding = false + const region = { + state: { + questionRequest: () => + state.question ? { ...activeQuestionRequest, id: `${activeQuestionRequest.id}:${state.revision}` } : undefined, + websearch: { + request: () => (state.websearch ? websearchRequest : undefined), + options: () => [ + { value: "exa", label: "Exa" }, + { value: "parallel", label: "Parallel" }, + ], + selected: () => state.searchProvider, + specific: () => false, + loading: () => false, + loadFailed: () => false, + failed: () => false, + sending: () => false, + connected: () => true, + select: (value) => setState("searchProvider", value), + retry() {}, + submit: async () => void setState("websearch", false), + } satisfies WebSearchRequestModel, + permissionRequest: () => (state.permission ? activePermissionRequest : undefined), + permissionResponding: () => false, + decide: () => { + deciding = true + setState("permission", false) + }, + blocked, + }, + centered: () => true, + onResponseSubmit: () => + queueMicrotask(() => { + if (!deciding) setState("question", false) + deciding = false + }), + setDockRef() {}, + child: () => state.subagent, + showComposer: () => !blocked() || state.subagent, + } satisfies SessionComposerRegionViewController + + const queue: SessionQueueView = { + rows: () => state.rows.map((index) => ({ id: `queued_${index}`, text: prompts[index]!, attachments: 0 })), + editing: () => undefined, + working: () => true, + busy: () => false, + steer: async (id) => dequeue(id), + remove: async (id) => dequeue(id), + undo: dequeue, + edit: () => false, + reorder: async (ids) => + void setState( + "rows", + ids.map((id) => Number(id.replace("queued_", ""))), + ), + } + + return ( + +
+ +
+
+ +
+
+
+
+
+ ) +} + +export default { + title: "OpenCode/Session/Composer docks", + id: "session-composer-docks", + component: ComposerDocksPreview, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "Request docks, the queue and the subagent bar around the composer. Toggle the args, or use the replay buttons in the Motion panel, to open and close each dock and tune its motion live.", + }, + }, + }, + argTypes: { + queue: { control: { type: "range", min: 0, max: 4, step: 1 } }, + }, + args: { question: false, permission: false, websearch: false, queue: 0, subagent: false } satisfies DockArgs, + render: (args: DockArgs) => , +} + +export const Empty = {} + +export const QueueOne = { args: { queue: 1 } } + +export const QueueThree = { args: { queue: 3 } } + +export const Question = { args: { question: true } } + +export const QuestionAndPermission = { args: { question: true, permission: true } } + +export const WebSearch = { args: { websearch: true } } + +export const Subagent = { args: { subagent: true } } + +// Opens a question, then answers it and dismisses the next one: the closes a reviewer should watch. +export const AnsweredAndDismissed = { + args: { question: true, queue: 2 }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const dock = () => canvasElement.querySelector('[data-component="dock-prompt"][data-kind="question"]') + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + await wait(800) + dock()?.querySelector('[role="radio"]')?.click() + ;[...(dock()?.querySelectorAll("button") ?? [])] + .find((button) => button.getAttribute("aria-keyshortcuts") === "Escape") + ?.click() + }, +} diff --git a/packages/app/src/session/composer/queue-panel.tsx b/packages/app/src/session/composer/queue-panel.tsx index e48a652d1a3..4f1f7b31504 100644 --- a/packages/app/src/session/composer/queue-panel.tsx +++ b/packages/app/src/session/composer/queue-panel.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, on, Show, untrack } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, untrack } from "solid-js" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" @@ -10,9 +10,10 @@ import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Spinner } from "@opencode/ui/spinner" import { Tooltip } from "@opencode/ui/tooltip" +import { prefersReducedMotion } from "@opencode/ui/motion" import { useLanguage } from "@/runtime/i18n/language" import type { SessionQueueView } from "./queue" -import { Grow } from "./spring-height" +import { cascade, Grow, queueStagger } from "./spring-height" import "./queue-panel.css" type QueueRow = ReturnType[number] @@ -39,16 +40,40 @@ export function SessionQueuePanel(props: { queue: SessionQueueView; hidden?: boo lastLive = next if (next.length === 0) return - // An in-place edit or reorder replaces row IDs without changing the row count. - if (next.length === prevLive.length && prevLive.length > 0) return setShown(next) - if (prevLive.length > 0 && !props.hidden) + // An in-place edit or reorder keeps the row count: a row whose ID it replaced swaps in place rather than closing, + // while a row that was already closing keeps closing until its exit completes. + const swap = next.length === prevLive.length && prevLive.length > 0 + if (!swap && prevLive.length > 0 && !props.hidden) next.forEach((row) => !prevLive.some((item) => item.id === row.id) && appearing.add(row.id)) - setShown(keepLeaving(untrack(shown), next)) + const kept = untrack(shown).filter( + (row) => !swap || next.some((item) => item.id === row.id) || !prevLive.some((item) => item.id === row.id), + ) + setShown(keepLeaving(kept, next)) }, { defer: true }, ), ) + // When the panel opens with prompts already queued, such as after a dock closes, its rows can cascade in. + let stopCascade: (() => void) | undefined + createEffect( + on( + open, + (now, was) => { + stopCascade?.() + stopCascade = undefined + const step = queueStagger() + if (!now || was !== false || step <= 0 || prefersReducedMotion()) return + queueMicrotask(() => { + if (!open()) return + stopCascade = cascade([...listRef.querySelectorAll('[data-component="session-queue-row"]')], step) + }) + }, + { defer: true }, + ), + ) + onCleanup(() => stopCascade?.()) + return ( setShown(props.queue.rows())}>
Math.round(px * dpr) / dpr const surface = @@ -20,7 +73,8 @@ type Face = { } // The composer and the docks that stand in for it as one surface: a new face grows the region to its height on one -// spring while the old face hands over in place. Faces present on mount rest immediately. +// spring while the old face closes in its own box above or below it, so the two never draw over each other. Faces +// present on mount rest immediately. export function ComposerMorph(props: { face: T children: (face: Accessor) => JSX.Element @@ -38,21 +92,62 @@ export function ComposerMorph(props: { root?.removeAttribute("data-moving") root?.removeAttribute("data-dock-border-underlay") root?.style.removeProperty("height") - list.forEach((item) => - ["opacity", "transform", "filter", "mask-image"].forEach((prop) => item.el?.style.removeProperty(prop)), - ) + list.forEach((item) => { + const content = item.el?.firstElementChild instanceof HTMLElement ? item.el.firstElementChild : undefined + ;["position", "inset-inline", "top", "transform", "clip-path", "opacity"].forEach((prop) => + item.el?.style.removeProperty(prop), + ) + ;["transform", "filter", "mask-image"].forEach((prop) => content?.style.removeProperty(prop)) + }) setFaces(list.filter((item) => !item.leaving())) return false } + // Face heights come from a ResizeObserver; this one read happens only when a flight starts. const measure = () => { for (const item of untrack(faces)) if (item.el) item.height = item.el.getBoundingClientRect().height } - const paintFace = (item: Face, shown: number) => { - if (!item.el) return - const p = Math.max(0, Math.min(1, item.presence.value)) - styleFace(item.el, p, item.leaving() ? 2 : 6, item.height - shown) + // Each face gets a box: a closing face keeps its presence's share of its height, and the face coming in takes what is + // left of the region. During a flight the faces are stacked with transforms and cut to their boxes with clip-path, so + // only the region's own height changes layout. Under reduced motion the closing faces fade out in full first, then + // the new face fades in. + const paintFaces = (list: Face[], shown: number) => { + const reduced = prefersReducedMotion() + const leaving = list.filter((item) => item.leaving()) + const waiting = reduced && leaving.some((item) => item.presence.value > 0.01) + const boxes = new Map( + leaving.map((item) => [ + item, + reduced ? (waiting ? item.height : 0) : snap(item.height * Math.max(0, Math.min(1, item.presence.value))), + ]), + ) + const used = [...boxes.values()].reduce((sum, box) => sum + box, 0) + let offset = 0 + list.forEach((item) => { + if (!item.el) return + const box = boxes.get(item) ?? (waiting ? 0 : Math.max(0, Math.min(item.height, shown - used))) + const content = item.el.firstElementChild instanceof HTMLElement ? item.el.firstElementChild : item.el + item.el.style.position = "absolute" + item.el.style.insetInline = "0" + item.el.style.top = "0" + item.el.style.transform = `translateY(${offset}px)` + item.el.style.clipPath = `inset(0 0 calc(100% - ${box.toFixed(2)}px) 0)` + offset += box + const p = item.leaving() ? (reduced ? item.presence.value : box / Math.max(1, item.height)) : item.presence.value + styleFace(item.el, Math.max(0, Math.min(1, p)), { + dy: item.leaving() ? 0 : 6, + out: item.leaving(), + hidden: item.height - box, + content, + }) + }) + } + + const regionHeight = (list: Face[], spring: Spring) => { + if (!prefersReducedMotion()) return snap(spring.value) + const leaving = list.filter((item) => item.leaving() && item.presence.value > 0.01) + return leaving.length ? leaving.reduce((sum, item) => sum + item.height, 0) : spring.target } const paint = (dt: number) => { @@ -60,20 +155,25 @@ export function ComposerMorph(props: { const entering = list.find((item) => !item.leaving()) if (!root || !height || !entering) return false - if (entering.el && height.target !== entering.height) height.set(entering.height) - if (prefersReducedMotion()) height.snap(entering.height) + // A face that just mounted has no height until its ResizeObserver reports one. + if (entering.height > 0 && height.target !== entering.height) height.set(entering.height) + const reduced = prefersReducedMotion() + if (reduced) height.snap(entering.height) height.step(dt) if (Math.abs(height.value - height.target) < 0.5 && Math.abs(height.velocity) < 5) height.snap(height.target) + const waiting = reduced && list.some((item) => item.leaving() && item.presence.value > 0.01) list.forEach((item) => { + if (waiting && !item.leaving()) return item.presence.step(dt) if (Math.abs(item.presence.value - item.presence.target) < 0.01 && Math.abs(item.presence.velocity) < 0.05) item.presence.snap(item.presence.target) }) if (height.resting && list.every((item) => item.presence.resting)) return settle(list) - const shown = snap(height.value) + const shown = regionHeight(list, height) + // Written every frame, never read back: the timeline inset follows the region's real height (as in #57559). root.style.height = `${shown}px` - list.forEach((item) => paintFace(item, shown)) + paintFaces(list, shown) return true } @@ -91,27 +191,33 @@ export function ComposerMorph(props: { const back = next.key === "composer" ? list.find((item) => item.key === next.key) : undefined const entering = back ?? face(next, 0) const active = list.filter((item) => item === entering || item.presence.value >= 0.01) + // The closing box must start at the face's full height, which only a fresh measure knows. + measure() active.forEach((item) => { if (item === entering) return item.leave(true) if (item.el?.contains(document.activeElement) && document.activeElement instanceof HTMLElement) document.activeElement.blur() - tune(item.presence, reduced ? fade : exit).set(0) + // A closing face's box closes on the region's spring, so the boxes always add up to the region. + tune(item.presence, reduced ? fade : morph()).set(0) }) entering.set(next) entering.leave(false) - tune(entering.presence, reduced ? fade : enter).set(1) - height ??= Spring.fromDuration(from, enter) - tune(height, enter) - const shown = snap(height.value) - const nextFaces = back ? active : [...active, entering] + tune(entering.presence, reduced ? fade : fadeIn()).set(1) + height ??= Spring.fromDuration(from, morph()) + tune(height, morph()) + // Docks always stack above the composer, whichever is coming or going. + const nextFaces = (back ? active : [...active, entering]).toSorted( + (a, b) => Number(a.key === "composer") - Number(b.key === "composer"), + ) + const shown = regionHeight(nextFaces, height) root.toggleAttribute("data-moving", true) root.toggleAttribute("data-dock-border-underlay", true) root.style.height = `${shown}px` - nextFaces.forEach((item) => paintFace(item, shown)) + paintFaces(nextFaces, shown) setFaces(nextFaces) - stop ??= frameLoop.addMeasured(measure, paint) + stop ??= frameLoop.add(paint) }, { defer: true }, ), @@ -127,13 +233,19 @@ export function ComposerMorph(props: {
{ item.el = el - if (height) paintFace(item, snap(height.value)) + const observer = new ResizeObserver((entries) => { + const size = entries[0]?.borderBoxSize[0]?.blockSize + if (size !== undefined) item.height = size + }) + observer.observe(el) + onCleanup(() => observer.disconnect()) + if (height) paintFaces(untrack(faces), snap(height.value)) }} data-slot="composer-face" inert={item.leaving()} aria-hidden={item.leaving() || undefined} class="shrink-0" - classList={{ "pointer-events-none absolute inset-x-0 top-0": item.leaving() }} + classList={{ "pointer-events-none": item.leaving() }} > {props.children(item.value)}
@@ -155,11 +267,21 @@ function tune(spring: Spring, options: DurationOptions) { return spring } -function styleFace(el: HTMLElement, p: number, dy: number, hidden = 0, content = el) { - el.style.opacity = `${(p * p).toFixed(4)}` - if (prefersReducedMotion()) return - content.style.transform = `translateY(${((1 - p) * dy).toFixed(2)}px)` - el.style.filter = p < 0.999 ? `blur(${((1 - p) * 4).toFixed(2)}px)` : "" +// A face coming in eases its opacity in late. A face going out stays opaque until only the fade-out tail of its box is +// left, then fades, so it is gone just as its box closes. +function styleFace( + el: HTMLElement, + p: number, + input: { dy: number; out: boolean; hidden?: number; content?: HTMLElement }, +) { + const content = input.content ?? el + const hidden = input.hidden ?? 0 + el.style.opacity = `${(input.out ? Math.min(1, p / fadeTail()) : p * p).toFixed(4)}` + // Movement, blur and the soft edge go on the content inside the clipping box, never on the box itself, so a blur + // can't bleed past it. Before the content mounts there is nothing to move. + if (prefersReducedMotion() || content === el) return + content.style.transform = `translateY(${((1 - p) * input.dy).toFixed(2)}px)` + content.style.filter = p < 0.999 && blur() > 0 ? `blur(${((1 - p) * blur()).toFixed(2)}px)` : "" content.style.maskImage = hidden > 0 ? `linear-gradient(to top, transparent ${hidden}px, #000 ${hidden + Math.min(24, hidden)}px)` : "" } @@ -171,6 +293,8 @@ export function Grow(props: { fade?: boolean card?: boolean class?: string + /** The spring it closes on, when it closes alongside other motion that should share it. */ + close?: () => DurationOptions onClosed?: () => void children: JSX.Element | ((item: NonNullable) => JSX.Element) }) { @@ -189,7 +313,7 @@ export function Grow(props: { } if (!el || (!entering && (!prev || prev[0] === when))) return entering = false - grow(el, when, props.fade, props.card, () => { + grow(el, when, props.fade, props.card, props.close?.(), () => { setPresent(false) props.onClosed?.() }) @@ -206,22 +330,36 @@ export function Grow(props: { > {(() => { const child = props.children - return typeof child === "function" ? {child} : child + return typeof child === "function" ? ( + + {child} + + ) : ( + child + ) })()}
) } -const growing = new WeakMap void }>() +const growing = new WeakMap void }>() -function grow(el: HTMLElement, open: boolean, withFade?: boolean, card?: boolean, done?: () => void) { +function grow( + el: HTMLElement, + open: boolean, + withFade?: boolean, + card?: boolean, + close?: DurationOptions, + done?: () => void, +) { const reduced = prefersReducedMotion() const current = growing.get(el) if (current) { current.open = open current.done = done - tune(current.spring, reduced ? fade : open ? enter : exit) + tune(current.spring, reduced ? fade : open ? morph() : (close ?? morph())) + tune(current.presence, reduced ? fade : fadeIn()) return } @@ -229,7 +367,12 @@ function grow(el: HTMLElement, open: boolean, withFade?: boolean, card?: boolean const prevMb = (!mb && el.previousElementSibling ? parseFloat(getComputedStyle(el.previousElementSibling).marginBottom) : 0) || 0 const state = { - spring: Spring.fromDuration(open ? 0 : el.getBoundingClientRect().height, reduced ? fade : open ? enter : exit), + spring: Spring.fromDuration( + open ? 0 : el.getBoundingClientRect().height, + reduced ? fade : open ? morph() : (close ?? morph()), + ), + // Opening fades in on its own spring; closing fades with the height, inside the closing box. + presence: Spring.fromDuration(open ? 0 : 1, reduced ? fade : fadeIn()), open, done, } @@ -243,56 +386,108 @@ function grow(el: HTMLElement, open: boolean, withFade?: boolean, card?: boolean } if ((withFade || reduced) && open) el.style.opacity = "0" - let full = 0 - frameLoop.addMeasured( - () => { - const child = el.firstElementChild - if (!(child instanceof HTMLElement)) return - const margin = parseFloat(getComputedStyle(child).marginBottom) || 0 - if (margin < 0) el.style.overflowClipMargin = `${-margin}px` - full = child.getBoundingClientRect().height + margin - }, - (dt) => { - if (!el.isConnected) { - growing.delete(el) - return false - } - const noMotion = prefersReducedMotion() - const size = state.open ? full : 0 - state.spring.set(size).step(dt) - if (Math.abs(state.spring.value - size) < 0.5 && Math.abs(state.spring.velocity) < 5) state.spring.snap(size) + // The content's height is read once here, then kept current by a ResizeObserver rather than read every frame. + const child = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : undefined + const margin = child ? parseFloat(getComputedStyle(child).marginBottom) || 0 : 0 + if (margin < 0) el.style.overflowClipMargin = `${-margin}px` + let full = child ? child.getBoundingClientRect().height + margin : 0 + const observer = new ResizeObserver((entries) => { + const size = entries[0]?.borderBoxSize[0]?.blockSize + if (size !== undefined) full = size + margin + }) + if (child) observer.observe(child) + frameLoop.add((dt) => { + if (!el.isConnected) { + observer.disconnect() + growing.delete(el) + return false + } + const noMotion = prefersReducedMotion() + const size = state.open ? full : 0 + state.spring.set(size).step(dt) + if (Math.abs(state.spring.value - size) < 0.5 && Math.abs(state.spring.velocity) < 5) state.spring.snap(size) + const shown = state.open ? 1 : 0 + state.presence.set(shown).step(dt) + if (Math.abs(state.presence.value - shown) < 0.01 && Math.abs(state.presence.velocity) < 0.05) + state.presence.snap(shown) - if (state.spring.resting) { - growing.delete(el) - finish(el, state.open, state.done) - return false - } + if (state.spring.resting && (!withFade || !state.open || state.presence.resting)) { + observer.disconnect() + growing.delete(el) + finish(el, state.open, state.done) + return false + } - const value = snap(Math.max(0, state.spring.value)) - const p = Math.max(0, Math.min(1, state.spring.value / Math.max(1, full))) - if (!noMotion) { - el.style.height = `${value}px` - if (mb > 0) el.style.marginBottom = `${snap(mb * p)}px` - if (prevMb > 0) el.style.marginTop = `${snap(-prevMb * (1 - p))}px` - } - if (withFade || noMotion) { - const child = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : el - styleFace(el, p, 4, card ? full - value : 0, child) - } - return true - }, - ) + const value = snap(Math.max(0, state.spring.value)) + const p = Math.max(0, Math.min(1, state.spring.value / Math.max(1, full))) + const presence = Math.max(0, Math.min(1, state.presence.value)) + if (!noMotion) { + // Written every frame, never read back: neighbours and the timeline inset follow the real height (as in #57559). + el.style.height = `${value}px` + if (mb > 0) el.style.marginBottom = `${snap(mb * p)}px` + if (prevMb > 0) el.style.marginTop = `${snap(-prevMb * (1 - p))}px` + } + if (withFade || noMotion) { + const child = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : el + styleFace(el, state.open ? presence : p, { + dy: state.open ? 4 : 0, + out: !state.open, + hidden: card ? full - value : 0, + content: child, + }) + } + return true + }) } function finish(el: HTMLElement, open: boolean, done?: () => void) { const child = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : undefined el.removeAttribute("data-moving") el.removeAttribute("data-dock-border-underlay") - ;["height", "margin-top", "margin-bottom", "overflow-clip-margin", "opacity", "filter", "transform", "mask-image"].forEach( - (prop) => { - el.style.removeProperty(prop) - child?.style.removeProperty(prop) - }, - ) + ;[ + "height", + "margin-top", + "margin-bottom", + "overflow-clip-margin", + "opacity", + "filter", + "transform", + "mask-image", + ].forEach((prop) => { + el.style.removeProperty(prop) + child?.style.removeProperty(prop) + }) if (!open) done?.() } + +// Fades `rows` in one after another on the fade-in spring, `step` seconds apart, on the shared frame loop. Returns its +// cancellation, which clears what it painted. +export function cascade(rows: HTMLElement[], step: number) { + const springs = rows.map(() => Spring.fromDuration(0, fadeIn())) + const clear = () => + rows.forEach((el) => ["opacity", "filter", "transform"].forEach((prop) => el.style.removeProperty(prop))) + let elapsed = 0 + const paintRow = (el: HTMLElement, p: number) => { + el.style.opacity = `${(p * p).toFixed(4)}` + el.style.transform = `translateY(${((1 - p) * 4).toFixed(2)}px)` + } + rows.forEach((el) => paintRow(el, 0)) + const stop = frameLoop.add((dt) => { + elapsed += dt + springs.forEach((spring, index) => { + if (elapsed < index * step) return + spring.set(1).step(dt) + if (Math.abs(spring.value - 1) < 0.01 && Math.abs(spring.velocity) < 0.05) spring.snap(1) + }) + if (springs.every((spring) => spring.value === 1)) { + clear() + return false + } + rows.forEach((el, index) => paintRow(el, Math.max(0, Math.min(1, springs[index]!.value)))) + return true + }) + return () => { + stop() + clear() + } +} diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 1a68d9f02d5..722cd8174a8 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -126,7 +126,7 @@ export function SessionPreview(props: SessionPreviewProps) { ) } -function createPromptController(input: { +export function createPromptController(input: { initial: string placeholder: string status: () => SessionStatus diff --git a/packages/storybook/.storybook/mocks/app/context/server-sdk.ts b/packages/storybook/.storybook/mocks/app/context/server-sdk.ts index 08e1de7f736..aa4a279aa8b 100644 --- a/packages/storybook/.storybook/mocks/app/context/server-sdk.ts +++ b/packages/storybook/.storybook/mocks/app/context/server-sdk.ts @@ -43,9 +43,11 @@ const client = { } const api = { - form: { - reply: async () => ({ data: true }), - cancel: async () => ({ data: true }), + session: { + form: { + reply: async () => ({ data: true }), + cancel: async () => ({ data: true }), + }, }, }