Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/app/e2e/regression/timeline-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
75 changes: 65 additions & 10 deletions packages/app/src/session/timeline/virtualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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,
)
}
Expand Down Expand Up @@ -336,6 +344,7 @@ export function createTimelineVirtualizer(input: Input) {
})
})
batchingColdSizes = false
pinAnchor()

if (coldPending) pinColdBottom()
settleColdBottom()
Expand Down Expand Up @@ -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<HTMLElement>(`[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<HTMLElement>(`[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()
}
Expand Down Expand Up @@ -600,13 +650,15 @@ 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()
}

const handleListTouchStart = (event: TouchEvent) => {
clearTouchTarget()
releaseAnchor()
input.onUserScroll(event.target)
touchScrolling = true
touchStart = event.touches[0]?.clientY
Expand Down Expand Up @@ -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
}
Expand All @@ -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()
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/session-ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
Loading