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
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,91 @@ export function isNativeSurfaceOccluded(rect: DOMRect, document: Document): bool
return bounds.width > 0 && bounds.height > 0 && bounds.left < rect.right && bounds.right > rect.left && bounds.top < rect.bottom && bounds.bottom > rect.top;
});
}

export interface NativeSurfaceWatch {
/** Re-run `sync` next frame for a change the watch cannot observe. */
refresh(): void;
dispose(): void;
}

/**
* Runs `sync` in a frame after the element's box or an overlay above it may
* have changed. With no overlay open, everything arrives through events and
* observers, so an idle surface costs nothing. An open overlay can move or
* resize with no event at all (text, attributes, anchor positioning), so
* frames are sampled for as long as one is open.
*/
export function watchNativeSurface(element: HTMLElement, sync: () => void): NativeSurfaceWatch {
const document = element.ownerDocument;
const view = document.defaultView!;
let frame = 0;
let moveKey = '';
let move: IntersectionObserver | undefined;
let emptyOverlays: Element[] = [];
// The toast viewport stays open while empty, and no toggle fires when a
// toast fills it.
const filled = new view.MutationObserver(() => refresh());
const overlayOpen = () => {
const open = Array.from(document.querySelectorAll(':popover-open, dialog[open]'));
const empty = open.filter((overlay) => overlay.matches(':empty'));
if (empty.length !== emptyOverlays.length || empty.some((overlay, index) => overlay !== emptyOverlays[index])) {
filled.disconnect();
for (const overlay of empty) filled.observe(overlay, { childList: true });
emptyOverlays = empty;
}
return open.length > empty.length;
};
const refresh = () => {
if (!frame) frame = view.requestAnimationFrame(run);
};
// A box can move without resizing: a window resize shifts a right-anchored
// strip. An observer whose root is shrunk to the box's own rect fires as soon
// as the box leaves it. It cannot see a move of a box that an ancestor
// already clips, so a partly hidden box is sampled until it is whole again.
let clipped = false;
const observeMove = () => {
const rect = element.getBoundingClientRect();
const root = document.documentElement;
const insets = [rect.top, root.clientWidth - rect.right, root.clientHeight - rect.bottom, rect.left].map(Math.floor);
const key = `${insets.join()},${rect.width},${rect.height}`;
if (key === moveKey) return;
moveKey = key;
move?.disconnect();
move = undefined;
clipped = false;
if (rect.width === 0 || rect.height === 0) return;
let armed = false;
move = new view.IntersectionObserver((entries) => {
const whole = entries.at(-1)?.intersectionRatio === 1;
const first = !armed;
armed = true;
clipped = !whole;
if (!first || !whole) refresh();
}, { rootMargin: insets.map((inset) => `${-inset}px`).join(' '), threshold: 1 });
move.observe(element);
};
const run = () => {
frame = 0;
sync();
observeMove();
if (overlayOpen() || clipped) refresh();
};
const resize = new view.ResizeObserver(refresh);
resize.observe(element);
view.addEventListener('resize', refresh);
// Toggle events do not bubble; capture sees every popover and dialog.
document.addEventListener('toggle', refresh, true);
refresh();
return {
refresh,
dispose() {
view.cancelAnimationFrame(frame);
frame = 0;
resize.disconnect();
move?.disconnect();
filled.disconnect();
view.removeEventListener('resize', refresh);
document.removeEventListener('toggle', refresh, true);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
* It mounts only for sessions with a live view (see browser:live), so an
* ordinary chat reserves no space.
*/
import { isNativeSurfaceOccluded } from '../../../../application/contracts/native-surface-occlusion.js';
import { isNativeSurfaceOccluded, watchNativeSurface } from '../../../../application/contracts/native-surface-occlusion.js';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ICON_SIZE, ChevronLeft, ChevronRight, Globe, RotateCw, X } from '@maka/ui/icons';
import { normalizeBrowserAddressInput, type BrowserState } from '@maka/core/browser';
Expand Down Expand Up @@ -123,10 +123,8 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) {
};
}, [browser, sessionId, hidden]);

