diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index be38e3ecd88b..a927f8dfc3f5 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -1012,6 +1012,99 @@ test.describe("background shortcut", () => { await expect(backgroundCard).toContainText("Background task (background)") }) + for (const fixture of [ + { name: "from far below", before: 40, searches: 40, after: 80 }, + // The collapsed timeline fits the viewport, so it only becomes scrollable once the group opens. + { name: "that opens the last row", before: 0, searches: 40, after: 0 }, + // Reaching the shell lands on the end of the timeline, which would otherwise follow new content again. + { name: "at the end of the timeline", before: 40, searches: 0, after: 0 }, + ]) { + test(`holds a revealed shell ${fixture.name} just under its stuck group header`, async ({ page }) => { + const timeline = await setupTimeline(page, { + settings: { timelineDetail: detailed }, + sessionStatus: { [sessionID]: { type: "busy" } }, + messages: [ + userMessage(), + assistantMessage( + [ + ...(fixture.before ? [textPart("prt_earlier", notes("Earlier", fixture.before))] : []), + ...searches(0, 42 - fixture.searches), + toolPart( + "prt_far_shell", + "shell", + "completed", + { command: "sleep 120" }, + { output: "working", metadata: { shellID: "shell_far", status: "running" } }, + ), + // The open group outgrows the viewport, so aligning the group instead of the shell misses it. + ...searches(42 - fixture.searches, fixture.searches), + ...(fixture.after ? [textPart("prt_follow_up", notes("Follow-up", fixture.after))] : []), + ], + { completed: false }, + ), + ], + }) + + await timeline.transport.send({ + id: "evt_far_shell_created", + created: 3, + type: "shell.created", + location: { directory }, + data: { + info: { + id: "shell_far", + status: "running", + command: "sleep 120", + cwd: directory, + shell: "bash", + file: "/tmp/far.out", + metadata: { sessionID }, + time: { started: 2 }, + }, + }, + }) + + const group = page + .locator('[data-component="collapsed-tool-group"]') + .locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]') + + const shellTrigger = page.locator('[data-timeline-part-id="prt_far_shell"] [data-slot="collapsible-trigger"]') + + const aligned = () => + expect + .poll(async () => { + const [header, shell] = await Promise.all([group.boundingBox(), shellTrigger.boundingBox()]) + + return header && shell ? Math.round(shell.y - (header.y + header.height)) : undefined + }) + .toBe(0) + + await expect( + fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group, + ).toBeInViewport() + await expect(shellTrigger).toHaveCount(0) + await page.getByRole("button", { name: "1 running", exact: true }).click() + await page + .getByRole("menu", { name: "1 running", exact: true }) + .getByRole("menuitem", { name: /sleep 120/ }) + .click() + + await expect(group).toHaveAttribute("aria-expanded", "true") + await expect(shellTrigger).toHaveAttribute("aria-expanded", "true") + await expect(shellTrigger).toBeInViewport() + + // A shell at the very end cannot rise under the header until more content arrives below it. + if (fixture.searches) await aligned() + // Nothing covers the row, so it takes the next click. + await shellTrigger.click({ trial: true }) + + // The agent keeps writing below the shell; the shell stays where it was revealed. + await timeline.send(partUpdated(textPart("prt_late", notes("Late", 30)))) + await expect(page.getByText("Late note 30.", { exact: true })).toBeAttached() + await aligned() + }) + } + test("hides the running switcher when viewing the only running subagent", async ({ page }) => { const childID = "ses_only_running_child" @@ -1735,6 +1828,16 @@ function pauseExitAnimations(locator: Locator) { }) } +function notes(label: string, count: number) { + return Array.from({ length: count }, (_, index) => `${label} note ${index + 1}.`).join("\n\n") +} + +function searches(from: number, count: number) { + return Array.from({ length: count }, (_, index) => + toolPart(`prt_search_${from + index}`, "grep", "completed", { pattern: `needle_${from + index}` }), + ) +} + function runningSubagent(): SessionMessageAssistant { return { ...completed, diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 58d8df0b2728..fe26622eaf1d 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -110,7 +110,14 @@ export function createTimelineVirtualizer(input: Input) { { defer: true }, ), ) - const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 }) + + const [rendering, setRendering] = createStore<{ + initialTail: boolean + scrollAdjustment: number + /** A revealed tool whose top edge stays put until the user scrolls. */ + anchor?: { key: string; partID: string } + }>({ initialTail: coldBottomMount, scrollAdjustment: 0 }) + const rows = input.projection.rows const rowByKey = input.projection.rowByKey @@ -137,6 +144,7 @@ export function createTimelineVirtualizer(input: Input) { const id = input.projection.activeMessageID() const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1 const initialTail = rendering.initialTail && input.pinned() + const anchored = rendering.anchor ? rowKeys().indexOf(rendering.anchor.key) : -1 return (range: Range) => { // Batch a bounded cheap suffix, but stop before unknown/large content. @@ -164,7 +172,7 @@ export function createTimelineVirtualizer(input: Input) { : defaultRangeExtractor({ ...range, overscan: 2 }) return filterVirtualIndexes( - [...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b), + [...new Set([...indexes, ...[active, anchored].filter((index) => index >= 0)])].sort((a, b) => a - b), range.count, ) } @@ -336,6 +344,7 @@ export function createTimelineVirtualizer(input: Input) { }) }) batchingColdSizes = false + pinAnchor() if (coldPending) pinColdBottom() settleColdBottom() @@ -403,19 +412,60 @@ export function createTimelineVirtualizer(input: Input) { const key = found.group.key - setToolOpen( - found.group.type === "context" - ? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true } - : { [key]: true }, - ) - input.onUnpin() prepareNavigation() - virtualizer.scrollToIndex(found.index, { align: "center" }) + // Opening the group and anchoring its row render the tool synchronously, wherever the row is. + batch(() => { + setToolOpen( + found.group.type === "context" + ? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true } + : { [key]: true }, + ) + setRendering("anchor", { key: TimelineRow.key(rows()[found.index]!), partID: found.partID }) + }) + // Until its ResizeObserver delivers, the opened row keeps its collapsed size, so the timeline may not scroll yet + // (and cannot unpin) or ends above the tool. Commit the real size first. + const opened = virtualContent?.querySelector(`[data-index="${found.index}"]`) + + if (opened) resizeItem(found.index, opened.offsetHeight) + input.onUnpin() + pinAnchor() return true } + // Puts the anchored tool's top edge at its scroll margin, just below the headers that stick above it. Its own + // growth extends downward, so only size changes at or above its row can move it; each of those calls this again. + function pinAnchor() { + const anchor = rendering.anchor + const root = listRoot() + + if (!anchor || !root) return + const element = virtualContent?.querySelector(`[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`) + + // Following the end is a different position to hold. + if (!element || !active() || input.pinned()) return releaseAnchor() + + const offset = Math.min( + root.scrollHeight - root.clientHeight, + Math.max( + 0, + root.scrollTop + + element.getBoundingClientRect().top - + root.getBoundingClientRect().top - + parseFloat(getComputedStyle(element).scrollMarginTop), + ), + ) + + if (Math.abs(offset - root.scrollTop) > 1) virtualizer.scrollToOffset(offset) + } + + function releaseAnchor() { + if (rendering.anchor) setRendering("anchor", undefined) + } + function prepareNavigation() { + releaseAnchor() + if (touchStart === undefined) touchScrolling = false flushTouchAdjustment() } @@ -600,6 +650,7 @@ export function createTimelineVirtualizer(input: Input) { // Upward input is the one intent geometry cannot recover: nudging up while still a pixel from // the end must stop following, even though the resulting position still looks like the end. const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { + releaseAnchor() input.onUserScroll(event.target) if (event.deltaY < 0) input.onUnpin() @@ -607,6 +658,7 @@ export function createTimelineVirtualizer(input: Input) { const handleListTouchStart = (event: TouchEvent) => { clearTouchTarget() + releaseAnchor() input.onUserScroll(event.target) touchScrolling = true touchStart = event.touches[0]?.clientY @@ -663,6 +715,7 @@ export function createTimelineVirtualizer(input: Input) { // Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key, // so a held pointer is what separates those from the virtualizer's own measurement adjustments. const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { + releaseAnchor() input.onUserScroll(event.target) pointerHeld = true } @@ -688,6 +741,7 @@ export function createTimelineVirtualizer(input: Input) { if (!isScrollKeyTarget(event.target, key)) return if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return + releaseAnchor() input.onUserScroll(event.currentTarget) if (upwardKeys.has(key)) input.onUnpin() @@ -706,7 +760,8 @@ export function createTimelineVirtualizer(input: Input) { const atEnd = maxScroll - scrollTop <= endEpsilon const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll - if (maxScroll <= 1 || (atEnd && arrived)) input.onPin() + // An anchor holds its tool even when reaching it lands at the end; only the user's own scroll lets it go. + if (maxScroll <= 1 || (atEnd && arrived && !rendering.anchor)) input.onPin() else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin() settleColdBottom() input.onScheduleScrollState(root) diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index b61e2b5b5aa4..adc60e285124 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -817,6 +817,8 @@ [data-component="tool-part-wrapper"] { width: 100%; + /* Revealing a tool lands its title just below the headers that stick above it. */ + scroll-margin-top: var(--sticky-accordion-top, 0px); } [data-component="dock-prompt"][data-kind="permission"] {