From 0169985037ab9ff73e74bb7c071d8564e97f2fca Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 22 Oct 2026 17:42:18 -0400 Subject: [PATCH] fix(app): grow pending messages in and drop the pending line instantly (#58233) --- .../session-message-projection.spec.ts | 24 ++---- .../session-ui/playwright.local.config.ts | 2 + .../src/components/message-part.css | 17 +++-- .../src/message/message-content.tsx | 18 ++++- .../src/timeline/session-timeline-row.tsx | 74 +++++++++++++++++-- 5 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 packages/session-ui/playwright.local.config.ts diff --git a/packages/session-ui/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts index df8bc59f22b2..3f071ea5d252 100644 --- a/packages/session-ui/component-tests/session-message-projection.spec.ts +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -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") diff --git a/packages/session-ui/playwright.local.config.ts b/packages/session-ui/playwright.local.config.ts new file mode 100644 index 000000000000..7f05ae20dcea --- /dev/null +++ b/packages/session-ui/playwright.local.config.ts @@ -0,0 +1,2 @@ +import base from "./playwright.components.config" +export default { ...base, projects: base.projects?.map((p) => ({ ...p, use: { ...p.use, channel: "chrome" } })) } diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index e353cd0f3dff..8a742d3b813d 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -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"] { diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index c6330fa04626..e46805434787 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -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) => { diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 35fd99447c8c..e89b2da65b10 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -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() } + const arrivals = createMemo(() => { + const sessionID = input.sessionID() + const loaded = seen.sessionID === sessionID && seen.ids.size > 0 + const arrived = new Set() + + 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 }) => (
{ + 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 ( - + {(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) +}