// Mirror the strip's on-screen rect to main every animation frame while it is
// showable. Position shifts on window resize and sidebar drags even when the
// size is unchanged, which a ResizeObserver would miss; a getBoundingClientRect
// per frame is negligible and the IPC only fires when the rect changes.
// Mirror the strip's on-screen rect to main while it is showable. The IPC
// only fires when the rect changes.
const showView = !hidden && state.hasPage;
useEffect(() => {
// Capture the injected capability because this passive cleanup may run
Expand All @@ -138,12 +136,11 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) {
}
const el = stripRef.current;
if (!el) return;
let raf = 0;
let last = '';
let active = true;
let covered = false;
let revision = 0;
const tick = () => {
const sync = () => {
const r = el.getBoundingClientRect();
const rect = {
x: Math.round(r.left),
Expand All @@ -167,18 +164,17 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) {
} else setBackdrop(undefined);
last = '';
}
if (occluded) { raf = requestAnimationFrame(tick); return; }
if (occluded) return;
const key = `${rect.x},${rect.y},${rect.width},${rect.height}`;
if (key !== last) {
last = key;
browser.setViewport({ sessionId, rect });
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const surface = watchNativeSurface(el, sync);
return () => {
active = false;
cancelAnimationFrame(raf);
surface.dispose();
browser.setViewport({ sessionId, rect: null });
};
}, [browser, sessionId, showView]);
Expand Down
15 changes: 8 additions & 7 deletions apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import type { WorkbarTogglePosition } from '@maka/core/settings';
import { isNativeSurfaceOccluded } from '../../../application/contracts/native-surface-occlusion.js';
import { isNativeSurfaceOccluded, watchNativeSurface, type NativeSurfaceWatch } from '../../../application/contracts/native-surface-occlusion.js';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Button } from '@astryxdesign/core';
import { useUiLocale } from '@maka/ui';
Expand Down Expand Up @@ -51,17 +51,17 @@ export function WorkHubDock({ enabled, visible = true, workbarCollapsed, workbar
unsubscribe();
};
}, [presentation]);
const surface = useRef<NativeSurfaceWatch>(undefined);
useLayoutEffect(() => {
const node = element.current;
if (!node) return;
let frame = 0;
let active = true;
let revision = 0;
let last = '';
let covered = false;
const docked = snapshot?.placement === 'docked';
const update = () => {
const rect = node.getBoundingClientRect();
const docked = snapshot?.placement === 'docked';
const occluded = visible && docked && isNativeSurfaceOccluded(rect, node.ownerDocument);
const host = {
visible: enabled && visible && rect.width > 0 && rect.height > 0,
Expand All @@ -80,19 +80,20 @@ export function WorkHubDock({ enabled, visible = true, workbarCollapsed, workbar
if (active && current === revision && image) setBackdrop(image);
}).catch(report);
}
// Menus animate and the sidebar can move without resizing this node.
// Only changed geometry/occlusion crosses IPC.
if (visible && docked) frame = requestAnimationFrame(update);
};
update();
surface.current = visible && docked ? watchNativeSurface(node, update) : undefined;
return () => {
active = false;
cancelAnimationFrame(frame);
surface.current?.dispose();
surface.current = undefined;
void presentation
.setHost({ visible: false, rect: { x: 0, y: 0, width: 0, height: 0 } })
.catch(() => undefined);
};
}, [enabled, presentation, visible, snapshot?.placement]);
// The host also reports Workbar state, which can change without moving this node.
useEffect(() => surface.current?.refresh(), [workbarCollapsed, workbarTogglePosition]);
return (
<section ref={element} className="workHubDock" data-native-edge={snapshot?.placement === 'docked' && !needsRecovery || undefined} hidden={!visible} aria-label={t.title}>
{backdrop && snapshot?.placement === 'docked' && <img className="workHubDockBackdrop" src={backdrop} alt="" aria-hidden draggable={false} />}
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@
}
.maka-preload-mark {
color: #71a8fd;
animation: maka-preload-breathe 2.4s ease-in-out infinite;
/* Four cycles outlast the overlay, which hides by 8s at the latest.
An infinite loop kept running, hidden, for the life of the window. */
animation: maka-preload-breathe 2.4s ease-in-out 4;
}
@keyframes maka-preload-breathe {
0%, 100% { opacity: 0.55; }
Expand Down
60 changes: 59 additions & 1 deletion apps/desktop/stories/session-workbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import { useState, type CSSProperties } from 'react';
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { expect, fn, spyOn, userEvent, waitFor, within } from 'storybook/test';
import type { ArtifactRecord } from '@maka/core/artifacts';
import type { BrowserState } from '@maka/core/browser';
import type { GitReviewReadResult, GitReviewSnapshot } from '@maka/core/git-review';
Expand Down Expand Up @@ -1357,6 +1357,64 @@ export const BrowserLoaded: Story = {
expect(canvasElement.querySelector('.maka-browser-backdrop')).toBeNull();
finishNativeMenu(null);
await waitFor(() => expect(add).toHaveAttribute('aria-expanded', 'false'));

// An idle strip samples no geometry; a strip that moves or shrinks in place
// is mirrored again; a DOM overlay across it parks the page until it closes.
await userEvent.unhover(add);
await waitFor(() => expect(document.querySelector(':popover-open:not(:empty)')).toBeNull());
const strip = canvasElement.querySelector<HTMLElement>('.maka-browser-strip')!;
const measure = spyOn(strip, 'getBoundingClientRect');
await new Promise((resolve) => setTimeout(resolve, 250));
expect(measure).not.toHaveBeenCalled();
measure.mockRestore();
const before = browserViewport.mock.lastCall![0].rect as { x: number; width: number };
strip.style.position = 'relative';
strip.style.left = '24px';
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toMatchObject({ x: before.x + 24, width: before.width }));
strip.style.removeProperty('position');
strip.style.removeProperty('left');
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toMatchObject({ x: before.x, width: before.width }));
strip.style.width = `${before.width - 40}px`;
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toMatchObject({ x: before.x, width: before.width - 40 }));
strip.style.removeProperty('width');
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toMatchObject({ x: before.x, width: before.width }));
const bounds = strip.getBoundingClientRect();
const overlay = document.createElement('div');
overlay.popover = 'manual';
overlay.textContent = 'menu';
Object.assign(overlay.style, { inset: 'auto', margin: '0', left: `${bounds.left + 8}px`, top: `${bounds.top + 8}px`, width: '80px', height: '40px' });
document.body.append(overlay);
// An open popover that moves, then grows, across the strip with no toggle.
for (const across of [{ left: `${bounds.left + 8}px` }, { width: '200px' }]) {
Object.assign(overlay.style, { left: `${bounds.left - 120}px`, width: '80px' });
overlay.showPopover();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
expect(browserViewport.mock.lastCall?.[0].rect).toMatchObject({ x: before.x, width: before.width });
Object.assign(overlay.style, across);
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toBeNull());
overlay.hidePopover();
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toMatchObject({ x: before.x, width: before.width }));
}
Object.assign(overlay.style, { left: `${bounds.left + 8}px`, width: '80px' });
// Closed by hiding, then by unmounting while still open.
for (const close of [() => overlay.hidePopover(), () => overlay.remove()]) {
overlay.showPopover();
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toBeNull());
await waitFor(() => expect(canvasElement.querySelector('.maka-browser-backdrop')).not.toBeNull());
close();
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toMatchObject({ x: before.x, width: before.width }));
expect(canvasElement.querySelector('.maka-browser-backdrop')).toBeNull();
}
// Like the toast viewport: open while empty, then filled with no toggle.
overlay.replaceChildren();
document.body.append(overlay);
overlay.showPopover();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
overlay.textContent = 'toast';
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toBeNull());
overlay.replaceChildren();
await waitFor(() => expect(browserViewport.mock.lastCall?.[0].rect).toMatchObject({ x: before.x, width: before.width }));
overlay.remove();
},
};

Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/__tests__/prompt-anchor-rail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import test from 'node:test';
import {
mergePromptAnchorRailTurns,
observeActivePromptRailVisibility,
reusePromptAnchorRailTurns,
selectPromptRailTick,
} from '../prompt-anchor-rail.js';

