From 7fe22bb2b099e7283ed077ffbcd116f6a3246984 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 1 Aug 2026 17:52:28 +0800 Subject: [PATCH 1/5] refactor(ui): migrate chat surfaces to Astryx layout --- apps/desktop/e2e/fixtures.ts | 2 +- apps/desktop/e2e/scroll-geometry.spec.ts | 19 +- apps/desktop/e2e/sidebar-geometry.spec.ts | 21 +- .../licenses/npm/THIRD_PARTY_NOTICES.txt | 30 -- .../ui-render-memo-boundary-contract.test.ts | 19 +- apps/desktop/src/renderer/app-shell.tsx | 443 +++++++++--------- .../src/renderer/quote-companion-panel.tsx | 140 +++--- apps/desktop/src/renderer/styles.css | 7 - apps/desktop/src/renderer/styles/base.css | 38 -- .../src/renderer/styles/chat-header.css | 80 +--- .../src/renderer/styles/shell-layout.css | 25 +- apps/desktop/stories/app-shell.stories.tsx | 25 +- apps/desktop/stories/onboarding.stories.tsx | 14 +- package-lock.json | 7 - packages/ui/package.json | 1 - .../__tests__/chat-surface-layout.test.tsx | 31 ++ .../__tests__/chat-view-empty-state.test.tsx | 25 + .../ui/src/__tests__/pinned-bottom.test.ts | 176 ------- packages/ui/src/chat-surface-layout.tsx | 23 + packages/ui/src/chat-turn.tsx | 12 +- packages/ui/src/chat-view.tsx | 266 +++++------ packages/ui/src/components.tsx | 2 + packages/ui/src/index.ts | 1 - packages/ui/src/overlay-scroll-area.tsx | 109 ----- packages/ui/src/pinned-bottom.ts | 95 ---- packages/ui/src/use-chat-scroll.ts | 148 ++---- packages/ui/stories/chat-surface.stories.tsx | 35 +- scripts/generate-third-party-notices.mjs | 1 - 28 files changed, 656 insertions(+), 1139 deletions(-) create mode 100644 packages/ui/src/__tests__/chat-surface-layout.test.tsx delete mode 100644 packages/ui/src/__tests__/pinned-bottom.test.ts create mode 100644 packages/ui/src/chat-surface-layout.tsx delete mode 100644 packages/ui/src/overlay-scroll-area.tsx delete mode 100644 packages/ui/src/pinned-bottom.ts 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..13ce6ed331 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 keeps the Astryx message list 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(); @@ -138,8 +138,7 @@ test('empty chat keeps its grid content flush with the viewport', async ({ windo }); 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'); } }); @@ -160,7 +159,7 @@ test('long session opens pinned to bottom and stays pinned while geometry settle 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..dd45aba350 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,17 @@ 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); + // (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 +210,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__/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/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 89025f5294..762fa032e0 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,216 @@ function AppShellContent({ onAppendMarkdown={appendDailyReviewMarkdown} onSaveMarkdown={(input) => saveDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} /> - ) : ( -