From ca6cfb1bd8f4a0f8aec6f74162f596a9bdb86ffb Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 9 Aug 2026 10:21:27 +0800 Subject: [PATCH] feat(desktop): keep activity headers reachable --- apps/desktop/e2e/activity-card-sticky.spec.ts | 102 ++++++++++++++++++ apps/desktop/e2e/fixtures.ts | 16 +++ .../src/renderer/styles/chat-message.css | 40 ++++++- packages/ui/src/astryx-chat-reasoning.tsx | 3 +- packages/ui/src/tool-activity.tsx | 8 +- 5 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/e2e/activity-card-sticky.spec.ts diff --git a/apps/desktop/e2e/activity-card-sticky.spec.ts b/apps/desktop/e2e/activity-card-sticky.spec.ts new file mode 100644 index 00000000000..467e672ed31 --- /dev/null +++ b/apps/desktop/e2e/activity-card-sticky.spec.ts @@ -0,0 +1,102 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from './fixtures'; + +async function takeScrollControl(page: Page, header: Locator): Promise { + await header.evaluate((element) => { + const scroller = element.closest('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('Chat scroll container not found'); + scroller.scrollTop += + element.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 120; + }); + const scrollport = await page.locator('[data-chat-scroll-container="true"]').boundingBox(); + if (!scrollport) throw new Error('Chat scroll container is not visible'); + await page.mouse.move(scrollport.x + scrollport.width / 3, scrollport.y + scrollport.height / 2); + await page.mouse.wheel(0, -24); +} + +async function scrollHeaderPastTop(header: Locator, distance = 96): Promise { + await header.evaluate((element, scrollDistance) => { + const scroller = element.closest('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('Chat scroll container not found'); + scroller.scrollTop += + element.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scrollDistance; + }, distance); +} + +async function headerOffset(header: Locator): Promise { + return header.evaluate((element) => { + const scroller = element.closest('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('Chat scroll container not found'); + return element.getBoundingClientRect().top - scroller.getBoundingClientRect().top; + }); +} + +async function backgroundAlpha(element: Locator): Promise { + return element.evaluate((target) => { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Canvas context not available'); + context.clearRect(0, 0, 1, 1); + context.fillStyle = getComputedStyle(target).backgroundColor; + context.fillRect(0, 0, 1, 1); + return context.getImageData(0, 0, 1, 1).data[3]; + }); +} + +test('expanded activity headers stay reachable while their long details scroll', async ({ + activityCardWindow: page, +}) => { + await page.setViewportSize({ width: 1000, height: 520 }); + + const reasoning = page.locator('.maka-deep-thinking').first(); + const reasoningHeader = reasoning.locator('[data-slot="activity-card-header"]'); + await takeScrollControl(page, reasoningHeader); + await reasoningHeader.click(); + await reasoning.locator('.maka-chat-reasoning-content').evaluate((element) => { + element.style.minHeight = '720px'; + }); + await expect.poll(() => reasoning.evaluate((element) => element.getBoundingClientRect().height)) + .toBeGreaterThan(700); + await scrollHeaderPastTop(reasoningHeader); + + await expect.poll(() => headerOffset(reasoningHeader)).toBeCloseTo(8, 0); + await reasoningHeader.hover(); + await expect.poll(() => backgroundAlpha(reasoningHeader)).toBe(255); + await reasoningHeader.click(); + await expect(reasoningHeader).toHaveAttribute('aria-expanded', 'false'); + + const toolGroup = page.locator('.maka-tool-activity-card').filter({ + has: page.locator(':scope > [role="button"]'), + }).first(); + const groupHeader = toolGroup.locator(':scope > [role="button"]'); + await groupHeader.click(); + + const callHeader = toolGroup.locator('[data-slot="chat-tool-call-row"]').first(); + await callHeader.click(); + await toolGroup.locator('.maka-chat-tool-detail-inner').first().evaluate((element) => { + element.style.minHeight = '720px'; + }); + await expect.poll(() => toolGroup.evaluate((element) => element.getBoundingClientRect().height)) + .toBeGreaterThan(700); + await scrollHeaderPastTop(callHeader); + + await expect.poll(() => headerOffset(groupHeader)).toBeCloseTo(8, 0); + await expect.poll(() => headerOffset(callHeader)).toBeCloseTo(32, 0); + await callHeader.click(); + await expect(callHeader).toHaveAttribute('aria-expanded', 'false'); + + await page.locator('.maka-chat-message-list').evaluate((element) => { + element.style.paddingBottom = '720px'; + }); + await expect.poll(() => toolGroup.evaluate((element) => element.getBoundingClientRect().height)) + .toBeLessThan(240); + await toolGroup.evaluate((element) => { + const scroller = element.closest('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('Chat scroll container not found'); + scroller.scrollTop += + element.getBoundingClientRect().bottom - scroller.getBoundingClientRect().top + 96; + }); + await expect.poll(() => headerOffset(groupHeader)).toBeLessThan(0); +}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 43dd6df9764..a3c03ef9beb 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -308,6 +308,7 @@ export const test = base.extend<{ modelPickerLongWindow: Page; sandboxBoundaryWindow: Page; readOnlyBoundaryWindow: Page; + activityCardWindow: Page; sessionWorkbarWindow: Page; artifactPaneWindow: Page; botSettingsWindow: Page; @@ -389,6 +390,21 @@ export const test = base.extend<{ use, ); }, + // A committed reasoning + multi-tool transcript. Geometry specs amplify the + // existing detail height in the browser; the production projection and + // disclosure components remain the fixture's rendering path. + activityCardWindow: async ({}, use) => { + await withE2eWindow( + { + seed: false, + readinessSelector: '.maka-deep-thinking', + e2eFixtureScenario: 'turn-narrative', + locale: 'zh', + showWindow: true, + }, + use, + ); + }, // Session workbar: seeds a task tree and opens the unified auxiliary // workspace so its shell controls and peer tabs run against real IPC data. sessionWorkbarWindow: async ({}, use) => { diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 4a675dfad4e..637a9bdfede 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -83,6 +83,44 @@ gap: var(--space-1); } +/* Expanded activity headers stay reachable while their own detail is being + read. Native sticky positioning keeps the header in the transcript flow, + so it leaves naturally at the card boundary and does not disturb ChatLayout + scroll anchoring while reasoning or tool output continues to stream. */ +.maka-turn :is(.maka-deep-thinking, .maka-tool-activity-card) { + --maka-activity-sticky-top: var(--space-2); + --maka-activity-header-height: var(--h-control-sm); +} + +.maka-turn .maka-activity-card-header[aria-expanded="true"], +.maka-turn .maka-tool-activity-card > [role="button"][aria-expanded="true"], +.maka-turn .maka-tool-activity-card [data-slot="chat-tool-call-row"][aria-expanded="true"] { + position: sticky; + top: var(--maka-activity-sticky-top); + z-index: var(--z-sticky); + background: var(--surface-raised); + border-radius: var(--radius-element); + box-shadow: 0 var(--border-width-hairline) 0 var(--border-soft); +} + +/* A call detail inside an expanded tool group forms a second, bounded sticky + tier. The group remains available to collapse the whole run; the lower row + collapses only the detail currently being read. */ +.maka-turn + .maka-tool-activity-card + > [role="button"][aria-expanded="true"] + ~ div + [data-slot="chat-tool-call-row"][aria-expanded="true"] { + top: calc(var(--maka-activity-sticky-top) + var(--maka-activity-header-height)); +} + +/* Astryx clips the animated group body with overflow:hidden. `clip` preserves + that visual boundary without becoming a scroll ancestor that captures the + nested sticky row. */ +.maka-turn .maka-tool-activity-card > [role="button"] + div > div { + overflow: clip; +} + /* Cursor for tool/reasoning disclosure triggers is owned by styles/native-cursor.css (role=button + default !important). */ @@ -139,7 +177,7 @@ @media (hover: hover) { .maka-turn .astryx-chat-tool-calls [role="button"]:hover, .maka-turn .astryx-chat-reasoning [role="button"]:hover { - background-color: var(--color-overlay-hover); + background-color: var(--foreground-5); border-radius: var(--radius-element); } } diff --git a/packages/ui/src/astryx-chat-reasoning.tsx b/packages/ui/src/astryx-chat-reasoning.tsx index 70212262411..009ea76edde 100644 --- a/packages/ui/src/astryx-chat-reasoning.tsx +++ b/packages/ui/src/astryx-chat-reasoning.tsx @@ -79,6 +79,7 @@ export function ChatReasoning(props: ChatReasoningProps) { {...rest} >
diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index 235a201e191..0501e73a387 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -315,7 +315,13 @@ export function ToolTrow({ // A group's own +/- is the whole turn's, not the last call's: a run of five // Edits collapses to one line, and "what did this turn change" is the // question that line has to answer. Per-call counts stay on the rows inside. - return ; + return ( + + ); } function standardToolCall(item: ToolActivityItem, locale: UiLocale): ChatToolCallItem {