fix(app): animate composer delivery controls (#44886)

This commit is contained in:
Brendan Allan 2026-08-25 13:34:53 +08:00 committed by GitHub
parent 5ad0f0dc5a
commit 1f7ae3f638
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 85 additions and 17 deletions

View file

@ -1,7 +1,8 @@
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { Button } from "@opencode-ai/ui/button"
@ -716,16 +717,23 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
if (queue.editing()) return "steer" as const
return queue.alternate()
})
const [button, setButton] = createSignal<HTMLButtonElement>()
const presence = createAnimatedPresence(action, () => button() ?? null)
return (
<Show when={action()} keyed>
<Show when={presence.present() && presence.value()} keyed>
{(delivery) => (
<Tooltip placement="top" inactive={delivery !== "steer"} value={i18n.t("ui.promptInput.steerHint")}>
<Button
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530]"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
onClick={() => props.controller.submit({ alternate: true })}
>
{delivery === "steer" ? i18n.t("ui.promptInput.steer") : i18n.t("ui.promptInput.queue")}

View file

@ -0,0 +1,21 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
show: () => animation().show,
animate: () => animation().animate,
value: () => animation().value,
}
}

View file

@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js"
import createPresence from "solid-presence"
import { createStore } from "solid-js/store"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
import { Badge } from "@opencode-ai/ui/badge"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
@ -479,17 +479,7 @@ function MessageTimelineView(
return row.group.ref.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintVisibility = createMemo<{ show: boolean; animate: boolean }>(
(previous) => {
const show = backgroundHintPartID() !== undefined
return { show, animate: previous.animate || previous.show !== show }
},
{ show: backgroundHintPartID() !== undefined, animate: false },
)
const backgroundHintPresence = createPresence({
show: () => backgroundHintVisibility().show,
element: () => backgroundHintRef() ?? null,
})
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
@ -507,9 +497,9 @@ function MessageTimelineView(
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintVisibility().animate && !backgroundHintVisibility().show,
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint />

View file

@ -0,0 +1,49 @@
import { expect, test } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { createRoot, createSignal } from "solid-js"
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
const [value, setValue] = createSignal<string | undefined>("steer")
const presence = createAnimatedPresence(value, () => null)
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(false)
expect(presence.value()).toBe("steer")
expect(presence.present()).toBe(true)
setValue("queue")
expect(presence.animate()).toBe(false)
expect(presence.value()).toBe("queue")
setValue(undefined)
expect(presence.show()).toBe(false)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("queue")
setValue("steer")
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("steer")
dispose()
})
})
test("animates the first appearance when initially hidden", () => {
createRoot((dispose) => {
const [value, setValue] = createSignal<string | undefined>()
const presence = createAnimatedPresence(value, () => null)
expect(presence.show()).toBe(false)
expect(presence.animate()).toBe(false)
expect(presence.present()).toBe(false)
setValue("steer")
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("steer")
dispose()
})
})