mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f545fed4 |
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user