mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-01 21:32:00 +00:00
fix(app): grow pending messages in and drop the pending line instantly (#58233)
This commit is contained in:
parent
42b90ff01b
commit
0169985037
5 changed files with 104 additions and 31 deletions
|
|
@ -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")
|
||||
|
|
|
|||
2
packages/session-ui/playwright.local.config.ts
Normal file
2
packages/session-ui/playwright.local.config.ts
Normal 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" } })) }
|
||||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue