Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6976f23
Make the mobile viewport handler idempotent and cheap
vburojevic Aug 24, 2026
08fc699
Take the resize cascade's geometry from the observer entries
vburojevic Aug 24, 2026
aa73340
Trim per-scroll-event work on coarse pointers
vburojevic Aug 24, 2026
33aebda
Clarify why focus-driven viewport passes remeasure the containing block
vburojevic Aug 24, 2026
ae946e8
Navigate app routes at transition priority with a pending signal
vburojevic Aug 24, 2026
345dfb2
Defer expander bodies out of the toggle's click commit
vburojevic Aug 24, 2026
42c0a85
Realize the mobile sidebar at transition priority
vburojevic Aug 24, 2026
69d1e4e
Early-exit the maximize restore loop
vburojevic Aug 24, 2026
d4b30db
Phase timeline height syncs through one shared ResizeObserver
vburojevic Aug 24, 2026
2fa119a
Halve the per-message action-bar ResizeObservers
vburojevic Aug 25, 2026
292a7b0
Mark the shared selection pointer listeners passive
vburojevic Aug 25, 2026
69a16d0
Attach statusChange metadata at host-runtime status publishes
vburojevic Aug 24, 2026
edf918d
Throttle the metadata-less status-changed list refetch to 1 Hz
vburojevic Aug 24, 2026
1d4fd10
Stop cancelling in-flight searches from the immediate status patch
vburojevic Aug 24, 2026
14ec59a
Gate the default reconnect refetch on lost realtime coverage
vburojevic Aug 24, 2026
ce28c92
Share one visibility-gated 1 Hz ticker and widen the touch debounce
vburojevic Aug 24, 2026
b7c699c
Single-flight the connect gate's per-isolate caches (plan 007 step 5)
vburojevic Aug 24, 2026
791d9bc
Front-load the stylesheet and font preload in the built document (pla…
vburojevic Aug 24, 2026
347aedd
Merge boot micro-chunks with rolldown advancedChunks (plan 007 step 2)
vburojevic Aug 24, 2026
41df3e3
Precompress the document and serve its sidecar on the SPA fallback (p…
vburojevic Aug 24, 2026
1e1f6a3
Edge-cacheable app shell: build-id ETag + connect revalidation (plan …
vburojevic Aug 24, 2026
5a51b64
Show the transient scrollbar on coarse pointers again
kirbyhood Aug 25, 2026
f1b735d
Re-query scroll-anchor rows on a windowed timeline
kirbyhood Aug 25, 2026
1653416
Pin the entry-derived resize path and keyboard pan in tests
kirbyhood Aug 25, 2026
f281b33
Open the expander region on the body's commit, not the tap's
kirbyhood Aug 25, 2026
d2379a6
Subtract the assistant column inset from the shared list width
kirbyhood Aug 25, 2026
0851ab6
Batch the host fan-out statusChange snapshots to active threads
kirbyhood Aug 25, 2026
83c2ca1
Refetch an in-flight search after a status change settles
kirbyhood Aug 25, 2026
9a36390
Move the coarse-pointer debounce test to its own isolated file
kirbyhood Aug 25, 2026
3b55fee
Refetch an in-flight search after a completed turn settles
kirbyhood Aug 25, 2026
dd52a39
Serve the app shell no-cache again; keep the edge copy's bound internal
kirbyhood Aug 25, 2026
1df5682
Make the shell 304-relay test store its own edge copy
kirbyhood Aug 25, 2026
9d0821f
Lower the boot brotli ratchet to 10% above the new payload
kirbyhood Aug 25, 2026
3388bcb
Pin the built head order without a build in the font-preload test
kirbyhood Aug 25, 2026
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
2 changes: 1 addition & 1 deletion apps/app/bundle-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"to print the static chain that pulled a package into the closure."
],
"maxBootBytes": 1723617,
"maxBootBrotliBytes": 479067,
"maxBootBrotliBytes": 429072,
"forbiddenBootPackages": [
"@pierre/diffs",
"@pierre/theming",
Expand Down
148 changes: 148 additions & 0 deletions apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ function withElementClientHeight(
}
}

// Waits out one scheduled rAF pass so "the pass ran and did nothing" is
// distinguishable from "the pass has not run yet".
async function flushScheduledViewportPass() {
await act(async () => {
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => resolve());
});
});
});
}

