diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 1e7079a7fc..ef3e714c42 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -271,7 +271,7 @@ export const test = base.extend<{ await withE2eWindow( { seed: false, - readinessSelector: '.maka-chatViewport:has(.maka-turn):not(:has(.maka-markdown-pending))', + readinessSelector: '[data-chat-scroll-container="true"]:has(.maka-turn):not(:has(.maka-markdown-pending))', e2eFixtureScenario: 'long-transcript', locale: 'zh', }, diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index 4eb9cfc6db..20aefaea56 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -17,7 +17,7 @@ import { test, expect } from './fixtures'; */ const probeScroller = `(() => { - const scroller = document.querySelector('.maka-chatViewport'); + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); return { scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, @@ -45,8 +45,8 @@ type ColumnGeometry = { async function probeColumnGeometry(page: import('@playwright/test').Page): Promise { return await page.evaluate(() => { - const host = document.querySelector('.maka-chat.messages'); - const viewport = document.querySelector('.maka-chatViewport'); + const host = document.querySelector('.maka-chat-layout'); + const viewport = document.querySelector('[data-chat-scroll-container="true"]'); const turn = document.querySelector('.maka-turn'); const composer = document.querySelector('.composer .maka-composer-astryx'); if (!host || !viewport || !turn || !composer) { @@ -91,7 +91,7 @@ async function probeColumnGeometry(page: import('@playwright/test').Page): Promi * done, so ask it. */ async function settleGeometry(page: import('@playwright/test').Page, options: { pinned: boolean }): Promise { - await expect(page.locator('.maka-chatViewport[data-turn-warmup="settled"]')).toBeAttached({ timeout: 15_000 }); + await expect(page.locator('[data-chat-scroll-container="true"][data-turn-warmup="settled"]')).toBeAttached({ timeout: 15_000 }); const settled = await page.evaluate(probeScroller) as { scrollHeight: number }; expect(settled.scrollHeight, JSON.stringify(settled)).toBeGreaterThan(WARMED_HEIGHT_FLOOR); // The last chunk's inflation reaches the pinned follower through a @@ -116,15 +116,15 @@ test('chat viewport and message column share the composer centerline', async ({ } }); -test('empty chat keeps its grid content flush with the viewport', async ({ window: page }) => { +test('empty chat has no phantom vertical range and stays flush with the viewport', async ({ window: page }) => { const content = page.locator('.mainColumn[data-home-surface="true"] .maka-chatContent'); await expect(content).toBeVisible(); for (const width of [900, 1180, 1440]) { await page.setViewportSize({ width, height: 760 }); const geometry = await page.evaluate(() => { - const host = document.querySelector('.maka-chat.messages'); - const viewport = document.querySelector('.maka-chatViewport'); + const host = document.querySelector('.maka-chat-layout'); + const viewport = document.querySelector('[data-chat-scroll-container="true"]'); const content = document.querySelector('.maka-chatContent'); if (!host || !viewport || !content) throw new Error('Expected the empty chat scroll surface'); const hostRect = host.getBoundingClientRect(); @@ -134,12 +134,15 @@ test('empty chat keeps its grid content flush with the viewport', async ({ windo hostViewportLeftDelta: viewportRect.left - hostRect.left, contentDisplay: contentStyle.display, contentGap: contentStyle.gap, + scrollRange: viewport.scrollHeight - viewport.clientHeight, + scrollTop: viewport.scrollTop, }; }); const diagnostics = JSON.stringify({ width, ...geometry }); expect(Math.abs(geometry.hostViewportLeftDelta), diagnostics).toBeLessThanOrEqual(1); - expect(geometry.contentDisplay, diagnostics).toBe('grid'); - expect(geometry.contentGap, diagnostics).toBe('0px'); + expect(geometry.contentDisplay, diagnostics).toBe('flex'); + expect(geometry.scrollRange, diagnostics).toBeLessThanOrEqual(1); + expect(geometry.scrollTop, diagnostics).toBeLessThanOrEqual(1); } }); @@ -156,11 +159,49 @@ test('long session opens pinned to bottom and stays pinned while geometry settle // And the pin still holds once the walk reports itself done, which is what // makes the poll above a contract rather than a lucky early read. await settleGeometry(page, { pinned: true }); + + const bottomBoundary = await page.evaluate(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + const lastTurn = scroller?.querySelector('.maka-turn:last-of-type'); + const dock = scroller?.lastElementChild; + if (!lastTurn || !dock) throw new Error('Expected the final turn and Astryx dock'); + return { + dockTop: dock.getBoundingClientRect().top, + lastTurnBottom: lastTurn.getBoundingClientRect().bottom, + }; + }); + expect( + bottomBoundary.lastTurnBottom, + JSON.stringify(bottomBoundary), + ).toBeLessThanOrEqual(bottomBoundary.dockTop + 1); + expect( + bottomBoundary.dockTop - bottomBoundary.lastTurnBottom, + JSON.stringify(bottomBoundary), + ).toBeLessThanOrEqual(48); +}); + +test('graph status stays docked above the composer while transcript history scrolls', async ({ longTranscriptWindow: page }) => { + await settleGeometry(page, { pinned: true }); + const graphPanel = page.locator('.maka-agent-graph-panel'); + await expect(graphPanel).toBeVisible(); + + const before = await graphPanel.boundingBox(); + expect(before).not.toBeNull(); + await page.evaluate(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('Expected the Astryx chat scroller'); + scroller.scrollTop = 0; + }); + const after = await graphPanel.boundingBox(); + expect(after).not.toBeNull(); + if (before && after) { + expect(Math.abs(after.y - before.y), JSON.stringify({ before, after })).toBeLessThanOrEqual(1); + } }); async function climbToTop(page: import('@playwright/test').Page) { return await page.evaluate(async () => { - const scroller = document.querySelector('.maka-chatViewport') as HTMLElement; + const scroller = document.querySelector('[data-chat-scroll-container="true"]') as HTMLElement; const started = performance.now(); // Self-imposed deadline well under the 60s test timeout: a stalled or // crawling compositor must produce a diagnosable assertion failure with diff --git a/apps/desktop/e2e/sidebar-geometry.spec.ts b/apps/desktop/e2e/sidebar-geometry.spec.ts index efb47451b5..9f817531fc 100644 --- a/apps/desktop/e2e/sidebar-geometry.spec.ts +++ b/apps/desktop/e2e/sidebar-geometry.spec.ts @@ -27,7 +27,7 @@ import type { Page } from '@playwright/test'; * and does not assert rendered footer-visibility geometry. The two layers * deliberately lock different declarations. * - `scroll-geometry.spec.ts` boots `long-transcript` and probes - * `.maka-chatViewport`, not the sidebar. + * Astryx `ChatLayout`, not the sidebar. * * This spec is the rendered-geometry lock. It boots the `sidebar-long-sessions` * fixture and asserts, against the live desktop shell: @@ -48,7 +48,7 @@ import type { Page } from '@playwright/test'; */ const LIST_CONTENT = '.maka-session-list'; -const CHAT_VIEWPORT = '.maka-chatViewport'; +const CHAT_VIEWPORT = '[data-chat-scroll-container="true"]'; interface ScrollerMetrics { scrollTop: number; @@ -90,7 +90,7 @@ async function readGeometry(page: Page): Promise { panel: rect(document.querySelector('.maka-session-panel')), viewport: { width: window.innerWidth, height: window.innerHeight }, list: scroller(document.querySelector(listContentSelector)?.parentElement ?? null), - chat: scroller(document.querySelector('.maka-chatViewport')), + chat: scroller(document.querySelector('[data-chat-scroll-container="true"]')), }; }, LIST_CONTENT); } @@ -167,6 +167,28 @@ test('sidebar list scrolls independently and keeps the footer in view with 60 se await expect(page.locator(LIST_CONTENT)).toHaveCount(1); await expect(page.locator(CHAT_VIEWPORT)).toHaveCount(1); + // Astryx owns the chat's initial fill and follows transcript geometry + // asynchronously. Establish its terminal pinned position before using the + // chat scrollTop as the sidebar-independence control value. + await expect(page.locator(`${CHAT_VIEWPORT}[data-turn-warmup="settled"]`)).toBeAttached(); + await expect.poll(async () => { + const geometry = await readGeometry(page); + return geometry.chat + ? Math.round(geometry.chat.scrollHeight - geometry.chat.scrollTop - geometry.chat.clientHeight) + : Number.POSITIVE_INFINITY; + }).toBeLessThanOrEqual(1); + + // The newest fixture session is one short exchange. It already fits above + // the composer, so reaching its final message must not create a second, + // composer-height scroll range below the conversation. + const settledChat = (await readGeometry(page)).chat; + expect(settledChat, JSON.stringify(settledChat)).not.toBeNull(); + if (settledChat) { + const diagnostics = JSON.stringify(settledChat); + expect(settledChat.scrollHeight - settledChat.clientHeight, diagnostics).toBeLessThanOrEqual(1); + expect(settledChat.scrollTop, diagnostics).toBeLessThanOrEqual(1); + } + // (1a) The sidebar list scroller actually overflows its constrained grid // row — this is what makes it an independent scroll container. If the panel // loses its constrained height, the row grows to content height and this @@ -199,8 +221,8 @@ test('sidebar list scrolls independently and keeps the footer in view with 60 se // The sidebar's own scrollTop moved... expect(after.list?.scrollTop ?? -1, afterDiag).toBeGreaterThan(listScrollTopBefore); - // ...while the chat viewport's scrollTop is unaffected (short transcript → - // stays pinned at 0). This is the scroll-independence lock for this seed. + // ...while the already-settled chat viewport's scrollTop is unaffected. + // This is the scroll-independence lock for this seed. expect(after.chat?.scrollTop ?? -1, afterDiag).toBe(chatScrollTopBefore); // (b) Footer still fully inside the panel and the window after scrolling the diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 5cdbda66d0..f6a3454b86 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -7003,36 +7003,6 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ -Package: overlayscrollbars@2.16.0 -Declared license: MIT -Selected license: MIT -Repository: https://github.com/KingSora/OverlayScrollbars.git#packages/overlayscrollbars - ---- VERSION-PINNED LICENSE TEXT OVERRIDE --- -MIT License - -Copyright (c) 2022 Rene Haas - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -================================================================================ - Package: p-finally@1.0.0 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/src/main/__tests__/attachment-chat-render.test.ts b/apps/desktop/src/main/__tests__/attachment-chat-render.test.ts index a224fdb2cb..5f4e686091 100644 --- a/apps/desktop/src/main/__tests__/attachment-chat-render.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-chat-render.test.ts @@ -3,11 +3,14 @@ import { describe, it } from 'node:test'; import { createElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import type { AttachmentRef, SessionSummary, StoredMessage } from '@maka/core'; -import { ChatView, LocaleProvider } from '@maka/ui'; +import { ChatSurfaceLayout, ChatView, LocaleProvider } from '@maka/ui'; function renderWithLocale(child: ReactNode): string { return renderToStaticMarkup( - createElement(LocaleProvider, { locale: 'zh', children: child }), + createElement(LocaleProvider, { + locale: 'zh', + children: createElement(ChatSurfaceLayout, { composer: null, children: child }), + }), ); } diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 8cd525edd0..9bed1141aa 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import type { SessionEvent } from '@maka/core'; import { armLiveTurn, + ChatSurfaceLayout, ChatView, LocaleProvider, type LiveTurnProjection, @@ -14,7 +15,10 @@ import { createAppShellSessionEventHandlers } from '../../renderer/app-shell-ses function renderWithLocale(child: ReactNode): string { return renderToStaticMarkup( - createElement(LocaleProvider, { locale: 'zh', children: child }), + createElement(LocaleProvider, { + locale: 'zh', + children: createElement(ChatSurfaceLayout, { composer: null, children: child }), + }), ); } diff --git a/apps/desktop/src/main/__tests__/ui-render-memo-boundary-contract.test.ts b/apps/desktop/src/main/__tests__/ui-render-memo-boundary-contract.test.ts index eee1ea9b13..e098a2b099 100644 --- a/apps/desktop/src/main/__tests__/ui-render-memo-boundary-contract.test.ts +++ b/apps/desktop/src/main/__tests__/ui-render-memo-boundary-contract.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { afterEach, describe, it } from 'node:test'; import type { SessionSummary } from '@maka/core'; -import { build, type Plugin } from 'esbuild'; +import { build } from 'esbuild'; import { act, createElement, type ReactElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -212,27 +212,10 @@ async function importSessionHistoryList(): Promise { format: 'esm', target: 'node20', logLevel: 'silent', - plugins: [mockOverlayScrollbars()], }); return await import(`${pathToFileURL(outfile).href}?t=${Date.now()}`) as SessionHistoryModule; } -function mockOverlayScrollbars(): Plugin { - return { - name: 'mock-overlayscrollbars', - setup(buildApi) { - buildApi.onResolve({ filter: /^overlayscrollbars$/ }, () => ({ - path: 'overlayscrollbars-mock', - namespace: 'memo-test', - })); - buildApi.onLoad({ filter: /^overlayscrollbars-mock$/, namespace: 'memo-test' }, () => ({ - loader: 'js', - contents: 'export function OverlayScrollbars() { return { destroy() {}, options() {} }; }', - })); - }, - }; -} - function installReactRenderer(): Root { installFakeDom(); const container = new FakeElement('div', document); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts b/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts index 2af12a7ce7..3641de7ea4 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts @@ -30,6 +30,7 @@ export function longTranscriptSession(now: number): SessionHeader { name: '超长会话滚动几何', connection: 'zai-live', model: 'glm-5.1', + orchestrationMode: 'graph', now, lastMessageAt: now - 5 * 60_000, }); diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 8afca1df8a..87d87bb880 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -99,6 +99,7 @@ export function header(input: { blockedReason?: SessionHeader['blockedReason']; isArchived?: boolean; isFlagged?: boolean; + orchestrationMode?: SessionHeader['orchestrationMode']; }): SessionHeader { return { id: input.id, @@ -116,6 +117,7 @@ export function header(input: { ...(input.blockedReason ? { blockedReason: input.blockedReason } : {}), statusUpdatedAt: input.lastMessageAt, hasUnread: input.hasUnread ?? false, + ...(input.orchestrationMode ? { orchestrationMode: input.orchestrationMode } : {}), // Legacy backend kinds like 'claude' aren't in the current BackendKind // union but are needed for the stale-sessions reproduction. Forward // the value verbatim into the JSONL so the renderer sees exactly what diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 89025f5294..e430d9d5d1 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -30,6 +30,7 @@ import { import { AutomationsPage, DailyReviewPage, + ChatSurfaceLayout, type ComposerHandle, type MakaUriDest, MakaUriContext, @@ -2178,8 +2179,229 @@ function AppShellContent({ onAppendMarkdown={appendDailyReviewMarkdown} onSaveMarkdown={(input) => saveDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} /> - ) : ( -