Expand All @@ -42,6 +43,23 @@ test('indexed Turns outside the loaded range precede it, and loaded Turns keep t
assert.equal(mergePromptAnchorRailTurns(loaded, index.slice(1), new Set(['turn-3'])), loaded);
});

test('a streaming reply delta hands back every other entry, and the list when nothing moved', () => {
const previous = [
{ turnId: 'turn-1', sequence: 8, label: 'Prompt 1' },
{ turnId: 'turn-2', label: 'Prompt 2', reply: 'Answer 2', highlighted: true },
{ turnId: 'turn-3', label: 'Prompt 3', reply: 'Answer' },
];
const delta = previous.map((turn) => ({ ...turn, ...(turn.turnId === 'turn-3' ? { reply: 'Answer 3' } : {}) }));
const next = reusePromptAnchorRailTurns(previous, delta);
assert.notEqual(next, previous);
assert.equal(next[0], previous[0]);
assert.equal(next[1], previous[1]);
assert.equal(next[2], delta[2]);
assert.equal(reusePromptAnchorRailTurns(next, next.map((turn) => ({ ...turn }))), next);
assert.deepEqual(reusePromptAnchorRailTurns(next, next.slice(1)), next.slice(1));
assert.notEqual(reusePromptAnchorRailTurns(next, [next[0]!, { ...next[1]!, highlighted: undefined }, next[2]!])[1], next[1]);
});