beforeEach(() => {
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
});
Expand Down Expand Up @@ -310,6 +322,142 @@ describe("useMobileVisualViewportHeight", () => {
expect(window.scrollTo).not.toHaveBeenCalled();
});
});

it("writes shell geometry only when a pass computes new values", async () => {
const visualViewport = new FakeVisualViewport();
visualViewport.offsetTop = 0;
await withFakeVisualViewport(visualViewport, async () => {
render(<VisualViewportShell enabled />);
const shell = screen.getByTestId("shell");
const shellHeightRoot = screen.getByTestId("shell-height-root");
expect(shell.style.height).toBe("500px");
const setShellHeightProperty = vi.spyOn(
shellHeightRoot.style,
"setProperty",
);

// Same geometry again: the pass must return before any style write, or
// every keyboard/URL-bar animation frame invalidates the whole tree.
act(() => {
visualViewport.dispatchEvent(new Event("resize"));
});
await flushScheduledViewportPass();
expect(setShellHeightProperty).not.toHaveBeenCalled();

act(() => {
visualViewport.height = 480;
visualViewport.dispatchEvent(new Event("resize"));
});
await waitFor(() => expect(shell.style.height).toBe("480px"));
expect(setShellHeightProperty).toHaveBeenCalledTimes(1);
});
});

it("reads the containing block only when the layout viewport can change", async () => {
const visualViewport = new FakeVisualViewport();
visualViewport.offsetTop = 0;
let containingBlockReads = 0;
await withElementClientHeight(
document.body,
() => {
containingBlockReads += 1;
return 800;
},
async () =>
withFakeVisualViewport(visualViewport, async () => {
render(<VisualViewportShell enabled />);
const shell = screen.getByTestId("shell");
expect(shell.style.height).toBe("500px");
const readsAfterMount = containingBlockReads;

// Visual-viewport ticks pan or resize only the visual viewport;
// they must reuse the cached containing-block height instead of
// forcing a full-document layout per animation frame.
act(() => {
visualViewport.offsetTop = 40;
visualViewport.dispatchEvent(new Event("scroll"));
});
await waitFor(() => expect(shell.style.top).toBe("40px"));
act(() => {
visualViewport.height = 460;
visualViewport.dispatchEvent(new Event("resize"));
});
await waitFor(() => expect(shell.style.height).toBe("460px"));
expect(containingBlockReads).toBe(readsAfterMount);

act(() => {
window.dispatchEvent(new Event("resize"));
});
await waitFor(() =>
expect(containingBlockReads).toBe(readsAfterMount + 1),
);
}),
);
});

it("runs a geometry pass when an editor is focused programmatically", async () => {
const visualViewport = new FakeVisualViewport();
visualViewport.offsetTop = 0;
await withElementClientHeight(
document.body,
() => 500,
async () =>
withFakeVisualViewport(visualViewport, async () => {
render(<VisualViewportShell enabled />);
const shell = screen.getByTestId("shell");
const editor = screen.getByTestId("editor");
// Native layout matches the visual viewport: no override applied.
expect(shell.style.height).toBe("");

// The keyboard shortens the visual viewport around the same time
// the composer autofocuses, without any window resize; the focus
// pass must pick the change up on its own.
visualViewport.height = 300;
act(() => editor.focus());
await waitFor(() => expect(shell.style.height).toBe("300px"));
}),
);
});

it("ignores visual viewport pans without a keyboard or an applied override", async () => {
const visualViewport = new FakeVisualViewport();
visualViewport.offsetTop = 0;
await withElementClientHeight(
document.body,
() => 500,
async () =>
withFakeVisualViewport(visualViewport, async () => {
render(<VisualViewportShell enabled />);
const shell = screen.getByTestId("shell");
const editor = screen.getByTestId("editor");
expect(shell.style.height).toBe("");

// A URL-bar pan with no keyboard: nothing to compensate.
act(() => {
visualViewport.offsetTop = 340;
visualViewport.dispatchEvent(new Event("scroll"));
});
await flushScheduledViewportPass();
expect(window.scrollTo).not.toHaveBeenCalled();
expect(shell.style.top).toBe("");

// With a keyboard editor focused, the same pan is Safari's
// focus-reveal pan and must still be compensated. Let the pan
// settle and the focus-scheduled pass run first, so that only the
// scroll handler's keyboard branch can produce the compensation.
visualViewport.offsetTop = 0;
act(() => editor.focus());
await flushScheduledViewportPass();
expect(shell.style.top).toBe("");
act(() => {
visualViewport.offsetTop = 340;
visualViewport.dispatchEvent(new Event("scroll"));
});
await waitFor(() => expect(shell.style.top).toBe("340px"));
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
}),
);
});
});

