Compare commits

..
Author SHA1 Message Date
Mohamed BoudraandElsa Granger e0f545fed4 fix(app): use the iPhone Duo top band beside the status region (#8901)
On the iPhone Duo inner screen in portrait, the workspace header, the left
sidebar's first row and the explorer tab rail now use the top band beside
the status region. The status region is published as the window's top-right
obstruction, the same slot desktop window controls use, so the row owning
that corner pads past it. Content gains 56pt.

Refs #7924

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-09 22:03:21 +07:00
24 changed files with 419 additions and 679 deletions
@@ -11,7 +11,6 @@ import {
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
answerFreshnessCheck,
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectResumeAnsweredWithoutFetch,
@@ -22,7 +21,6 @@ import {
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
returnWithFreshnessCheckUnanswered,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
@@ -103,25 +101,6 @@ test.describe("Agent timeline resume", () => {
}
});
test("a check the daemon has not answered reports the update until it does", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await returnWithFreshnessCheckUnanswered(page, gate);
await answerFreshnessCheck(page, gate);
expectResumeAnsweredWithoutFetch(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("returning to a changed chat reports the update and shows the new messages", async ({
page,
}) => {
@@ -1,11 +1,5 @@
import { test } from "../support/fixtures";
import {
expectToolbarNeverCondensedOrClipped,
recordToolbarFramesFromLoad,
withComposerControlsExample,
withPickerComposerItem,
withToggleAndWatcherComposerItems,
} from "../support/helpers/plugin-composer-items";
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
test.setTimeout(120_000);
@@ -16,66 +10,3 @@ test("a narrow composer's overflow sheet shows a plugin item's row", async ({ pa
await composer.expectFeatureRow("Insert checklist");
});
});
test("a toolbar with room keeps a plugin toggle inline at full density", async ({ page }) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
// Wider than what renders, too narrow for two 125-wide "Plugin item" estimates.
await composer.setWindowWidth(1000);
await composer.expectToolbarWidthBetween(470, 620);
await composer.expectToggleInlineAtFullDensity();
});
});
test("a toolbar without room moves a plugin toggle to the overflow sheet and back", async ({
page,
}) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.setWindowWidth(1000);
await composer.expectToggleInlineAtFullDensity();
// Too narrow for full labels, wide enough that the controls have not folded away.
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.expectToggleInOverflow();
await composer.closeOverflowSheet();
await composer.setWindowWidth(1000);
await composer.expectToggleInlineAtFullDensity();
});
});
test("an open plugin picker closes when its trigger moves to the overflow", async ({ page }) => {
test.setTimeout(120_000);
await withPickerComposerItem(page, async (composer) => {
await composer.setWindowWidth(1000);
await composer.openInlinePicker();
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.expectPickerClosed();
});
});
test("the toolbar settles on one density without flipping as the window widens", async ({
page,
}) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.startRecordingDensity();
for (let width = 880; width <= 1000; width += 8) await composer.setWindowWidth(width);
await composer.expectAtMostOneDensityChange();
});
});
test("a toolbar with room never paints condensed or clipped while the plugin loads", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1000, height: 800 });
await recordToolbarFramesFromLoad(page);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.expectToggleInlineAtFullDensity();
await expectToolbarNeverCondensedOrClipped(page);
});
});
@@ -1,11 +1,8 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { copyPluginExample } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
const PLUGIN_ID = "composer-controls-example";
@@ -60,236 +57,3 @@ export async function withComposerControlsExample(
await workspace.cleanup();
}
}
const ONE_TOGGLE_PLUGIN_ID = "composer-toggle-and-watcher-e2e";
const ONE_TOGGLE_NAME = "Sandbox flag";
const ONE_TOGGLE_PLUGIN_SOURCE = `import { useComposer } from "@getpaseo/plugin/client";
import { Toggle } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
function PromptWatcher() {
useComposer();
return null;
}
function SandboxFlag() {
const [value, setValue] = useState(false);
return (
<Toggle
title="${ONE_TOGGLE_NAME}"
icon="Lock"
value={value}
onValueChange={({ value: next }) => setValue(next)}
/>
);
}
export default function contribute(client) {
client.addComposerItem({ id: "prompt-watcher", Component: PromptWatcher });
client.addComposerItem({ id: "sandbox-flag", Component: SandboxFlag });
return () => {};
}`;
const PICKER_NAME = "Review state";
const PICKER_PLUGIN_SOURCE = `import { Select } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
const OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "ready", label: "Ready" },
];
function ReviewState() {
const [value, setValue] = useState("draft");
return (
<Select
title="${PICKER_NAME}"
icon="CircleDot"
value={value}
options={OPTIONS}
onValueChange={({ value: next }) => setValue(next)}
/>
);
}
export default function contribute(client) {
client.addComposerItem({ id: "review-state", Component: ReviewState });
return () => {};
}`;
function toolbar(page: Page) {
return page.getByTestId("agent-controls-toolbar").filter({ visible: true }).first();
}
interface ToolbarFrame {
overflowButton: boolean;
clipped: boolean;
}
declare global {
interface Window {
__pluginToolbarFrames?: ToolbarFrame[];
}
}
/** From the first frame on, records whether the toolbar condensed or clipped. Call before loading. */
export async function recordToolbarFramesFromLoad(page: Page): Promise<void> {
await page.addInitScript(() => {
const frames: ToolbarFrame[] = [];
window.__pluginToolbarFrames = frames;
const tick = () => {
const row = Array.from(
document.querySelectorAll('[data-testid="agent-controls-toolbar"]'),
).find((node) => node.getClientRects().length > 0);
if (row) {
frames.push({
overflowButton: row.querySelector('[data-testid="agent-controls-features"]') !== null,
clipped: row.scrollWidth > row.clientWidth,
});
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}
export async function expectToolbarNeverCondensedOrClipped(page: Page): Promise<void> {
const frames = await page.evaluate(() => window.__pluginToolbarFrames ?? []);
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.overflowButton || frame.clipped)).toEqual([]);
}
function agentFeaturesButton(page: Page) {
return page.getByRole("button", { name: "Open agent features", exact: true });
}
interface ToolbarPluginComposer {
setWindowWidth(width: number): Promise<void>;
expectToolbarWidthBetween(min: number, max: number): Promise<void>;
expectToggleInlineAtFullDensity(): Promise<void>;
expectToggleInOverflow(): Promise<void>;
closeOverflowSheet(): Promise<void>;
startRecordingDensity(): Promise<void>;
expectAtMostOneDensityChange(): Promise<void>;
openInlinePicker(): Promise<void>;
expectPickerClosed(): Promise<void>;
}
/** Runs `run` in an agent's composer with one icon toggle and one item that renders nothing. */
export function withToggleAndWatcherComposerItems(
page: Page,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
return withToolbarPlugin(page, ONE_TOGGLE_PLUGIN_SOURCE, run);
}
/** Runs `run` in an agent's composer with one plugin `Select` in the toolbar. */
export function withPickerComposerItem(
page: Page,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
return withToolbarPlugin(page, PICKER_PLUGIN_SOURCE, run);
}
async function withToolbarPlugin(
page: Page,
pluginSource: string,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-measured-items-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Measured items agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-measured-items-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: ONE_TOGGLE_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expect(toolbar(page)).toBeVisible({ timeout: 30_000 });
await run({
setWindowWidth: (width) =>
test.step(`set the window to ${width} wide`, () =>
page.setViewportSize({ width, height: 800 })),
expectToolbarWidthBetween: (min, max) =>
test.step(`the toolbar is between ${min} and ${max} wide`, async () => {
await expect
.poll(async () => (await toolbar(page).boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(min);
expect((await toolbar(page).boundingBox())?.width ?? 0).toBeLessThanOrEqual(max);
}),
expectToggleInlineAtFullDensity: () =>
test.step("the toggle is inline and the toolbar keeps its labels", async () => {
const toggle = toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true });
await expect(toggle).toBeVisible();
await expect(agentFeaturesButton(page)).toHaveCount(0);
await expect(toolbar(page).getByTestId("agent-thinking-selector")).toHaveText("Low");
const row = await toolbar(page).boundingBox();
const box = await toggle.boundingBox();
expect(box && row && box.x + box.width <= row.x + row.width).toBe(true);
}),
expectToggleInOverflow: () =>
test.step("the toggle has moved to the overflow sheet", async () => {
await expect(
toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }),
).toHaveCount(0);
await agentFeaturesButton(page).click();
await expect(
page.getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }),
).toBeVisible();
}),
closeOverflowSheet: () =>
test.step("close the overflow sheet", async () => {
await page.keyboard.press("Escape");
await expect(page.getByTestId("agent-features-sheet")).toHaveCount(0);
}),
startRecordingDensity: () =>
test.step("record which density each frame paints", () =>
page.evaluate(() => {
const frames: boolean[] = [];
const paint = () => {
frames.push(
document.querySelector('[data-testid="agent-controls-features"]') !== null,
);
window.requestAnimationFrame(paint);
};
window.requestAnimationFrame(paint);
Object.assign(window, { __densityFrames: frames });
})),
expectAtMostOneDensityChange: () =>
test.step("the density changed once at most", async () => {
const changes = await page.evaluate(() => {
const frames = (window as unknown as { __densityFrames: boolean[] }).__densityFrames;
return frames.filter((frame, index) => index > 0 && frame !== frames[index - 1]).length;
});
expect(changes).toBeLessThanOrEqual(1);
}),
openInlinePicker: () =>
test.step("open the plugin picker from the toolbar", async () => {
await toolbar(page).getByRole("button", { name: PICKER_NAME, exact: true }).click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible();
}),
expectPickerClosed: () =>
test.step("the plugin picker has closed", () =>
expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0)),
});
} finally {
await client.removePlugin(ONE_TOGGLE_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}
@@ -13,12 +13,6 @@ const SUBSCRIPTION_REQUEST = "agent.timeline.set_subscription.request";
const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
/**
* `FRESHNESS_CHECK_QUIET_MS` is 500: a check unanswered that long reports Updating messages by
* design, however long the daemon takes. The app starts that wait just before it sends the question
* this helper times, so the bound stops short of 500.
*/
const QUIET_WINDOW_FLOOR_MS = 300;
export interface TimelineRequestCounts {
agentId?: string;
@@ -83,88 +77,48 @@ export async function waitForResumeAnswer(
]);
}
interface UpdatingToastTiming {
askedAt: number | null;
shownAt: number | null;
}
declare global {
interface Window {
__updatingToastTiming?: UpdatingToastTiming;
}
}
/** Runs in the page: times the freshness question and the toast's first commit on its own clock. */
function recordUpdatingToastTiming(): void {
const noteToast = (timing: UpdatingToastTiming) => {
if (document.querySelector('[data-testid="agent-updating-toast"]') === null) return;
timing.shownAt ??= performance.now();
};
const existing = window.__updatingToastTiming;
if (existing) {
existing.askedAt = null;
existing.shownAt = null;
noteToast(existing);
return;
}
const timing: UpdatingToastTiming = { askedAt: null, shownAt: null };
window.__updatingToastTiming = timing;
const send = WebSocket.prototype.send;
WebSocket.prototype.send = function sendRecordingQuestion(data) {
if (
typeof data === "string" &&
data.includes("agent.timeline.set_subscription.request") &&
data.includes('"agentIds":["')
) {
timing.askedAt ??= performance.now();
}
return send.call(this, data);
};
new MutationObserver(() => noteToast(timing)).observe(document, {
childList: true,
subtree: true,
});
noteToast(timing);
}
/** A toast may only follow an unanswered question; nothing about the daemon's speed changes that. */
async function expectToastWaitedForTheDaemon(page: Page): Promise<void> {
await waitForNextPaint(page);
const showedTooSoon = await page.evaluate((floorMs) => {
const { askedAt, shownAt } = window.__updatingToastTiming as UpdatingToastTiming;
if (shownAt === null) return false;
return askedAt === null || shownAt - askedAt < floorMs;
}, QUIET_WINDOW_FLOOR_MS);
expect(showedTooSoon, "the Updating messages toast did not wait for the daemon").toBe(false);
}
/** Records every appearance of the toast, including one that lasts a single commit. */
export async function watchForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.evaluate(recordUpdatingToastTiming);
return () => expectToastWaitedForTheDaemon(page);
const watch = await page.evaluateHandle(() => {
let shown = false;
const check = () => {
shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null;
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
check();
return {
stop() {
observer.disconnect();
return shown;
},
};
});
return async () => {
await waitForNextPaint(page);
const shown = await watch.evaluate((recorder) => recorder.stop());
await watch.dispose();
expect(shown, "the Updating messages toast appeared").toBe(false);
};
}
/** Reopens the app on this chat, recording the toast from the first paint of the new page. */
export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.addInitScript(recordUpdatingToastTiming);
await page.addInitScript(() => {
const recorder = window as Window & { updatingToastShown?: boolean };
new MutationObserver(() => {
recorder.updatingToastShown ||=
document.querySelector('[data-testid="agent-updating-toast"]') !== null;
}).observe(document, { childList: true, subtree: true });
});
await page.reload();
return () => expectToastWaitedForTheDaemon(page);
}
/** Returns to the chat with the daemon's answer held, past the point the toast reports it. */
export async function returnWithFreshnessCheckUnanswered(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextClientRequest(SUBSCRIPTION_REQUEST);
await hideTabAndReturn(page);
await gate.waitForHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
}
export async function answerFreshnessCheck(page: Page, gate: DaemonWebSocketGate): Promise<void> {
gate.releaseHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
return async () => {
await waitForNextPaint(page);
const shown = await page.evaluate(
() => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true,
);
expect(shown, "the Updating messages toast appeared").toBe(false);
};
}
async function waitForNextPaint(page: Page): Promise<void> {
@@ -18,6 +18,8 @@ final class WindowLayoutView: ExpoView {
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var bottomBarGuide: UILayoutGuide?
private var topBarGuide: UILayoutGuide?
private var occlusions: [CGRect] = []
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
@@ -38,6 +40,7 @@ final class WindowLayoutView: ExpoView {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
topBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
@@ -62,6 +65,8 @@ final class WindowLayoutView: ExpoView {
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
// Frames already include the system's interactive margins.
occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
@@ -73,6 +78,11 @@ final class WindowLayoutView: ExpoView {
// screen's corners rather than the rectangular safe area.
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
}
if topBarGuide == nil {
// Where a top bar rests. Beside a partial status region it can start above the
// rectangular safe area; content still keeps out of the occlusions.
topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44))
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
@@ -106,11 +116,17 @@ final class WindowLayoutView: ExpoView {
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
}
var topBar: Any = NSNull()
if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 {
topBar = rect(frame)
}
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"bottomClearance": bottomClearance,
"topBar": topBar,
"occlusions": occlusions.map(rect),
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
@@ -11,6 +11,7 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
opacity: { 50: 0.5 },
borderRadius: { full: 999, md: 6, lg: 8 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
@@ -12,8 +12,13 @@ import {
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useTopBand } from "@/window-layout";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
@@ -39,7 +44,8 @@ interface CompactHeaderProps {
* left-aligned between them. Neither the header nor its islands draw a background, so it reads the
* same in flow and floating over content (see `FloatingHeaderLayout`, whose fade gives the icons
* contrast). Its height matches the old flat header, so anything
* offset by the header height still lines up.
* offset by the header height still lines up. In a window's top band it takes the top bar's
* place, like the full header. The column hosting it reserves any window-chrome corner it owns.
*/
export function CompactHeader({
navigation,
@@ -51,22 +57,23 @@ export function CompactHeader({
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const inTopBand = useTopBand() !== null;
const rootStyle = useMemo(
() => [
styles.root,
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE),
marginLeft: insets.left,
marginRight: insets.right,
},
],
[insets.top, insets.left, insets.right],
[inTopBand, insets.top, insets.left, insets.right],
);
return (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View style={styles.row}>
<View style={inTopBand ? styles.topBandRow : styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
</View>
@@ -148,6 +155,12 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
topBandRow: {
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
titleGroup: {
flex: 1,
minWidth: 0,
+6 -4
View File
@@ -31,7 +31,7 @@ import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import { useTopBand, useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -807,9 +807,11 @@ function DesktopSidebar({
],
[insetsTop, insets.bottom, insets.left, overlay, divisionLayout],
);
// Below the window controls' row, or in the top band's place, the first row starts flush.
const inTopBand = useTopBand() !== null;
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
[ownsTopLeft],
() => [styles.sidebarHeaderGroup, (ownsTopLeft || inTopBand) && styles.sidebarHeaderGroupFlush],
[ownsTopLeft, inTopBand],
);
return (
<MobilePanelOverlay
@@ -938,7 +940,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
sidebarHeaderGroupBelowChrome: {
sidebarHeaderGroupFlush: {
paddingTop: 0,
},
// The nav block caps at half the sidebar; a long item list scrolls inside the
@@ -13,8 +13,6 @@ beforeEach(() => vi.stubGlobal("React", React));
afterEach(cleanup);
const base = { id: "x", title: "Context", icon: Scan };
// The sheet lays its rows out in a column, which is what stretches a row across it.
const SHEET_COLUMN = { display: "flex", flexDirection: "column", width: 382 } as const;
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
@@ -94,29 +92,6 @@ describe("ComposerControls", () => {
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
});
it("gives a sheet row's value the row's width, so a short value is not truncated", () => {
const toast = createToast();
render(
<div style={SHEET_COLUMN}>
<Controls
items={[
{
...base,
type: "select",
value: "full",
choices: [{ value: "full", label: "Full access" }],
onChange: () => undefined,
},
]}
surface="sheet"
toast={toast}
/>
</div>,
);
const value = screen.getByText("Full access");
expect(value.scrollWidth).toBeLessThanOrEqual(value.clientWidth);
});
it("reports a failed command the way a built-in control does, and frees the control", async () => {
let reject: (error: Error) => void = () => undefined;
const onPress = vi.fn(
@@ -73,7 +73,6 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
accessibilityLabel={accessibilityLabel}
testID={testID}
chevron={showCaret ? undefined : null}
block={isSheet}
>
{isSheet ? (
<View style={styles.sheetGlyph}>
@@ -87,10 +87,6 @@ import { composerModelFromHalves } from "@/composer/halves-model";
import { ComposerModelProvider } from "@/composer/model";
import { ComposerAgentProvider, useComposerInput } from "@/composer/session";
import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items";
import {
PluginToolbarSlot,
usePluginToolbarWidth,
} from "@/composer/agent-controls/plugin-toolbar-slot";
import {
useAgentProfileEditor,
useAgentProfilePicker,
@@ -470,7 +466,6 @@ function ControlledAgentControls({
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const pluginItemCount = usePluginComposerItemCount(modelSelectorServerId);
const { width: pluginItemsWidth, onLayout: handlePluginItemsLayout } = usePluginToolbarWidth();
const formattedThinkingOptions = useMemo(
() => toThinkingControlOptions(thinkingOptions),
@@ -493,7 +488,6 @@ function ControlledAgentControls({
() =>
(optionControls?.controls ?? []).map((control) => {
if (control.type === "toggle") return { type: "toggle" as const };
if (control.desktopTrigger === "icon") return { type: "select" as const, label: null };
const selected = readSelectOption(control, optionControls?.options ?? {});
const choice = control.choices.find((candidate) => candidate.value === selected);
return {
@@ -509,10 +503,10 @@ function ControlledAgentControls({
hasThinking: canSelectThinking,
hasMode: modeControl !== null && modeControl !== undefined,
features: featureControls,
pluginItemsWidth,
pluginItems: pluginItemCount,
fontScale,
}),
[canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemsWidth],
[canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemCount],
);
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
const layoutContextValue = useMemo(
@@ -641,7 +635,7 @@ function ControlledAgentControls({
return (
<ComposerControlLayoutProvider value={layoutContextValue}>
<View style={styles.container} onLayout={handleLayout} testID="agent-controls-toolbar">
<View style={styles.container} onLayout={handleLayout}>
{!presentation.foldControls ? (
<DesktopAgentControlsContent
provider={provider}
@@ -667,7 +661,7 @@ function ControlledAgentControls({
handleSheetOpenChange={setOpenSelector}
modeControl={modeControl}
presentation={presentation}
hasPluginItems={pluginItemsWidth > 0}
pluginItemCount={pluginItemCount}
glyphs={layoutContextValue.glyphs}
activeSheet={activeSheet}
handleOpenSheet={handleOpenSheet}
@@ -704,14 +698,6 @@ function ControlledAgentControls({
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/>
)}
{!isCompact ? (
<PluginToolbarSlot
serverId={modelSelectorServerId}
width={pluginItemsWidth}
fits={!presentation.aggregateFeatures}
onLayout={handlePluginItemsLayout}
/>
) : null}
</View>
</ComposerControlLayoutProvider>
);
@@ -746,7 +732,7 @@ interface DesktopAgentControlsContentProps extends AgentControlsContentProps {
handleDesktopModelSelect: (providerId: string, modelId: string) => void;
handleToolbarOpenChange: (id: string | null) => void;
presentation: ComposerControlPresentation;
hasPluginItems: boolean;
pluginItemCount: number;
activeSheet: ActiveSheet;
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
handleCloseSheet: () => void;
@@ -780,7 +766,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
handleSheetOpenChange,
modeControl,
presentation,
hasPluginItems,
pluginItemCount,
glyphs,
activeSheet,
handleOpenSheet,
@@ -796,7 +782,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
[t],
);
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
const hasFeatures = featureItems.length > 0 || hasPluginItems;
const hasFeatures = featureItems.length > 0 || pluginItemCount > 0;
return (
<>
{canSelectModel ? (
@@ -878,14 +864,17 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</AdaptiveModalSheet>
</>
) : (
<ComposerControls
items={featureItems}
surface="toolbar"
disabled={disabled}
openId={openSelector}
onOpenIdChange={handleToolbarOpenChange}
onActionComplete={onDropdownClose}
/>
<>
<ComposerControls
items={featureItems}
surface="toolbar"
disabled={disabled}
openId={openSelector}
onOpenIdChange={handleToolbarOpenChange}
onActionComplete={onDropdownClose}
/>
<PluginComposerItems serverId={modelSelectorServerId} surface="toolbar" />
</>
)}
</>
);
@@ -47,7 +47,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
pluginItemsWidth: 0,
pluginItems: 0,
fontScale: 1,
};
@@ -116,7 +116,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItemsWidth: 0,
pluginItems: 0,
fontScale: 1,
},
}),
@@ -129,7 +129,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
pluginItemsWidth: 0,
pluginItems: 0,
fontScale: 1,
},
}),
@@ -142,7 +142,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItemsWidth: 0,
pluginItems: 0,
fontScale: 1.25,
},
}),
@@ -158,7 +158,7 @@ describe("composer control layout", () => {
hasModel: true,
hasThinking: true,
hasMode: true,
pluginItemsWidth: 0,
pluginItems: 0,
fontScale: 1,
},
};
@@ -180,61 +180,34 @@ describe("composer control layout", () => {
).toBe("condensed");
});
describe("plugin items", () => {
it("condenses to make room for plugin items and keeps them in the overflow", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
geometry: POINTER,
};
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
pluginItemsWidth: 0,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
pluginItems: 0,
},
};
it("budgets the width the items measured, so an icon toggle fits where a label would not", () => {
expect(
resolveComposerControlDensity({
...base,
controls: { ...controls, pluginItemsWidth: 28 },
}),
).toBe("full");
expect(
resolveComposerControlDensity({
...base,
controls: { ...controls, pluginItemsWidth: 125 },
}),
).toBe("condensed");
});
it("reserves no overflow button for items that render nothing", () => {
// 264 for the condensed row; an overflow button would take it to 296.
const tight = { ...base, availableWidth: 290, currentDensity: "tight" as const };
expect(resolveComposerControlDensity({ ...tight, controls })).toBe("condensed");
expect(
resolveComposerControlDensity({
...tight,
controls: { ...controls, pluginItemsWidth: 28 },
}),
).toBe("tight");
});
it("holds either density at the full floor while the items' width stays the same", () => {
// 412 is the full floor with a 28-wide item, and 12 the hysteresis.
const atFloor = { ...base, availableWidth: 412 - 12 };
const withItem = { ...controls, pluginItemsWidth: 28 };
expect(resolveComposerControlDensity({ ...atFloor, controls: withItem })).toBe("full");
expect(
resolveComposerControlDensity({
...atFloor,
currentDensity: "condensed",
controls: withItem,
}),
).toBe("condensed");
});
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }),
).toBe("full");
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }),
).toBe("condensed");
expect(
resolveComposerControlDensity({
...base,
availableWidth: 250,
controls: { ...base.controls, pluginItems: 2 },
}),
).toBe("tight");
});
it("gives every toolbar control one shell and one glyph per role and control sizing", () => {
@@ -257,28 +230,6 @@ describe("composer control layout", () => {
});
});
it("budgets an icon-only select as the icon it draws", () => {
// Codex's service tier: a select whose toolbar trigger is an icon. 476 with its icon, 543 with
// the label "Priority", against a 502-wide toolbar.
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "select" as const, label: null }, { type: "toggle" as const }],
fontScale: 1,
pluginItemsWidth: 28,
};
expect(
resolveComposerControlDensity({
availableWidth: 502,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("full");
});
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
const input = {
availableWidth: 400,
@@ -289,7 +240,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
pluginItemsWidth: 0,
pluginItems: 0,
},
};
@@ -306,7 +257,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [{ type: "toggle" as const }, { type: "toggle" as const }],
fontScale: 1,
pluginItemsWidth: 0,
pluginItems: 0,
};
it("keeps the model family label with icon-only controls on an empty draft", () => {
@@ -365,7 +316,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [],
fontScale: 1,
pluginItemsWidth: 0,
pluginItems: 0,
},
};
@@ -26,15 +26,12 @@ export interface ComposerControlPresence {
hasThinking: boolean;
hasMode: boolean;
features: readonly ComposerFeatureControlPresence[];
/** The plugin items' measured width in the toolbar row; 0 when none of them renders anything. */
pluginItemsWidth: number;
/** Plugin composer items. Each is budgeted like a select with a short label. */
pluginItems: number;
fontScale: number;
}
export type ComposerFeatureControlPresence =
| { type: "toggle" }
/** `label` is what the toolbar trigger draws; null for an icon-only trigger. */
| { type: "select"; label: string | null };
export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string };
export interface ComposerControlPresentation {
showCarets: boolean;
@@ -77,6 +74,7 @@ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarG
}
const DENSITY_HYSTERESIS = 12;
const PLUGIN_ITEM_LABEL = "Plugin item";
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
@@ -105,7 +103,7 @@ function resolveFeatureControlWidth(
fontScale: number,
geometry: ComposerToolbarGeometry,
): number {
if (feature.type === "toggle" || feature.label === null) return geometry.controlSize;
if (feature.type === "toggle") return geometry.controlSize;
return (
geometry.controlSize +
geometry.iconLabelGap +
@@ -124,7 +122,7 @@ function resolveCondensedFloor(
if (controls.hasModel) widths.push(chrome + 60 * fontScale);
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(chrome + 96 * fontScale);
if (controls.features.length > 0 || controls.pluginItemsWidth > 0) {
if (controls.features.length > 0 || controls.pluginItems > 0) {
widths.push(geometry.controlSize);
}
return sumControlWidths(widths, geometry);
@@ -144,7 +142,11 @@ function resolveFullFloor(
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
}
if (controls.pluginItemsWidth > 0) widths.push(controls.pluginItemsWidth);
for (let index = 0; index < controls.pluginItems; index++) {
widths.push(
resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale, geometry),
);
}
return sumControlWidths(widths, geometry);
}
@@ -160,7 +162,7 @@ function resolveTightFloor(
}
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(geometry.controlSize);
if (controls.features.length > 0 || controls.pluginItemsWidth > 0) {
if (controls.features.length > 0 || controls.pluginItems > 0) {
widths.push(geometry.controlSize);
}
return sumControlWidths(widths, geometry);
@@ -1,82 +0,0 @@
import { useCallback, useState } from "react";
import { type LayoutChangeEvent, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { TOOLBAR_CONTROL_GAP } from "@/components/ui/control-geometry";
import { isWeb } from "@/constants/platform";
import { PluginComposerItems } from "@/plugins/composer-items";
/** A retained tab under `display: none` reports 0 by 0; see `styles.slot` for why 0 wide is real. */
function readSlotWidth(event: LayoutChangeEvent): number | null {
const { width, height } = event.nativeEvent.layout;
return height > 0 ? width : null;
}
/** What the plugin items take up in the toolbar row, in points, as `PluginToolbarSlot` reports it. */
export function usePluginToolbarWidth(): {
width: number;
onLayout: (event: LayoutChangeEvent) => void;
} {
const [width, setWidth] = useState(0);
const onLayout = useCallback((event: LayoutChangeEvent) => {
const next = readSlotWidth(event);
if (next !== null) setWidth(next);
}, []);
return { width, onLayout };
}
/**
* The plugin items in their toolbar form, measured whether or not the toolbar shows them. An item
* is an arbitrary component, so its width is known only from rendering it, and it is always the
* toolbar form that is measured: the width never depends on the density it decides, so moving the
* items to the overflow sheet cannot change the answer. Out of the row, the same node stays
* mounted and measured, unseen and out of reach, and inactive so an open picker closes with it.
*/
export function PluginToolbarSlot({
serverId,
width,
fits,
onLayout,
}: {
serverId: string | null;
width: number;
/** Whether the toolbar has room for the items at the width they measured. */
fits: boolean;
onLayout: (event: LayoutChangeEvent) => void;
}) {
// An empty slot stays out of the row, or the row's gap would reserve room for nothing.
const inline = fits && width > 0;
return (
<View
style={[styles.slot, !inline && styles.measuring]}
onLayout={onLayout}
testID="plugin-toolbar-items"
accessibilityElementsHidden={!inline}
importantForAccessibility={inline ? "auto" : "no-hide-descendants"}
aria-hidden={!inline}
>
<RetainedPanelActivity active={inline}>
<PluginComposerItems serverId={serverId} surface="toolbar" />
</RetainedPanelActivity>
</View>
);
}
const styles = StyleSheet.create({
slot: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: TOOLBAR_CONTROL_GAP,
// Keeps a slot whose items render nothing distinguishable from one that is not rendered.
minHeight: 1,
},
measuring: {
position: "absolute",
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
...(isWeb ? ({ visibility: "hidden" } as const) : null),
},
});
@@ -26,7 +26,7 @@ function useServerPlugins(serverId: string | null): InstalledPlugin[] {
);
}
/** How many composer items the host's plugins registered, whether or not each renders anything. */
/** How many composer items the host's plugins registered; the toolbar budgets room for them. */
export function usePluginComposerItemCount(serverId: string | null): number {
const plugins = useServerPlugins(serverId);
return plugins.reduce((count, plugin) => count + plugin.composerItems.length, 0);
@@ -164,7 +164,11 @@ import {
WorkspaceExplorerSidebarToggle,
WorkspaceHeaderExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { useHasWindowChromeObstruction } from "@/utils/desktop-window";
import {
useHasWindowChromeObstruction,
WindowChromeRegion,
WindowChromeSafeArea,
} from "@/utils/desktop-window";
import {
resolveWorkspaceHeaderRenderState,
type WorkspaceHeaderCheckoutState,
@@ -1029,6 +1033,23 @@ function useLastMainPane(input: {
return lastMainPaneRef;
}
/** The docked explorer beside the center column takes the top-right window-chrome corner. */
function DesktopCenterColumnChrome({
explorerOpen,
focusModeEnabled,
children,
}: {
explorerOpen: boolean;
focusModeEnabled: boolean;
children: ReactNode;
}) {
return (
<WindowChromeRegion corners={explorerOpen && !focusModeEnabled ? "top-left" : "both"}>
<View style={styles.centerColumn}>{children}</View>
</WindowChromeRegion>
);
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -3516,6 +3537,11 @@ function WorkspaceScreenContent({
},
};
const compactWorkspaceHeader = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs);
// SplitContainer reserves the corner around its own compact headers; this is the column's.
const centerColumnCompactHeader = useMemo(
() => <WindowChromeSafeArea placement="inline">{compactWorkspaceHeader}</WindowChromeSafeArea>,
[compactWorkspaceHeader],
);
const fullWorkspaceActions = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs, true);
const desktopSplitContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
@@ -3639,9 +3665,12 @@ function WorkspaceScreenContent({
<SheetTabs onCloseTab={handleCloseTabById} />
</View>
) : (
<View style={styles.centerColumn}>
<DesktopCenterColumnChrome
explorerOpen={isExplorerSidebarShowing}
focusModeEnabled={desktopFocusModeEnabled}
>
{rendersDesktopSplitContent ? null : (
<AdaptiveWorkspaceChrome compactHeader={compactWorkspaceHeader}>
<AdaptiveWorkspaceChrome compactHeader={centerColumnCompactHeader}>
{fullWorkspaceActions}
{renderWorkspaceScreenHeader()}
@@ -3678,7 +3707,7 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
<View style={styles.content}>{desktopContent}</View>
</View>
</View>
</DesktopCenterColumnChrome>
);
const [workspaceViewportWidth, setWorkspaceViewportWidth] = useState(0);
+38 -7
View File
@@ -9,30 +9,61 @@ import {
describe("window chrome", () => {
it("has no corner obstruction outside Electron or in fullscreen", () => {
expect(resolveWindowChromeObstruction({ mode: null, isFullscreen: false })).toEqual({
expect(
resolveWindowChromeObstruction({ mode: null, isFullscreen: false, topBand: null }),
).toEqual({
topLeft: null,
topRight: null,
});
expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true })).toEqual({
expect(
resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true, topBand: null }),
).toEqual({
topLeft: null,
topRight: null,
});
});
it("places native controls in their physical top corner", () => {
expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false })).toEqual({
expect(
resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false, topBand: null }),
).toEqual({
topLeft: { width: 78, height: 45 },
topRight: null,
});
expect(resolveWindowChromeObstruction({ mode: "custom-windows", isFullscreen: false })).toEqual(
{ topLeft: null, topRight: { width: 138, height: 36 } },
);
expect(resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false })).toEqual({
expect(
resolveWindowChromeObstruction({
mode: "custom-windows",
isFullscreen: false,
topBand: null,
}),
).toEqual({ topLeft: null, topRight: { width: 138, height: 36 } });
expect(
resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false, topBand: null }),
).toEqual({
topLeft: null,
topRight: { width: 108, height: 36 },
});
});
// Measured: iPhone Duo inner screen, portrait. The top bar ends at x 525, where the status
// region (0..82) starts; the band starts at y 26.
it("treats the status region beside a native top band as top-right window controls", () => {
expect(
resolveWindowChromeObstruction({
mode: null,
isFullscreen: false,
topBand: { top: 26, trailingObstruction: { width: 144, height: 56 } },
}),
).toEqual({ topLeft: null, topRight: { width: 144, height: 56 } });
expect(
resolveWindowChromeObstruction({
mode: null,
isFullscreen: false,
topBand: { top: 26, trailingObstruction: null },
}),
).toEqual({ topLeft: null, topRight: null });
});
it("insets and reserves only claimed corners", () => {
const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } };
expect(
+9 -1
View File
@@ -12,6 +12,7 @@ import {
getDesktopWindowControlsWidth,
} from "@/desktop/window-chrome-presentation";
import { isNative } from "@/constants/platform";
import { useTopBand, type TopBand } from "@/window-layout";
export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both";
type WindowChromeSafeAreaPlacement = "inline" | "below";
@@ -105,10 +106,15 @@ export function removeWindowChromeCorner(
return corner === "top-left" ? "top-right" : "top-left";
}
/** Desktop window controls, or the system region a native top band gives way to. */
export function resolveWindowChromeObstruction(input: {
mode: DesktopWindowChromeMode | null;
isFullscreen: boolean;
topBand: TopBand | null;
}): WindowChromeObstruction {
if (input.topBand?.trailingObstruction) {
return { topLeft: null, topRight: input.topBand.trailingObstruction };
}
if (!input.mode || input.isFullscreen) return EMPTY_OBSTRUCTION;
if (input.mode === "native-mac") {
return {
@@ -217,13 +223,15 @@ export function WindowChromeProvider({ children }: { children: ReactNode }) {
};
}, []);
const topBand = useTopBand();
const obstruction = useMemo(
() =>
resolveWindowChromeObstruction({
mode: isElectronReady ? getDesktopWindowChromeMode() : null,
isFullscreen: windowState.isFullscreen,
topBand,
}),
[isElectronReady, windowState.isFullscreen],
[isElectronReady, windowState.isFullscreen, topBand],
);
const desktopWindowChrome = useMemo(
() => ({
+9 -1
View File
@@ -1,9 +1,12 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { WindowLayout } from "./model";
import type { TopBand, WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
export const TopBandContext = createContext<TopBand | null>(null);
export function useWindowLayout(): WindowLayout | null {
const layout = useContext(WindowLayoutContext);
const { width, height } = useWindowDimensions();
@@ -13,3 +16,8 @@ export function useWindowLayout(): WindowLayout | null {
? layout
: null;
}
/** The top band beside the system's status region, or null where the window has none. */
export function useTopBand(): TopBand | null {
return useContext(TopBandContext);
}
+17 -5
View File
@@ -2,18 +2,19 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
const GeometryProbe = requireNativeViewManager<
ViewProps & {
@@ -41,12 +42,23 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
);
}
/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */
/**
* The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and
* the top band that `WindowChromeProvider` reads through `useTopBand()`.
*/
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
const systemInsets = useSafeAreaInsets();
const layout = useWindowLayout();
const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]);
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
const topBand = useMemo(
() => resolveTopBand(systemInsets.top, layout),
[systemInsets.top, layout],
);
return (
<TopBandContext.Provider value={topBand}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</TopBandContext.Provider>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+2 -1
View File
@@ -3,12 +3,13 @@ import type { ReactNode } from "react";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
return children;
+126 -6
View File
@@ -1,35 +1,155 @@
import { describe, expect, it } from "vitest";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import {
resolveSafeAreaInsets,
resolveTopBand,
type WindowLayout,
type WindowLayoutRect,
} from "./model";
const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 };
function layout(bottomClearance: number | null | undefined): WindowLayout {
function layout(input: Partial<WindowLayout> = {}): WindowLayout {
return {
bounds: { x: 0, y: 0, width: 402, height: 874 },
verticalBarEdge: "none",
verticalBar: null,
bottomClearance,
divisions: [],
...input,
};
}
describe("resolveSafeAreaInsets", () => {
it("replaces only the bottom with the measured bar clearance", () => {
expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 24 }))).toEqual({
...systemInsets,
bottom: 24,
});
});
it("keeps a measured zero instead of falling back to the system inset", () => {
expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0);
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 0 })).bottom).toBe(0);
});
it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => {
expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets);
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: value }))).toBe(
systemInsets,
);
});
it("leaves system insets alone before the native layout arrives", () => {
expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets);
});
it("starts the top where the top bar rests when it sits above the system top", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 12, width: 402, height: 44 } }),
);
expect(insets).toEqual({ ...systemInsets, top: 12 });
});
it("keeps the system top when the top bar rests at or below it", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 59, width: 402, height: 44 } }),
);
expect(insets).toBe(systemInsets);
});
});
// Measured: iPhone Duo inner screen, portrait (669×951), iOS 27.1 simulator unfolded in
// DeviceHub.
const duo = {
systemTop: 82,
topBar: { x: 16, y: 26, width: 509, height: 44 },
statusRegion: { x: 525, y: 0, width: 144, height: 82 },
};
function duoLayout(occlusions: WindowLayoutRect[]): WindowLayout {
return layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: duo.topBar,
occlusions,
});
}
describe("resolveTopBand", () => {
it("publishes the top bar's start and the status region past its trailing end", () => {
expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({
top: 26,
trailingObstruction: { width: 144, height: 56 },
});
});
it("reaches as low as the deepest occlusion beside the bar", () => {
const deeper = { x: 600, y: 0, width: 69, height: 96 };
expect(
resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion, deeper]))?.trailingObstruction,
).toEqual({ width: 144, height: 70 });
});
it("rounds a fractional obstruction up so controls never touch the status region", () => {
const fractional = layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: { x: 16, y: 26, width: 509.25, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 143.75, height: 82.5 }],
});
expect(resolveTopBand(duo.systemTop, fractional)?.trailingObstruction).toEqual({
width: 144,
height: 57,
});
});
it("has no trailing obstruction when the bar reaches the window's trailing edge", () => {
const fullWidth = layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: { x: 0, y: 26, width: 669, height: 44 },
});
expect(resolveTopBand(duo.systemTop, fullWidth)).toEqual({
top: 26,
trailingObstruction: null,
});
});
it("ignores occlusions without a top bar guide (before iOS 27.1 or unresolved)", () => {
const withoutGuide = layout({ topBar: null, occlusions: [duo.statusRegion] });
expect(resolveTopBand(duo.systemTop, withoutGuide)).toBeNull();
expect(resolveTopBand(duo.systemTop, layout({ occlusions: [duo.statusRegion] }))).toBeNull();
});
it("ignores occlusions when the top bar guide sits below the status region", () => {
const below = layout({
topBar: { x: 16, y: 82, width: 637, height: 44 },
occlusions: [duo.statusRegion],
});
expect(resolveTopBand(duo.systemTop, below)).toBeNull();
});
// Measured: iPhone 17 Pro, iOS 27.2 simulator, portrait.
it("is null on a regular iPhone, whose top bar starts at the status bar's bottom", () => {
const iphone = layout({
topBar: { x: 18, y: 62, width: 366, height: 44 },
occlusions: [{ x: 0, y: 0, width: 402, height: 54 }],
});
expect(resolveTopBand(62, iphone)).toBeNull();
});
// Measured: iPhone Duo outer screen, iOS 27.1 simulator. The status region is a trailing
// column, which the vertical bar layout already owns.
it("is null on the Duo outer screen, whose top bar starts below a zero system top", () => {
const outer = layout({
bounds: { x: 0, y: 0, width: 466, height: 678 },
verticalBarEdge: "trailing",
topBar: { x: 7 / 3, y: 26, width: 1139 / 3, height: 44 },
occlusions: [
{ x: 1199 / 3, y: 88 / 3, width: 37, height: 37 },
{ x: 382, y: 0, width: 84, height: 174 },
],
});
expect(resolveTopBand(0, outer)).toBeNull();
});
it("is null before the native layout arrives", () => {
expect(resolveTopBand(duo.systemTop, null)).toBeNull();
});
});
+43 -5
View File
@@ -14,18 +14,56 @@ export interface WindowLayout {
* the system has no bottom bar guide. Zero is a measured value.
*/
bottomClearance?: number | null;
/** Where a top bar rests, or null until the system resolves its top bar guide. */
topBar?: WindowLayoutRect | null;
/** Active system occlusions (status region, camera, Live Activities), margins included. */
occlusions?: WindowLayoutRect[];
divisions: WindowLayoutRect[];
}
/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */
export function resolveSafeAreaInsets<Insets extends { bottom: number }>(
/**
* The band above the rectangular safe area that a top bar may use because the system
* occupies only its trailing end, like window controls on a desktop title bar.
*/
export interface TopBand {
/** Where the top bar starts, in window points. */
top: number;
/**
* The system region past the bar's trailing end, measured from the band's top, or null
* when the bar reaches the window's trailing edge.
*/
trailingObstruction: { width: number; height: number } | null;
}
/** Null unless the top bar guide starts above the system's rectangular safe area. */
export function resolveTopBand(systemTop: number, layout: WindowLayout | null): TopBand | null {
const bar = layout?.topBar;
if (!layout || !bar || bar.y >= systemTop) return null;
const top = Math.max(0, bar.y);
const barEnd = bar.x + bar.width;
const width = Math.ceil(layout.bounds.width - barEnd);
if (width <= 0) return { top, trailingObstruction: null };
const beside = (layout.occlusions ?? []).filter(
(frame) => frame.x + frame.width > barEnd && frame.y < bar.y + bar.height,
);
const bottom = Math.max(bar.y + bar.height, ...beside.map((frame) => frame.y + frame.height));
return { top, trailingObstruction: { width, height: Math.ceil(bottom - top) } };
}
/**
* The window's measured bars replace the rectangular safe area: the bottom bar's clearance,
* and the top bar's start when it sits above the system top.
*/
export function resolveSafeAreaInsets<Insets extends { top: number; bottom: number }>(
systemInsets: Insets,
layout: WindowLayout | null,
): Insets {
const clearance = layout?.bottomClearance;
return typeof clearance === "number" && clearance !== systemInsets.bottom
? { ...systemInsets, bottom: clearance }
: systemInsets;
const bottom = typeof clearance === "number" ? clearance : systemInsets.bottom;
const top = resolveTopBand(systemInsets.top, layout)?.top ?? systemInsets.top;
return top === systemInsets.top && bottom === systemInsets.bottom
? systemInsets
: { ...systemInsets, top, bottom };
}
export interface DesktopDivisionLayout {
+3 -4
View File
@@ -1984,10 +1984,9 @@ controls render disabled, `setPrompt` and `insertText` throw, and the agent comm
"The composer is locked while the agent is being created." `composer.agent.options` and
`composer.agent.metadata` are frozen copies; change them through `setOptions` and `setMetadata`.
Paseo measures what an item renders in the toolbar to decide what fits, so an item that renders
nothing takes no room and adds no overflow button. On wide composers the item stays mounted while
it is in the overflow, and the overflow sheet mounts a second instance while it is open, so keep
effects safe to run twice. On a phone an item mounts only while the overflow sheet is open.
A composer item counts toward how much fits before Paseo knows what it renders. An item that
renders nothing can still leave an empty overflow sheet on a narrow composer, so always render a
control.
The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)
registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`, plus an