const orderedTurnIds = Array.from({ length: 120 }, (_, index) => `turn-${index + 1}`);
const sampledRailTurnIds = Array.from({ length: 64 }, (_, railIndex) =>
orderedTurnIds[Math.round(railIndex * 119 / 63)]!,
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,49 @@ test('portals landmarks into the layout host and keeps them actionable', async (
assert.match(dom.container.innerHTML, /aria-label="Jump to prompt: Prompt 2"/);
});

test('re-rendering a tick keeps its hover card bound to the same trigger', async () => {
dom = installTranscriptDom();
const scrollRef = { current: null };
const turns: PromptAnchorRailTurn[] = Array.from({ length: 3 }, (_, index) => ({
turnId: `turn-${index}`, label: `Prompt ${index}`,
}));
const render = (navigate: (turn: PromptAnchorRailTurn) => void) =>
createElement(LocaleProvider, {
locale: 'en', children: createElement(ChatSurfaceLayout, {
composer: null, children: createElement(PromptAnchorRail, { turns, scrollRef, onNavigateTurn: navigate }),
}),
});
await dom.render(render(() => {}));
const tick = dom.container.querySelector<HTMLElement>('[data-prompt-turn-id="turn-1"]')!;
const bound: string[] = [];
const addEventListener = tick.addEventListener.bind(tick);
tick.addEventListener = (type: string, ...rest: [EventListenerOrEventListenerObject, AddEventListenerOptions?]) => {
bound.push(type);
addEventListener(type, ...rest);
};
await dom.render(render(() => {}));
assert.equal(dom.container.querySelector('[data-prompt-turn-id="turn-1"]'), tick);
assert.deepEqual(bound.filter((type) => /^(pointer|mouse|focus)/.test(type)), []);
});

test('a streaming reply re-renders only its own tick when the caller passes a fresh highlight handler', async () => {
const { dom } = await mountTranscript();
// A tick that re-renders hands its button new handler props.
const tickProps = (turnId: string): unknown => {
const tick = dom.container.querySelector(`[data-prompt-turn-id="${turnId}"]`)!;
const key = Object.keys(tick).find((name) => name.startsWith('__reactProps$'))!;
return (tick as unknown as Record<string, unknown>)[key];
};
await dom.render(view(turnMessages(), { onPromptRailHighlight: () => {} }));
const settled = tickProps('turn-0');
const streaming = tickProps('turn-5');
const delta = turnMessages().map((message) =>
message.id === 'assistant-5' && message.type === 'assistant' ? { ...message, text: '答案还在写' } : message);
await dom.render(view(delta, { onPromptRailHighlight: () => {} }));
assert.ok(tickProps('turn-0') === settled, 'the settled tick did not re-render');
assert.ok(tickProps('turn-5') !== streaming, 'the streaming tick re-rendered');
});

test('a retained tick uses updated content, decoration, and navigation callbacks', async () => {
dom = installTranscriptDom();
const scrollRef = { current: null };
Expand Down
Loading