describe("shouldRestoreIOSViewportOnKeyboardDismissal", () => {
Expand Down
80 changes: 68 additions & 12 deletions apps/app/src/components/layout/useMobileVisualViewportHeight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,22 @@ export function useMobileVisualViewportHeight(
if (!shell || !shellHeightRoot || !enabled || !visualViewport) return;

let animationFrame: number | null = null;
// The override last written to the shell, or null while none is applied.
// Writing shell `top`/`height` and the inherited `--bb-shell-height`
// invalidates computed style for the whole app tree, and passes run at
// visual-viewport event cadence (keyboard animation, URL-bar collapse),
// so a pass that recomputes unchanged geometry must not write at all.
let appliedOverride: { top: number; height: number } | null = null;
// Reading `document.body.clientHeight` forces a full-document layout. The
// shell's containing block only changes when the layout viewport does, so
// cache the read and mark it stale only on triggers that can resize the
// layout viewport — never on visualViewport ticks, which move or resize
// only the visual viewport.
let shellContainingBlockHeight = 0;
let shellContainingBlockHeightStale = true;
const clearViewportOverride = () => {
if (appliedOverride === null) return;
appliedOverride = null;
shell.style.removeProperty("top");
shell.style.removeProperty("height");
shellHeightRoot.style.removeProperty("--bb-shell-height");
Expand All @@ -68,12 +83,15 @@ export function useMobileVisualViewportHeight(
}

const visualViewportHeight = Math.round(visualViewport.height);
// `documentElement.clientHeight` is the visible viewport height for the
// root element, even when that root's actual CSS box extends behind an
// Android in-app browser toolbar. The body inherits the root box and
// therefore exposes the containing-block height the app shell really
// receives.
const shellContainingBlockHeight = document.body.clientHeight;
if (shellContainingBlockHeightStale) {
// `documentElement.clientHeight` is the visible viewport height for the
// root element, even when that root's actual CSS box extends behind an
// Android in-app browser toolbar. The body inherits the root box and
// therefore exposes the containing-block height the app shell really
// receives.
shellContainingBlockHeight = document.body.clientHeight;
shellContainingBlockHeightStale = false;
}
const hasVisualViewportPan =
visualViewport.offsetTop > 1 || window.scrollY > 0;
if (
Expand All @@ -91,7 +109,16 @@ export function useMobileVisualViewportHeight(
// compensation below also handles a visual-viewport-only pan.
window.scrollTo(0, 0);
}
shell.style.top = `${getVisualViewportPageTop(visualViewport)}px`;
const shellTop = getVisualViewportPageTop(visualViewport);
if (
appliedOverride !== null &&
appliedOverride.top === shellTop &&
appliedOverride.height === visualViewportHeight
) {
return;
}
appliedOverride = { top: shellTop, height: visualViewportHeight };
shell.style.top = `${shellTop}px`;
shell.style.height = `${visualViewportHeight}px`;
// Fixed-position descendants cannot inherit the shell element's pixel
// height. Publish the same correction through the existing shell-height
Expand All @@ -108,6 +135,28 @@ export function useMobileVisualViewportHeight(
}
animationFrame = window.requestAnimationFrame(updateHeight);
};
// For triggers where the layout viewport may have changed: window resize,
// rotation, and an editor gaining focus — the pass that sizes the shell
// for the arriving keyboard must start from the real containing block,
// and these triggers are rare enough that the forced layout is fine.
const scheduleContainingBlockUpdate = () => {
shellContainingBlockHeightStale = true;
scheduleUpdate();
};
const handleVisualViewportScroll = () => {
// Keyboard-less visual-viewport pans (URL-bar collapse, momentum
// settling) don't change the containing block and need no override —
// the pan compensation exists for the keyboard focus-reveal pan. Only
// an already-applied override still has to track pans, because embedded
// browsers apply one without any keyboard.
if (
appliedOverride === null &&
!isKeyboardFocusTarget(document.activeElement)
) {
return;
}
scheduleUpdate();
};

// Safari with its bottom toolbar visible does not update the visual
// viewport until the keyboard animation ends. Restore the normal shell
Expand All @@ -124,22 +173,29 @@ export function useMobileVisualViewportHeight(
};
const handleFocusIn = (event: FocusEvent) => {
if (!isKeyboardFocusTarget(event.target)) return;
scheduleUpdate();
// Programmatic focus (composer autofocus) can be the only trigger for a
// keyboard, so this must always schedule a full, freshly measured pass.
scheduleContainingBlockUpdate();
};

updateHeight();
visualViewport.addEventListener("resize", scheduleUpdate);
visualViewport.addEventListener("scroll", scheduleUpdate);
window.addEventListener("resize", scheduleUpdate);
visualViewport.addEventListener("scroll", handleVisualViewportScroll);
window.addEventListener("resize", scheduleContainingBlockUpdate);
window.addEventListener("orientationchange", scheduleContainingBlockUpdate);
if (restoreImmediatelyOnKeyboardDismissal) {
document.addEventListener("focusout", handleFocusOut);
document.addEventListener("focusin", handleFocusIn);
}

return () => {
visualViewport.removeEventListener("resize", scheduleUpdate);
visualViewport.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
visualViewport.removeEventListener("scroll", handleVisualViewportScroll);
window.removeEventListener("resize", scheduleContainingBlockUpdate);
window.removeEventListener(
"orientationchange",
scheduleContainingBlockUpdate,
);
if (restoreImmediatelyOnKeyboardDismissal) {
document.removeEventListener("focusout", handleFocusOut);
document.removeEventListener("focusin", handleFocusIn);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ import {
import { turnRequestLabel } from "@bb/client-core";
import { splitStreamingMarkdown } from "./streaming-markdown-split.js";
import { TurnRequestLabel } from "./TurnRequestLabel.js";
import { MessageActionBar } from "./MessageActionBar.js";
import {
MessageActionBar,
PROSE_COLUMN_INSET_CLASS,
} from "./MessageActionBar.js";
import {
ConversationMessageOverflowToggle,
useIsOverflowing,
Expand Down Expand Up @@ -678,7 +681,10 @@ function AssistantConversationMessage({

return (
<div
className="group/message w-full px-2 text-sm font-normal leading-relaxed"
className={cn(
"group/message w-full text-sm font-normal leading-relaxed",
PROSE_COLUMN_INSET_CLASS,
)}
data-message-column=""
>
{/*
Expand Down
Loading
Loading