fix(app): grow pending messages in and drop the pending line instantly (#58233)

This commit is contained in:
James Long 2026-10-22 17:42:18 -04:00 • committed by GitHub
parent 42b90ff01b
commit 0169985037
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 104 additions and 31 deletions

View file

@ -44,7 +44,7 @@ story.describe("message timestamp", () => {
})
})
story("smoothly delivers a pending footer with and without hover", async ({ mount, page }) => {
story("drops a pending footer at once on delivery with and without hover", async ({ mount, page }) => {
const timeline = await mount("current-prompt-message--pending")
const message = timeline.locator('[data-component="user-message"]')
const footer = timeline.locator('[data-slot="user-message-copy-wrapper"]')
@ -73,36 +73,24 @@ story("smoothly delivers a pending footer with and without hover", async ({ moun
expect(actions.x - label.x - label.width).toBeLessThanOrEqual(9)
await timeline.getByLabel("Delivery state").selectOption("delivered")
const middle = await message.evaluate((element) => {
for (const animation of element.getAnimations({ subtree: true })) {
animation.pause()
animation.currentTime = 100
}
// The first frame after delivery already shows the delivered footer: nothing shrinks or fades out.
const after = await message.evaluate((element) => {
const pending = element.querySelector(
'[data-slot="user-message-transition"]:has([data-slot="user-message-pending"])',
)!
const footer = element.querySelector('[data-slot="user-message-copy-wrapper"]')!
return {
animations: [...pending.getAnimations(), ...footer.getAnimations()].length,
width: pending.getBoundingClientRect().width,
opacity: Number(getComputedStyle(pending).opacity),
footer: Number(getComputedStyle(footer).opacity),
}
})
expect(middle.width).toBeGreaterThan(0)
expect(middle.width).toBeLessThan(before.width)
expect(middle.opacity).toBeGreaterThan(0)
expect(middle.opacity).toBeLessThan(1)
if (hover) expect(middle.footer).toBe(1)
if (!hover) {
expect(middle.footer).toBeGreaterThan(0)
expect(middle.footer).toBeLessThan(1)
}
expect(after).toEqual({ animations: 0, width: 0, opacity: 0, footer: hover ? 1 : 0 })
expect(before.width).toBeGreaterThan(0)
await message.evaluate((element) =>
element.getAnimations({ subtree: true }).forEach((animation) => animation.finish()),
)
await expect(pending).toBeHidden()
await expect(footer).toHaveCSS("opacity", hover ? "1" : "0")
await expect(timestamp).toHaveCSS("opacity", hover ? "1" : "0")

View file

@ -0,0 +1,2 @@
import base from "./playwright.components.config"
export default { ...base, projects: base.projects?.map((p) => ({ ...p, use: { ...p.use, channel: "chrome" } })) }

View file

@ -333,18 +333,17 @@
opacity: 0;
visibility: hidden;
margin-inline-end: calc(-1 * var(--user-message-transition-gap));
transition:
grid-template-columns 200ms ease-out,
margin-inline-end 200ms ease-out,
opacity 200ms ease-out,
visibility 0s linear 200ms;
/* Labels and actions slide in as they arrive and leave at once, like the Pending label on delivery. */
&[data-active="true"] {
grid-template-columns: 1fr;
margin-inline-end: 0;
opacity: 1;
visibility: visible;
transition-delay: 0s;
transition:
grid-template-columns 200ms ease-out,
margin-inline-end 200ms ease-out,
opacity 200ms ease-out;
}
}
@ -361,7 +360,11 @@
overflow: visible;
}
:root[data-motion="reduce"] & {
/* Delivery swaps Pending for the time at once, and a growing row settles its labels in place: its pending status
can land a few frames after it appears. */
:root[data-motion="reduce"] &,
&[data-delivering],
[data-timeline-grow] & {
[data-slot="user-message-copy-wrapper"],
[data-slot="user-message-meta-tail"],
[data-slot="user-message-transition"] {

View file

@ -4,6 +4,7 @@ import {
createSignal,
For,
Index,
on,
onCleanup,
Show,
untrack,
@ -383,7 +384,22 @@ export function CurrentUserMessageDisplay(props: {
el.style.setProperty("--user-run-bottom", `${value.bottom}`)
el.toggleAttribute("data-run-meta-hidden", value.meta === 0)
},
userRunSpring,
// Folding a meta line away is instant; the next message's line takes its place as that message grows in.
{ ...userRunSpring, snap: folded },
)
// Delivery drops the Pending footer at once rather than fading it like the hover reveal.
createEffect(
on(
waiting,
(now, before) => {
if (now || !before || !root) return
const element = root
element.toggleAttribute("data-delivering", true)
requestAnimationFrame(() => element.toggleAttribute("data-delivering", false))
},
{ defer: true },
),
)
const copy = async (text: string) => {

View file

@ -6,9 +6,10 @@ import type {
} from "@opencode/client/promise"
import { useI18n } from "@opencode/ui/context/i18n"
import { Option, Predicate, Schema } from "effect"
import { For, Show, createEffect, createMemo, untrack, type Accessor, type JSX } from "solid-js"
import { For, Show, createEffect, createMemo, onCleanup, untrack, type Accessor, type JSX } from "solid-js"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { Spring, frameLoop, prefersReducedMotion } from "@opencode/ui/motion"
import { springStyle } from "@opencode/ui/motion/solid"
import { Dynamic } from "solid-js/web"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
@ -141,6 +142,29 @@ export function createSessionTimelineRowRenderer(input: {
})
})
// A steer sent while the timeline is open grows in. Messages already there when a session loads, and rows the
// virtualizer remounts, appear as they are.
const seen = { sessionID: "", ids: new Set<string>() }
const arrivals = createMemo(() => {
const sessionID = input.sessionID()
const loaded = seen.sessionID === sessionID && seen.ids.size > 0
const arrived = new Set<string>()
if (seen.sessionID !== sessionID) seen.ids.clear()
seen.sessionID = sessionID
input.projection.rows().forEach((row) => {
if (!Predicate.isTagged(row, "UserMessage") || seen.ids.has(row.userMessageID)) return
seen.ids.add(row.userMessageID)
if (loaded) arrived.add(row.userMessageID)
})
return arrived
})
// The input's pending status lands after its row, so a message sent while the session works counts as a steer.
const arriving = (messageID: string) => untrack(() => arrivals().delete(messageID) && input.status().type !== "idle")
const run = (messageID: string) =>
createMemo(() => runs().get(messageID), undefined, {
equals: (a, b) => a?.above === b?.above && a?.below === b?.below,
@ -519,8 +543,11 @@ export function createSessionTimelineRowRenderer(input: {
return { label: message.description ?? message.text }
}
const Frame = (props: { row: FramedTimelineRow; children: JSX.Element }) => (
const Frame = (props: { row: FramedTimelineRow; grow?: boolean; children: JSX.Element }) => (
<div
ref={(el) => {
if (props.grow) growIn(el)
}}
id={Predicate.isTagged(props.row, "UserMessage") ? input.anchor?.(props.row.userMessageID) : undefined}
data-message-id={props.row.userMessageID}
data-timeline-row={props.row._tag}
@ -821,7 +848,7 @@ export function createSessionTimelineRowRenderer(input: {
)
}
// A run's messages sit as close as the iOS app's (4 px); the gap springs as a message joins or leaves.
// A run's messages sit as close as the iOS app's (4 px); the gap springs open as a message leaves.
function TurnGap(props: { messageID: string }) {
let element: HTMLDivElement | undefined
const joined = run(props.messageID)
@ -832,7 +859,8 @@ export function createSessionTimelineRowRenderer(input: {
(el, value) => {
el.style.height = `${value.height}px`
},
userRunSpring,
// Closing up as a message joins is instant; the joining message grows in instead.
{ ...userRunSpring, snap: () => !!joined()?.above },
)
return (
@ -868,9 +896,10 @@ export function createSessionTimelineRowRenderer(input: {
const context = createMemo(() => input.projection.userContextByID().get(current().userMessageID))
const joined = run(current().userMessageID)
const grow = arriving(current().userMessageID)
return (
<Frame row={current()}>
<Frame row={current()} grow={grow}>
<Show when={message()}>
{(message) => {
const presentation = () => input.presentation(message())
@ -1173,3 +1202,38 @@ function outcomeNoticeKey(state: string | undefined) {
return "ui.sessionTimeline.notice.finished"
}
/** Grows a row from nothing to its natural height, clipping its content on the way. Rows below follow its edge. */
function growIn(el: HTMLElement) {
if (prefersReducedMotion()) return
const spring = Spring.fromDuration(0, { duration: 0.2, bounce: 0 })
const content = el.firstElementChild
let natural = 0
el.style.height = "0px"
el.style.overflow = "clip"
el.toggleAttribute("data-timeline-grow", true)
const remove = frameLoop.addMeasured(
() => {
natural = content instanceof HTMLElement ? content.offsetHeight : 0
},
(dt) => {
if (!el.isConnected) return true
spring.set(natural)
const height = spring.step(dt)
if (!spring.resting) {
el.style.height = `${height}px`
return true
}
el.style.height = ""
el.style.overflow = ""
el.toggleAttribute("data-timeline-grow", false)
return false
},
)
onCleanup(remove)
}