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
102 changes: 102 additions & 0 deletions apps/desktop/e2e/activity-card-sticky.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Locator, Page } from '@playwright/test';
import { expect, test } from './fixtures';

async function takeScrollControl(page: Page, header: Locator): Promise<void> {
await header.evaluate((element) => {
const scroller = element.closest<HTMLElement>('[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<void> {
await header.evaluate((element, scrollDistance) => {
const scroller = element.closest<HTMLElement>('[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<number> {
return header.evaluate((element) => {
const scroller = element.closest<HTMLElement>('[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<number> {
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<HTMLElement>('[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);
});
16 changes: 16 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ export const test = base.extend<{
modelPickerLongWindow: Page;
sandboxBoundaryWindow: Page;
readOnlyBoundaryWindow: Page;
activityCardWindow: Page;
sessionWorkbarWindow: Page;
artifactPaneWindow: Page;
botSettingsWindow: Page;
Expand Down Expand Up @@ -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) => {
Expand Down
40 changes: 39 additions & 1 deletion apps/desktop/src/renderer/styles/chat-message.css
Original file line number Diff line number Diff line change
Expand Up @@ -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). */

Expand Down Expand Up @@ -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);
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/astryx-chat-reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function ChatReasoning(props: ChatReasoningProps) {
{...rest}
>
<div
data-slot="activity-card-header"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
Expand All @@ -89,7 +90,7 @@ export function ChatReasoning(props: ChatReasoningProps) {
toggle();
}
}}
className="x78zum5 x6s0dn4 x1s4dlld x1ypdohk x87ps6o xjwf9q1 x13f7esw"
className="maka-activity-card-header x78zum5 x6s0dn4 x1s4dlld x1ypdohk x87ps6o xjwf9q1 x13f7esw"
>
<span className="x3nfvp2 x6s0dn4 xl56j7k x2lah0s x1kky2od xlup9mm xv1l7n4">
<ThinkingIcon />
Expand Down
8 changes: 7 additions & 1 deletion packages/ui/src/tool-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ChatToolCalls calls={calls} {...diffStats(items.flatMap(itemDiffs))} />;
return (
<ChatToolCalls
className="maka-tool-activity-card"
calls={calls}
{...diffStats(items.flatMap(itemDiffs))}
/>
);
}

function standardToolCall(item: ToolActivityItem, locale: UiLocale): ChatToolCallItem {
Expand Down
Loading