mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): float the activity pill in reserved space above the composer (#57273)
This commit is contained in:
committed by
GitHub
Unverified
parent
15be9f1939
commit
2a00a32086
@@ -62,10 +62,10 @@ for (const width of [1440, 390]) {
|
||||
const reveal = await page.evaluate(() => window.__coldReveal)
|
||||
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
|
||||
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
||||
// The gap above the composer is part of the tail.
|
||||
// The gap above the composer is part of the tail: the status lane, then the activity pill's lane.
|
||||
const spacer = page.locator('[data-timeline-row="bottom-spacer"]')
|
||||
await expect(spacer).toBeVisible()
|
||||
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
|
||||
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(80)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
|
||||
@@ -670,15 +670,17 @@ test("scrolls the timeline behind the composer region, fading at the card, and k
|
||||
const dock = page.locator('[data-component="session-composer-dock"]')
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(page.getByRole("button", { name: "1 subagent", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-background-hint-row"]')).toBeVisible()
|
||||
await expect(page.getByText("The last answer ends above the composer region.", { exact: true })).toBeVisible()
|
||||
|
||||
const geometry = () =>
|
||||
page.evaluate(() => {
|
||||
const edge = "--session-timeline-fade-edge"
|
||||
const viewport = document.querySelector<HTMLElement>('[data-slot="session-timeline-scroll"] > *')!
|
||||
const region = document.querySelector('[data-component="session-composer-dock"]')!
|
||||
const dock = region.getBoundingClientRect()
|
||||
const card = region.querySelector('[data-component="composer"]')!.getBoundingClientRect()
|
||||
const pill = region.querySelector('[data-slot="session-activity-chip"]')!.getBoundingClientRect()
|
||||
const hint = viewport.querySelector('[data-component="session-background-hint-row"]')!.getBoundingClientRect()
|
||||
const parts = [...viewport.querySelectorAll("[data-timeline-part-id]")]
|
||||
const last = parts.find((element) => element.textContent?.includes("The last answer ends"))!
|
||||
|
||||
@@ -686,15 +688,19 @@ test("scrolls the timeline behind the composer region, fading at the card, and k
|
||||
// The scroll area runs on behind the composer region to the panel's bottom edge.
|
||||
behind: Math.round(viewport.getBoundingClientRect().bottom - dock.bottom),
|
||||
inset: Math.round(viewport.querySelector<HTMLElement>("[data-timeline-inset]")!.offsetHeight - dock.height),
|
||||
// Content stays visible behind the pill and fades out where the composer card starts.
|
||||
fade: Math.round(dock.bottom - card.top - parseFloat(getComputedStyle(viewport).getPropertyValue(edge))),
|
||||
behindPill: card.top - dock.top > 20,
|
||||
// Content fades out where the composer card starts.
|
||||
fade: Math.round(
|
||||
dock.bottom - card.top - parseFloat(getComputedStyle(viewport).getPropertyValue("--session-timeline-inset")),
|
||||
),
|
||||
// The pill floats above the region out of flow, so it never changes the inset the timeline scrolls above.
|
||||
floats: pill.bottom <= dock.top,
|
||||
// It floats in the timeline's reserved tail, below the background hint and the last answer.
|
||||
reserved: hint.bottom <= pill.top && last.getBoundingClientRect().bottom <= hint.top,
|
||||
atEnd: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= 1,
|
||||
clear: last.getBoundingClientRect().bottom <= dock.top,
|
||||
}
|
||||
})
|
||||
|
||||
const expected = { behind: 0, inset: 0, fade: 0, behindPill: true, atEnd: true, clear: true }
|
||||
const expected = { behind: 0, inset: 0, fade: 0, floats: true, reserved: true, atEnd: true }
|
||||
await expect.poll(geometry).toEqual(expected)
|
||||
const before = (await dock.boundingBox())!.height
|
||||
|
||||
|
||||
@@ -69,11 +69,11 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* The timeline scrolls on under the composer region, passing behind its pill and docks, and fades out as it reaches
|
||||
the composer card. A mask fades the content itself, so the edge works over any theme's background; smoothstep
|
||||
stops avoid a visible horizon. */
|
||||
/* The timeline scrolls on under the composer region, passing behind its floating pill, and fades out as it reaches
|
||||
the region's opaque card. A mask fades the content itself, so the edge works over any theme's background;
|
||||
smoothstep stops avoid a visible horizon. */
|
||||
[data-slot="session-timeline-scroll"] > .scroll-view__viewport {
|
||||
--session-timeline-fade-start: var(--session-timeline-fade-edge, 0px);
|
||||
--session-timeline-fade-start: var(--session-timeline-inset, 0px);
|
||||
scroll-padding-bottom: var(--session-timeline-inset, 0px);
|
||||
mask-image: linear-gradient(
|
||||
to top,
|
||||
|
||||
@@ -15,7 +15,8 @@ export type SessionActivityItem = {
|
||||
target: string
|
||||
}
|
||||
|
||||
// Live work above the composer, one capsule like the iOS app's background-work summary.
|
||||
// Live work above the composer, one capsule like the iOS app's background-work summary. It floats out of flow in a
|
||||
// lane the timeline reserves at its tail, so coming and going never resizes the composer region or moves the timeline.
|
||||
export function SessionActivityChips(props: {
|
||||
subagents: readonly SessionActivityItem[]
|
||||
shells: readonly SessionActivityItem[]
|
||||
@@ -77,7 +78,10 @@ export function SessionActivityChips(props: {
|
||||
|
||||
return (
|
||||
<Show when={active()}>
|
||||
<div data-component="session-activity-chips" class="pointer-events-none flex items-center pb-2">
|
||||
<div
|
||||
data-component="session-activity-chips"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-full flex items-center pb-2"
|
||||
>
|
||||
<Menu gutter={6} placement="top-start">
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
|
||||
@@ -238,7 +238,6 @@ function SessionScreenContent(props: {
|
||||
reserveReviewToggle={!sideVisible()}
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
bottomInset={timeline.inset()}
|
||||
fadeInset={timeline.edge()}
|
||||
setVisibleRef={timeline.view.setVisibleRef}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
|
||||
@@ -19,10 +19,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
pendingMessage: string | undefined
|
||||
scroll: { overflow: boolean; jump: boolean }
|
||||
follow: { sessionKey: string; pinned: boolean }
|
||||
/** The composer region's height; the timeline scrolls on underneath it. */
|
||||
/** The composer region's height; the timeline scrolls on underneath it and fades out at its top. */
|
||||
inset: number
|
||||
/** Distance from the composer region's bottom to the top of its opaque card, where the timeline fades out. */
|
||||
edge: number
|
||||
refs: {
|
||||
scroller: HTMLDivElement | undefined
|
||||
content: HTMLDivElement | undefined
|
||||
@@ -41,7 +39,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
pinned: true,
|
||||
},
|
||||
inset: 0,
|
||||
edge: 0,
|
||||
refs: {
|
||||
scroller: undefined,
|
||||
content: undefined,
|
||||
@@ -361,15 +358,9 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
)
|
||||
createResizeObserver(
|
||||
() => state.refs.dock,
|
||||
(_rect, element, entry) => {
|
||||
(_rect, _element, entry) => {
|
||||
// The activity pill floats above the region out of flow, so only the docks, queue and composer count here.
|
||||
const next = Math.ceil(entry.borderBoxSize[0]?.blockSize ?? 0)
|
||||
// Only the activity pill floats with gaps beside it; the docks, queue and composer below it form one opaque
|
||||
// card. The pill changes the region's height as it comes and goes, so this observer sees every move of the edge.
|
||||
const region = element.getBoundingClientRect()
|
||||
const pill = element.querySelector('[data-component="session-activity-chips"]')?.getBoundingClientRect()
|
||||
const edge = pill && pill.top - region.top < 1 ? Math.round(region.bottom - pill.bottom) : next
|
||||
|
||||
if (edge !== state.edge) setState("edge", edge)
|
||||
|
||||
if (next === state.inset) return
|
||||
const delta = next - state.inset
|
||||
@@ -404,7 +395,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setActiveMessage,
|
||||
},
|
||||
inset: () => state.inset,
|
||||
edge: () => state.edge,
|
||||
lastUserMessage: timeline.lastUserMessage,
|
||||
resource: timeline.resource,
|
||||
ready: timeline.ready,
|
||||
|
||||
@@ -81,7 +81,6 @@ type MessageTimelineProps = {
|
||||
reserveReviewToggle: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
bottomInset?: number
|
||||
fadeInset?: number
|
||||
setVisibleRef?: (el: HTMLDivElement | undefined) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
@@ -177,7 +176,6 @@ function MessageTimelineView(
|
||||
setScrollRef: props.setScrollRef,
|
||||
setContentRef: props.setContentRef,
|
||||
bottomInset: () => props.bottomInset ?? 0,
|
||||
fadeInset: () => props.fadeInset ?? 0,
|
||||
setVisibleRef: props.setVisibleRef,
|
||||
onScheduleScrollState: props.onScheduleScrollState,
|
||||
onPin: props.onPin,
|
||||
@@ -401,37 +399,36 @@ function MessageTimelineView(
|
||||
// Undelivered input waits below the work the model has seen, so live status moves into the row above it.
|
||||
const activityRow = createMemo(() => projection.rows().some((row) => Predicate.isTagged(row, "Activity")))
|
||||
|
||||
// The lane keeps its height while empty, so Working and the hint come and go without moving the rows around it.
|
||||
const activity = () => (
|
||||
<Show when={showWorking() || backgroundHintPresence.present()}>
|
||||
<div
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-session md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class={`flex h-9 items-center gap-2 pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
|
||||
<Show when={showWorking()}>
|
||||
<div data-component="session-working" role="status">
|
||||
<TextShimmer text={language.t("session.timeline.working")} active />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
ref={setBackgroundHintRef}
|
||||
data-component="session-background-hint-row"
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint onMove={props.background.move} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-session md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class={`flex h-9 items-center gap-2 pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
|
||||
<Show when={showWorking()}>
|
||||
<div data-component="session-working" role="status">
|
||||
<TextShimmer text={language.t("session.timeline.working")} active />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
ref={setBackgroundHintRef}
|
||||
data-component="session-background-hint-row"
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint onMove={props.background.move} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,6 +32,10 @@ import { prefersReducedMotion } from "@opencode/ui/motion"
|
||||
|
||||
const fallbackItemSize = 60
|
||||
|
||||
// Reserved below the last row: a 36px lane for the live status row and its background hint, an 8px gap, then a 36px
|
||||
// lane that the composer's activity pill floats in. Both come and go inside it, so neither moves the rows above.
|
||||
const tailSize = 80
|
||||
|
||||
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
|
||||
|
||||
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
|
||||
@@ -67,10 +71,8 @@ type Input = {
|
||||
onResumeScroll: (behavior?: ScrollBehavior) => void
|
||||
setScrollRef: (element: HTMLDivElement | undefined) => void
|
||||
setContentRef: (element: HTMLDivElement) => void
|
||||
/** Height of the composer region floating over the timeline's bottom edge. */
|
||||
/** Height of the composer region floating over the timeline's bottom edge, where the timeline fades out. */
|
||||
bottomInset?: Accessor<number>
|
||||
/** Height from the bottom edge to where the timeline fades out, the top of the composer region's card. */
|
||||
fadeInset?: Accessor<number>
|
||||
setVisibleRef?: (element: HTMLDivElement | undefined) => void
|
||||
onScheduleScrollState: (element: HTMLDivElement) => void
|
||||
onPin: () => void
|
||||
@@ -298,7 +300,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// Empty projections still need the bottom spacer for running status.
|
||||
return rows().length > 0 ? topOffset() : 0
|
||||
},
|
||||
paddingEnd: 64,
|
||||
paddingEnd: tailSize,
|
||||
get rangeExtractor() {
|
||||
return rangeExtractor()
|
||||
},
|
||||
@@ -877,7 +879,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
class="relative w-full h-full min-w-0"
|
||||
style={{
|
||||
"--session-timeline-inset": `${bottomInset()}px`,
|
||||
"--session-timeline-fade-edge": `${input.fadeInset?.() ?? 0}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -945,8 +946,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64 - rendering.scrollAdjustment}px)` }}
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: `${tailSize}px`,
|
||||
transform: `translateY(${virtualizer.getTotalSize() - tailSize - rendering.scrollAdjustment}px)`,
|
||||
}}
|
||||
>
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user