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
24 changes: 16 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,24 +128,32 @@ function App() {
Math.ceil(rect.height) + CONTAINER_VERTICAL_PADDING;
const anchor = windowAnchorRef.current;
if (anchor) {
// On the very first observer event for an anchored session,
// expand the window to max height immediately. This fires
// during the Framer Motion entrance fade-in (opacity 0→1),
// so the user never sees the jump. All subsequent events
// are skipped — content grows inside the fixed window.
// Once the window has reached max height for this anchor
// session, skip all further adjustments — content scrolls
// internally inside the fixed-size window.
if (isPreExpandedRef.current) return;
isPreExpandedRef.current = true;

const maxHeight = Math.min(
MAX_CHAT_WINDOW_HEIGHT,
anchor.bottom_y - anchor.min_y,
);
const newY = anchor.bottom_y - maxHeight;
const neededHeight = Math.min(targetHeight, maxHeight);

// Lock the observer once max height is reached.
if (neededHeight >= maxHeight) {
isPreExpandedRef.current = true;
}

// Grow upward incrementally: pin the window bottom to the
// anchor and expand the top edge as content grows. Because
// `set_window_frame` applies position + size atomically on
// the main thread, there is no inter-frame jitter.
const newY = anchor.bottom_y - neededHeight;
void invoke('set_window_frame', {
x: anchor.x,
y: newY,
width: OVERLAY_WIDTH,
height: maxHeight,
height: neededHeight,
});
} else {
void getCurrentWindow().setSize(
Expand Down
283 changes: 283 additions & 0 deletions src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,289 @@ describe('App', () => {
expect(outer).not.toBeNull();
});

describe('ResizeObserver window sizing with anchor', () => {
let capturedCallback: ResizeObserverCallback | null = null;

function spyOnResizeObserver() {
const OriginalMock = globalThis.ResizeObserver;
vi.spyOn(globalThis, 'ResizeObserver').mockImplementation(function (
callback: ResizeObserverCallback,
) {
capturedCallback = callback;
return new OriginalMock(callback) as ResizeObserver;
});
}

function triggerResize(element: Element, contentHeight: number) {
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
height: contentHeight,
width: 600,
top: 0,
left: 0,
right: 600,
bottom: contentHeight,
x: 0,
y: 0,
toJSON: () => ({}),
});
if (capturedCallback) {
capturedCallback(
[{ target: element } as ResizeObserverEntry],
{} as ResizeObserver,
);
}
}

it('calls set_window_frame with content height on first anchor event, not max height', async () => {
spyOnResizeObserver();

render(<App />);
await act(async () => {});

// Show with anchor — bottom_y=884 means the window is at the bottom of a 900px screen
await act(async () => {
emitTauriEvent('thuki://visibility', {
state: 'show',
selected_text: null,
window_anchor: { x: 100, bottom_y: 884, min_y: 40 },
});
});

invoke.mockClear();

const container = document.querySelector('.morphing-container');
expect(container).not.toBeNull();
expect(capturedCallback).not.toBeNull();

// Simulate first observer event: only the askbar is visible (~60px content)
act(() => {
triggerResize(container!, 60);
});

// REGRESSION: must use content height (60+48=108), NOT max height (648)
expect(invoke).toHaveBeenCalledWith('set_window_frame', {
x: 100,
y: 884 - 108, // 776 — window bottom stays pinned, top moves to fit content
width: 600,
height: 108,
});
expect(invoke).not.toHaveBeenCalledWith(
'set_window_frame',
expect.objectContaining({ height: 648 }),
);
});

it('grows incrementally: each resize event updates position and height', async () => {
spyOnResizeObserver();

render(<App />);
await act(async () => {});

await act(async () => {
emitTauriEvent('thuki://visibility', {
state: 'show',
selected_text: null,
window_anchor: { x: 50, bottom_y: 800, min_y: 40 },
});
});

const container = document.querySelector('.morphing-container');
expect(container).not.toBeNull();

// First event: askbar only
invoke.mockClear();
act(() => {
triggerResize(container!, 60);
});
expect(invoke).toHaveBeenCalledWith('set_window_frame', {
x: 50,
y: 800 - 108,
width: 600,
height: 108,
});

// Second event: chat started, content grew
invoke.mockClear();
act(() => {
triggerResize(container!, 200);
});
expect(invoke).toHaveBeenCalledWith('set_window_frame', {
x: 50,
y: 800 - 248,
width: 600,
height: 248,
});
});

it('locks at max height and skips further resize events', async () => {
spyOnResizeObserver();

render(<App />);
await act(async () => {});

await act(async () => {
emitTauriEvent('thuki://visibility', {
state: 'show',
selected_text: null,
window_anchor: { x: 100, bottom_y: 884, min_y: 40 },
});
});

const container = document.querySelector('.morphing-container');
expect(container).not.toBeNull();

// Grow to max height (content=600 → window=648)
invoke.mockClear();
act(() => {
triggerResize(container!, 600);
});
expect(invoke).toHaveBeenCalledWith(
'set_window_frame',
expect.objectContaining({ height: 648 }),
);

// Next event should be a no-op (isPreExpandedRef is now true)
invoke.mockClear();
act(() => {
triggerResize(container!, 620);
});
expect(invoke).not.toHaveBeenCalledWith(
'set_window_frame',
expect.anything(),
);
});

it('uses setSize (not set_window_frame) after drag clears the anchor', async () => {
spyOnResizeObserver();

render(<App />);
await act(async () => {});

await act(async () => {
emitTauriEvent('thuki://visibility', {
state: 'show',
selected_text: null,
window_anchor: { x: 100, bottom_y: 884, min_y: 40 },
});
});

const container = document.querySelector('.morphing-container');
expect(container).not.toBeNull();

// Simulate drag: mousedown then mouseup clears the anchor
act(() => {
fireEvent.mouseDown(container!);
});
act(() => {
fireEvent.mouseUp(window);
});

invoke.mockClear();
__mockWindow.setSize.mockClear?.();

// After drag, anchor is null — ResizeObserver should use setSize, not set_window_frame
act(() => {
triggerResize(container!, 60);
});
expect(invoke).not.toHaveBeenCalledWith(
'set_window_frame',
expect.anything(),
);
expect(__mockWindow.setSize).toHaveBeenCalled();
});

it('clamps to available space when screen gap is smaller than MAX_CHAT_WINDOW_HEIGHT', async () => {
spyOnResizeObserver();

render(<App />);
await act(async () => {});

// Available space: bottom_y - min_y = 300 - 100 = 200, which is < MAX_CHAT_WINDOW_HEIGHT (648)
await act(async () => {
emitTauriEvent('thuki://visibility', {
state: 'show',
selected_text: null,
window_anchor: { x: 50, bottom_y: 300, min_y: 100 },
});
});

const container = document.querySelector('.morphing-container');
expect(container).not.toBeNull();

invoke.mockClear();
// Content height (300) → targetHeight (348) exceeds available space (200) → clamped to 200
act(() => {
triggerResize(container!, 300);
});
expect(invoke).toHaveBeenCalledWith('set_window_frame', {
x: 50,
y: 100, // bottom_y (300) - clamped height (200) = 100
width: 600,
height: 200, // clamped to available space, not targetHeight (348) or MAX (648)
});

// isPreExpandedRef is now true — next event is a no-op
invoke.mockClear();
act(() => {
triggerResize(container!, 400);
});
expect(invoke).not.toHaveBeenCalledWith(
'set_window_frame',
expect.anything(),
);
});

it('isPreExpandedRef resets on session reopen, allowing incremental growth again', async () => {
spyOnResizeObserver();

render(<App />);
await act(async () => {});

// Session 1: grow to max height, locking isPreExpandedRef
await act(async () => {
emitTauriEvent('thuki://visibility', {
state: 'show',
selected_text: null,
window_anchor: { x: 100, bottom_y: 884, min_y: 40 },
});
});

const container1 = document.querySelector('.morphing-container');
act(() => {
triggerResize(container1!, 600); // locks isPreExpandedRef = true
});

// Close the overlay — requestHideOverlay resets isPreExpandedRef to false
await act(async () => {
emitTauriEvent('thuki://visibility', { state: 'hide-request' });
});

// Session 2: reopen with new anchor — incremental growth must work again
await act(async () => {
emitTauriEvent('thuki://visibility', {
state: 'show',
selected_text: null,
window_anchor: { x: 100, bottom_y: 884, min_y: 40 },
});
});

const container2 = document.querySelector('.morphing-container');
expect(container2).not.toBeNull();

invoke.mockClear();
// Small content — must NOT be skipped even though the previous session was locked
act(() => {
triggerResize(container2!, 60);
});
expect(invoke).toHaveBeenCalledWith('set_window_frame', {
x: 100,
y: 776, // bottom_y (884) - neededHeight (108) = 776
width: 600,
height: 108, // content height (60) + padding (48)
});
});
});

it('requestHideOverlay is a no-op when already hidden', async () => {
render(<App />);
await act(async () => {});
Expand Down