Compare commits

..
Author SHA1 Message Date
Mohamed BoudraandElsa Granger 5bf7f8b7e7 fix(app): keep the header in the top band whichever sidebar is open
With the left sidebar pinned open on the iPhone Duo inner screen, the
workspace header dropped below the status region, so it jumped 56pt
between sidebar states. Headers now always trim their trailing side to
the status region and let the title give up width instead.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-06 15:19:10 +01:00
Mohamed BoudraandElsa Granger 142b2d2bc4 fix(app): keep explorer tab labels whole beside the Duo status region
Measured the iPhone Duo inner screen in portrait (669×951, iOS 27.1
simulator unfolded in DeviceHub): system top 82, top bar guide
{16, 26, 509, 44}, status region occlusion {525, 0, 144, 82}. The tests
now use those values instead of estimates.

On that screen the explorer tab rail, trimmed to the status region,
cut the Changes tab down to "C". The rail now starts below an occlusion
it meets, like toasts; the workspace pane keeps the band.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-06 10:14:37 +01:00
Mohamed BoudraandElsa Granger 7bafd0fdad fix(app): use the top band beside the iPhone Duo status region
On iOS 27.1+, the window layout probe publishes the top bar guide frame
and active occlusion rects. When the top bar starts above the system top
inset, the safe-area top moves up to it, and each header row that lands
in the band trims its trailing padding to the status region. A row
whose controls cannot fit beside it starts below the region on its own.

When the guide is missing or starts at or below the system top, the
override is a no-op, which is the case on every iPhone and on the Duo
outer screen.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-06 05:10:28 +01:00
60 changed files with 707 additions and 1652 deletions
-6
View File
@@ -136,12 +136,6 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
This is informational ancestry for future workspace grouping. It does not change placement,
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
never inferred from directories, projects, or Git branches.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
+1 -1
View File
@@ -68,7 +68,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
+2 -3
View File
@@ -226,8 +226,7 @@ its own.
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
@@ -1,127 +0,0 @@
import { expect, test } from "../support/fixtures";
import { addProjectFlow } from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
chooseFooterOverflowAction,
expectFooterLine,
expectFooterMenusInsideViewport,
expectFooterNeverClipped,
expectFooterOverflowActions,
footerOverflowMenu,
openFooterOverflow,
seedDockedSidebarWidth,
watchFooterClipping,
} from "../support/helpers/sidebar-footer-overflow";
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
const TOUCH_CONTROL = 44;
const MIN_SIDEBAR_WIDTH = 200;
const ALL_INLINE = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
];
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
const NARROW_TOUCH_LINE = [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-footer-overflow",
"sidebar-settings",
];
test.describe("Sidebar footer on a touch screen", () => {
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
page,
}) => {
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
await watchFooterClipping(page);
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await test.step("Help opens as a page of the overflow menu", async () => {
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await chooseFooterOverflowAction(page, "Help and support");
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(footerOverflowMenu(page)).toBeHidden();
});
await test.step("Usage opens from the overflow menu", async () => {
await openFooterOverflow(page);
await chooseFooterOverflowAction(page, "Usage");
await expect(footerOverflowMenu(page)).toBeHidden();
await expect(page.getByTestId("usage-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("usage-modal")).toBeHidden();
});
await test.step("Add project and Settings shortcuts still work", async () => {
await page.keyboard.press("Control+o");
await expect(addProjectFlow(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.keyboard.press("Control+,");
await expect(page).toHaveURL(/\/settings\/general$/);
});
});
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
});
});
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
test("the overflow menu reaches the hidden actions", async ({ page }) => {
await watchFooterClipping(page);
await gotoAppShell(page);
await openCompactSidebar(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await expectFooterMenusInsideViewport(page);
});
});
test.describe("Sidebar footer on a short, narrow phone", () => {
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostsAndReload(
page,
Array.from({ length: 10 }, (_, index) => ({
serverId: `overflow-host-${index}`,
label: `Overflow host ${index}`,
})),
);
await openCompactSidebar(page);
await expectFooterLine(page, {
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
controlSize: TOUCH_CONTROL,
});
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
await expectFooterMenusInsideViewport(page);
await chooseFooterOverflowAction(page, "Hosts");
await expectFooterMenusInsideViewport(page);
const addHost = page.getByRole("menuitem", { name: "Add host" });
await addHost.scrollIntoViewIfNeeded();
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
await expect(addHost).toBeInViewport();
await addHost.click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
});
});
@@ -8,7 +8,7 @@ import {
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
import { openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterItemSetting,
expectFooterSeparator,
@@ -42,7 +42,6 @@ import {
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildOpenProjectRoute } from "@/utils/host-routes";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -71,112 +70,7 @@ async function footerClip(page: Page) {
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
}
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
test.describe("Usage item", () => {
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
let usedPct = 31;
const usage = await installUsageReportsFixture(page, {
lists: [
() => [
{
...claudeAndCodexReports()[0]!,
report: {
status: "available",
windows: [
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
],
},
},
],
],
});
await page.clock.install();
await page.setViewportSize(WIDE);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h"]);
const startingUrl = page.url();
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
const requestsBeforeRefresh = usage.listRequests();
usedPct = 42;
await page.clock.fastForward(5 * 60_000);
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
await expectPinnedUsage(page, ["42% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await expect(page).toHaveURL(startingUrl);
expect(usage.listRequests()).toEqual([
...requestsBeforeRefresh,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await setPageVisibility(page, "hidden");
const requestsBeforeBackgrounding = usage.listRequests();
usedPct = 47;
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
await expectPinnedUsage(page, ["42% 5h"]);
await setPageVisibility(page, "visible");
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["47% 5h"]);
expect(usage.listRequests()).toEqual([
...requestsBeforeBackgrounding,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await page.setViewportSize(COMPACT);
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeHiding = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
await openCompactSidebar(page);
await expectPinnedUsage(page, ["47% 5h"]);
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
usedPct = 53;
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["53% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await page.setViewportSize(WIDE);
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectNoUsageItem(page);
const requestsBeforeDisabling = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
});
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.clock.install();
await page.setViewportSize(COMPACT);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsHostSection(page, getServerId(), "usage");
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeWaiting = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
});
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
await page.setViewportSize(COMPACT);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
+11 -25
View File
@@ -22,27 +22,15 @@ export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
export async function addOfflineHostsAndReload(
page: Page,
hosts: Array<{ serverId: string; label: string }>,
options: { primaryLabel?: string } = {},
): Promise<void> {
const nowIso = new Date().toISOString();
const offlineHosts = hosts.map((host) =>
buildSeededHost({
serverId: host.serverId,
label: host.label,
endpoint: "127.0.0.1:59999",
nowIso,
}),
);
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ hosts: added, keys, primaryLabel }) => {
({ host, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
@@ -52,22 +40,20 @@ export async function addOfflineHostsAndReload(
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
for (const host of added) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: offlineHosts,
host: offlineHost,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: options.primaryLabel,
primaryLabel: input.primaryLabel,
},
);
@@ -1,127 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** Every footer control that can sit in the icon line, caret included. */
const FOOTER_LINE_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-footer-overflow",
"sidebar-settings",
];
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
const GEOMETRY_TOLERANCE = 0.01;
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
await page.addInitScript((sidebarWidth) => {
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
}, width);
}
/**
* From the first frame, record any frame in which a footer button reaches past its row, so a row
* laid out before it measured shows up even if it lasted one frame.
*/
export async function watchFooterClipping(page: Page): Promise<void> {
await page.addInitScript((testIDs) => {
const clippedFrames: string[] = [];
Object.assign(window, { __footerClippedFrames: clippedFrames });
const sample = () => {
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
const rowRect = row.getBoundingClientRect();
if (rowRect.width === 0) continue;
for (const testID of testIDs) {
const button = row.querySelector(`[data-testid="${testID}"]`);
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
clippedFrames.push(testID);
}
}
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, FOOTER_LINE_TEST_IDS);
}
export async function expectFooterNeverClipped(page: Page): Promise<void> {
const clipped = await page.evaluate(
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
);
expect(clipped).toEqual([]);
}
function footerLine(page: Page): Locator {
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
}
export function footerOverflowMenu(page: Page): Locator {
return page.getByTestId("sidebar-footer-overflow-menu");
}
/**
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
* line's box, so no button is cut off by the sidebar's edge.
*/
export async function expectFooterLine(
page: Page,
input: { testIDs: string[]; controlSize: number },
): Promise<void> {
const line = footerLine(page);
await expect(line).toHaveCount(1);
await expect
.poll(async () =>
line
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
)
.toEqual(input.testIDs);
const { row, buttons } = await line.evaluate((element, testIDs) => {
const rect = (node: Element) => {
const { x, width, height } = node.getBoundingClientRect();
return { x, width, height };
};
return {
row: rect(element),
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
};
}, input.testIDs);
for (const button of buttons) {
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
}
}
export async function openFooterOverflow(page: Page): Promise<void> {
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
await expect(footerOverflowMenu(page)).toBeVisible();
}
/** The overflow menu lists exactly these actions, in row order. */
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
}
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
}
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
const viewport = page.viewportSize()!;
const rows = page.getByRole("menuitem");
await expect(rows.first()).toBeVisible();
const boxes = await rows.evaluateAll((elements) =>
elements.map((element) => {
const { x, width } = element.getBoundingClientRect();
return { x, width };
}),
);
for (const box of boxes) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
}
}
@@ -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 }
@@ -13,11 +13,14 @@ import {
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
import { useTopBandRow } from "@/window-layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const NO_SHORTCUT: ShortcutKey[] = [];
const ROW_PADDING = SPACING[3];
interface CompactHeaderProps {
/** The leading island: the sidebar menu, or a back button. */
@@ -51,21 +54,29 @@ export function CompactHeader({
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
// The trailing side gives way to the occlusion, so it needs no padding of its own.
const topBand = useTopBandRow({
offset: insets.top + HEADER_TOP_PADDING_MOBILE,
height: HEADER_INNER_HEIGHT_MOBILE,
// The header stays in the band in every sidebar state; the title gives up width instead.
minimumWidth: 0,
});
const rootStyle = useMemo(
() => [
styles.root,
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE + topBand.top,
paddingRight: Math.max(ROW_PADDING, topBand.trailing),
marginLeft: insets.left,
marginRight: insets.right,
},
],
[insets.top, insets.left, insets.right],
[insets.top, insets.left, insets.right, topBand.top, topBand.trailing],
);
return (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
@@ -140,7 +151,7 @@ function BackButton({ onBack }: { onBack?: () => void }) {
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[3],
paddingLeft: ROW_PADDING,
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
@@ -12,6 +12,7 @@ import {
} from "@/constants/layout";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useTopBandRow } from "@/window-layout";
interface ScreenHeaderProps {
left?: ReactNode;
@@ -41,17 +42,33 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3];
const topBand = useTopBandRow({
offset: insets.top + topPadding,
height: isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT,
// The header stays in the band in every sidebar state; the title gives up width instead.
minimumWidth: 0,
});
const innerStyle = useMemo(
() => [styles.inner, { paddingTop: insets.top + topPadding }],
[insets.top, topPadding],
() => [styles.inner, { paddingTop: insets.top + topPadding + topBand.top }],
[insets.top, topPadding, topBand.top],
);
const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]);
const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]);
const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
// The occlusion replaces the row's trailing padding rather than adding to it.
const rightCombinedStyle = useMemo(
() => [
styles.right,
rightStyle,
topBand.trailing > baseHorizontalPadding && {
marginRight: topBand.trailing - baseHorizontalPadding,
},
],
[rightStyle, topBand.trailing, baseHorizontalPadding],
);
return (
<ComposerDockBackground style={styles.header} testID="composer-dock-header">
<View style={innerStyle}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={innerStyle}>
<WindowChromeSafeArea
placement="inline"
horizontalPadding={baseHorizontalPadding}
+45 -89
View File
@@ -17,7 +17,6 @@ import {
Text,
useWindowDimensions,
View,
type LayoutChangeEvent,
} from "react-native";
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
@@ -30,7 +29,7 @@ import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import { useWindowLayout, resolveDesktopDivisionLayout, useTopBandRow } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -41,23 +40,13 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarFooterOverflowMenu } from "@/components/sidebar/sidebar-footer-overflow-menu";
import {
resolveSidebarFooterLayout,
SIDEBAR_FOOTER_ACTIONS,
type SidebarFooterAction,
} from "@/components/sidebar-footer-layout";
import {
controlSizingForLayout,
mapLayout,
TOOLBAR_CONTROL_SIZES,
} from "@/components/ui/control-geometry";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { isNative as platformIsNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
@@ -67,7 +56,6 @@ import {
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import { SPACING } from "@/styles/theme";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -92,9 +80,6 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
/** The footer icon line's inset; the overflow layout counts it as space the buttons can't use. */
const FOOTER_ROW_PADDING = SPACING[2];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
interface SidebarSharedProps {
@@ -426,72 +411,35 @@ function SidebarFooter({
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const isCompactLayout = useIsCompactFormFactor();
const [rowWidth, setRowWidth] = useState(0);
const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
setRowWidth(event.nativeEvent.layout.width);
}, []);
const { inline, overflow } = useMemo(
() =>
resolveSidebarFooterLayout({
rowWidth,
controlSize: TOOLBAR_CONTROL_SIZES[controlSizingForLayout(isCompactLayout)],
rowPadding: FOOTER_ROW_PADDING,
actions: SIDEBAR_FOOTER_ACTIONS,
}),
[rowWidth, isCompactLayout],
);
const isInline = (action: SidebarFooterAction) => inline.includes(action);
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. Actions that
// don't fit move behind a caret before Settings.
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
return (
<UsageSidebarRoot>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View
style={styles.sidebarFooter}
onLayout={handleRowLayout}
testID="sidebar-footer-bottom-line"
>
{isInline("add-project") ? (
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
/>
) : null}
{isInline("usage") ? <SidebarUsageIcon label={labels.usage} /> : null}
{isInline("hosts") ? (
<SidebarHostPicker
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
) : null}
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
/>
<SidebarUsageIcon label={labels.usage} />
<SidebarHostPicker
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View style={styles.footerSpacer} />
{isInline("help") ? <SidebarHelpMenu /> : null}
{overflow.length > 0 ? (
<SidebarFooterOverflowMenu
actions={overflow}
labels={labels}
onAddProject={handleOpenProject}
onSettings={handleSettings}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
) : null}
{isInline("settings") ? (
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
) : null}
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
</View>
</View>
</UsageSidebarRoot>
@@ -580,6 +528,14 @@ function MobileSidebar({
closeSidebar();
}, [closeSidebar]);
// The first nav row carries the close button; the panel slides in, so re-measure once settled.
const navBand = useTopBandRow({
offset: theme.spacing[2],
height: TOUCH_TARGET,
minimumWidth: TOUCH_TARGET * 2,
settled: active,
});
const navBandStyle = useMemo(() => ({ paddingTop: navBand.top }), [navBand.top]);
const closeButton = useMemo(
() => (
<SidebarCloseButton
@@ -587,10 +543,10 @@ function MobileSidebar({
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton(insets.right)}
style={styles.mobileCloseButton(insets.right, navBand.trailing)}
/>
),
[labels.closeSidebar, closeSidebar, insets.right],
[labels.closeSidebar, closeSidebar, insets.right, navBand.trailing],
);
const mobileSidebarInsetStyle = useMemo(
@@ -603,11 +559,13 @@ function MobileSidebar({
);
const navRows = (
<SidebarNavRows
style={styles.mobileNavRowsGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
<View ref={navBand.ref} onLayout={navBand.onLayout} style={navBandStyle}>
<SidebarNavRows
style={styles.mobileNavRowsGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
</View>
);
return (
@@ -947,8 +905,8 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
mobileCloseButton: (rightInset: number) => ({
marginRight: theme.spacing[2] + rightInset,
mobileCloseButton: (rightInset: number, topBandTrailing: number) => ({
marginRight: Math.max(theme.spacing[2] + rightInset, topBandTrailing),
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
@@ -990,12 +948,10 @@ const styles = StyleSheet.create((theme) => ({
borderTopColor: theme.colors.border,
},
// Buttons sit edge to edge; their own inset spaces the glyphs.
// As tall as its buttons from the first frame, which places none until the row has measured.
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
paddingHorizontal: FOOTER_ROW_PADDING,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
},
// Pushes Help and Settings to the end of the icon line.
@@ -1,42 +0,0 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { within, waitFor } from "@testing-library/dom";
import { EnrichedMarkdownText } from "react-native-enriched-markdown";
import { expect, it } from "vitest";
import { normalizeMarkdownHtml } from "./html";
const FLAGS = { hardSoftBreaks: true, latexMath: false };
it.each([false, true])(
"renders table-cell breaks on separate lines (details: %s)",
async (details) => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const table =
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
const source = details ? `<details><summary>Results</summary>\n${table}\n</details>` : table;
try {
act(() =>
root.render(
<EnrichedMarkdownText markdown={normalizeMarkdownHtml(source)} md4cFlags={FLAGS} />,
),
);
await waitFor(() => expect(within(container).getAllByRole("row")).toHaveLength(4));
for (const row of within(container).getAllByRole("row").slice(1)) {
const cell = within(row).getAllByRole("cell")[1];
expect(cell.innerText).toBe("first\nsecond");
const first = document.createRange();
first.selectNodeContents(cell.firstChild!);
const second = document.createRange();
second.selectNodeContents(cell.lastChild!);
expect(second.getBoundingClientRect().top).toBeGreaterThan(
first.getBoundingClientRect().top,
);
}
} finally {
act(() => root.unmount());
container.remove();
}
},
);
+7 -25
View File
@@ -1,7 +1,5 @@
import { Parser } from "htmlparser2";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmTable } from "micromark-extension-gfm-table";
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
import { decodeString } from "micromark-util-decode-string";
import type { Nodes } from "mdast";
@@ -36,35 +34,25 @@ const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "
export function normalizeMarkdownHtml(source: string): string {
if (!source.includes("<")) return source;
const htmlRanges: { start: number; end: number }[] = [];
const tableRanges: { start: number; end: number }[] = [];
function findHtml(node: Nodes) {
if (node.type === "html" && node.position) {
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
} else if ("children" in node) node.children.forEach(findHtml);
}
findHtml(
fromMarkdown(source, {
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}),
);
findHtml(fromMarkdown(source));
const codeRanges: { start: number; end: number }[] = [];
// Disable HTML parsing so code and tables inside <details> remain visible.
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
const tree = fromMarkdown(source, {
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }, gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
});
function findMarkdownRanges(node: Nodes) {
if (node.type === "table" && node.position) {
tableRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
}
function protectCode(node: Nodes) {
if (node.type === "code" || node.type === "inlineCode") {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
} else if ("children" in node) node.children.forEach(findMarkdownRanges);
} else if ("children" in node) node.children.forEach(protectCode);
}
findMarkdownRanges(tree);
protectCode(tree);
const links: (string | undefined)[] = [];
let previousTag: { name: string; end: number } | null = null;
const convert = (raw: string, offset: number): string => {
@@ -96,13 +84,6 @@ export function normalizeMarkdownHtml(source: string): string {
: /^\s*$/.test(source.slice(0, offset));
previousTag = { name, end: offset + raw.length };
if (EMPHASIS[name]) return EMPHASIS[name];
if (name === "br") {
if (closing) return "";
// A Unicode line separator keeps the row intact; its space ends GFM autolinks.
return tableRanges.some(({ start, end }) => offset >= start && offset < end)
? " \u2028"
: " \n";
}
const boundary = layoutBoundary(name, source, raw, offset, closing, startsAfterBoundary);
if (boundary !== null) return boundary;
if (name === "a") return markdownAnchor(raw, closing, links);
@@ -187,6 +168,7 @@ function layoutBoundary(
closing: boolean,
startsAfterBoundary: boolean,
): string | null {
if (name === "br") return closing ? "" : " \n";
if (name === "summary") return closing ? "**\n\n" : "**";
if (name === "details")
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
@@ -39,88 +39,6 @@ it("resolves authored root-relative links only when the target is a known comman
});
describe("shared Markdown source adaptation", () => {
it("keeps HTML line breaks inside their Markdown table cells", async () => {
const source =
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
const rows: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "TableRow") rows.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(rows).toHaveLength(4);
const text = (node: ASTNode): string =>
(node.content ?? "") + (node.children?.map(text).join("") ?? "");
expect(rows.map((row) => row.children?.map(text))).toEqual([
["A", "B"],
["1", "first \u2028second"],
["2", "first \u2028second"],
["3", "first \u2028second"],
]);
});
it("keeps table breaks inside a details wrapper without a blank line", async () => {
const source =
"<details><summary>Results</summary>\n| A | B |\n|---|---|\n| 1 | first<br>second |\n</details>";
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
const tables: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Table") tables.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(tables).toHaveLength(1);
expect(tables[0].attributes?.bodyRowCount).toBe("1");
expect(tables[0].children?.[1]?.children?.[0]?.children?.[1]?.children).toEqual([
{ type: "Text", content: "first \u2028second" },
]);
});
it.each([
["src/first.ts", "src/second.ts"],
["https://example.com/first", "src/second.ts"],
["https://example.com/first", "https://example.com/second"],
])("keeps %s and %s separate across a table break", async (first, second) => {
const prepared = prepareMarkdownSource(
`| A | B |\n|---|---|\n| 1 | ${first}<br>${second} |`,
"/repo",
);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { hardSoftBreaks: true, latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const links: string[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Link") links.push(String(node.attributes?.url));
node.children?.forEach(collect);
};
collect(tree);
expect(links).toEqual([first, second]);
});
it.each([
["| A<br>B | C |\n|---|---|\n| x | y |", "| A \u2028B | C |\n|---|---|\n| x | y |"],
[
"> | A | B |\n> |---|---|\n> | x | first<br>second |",
"> | A | B |\n> |---|---|\n> | x | first \u2028second |",
],
[
"| A | B |\n|---|---|\n| `<br>` | **first<br><BR />second** |",
"| A | B |\n|---|---|\n| `<br>` | **first \u2028 \u2028second** |",
],
[
"| A | B |\n|---|---|\n| x | \\<br> &lt;br&gt; |",
"| A | B |\n|---|---|\n| x | \\<br> &lt;br&gt; |",
],
["Not a table | first<br>second", "Not a table | first \nsecond"],
])("normalizes table breaks without changing literal tags: %s", (source, expected) => {
expect(normalizeMarkdownHtml(source)).toBe(expected);
});
it("keeps paragraphs, fences and quotes together for the library", () => {
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
expect(prepareMarkdownSource(source).render()).toBe(source);
@@ -10,10 +10,9 @@ import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
// U+2028 is an inline line break, including between table-cell file links.
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%+-]`;
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\uFFFF_@.%+-]`;
const PATH_LETTER = String.raw`[a-zA-Z]`;
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+-]`;
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\uFFFF_@%+-]`;
const LINE_SUFFIX = String.raw`(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?`;
const FILE_EXTENSIONS = [...ASSISTANT_FILE_EXTENSIONS]
.map((extension) => extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`))
@@ -30,7 +29,7 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
@@ -1,89 +0,0 @@
import { describe, expect, it } from "vitest";
import {
resolveSidebarFooterLayout,
SIDEBAR_FOOTER_ACTIONS,
} from "@/components/sidebar-footer-layout";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
const TOUCH = 44;
const POINTER = 28;
const ROW_PADDING = 8;
function layout(rowWidth: number, controlSize: number) {
return resolveSidebarFooterLayout({
rowWidth,
controlSize,
rowPadding: ROW_PADDING,
actions: SIDEBAR_FOOTER_ACTIONS,
});
}
describe("sidebar footer layout", () => {
it("keeps every action inline at the default width with either sizing", () => {
for (const controlSize of [TOUCH, POINTER]) {
expect(layout(DEFAULT_SIDEBAR_WIDTH, controlSize)).toEqual({
inline: ["add-project", "usage", "hosts", "help", "settings"],
overflow: [],
});
}
});
it("keeps every action inline at the minimum width with pointer sizing", () => {
expect(layout(MIN_SIDEBAR_WIDTH, POINTER).overflow).toEqual([]);
});
// The iPhone Duo report: the folded sidebar column is about the minimum width, and touch
// sizing makes five 44pt buttons plus padding 236pt wide.
it("moves Help and Usage behind the caret at the minimum width with touch sizing", () => {
expect(layout(MIN_SIDEBAR_WIDTH, TOUCH)).toEqual({
inline: ["add-project", "hosts", "settings"],
overflow: ["usage", "help"],
});
});
it("keeps Settings and Add project inline longest", () => {
// Three slots: two actions and the caret.
expect(layout(3 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
inline: ["add-project", "settings"],
overflow: ["usage", "hosts", "help"],
});
// Two slots: Settings and the caret.
expect(layout(2 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
inline: ["settings"],
overflow: ["add-project", "usage", "hosts", "help"],
});
});
it("puts everything behind the caret when only the caret fits", () => {
expect(layout(TOUCH + 2 * ROW_PADDING - 10, TOUCH)).toEqual({
inline: [],
overflow: ["add-project", "usage", "hosts", "help", "settings"],
});
});
it("fits all five actions exactly at their combined width", () => {
const exact = 5 * TOUCH + 2 * ROW_PADDING;
expect(layout(exact, TOUCH).overflow).toEqual([]);
// Browser layout rounds a little below the exact width.
expect(layout(exact - 0.4, TOUCH).overflow).toEqual([]);
expect(layout(exact - 1, TOUCH).overflow).toEqual(["usage", "help"]);
});
it("only places the actions it is given", () => {
expect(
resolveSidebarFooterLayout({
rowWidth: 3 * TOUCH + 2 * ROW_PADDING,
controlSize: TOUCH,
rowPadding: ROW_PADDING,
actions: ["add-project", "hosts", "settings"],
}),
).toEqual({ inline: ["add-project", "hosts", "settings"], overflow: [] });
});
// Laying out against a guessed width would paint a clipped row for a frame on narrow sidebars.
it("places nothing before the row has been measured", () => {
for (const controlSize of [TOUCH, POINTER]) {
expect(layout(0, controlSize)).toEqual({ inline: [], overflow: [] });
}
});
});
@@ -1,62 +0,0 @@
/** The sidebar footer's icon line, in row order. */
export const SIDEBAR_FOOTER_ACTIONS = [
"add-project",
"usage",
"hosts",
"help",
"settings",
] as const;
export type SidebarFooterAction = (typeof SIDEBAR_FOOTER_ACTIONS)[number];
/** The order actions keep their place in a row too narrow for all of them. */
const KEEP_ORDER: readonly SidebarFooterAction[] = [
"settings",
"add-project",
"hosts",
"usage",
"help",
];
/** Measured widths land a hair under the exact sum of their parts. */
const MEASUREMENT_TOLERANCE = 0.5;
export interface SidebarFooterLayout {
/** Actions drawn in the row, in row order. */
inline: SidebarFooterAction[];
/** Actions behind the overflow caret, in row order. Empty when everything fits. */
overflow: SidebarFooterAction[];
}
/**
* Splits the footer's actions between the row and the overflow menu. Buttons keep their full size;
* when they don't all fit, the caret takes one slot and the actions that lose their place move
* behind it, Help first and Settings last. Nothing is placed until the row has a measured width.
*/
export function resolveSidebarFooterLayout(input: {
rowWidth: number;
controlSize: number;
rowPadding: number;
actions: readonly SidebarFooterAction[];
}): SidebarFooterLayout {
const { rowWidth, controlSize, rowPadding, actions } = input;
// An unmeasured row stays empty: any guess would paint a clipped row for a frame on a narrow
// sidebar before the real width arrives.
if (rowWidth <= 0) {
return { inline: [], overflow: [] };
}
const slots = Math.floor(
(rowWidth - rowPadding * 2 + MEASUREMENT_TOLERANCE) / Math.max(controlSize, 1),
);
if (slots >= actions.length) {
return { inline: [...actions], overflow: [] };
}
const inlineCount = Math.max(slots - 1, 0);
const kept = new Set(
KEEP_ORDER.filter((action) => actions.includes(action)).slice(0, inlineCount),
);
return {
inline: actions.filter((action) => kept.has(action)),
overflow: actions.filter((action) => !kept.has(action)),
};
}
@@ -1,69 +0,0 @@
import type { ReactElement } from "react";
import { Text, View } from "react-native";
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { density } from "@/styles/density";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
/**
* A footer icon that opens a menu. Sized and drawn like the footer's other icon buttons; the
* tooltip stays out of the way while the menu is open.
*/
export function SidebarFooterMenuTrigger({
icon,
label,
menuOpen,
testID,
}: {
icon: LucideIcon;
label: string;
menuOpen: boolean;
testID: string;
}): ReactElement {
return (
<Tooltip delayDuration={300} enabledOnDesktop={!menuOpen}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
>
{({ hovered, pressed, open }) => (
<>
<SidebarFooterIconHighlight visible={pressed || open} />
<DensityIcon
icon={icon}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
}));
@@ -1,245 +0,0 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronUp,
CircleGauge,
CircleHelp,
FolderPlus,
Plus,
Server,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import type { SidebarFooterAction } from "@/components/sidebar-footer-layout";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useHosts } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { orderHostsLocalFirst } from "@/types/host-connection";
import { useOpenSidebarUsage } from "@/usage";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
import { SidebarHelpMenuItems } from "./sidebar-help-menu";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedCircleGauge = withUnistyles(CircleGauge);
const ThemedServer = withUnistyles(Server);
const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedSettings = withUnistyles(Settings);
const ThemedPlus = withUnistyles(Plus);
const addProjectLeading = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const usageLeading = <ThemedCircleGauge size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const hostsLeading = <ThemedServer size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const helpLeading = <ThemedCircleHelp size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const settingsLeading = <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const addHostLeading = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const HOSTS_PAGE_ID = "hosts";
const HELP_PAGE_ID = "help";
export interface SidebarFooterOverflowMenuProps {
/** The footer actions that did not fit in the row, in row order. */
actions: readonly SidebarFooterAction[];
labels: { addProject: string; hosts: string; settings: string; usage: string };
onAddProject: () => void;
onSettings: () => void;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}
/**
* The caret at the end of a footer row too narrow for every action. It lists the actions that
* lost their place, with the same test IDs they carry in the row; Hosts and Help, menus of their
* own in the row, open as pages of this menu.
*/
export function SidebarFooterOverflowMenu({
actions,
labels,
onAddProject,
onSettings,
onAddHost,
onOpenHostSettings,
}: SidebarFooterOverflowMenuProps): ReactElement {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const openUsage = useOpenSidebarUsage();
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const helpLabel = t("sidebar.help.trigger");
const addProjectTrailing = useMemo(
() => (newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null),
[newAgentKeys],
);
const settingsTrailing = useMemo(
() => (settingsKeys ? <Shortcut chord={settingsKeys} /> : null),
[settingsKeys],
);
const pages = useMemo((): MenuPageDefinition[] => {
const result: MenuPageDefinition[] = [];
if (actions.includes("hosts")) {
result.push({
id: HOSTS_PAGE_ID,
title: labels.hosts,
content: <HostsPage onAddHost={onAddHost} onOpenHostSettings={onOpenHostSettings} />,
});
}
if (actions.includes("help")) {
result.push({ id: HELP_PAGE_ID, title: helpLabel, content: <SidebarHelpMenuItems /> });
}
return result;
}, [actions, helpLabel, labels.hosts, onAddHost, onOpenHostSettings]);
function renderAction(action: SidebarFooterAction): ReactElement {
switch (action) {
case "add-project":
return (
<DropdownMenuItem
key={action}
testID="sidebar-add-project"
leading={addProjectLeading}
trailing={addProjectTrailing}
onSelect={onAddProject}
>
{labels.addProject}
</DropdownMenuItem>
);
case "usage":
return (
<DropdownMenuItem
key={action}
testID="sidebar-usage-icon"
leading={usageLeading}
onSelect={openUsage}
>
{labels.usage}
</DropdownMenuItem>
);
case "hosts":
return (
<DropdownMenuSubTrigger
key={action}
id={HOSTS_PAGE_ID}
testID="sidebar-hosts-trigger"
leading={hostsLeading}
>
{labels.hosts}
</DropdownMenuSubTrigger>
);
case "help":
return (
<DropdownMenuSubTrigger
key={action}
id={HELP_PAGE_ID}
testID="sidebar-help"
leading={helpLeading}
>
{helpLabel}
</DropdownMenuSubTrigger>
);
case "settings":
return (
<DropdownMenuItem
key={action}
testID="sidebar-settings"
leading={settingsLeading}
trailing={settingsTrailing}
onSelect={onSettings}
>
{labels.settings}
</DropdownMenuItem>
);
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<SidebarFooterMenuTrigger
icon={ChevronUp}
label={t("sidebar.actions.moreActions")}
menuOpen={open}
testID="sidebar-footer-overflow"
/>
<DropdownMenuContent
side="top"
align="end"
offset={8}
width={240}
pages={pages}
scrollable
testID="sidebar-footer-overflow-menu"
>
{actions.map(renderAction)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The host picker's choices as a menu page: each host opens its settings, then Add host. */
function HostsPage({
onAddHost,
onOpenHostSettings,
}: {
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}): ReactElement {
const { t } = useTranslation();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
return (
<>
{orderedHosts.map((host) => (
<HostRow
key={host.serverId}
serverId={host.serverId}
label={host.label}
onOpenHostSettings={onOpenHostSettings}
/>
))}
{orderedHosts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem testID="sidebar-host-add" leading={addHostLeading} onSelect={onAddHost}>
{t("settings.addHost")}
</DropdownMenuItem>
</>
);
}
function HostRow({
serverId,
label,
onOpenHostSettings,
}: {
serverId: string;
label: string;
onOpenHostSettings: (serverId: string) => void;
}): ReactElement {
const handleSelect = useCallback(
() => onOpenHostSettings(serverId),
[onOpenHostSettings, serverId],
);
const leading = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
return (
<DropdownMenuItem
testID={`sidebar-host-row-${serverId}`}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { View } from "react-native";
import { Text, View } from "react-native";
import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -12,13 +12,17 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -73,32 +77,12 @@ function HostVersionHint({ host }: { host: HostProfile }) {
);
}
/** The Help menu's trigger, and the menu it opens. */
export function SidebarHelpMenu() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<SidebarFooterMenuTrigger
icon={CircleHelp}
label={t("sidebar.help.trigger")}
menuOpen={open}
testID="sidebar-help"
/>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<SidebarHelpMenuItems />
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The Help menu's rows, shared by the Help menu and the footer's overflow page for it. */
export function SidebarHelpMenuItems() {
const { t } = useTranslation();
const shortcutsAvailable = useHardwareKeyboardPresent();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
const [open, setOpen] = useState(false);
const version = formatVersionWithPrefix(resolveAppVersion());
const hosts = useHosts();
@@ -115,65 +99,105 @@ export function SidebarHelpMenuItems() {
}, []);
return (
<>
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered, pressed, open: menuOpen }) => (
<>
<SidebarFooterIconHighlight visible={pressed || menuOpen} />
<DensityIcon
icon={CircleHelp}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("sidebar.help.trigger")}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenuItem
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
>
{t("sidebar.help.shortcuts")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
>
{t("sidebar.help.shortcuts")}
{t("sidebar.help.whatsNew")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
>
{t("sidebar.help.whatsNew")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
>
{t("sidebar.help.diagnostics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
<DropdownMenuItem
testID="sidebar-help-discord"
leading={discordLeadingIcon}
onSelect={openDiscord}
>
{t("sidebar.help.discord")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-github"
leading={githubLeadingIcon}
onSelect={openGitHubIssue}
>
{t("sidebar.help.github")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<View style={styles.versionList}>
<DropdownMenuHint
style={styles.versionHint}
trailing={version}
testID="sidebar-help-version"
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</>
{t("sidebar.help.diagnostics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
<DropdownMenuItem
testID="sidebar-help-discord"
leading={discordLeadingIcon}
onSelect={openDiscord}
>
{t("sidebar.help.discord")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-github"
leading={githubLeadingIcon}
onSelect={openGitHubIssue}
>
{t("sidebar.help.github")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<View style={styles.versionList}>
<DropdownMenuHint
style={styles.versionHint}
trailing={version}
testID="sidebar-help-version"
>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</DropdownMenuContent>
</DropdownMenu>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
versionList: {
gap: theme.spacing[1],
paddingVertical: theme.spacing[2],
+20 -1
View File
@@ -19,6 +19,8 @@ import type { Theme } from "@/styles/theme";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { useTopBandRow } from "@/window-layout";
import { AlertTriangle, CheckCircle2, Info } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
@@ -200,13 +202,30 @@ export function ToastViewport({
const topOffset =
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
// A centred toast cannot trim one side, so it always moves below an occlusion it meets.
const topBand = useTopBandRow({
offset: 0,
height: TOUCH_TARGET,
minimumWidth: Number.POSITIVE_INFINITY,
});
const widthBoundaryStyle = useMemo(
() => [styles.widthBoundary, { paddingTop: topBand.top }],
[topBand.top],
);
const content = (
<View
style={styles.container(placement, topOffset)}
pointerEvents="box-none"
collapsable={false}
>
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
<View
ref={topBand.ref}
onLayout={topBand.onLayout}
style={widthBoundaryStyle}
pointerEvents="box-none"
collapsable={false}
>
{toast ? (
<Animated.View
entering={toastEntering}
+1 -24
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
@@ -97,26 +97,3 @@ describe("getTransformOrigin", () => {
expect(getTransformOrigin(placement, alignment)).toEqual(expected);
});
});
describe("fitSurfaceWidth", () => {
it("leaves a surface that fits the display alone, capped at the display", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({
width: 240,
minWidth: 180,
maxWidth: 984,
});
});
it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({
width: 204,
minWidth: 180,
maxWidth: 204,
});
expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({
width: undefined,
minWidth: 134,
maxWidth: 134,
});
});
});
-21
View File
@@ -120,27 +120,6 @@ function clampToDisplayArea(input: {
};
}
export interface SurfaceWidth {
width?: number;
minWidth?: number;
maxWidth?: number;
}
/**
* Caps a surface's requested width to the display. Position clamping can move a surface but not
* shrink it, so a menu wider than a narrow screen would otherwise hang off the far edge.
*/
export function fitSurfaceWidth(requested: SurfaceWidth, displayWidth: number): SurfaceWidth {
const available = Math.max(displayWidth - EDGE_PADDING * 2, 0);
const cap = (value: number | undefined) =>
typeof value === "number" ? Math.min(value, available) : undefined;
return {
width: cap(requested.width),
minWidth: cap(requested.minWidth),
maxWidth: cap(requested.maxWidth) ?? available,
};
}
export function computePosition({
triggerRect,
contentSize,
@@ -14,8 +14,6 @@ import {
getControlInteractionPhase,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_SIZE,
TOOLBAR_DISC_SIZES,
TOUCH_TARGET,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -92,11 +90,6 @@ describe("control sizing gate", () => {
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
// A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its
// 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph.
expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer });
expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 });
expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 });
for (const size of ["sm", "md"] as const) {
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
controlHeightForLayout(size, false),
@@ -93,14 +93,6 @@ export const ICON_BUTTON_SIZES = {
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
/**
* The painted disc of a filled toolbar control, such as the composer's send and stop. The
* control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so
* touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame.
*/
export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES);
/**
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
* text, so each step is larger.
@@ -29,7 +29,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
measureElement,
type Alignment,
@@ -284,7 +283,11 @@ export function AnchoredSurface({
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
resolvedWidthStyle,
{
+2 -10
View File
@@ -35,7 +35,6 @@ import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_SIZE,
} from "@/components/ui/control-geometry";
import { usePanelStore } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
@@ -1118,7 +1117,7 @@ function ComposerCancelButton({
accessibilityRole="button"
style={cancelButtonStyle}
>
<View style={styles.cancelButtonDisc}>{icon}</View>
{icon}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -2596,21 +2595,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
width: "100%",
gap: theme.spacing[3],
},
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
cancelButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
cancelButtonDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
rightControls: {
flexDirection: "row",
+21 -38
View File
@@ -33,7 +33,6 @@ import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_SIZE,
} from "@/components/ui/control-geometry";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
@@ -350,11 +349,7 @@ function VoiceButtonIcon({
buttonIconSize: number;
}) {
if (isDictating) {
return (
<View style={styles.voiceButtonRecordingDisc}>
<Square size={buttonIconSize} color="white" fill="white" />
</View>
);
return <Square size={buttonIconSize} color="white" fill="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) {
@@ -842,14 +837,12 @@ function SendButtonTooltip({
testID={submitButtonTestID}
style={sendButtonCombinedStyle}
>
<View style={submitLabel ? styles.sendButtonDiscLabeled : styles.sendButtonDisc}>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
</View>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
@@ -1932,6 +1925,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
styles.voiceButton,
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
!isDictationStartEnabled && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
],
[isDictating, isDictationStartEnabled],
);
@@ -1980,8 +1974,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
allowEmpty: allowEmptySubmit,
});
const sendButtonCombinedStyle = useMemo(
() => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled],
[isPresentedSendDisabled],
() => [
styles.sendButton,
submitLabel ? styles.sendButtonLabeled : undefined,
isPresentedSendDisabled && styles.buttonDisabled,
],
[isPresentedSendDisabled, submitLabel],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
@@ -2418,38 +2416,23 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecordingDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
alignItems: "center",
justifyContent: "center",
},
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
sendButton: {
minWidth: TOOLBAR_CONTROL_HEIGHT,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
sendButtonDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
sendButtonDiscLabeled: {
minWidth: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
sendButtonLabeled: {
width: "auto",
minWidth: TOOLBAR_CONTROL_HEIGHT,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
sendButtonLabel: {
fontSize: theme.fontSize.base,
@@ -13,6 +13,7 @@ import {
type ControlSizing,
} from "@/components/ui/control-geometry";
import { StyleSheet } from "react-native-unistyles";
import { useTopBandRow } from "@/window-layout";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
@@ -83,6 +84,12 @@ export function ComposerInputPresentation({
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
const exitSize = TOOLBAR_CONTROL_SIZES[controlSizing];
const topBand = useTopBandRow({ offset: 0, height: exitSize, minimumWidth: exitSize });
const exitRowStyle = useMemo(
() => [styles.exitRow, { paddingTop: topBand.top, paddingRight: topBand.trailing }],
[topBand.top, topBand.trailing],
);
return (
<View
ref={exitScopeRef}
@@ -90,7 +97,7 @@ export function ComposerInputPresentation({
testID={fullscreen ? "composer-fullscreen" : undefined}
>
{fullscreen ? (
<View style={styles.exitRow}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={exitRowStyle}>
<Button
variant="ghost"
size="sm"
-1
View File
@@ -1236,7 +1236,6 @@ export const ar: TranslationResources = {
hosts: "المضيفون",
settings: "إعدادات",
closeSidebar: "إغلاق الشريط الجانبي",
moreActions: "المزيد من الإجراءات",
},
footer: {
usage: "الاستخدام",
-1
View File
@@ -1244,7 +1244,6 @@ export const en = {
hosts: "Hosts",
settings: "Settings",
closeSidebar: "Close sidebar",
moreActions: "More actions",
},
footer: {
usage: "Usage",
-1
View File
@@ -1272,7 +1272,6 @@ export const es: TranslationResources = {
hosts: "Hosts",
settings: "Ajustes",
closeSidebar: "Cerrar barra lateral",
moreActions: "Más acciones",
},
footer: {
usage: "Uso",
-1
View File
@@ -1266,7 +1266,6 @@ export const fr: TranslationResources = {
hosts: "Hôtes",
settings: "Paramètres",
closeSidebar: "Fermer la barre latérale",
moreActions: "Plus d’actions",
},
footer: {
usage: "Utilisation",
-1
View File
@@ -1250,7 +1250,6 @@ export const ja: TranslationResources = {
hosts: "ホスト",
settings: "設定",
closeSidebar: "サイドバーを閉じる",
moreActions: "その他のアクション",
},
footer: {
usage: "使用状況",
-1
View File
@@ -1243,7 +1243,6 @@ export const ko: TranslationResources = {
hosts: "호스트",
settings: "설정",
closeSidebar: "사이드바 닫기",
moreActions: "추가 작업",
},
footer: {
usage: "사용량",
-1
View File
@@ -1262,7 +1262,6 @@ export const ptBR: TranslationResources = {
hosts: "Hosts",
settings: "Configurações",
closeSidebar: "Fechar barra lateral",
moreActions: "Mais ações",
},
footer: {
usage: "Uso",
-1
View File
@@ -1254,7 +1254,6 @@ export const ru: TranslationResources = {
hosts: "Хосты",
settings: "Настройки",
closeSidebar: "Закрыть боковую панель",
moreActions: "Дополнительные действия",
},
footer: {
usage: "Использование",
-1
View File
@@ -1228,7 +1228,6 @@ export const zhCN: TranslationResources = {
hosts: "Hosts",
settings: "设置",
closeSidebar: "关闭侧边栏",
moreActions: "更多操作",
},
footer: {
usage: "使用情况",
@@ -321,26 +321,6 @@ describe("ReplicaCache", () => {
expect(restored?.range).toBeNull();
});
it("preserves workspace parentage and its checkpoint across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
const workspace = normalizeWorkspaceDescriptor({
...workspacePayload(),
parentWorkspaceId: "parent",
});
baseline.workspaces.set(workspace.id, workspace);
commitDirectory(writer, SERVER_ID, baseline);
await writer.flush();
const reader = createCache(storage);
const restored = await reader.readDirectory(SERVER_ID);
expect(restored.checkpoint).toEqual(baseline.checkpoint);
expect(restored.workspaces.get(workspace.id)?.parentWorkspaceId).toBe("parent");
expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace.parentWorkspaceId).toBe(
"parent",
);
});
it("preserves workspace sorting timestamps across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
@@ -299,7 +299,6 @@ const WorkspaceGitRuntimeSchema = z
.optional();
const StoredWorkspaceSchema = z.strictObject({
parentWorkspaceId: z.string().optional(),
// Require both keys: a cursor without these values would preserve the wrong sort on reconnect.
createdAt: IsoDateSchema.nullable(),
lastUserMessageAt: IsoDateSchema.nullable(),
@@ -708,7 +707,6 @@ function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor {
function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
return {
parentWorkspaceId: workspace.parentWorkspaceId,
createdAt: workspace.createdAt?.toISOString() ?? null,
lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null,
id: workspace.id,
@@ -16,6 +16,8 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import type { SplitPane } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { useTopBandRow } from "@/window-layout";
interface ExplorerSidebarDockProps extends Pick<
WorkspaceDesktopTabsRowProps,
@@ -117,48 +119,61 @@ export function ExplorerSidebarDock({
[onCloseOtherTabs, tabs],
);
const topBand = useTopBandRow({
offset: 0,
height: TOUCH_TARGET,
// Trimming the rail beside the status region truncates its tab labels; start below instead.
minimumWidth: Number.POSITIVE_INFINITY,
});
const railBandStyle = useMemo(
() => ({ paddingTop: topBand.top, paddingRight: topBand.trailing }),
[topBand.top, topBand.trailing],
);
return (
<RetainedPanel active>
<WindowChromeRegion corners="top-right">
<View style={styles.dock} testID="workspace-explorer-sidebar">
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
<TitlebarDragRegion />
<View style={styles.tabRow}>
<WorkspaceDesktopTabsRow
host="explorer"
launchPurpose="supporting"
isFocused={isWorkspaceFocused}
ownsKeyboardShortcuts={false}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
focusModeEnabled={false}
onExitFocusMode={onExitFocusMode}
externalDndContext={externalDndContext}
paneId={pane.id}
tabs={tabItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
onNavigateTab={handleSelectTab}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
/>
</View>
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
<View pointerEvents="none" style={styles.tabRailDivider} />
</WindowChromeSafeArea>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={railBandStyle}>
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
<TitlebarDragRegion />
<View style={styles.tabRow}>
<WorkspaceDesktopTabsRow
host="explorer"
launchPurpose="supporting"
isFocused={isWorkspaceFocused}
ownsKeyboardShortcuts={false}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
focusModeEnabled={false}
onExitFocusMode={onExitFocusMode}
externalDndContext={externalDndContext}
paneId={pane.id}
tabs={tabItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
onNavigateTab={handleSelectTab}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
/>
</View>
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
<View pointerEvents="none" style={styles.tabRailDivider} />
</WindowChromeSafeArea>
</View>
<View style={styles.content}>
<WorkspacePanelHost
paneId={pane.id}
@@ -557,33 +557,6 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(blank.workspaceDirectory).toBe("");
});
it("preserves informational workspace parentage", () => {
const payload = {
id: "child",
parentWorkspaceId: "parent",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
} as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.parentWorkspaceId).toBe("parent");
expect(
normalizeWorkspaceDescriptor({ ...payload, parentWorkspaceId: undefined }).parentWorkspaceId,
).toBeUndefined();
});
it("defaults missing scripts to an empty array", () => {
const payload = {
id: "1",
-2
View File
@@ -105,7 +105,6 @@ export interface Agent {
}
export interface WorkspaceDescriptor {
parentWorkspaceId?: string;
createdAt: Date | null;
lastUserMessageAt: Date | null;
id: string;
@@ -163,7 +162,6 @@ export function normalizeWorkspaceDescriptor(
projectKind: payload.projectKind,
workspaceKind: payload.workspaceKind,
background: payload.background ?? false,
parentWorkspaceId: payload.parentWorkspaceId,
name: payload.name,
title: payload.title ?? null,
pinnedAt: payload.pinnedAt ?? null,
+7 -14
View File
@@ -33,10 +33,9 @@ import {
} from "./model";
import type { UsageReportEntry, UsageView } from "./types";
// The daemon caches each report for five minutes, so re-reading it is cheap.
// An explicit refresh passes `forceRefresh` to bypass that cache.
// The daemon caches each report for five minutes, so re-reading it is cheap. Only
// an explicit refresh passes `forceRefresh` and reaches the source's API.
const REPORTS_STALE_TIME_MS = 60_000;
const REPORTS_REFETCH_INTERVAL_MS = 5 * 60_000;
/** Every report list of a host: its own and each agent's. */
function hostUsageQueryKey(serverId: string) {
@@ -165,16 +164,11 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const NO_REPORTS: UsageReportEntry[] = [];
interface UsageHostReportsInput {
serverId: string | null;
shouldPoll?: boolean;
}
/** A host's reports, with periodic refresh enabled only by the visible sidebar summary. */
export function useUsageHostReports({
serverId,
shouldPoll = false,
}: UsageHostReportsInput): UsageReportEntry[] {
/**
* The reports of the sidebar's usage host, which is connected and reports usage; none until they
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
@@ -183,7 +177,6 @@ export function useUsageHostReports({
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
+1 -8
View File
@@ -16,8 +16,6 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
@@ -38,14 +36,9 @@ import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
export function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const active = useRetainedPanelActive();
const isAppVisible = useAppVisible();
const { inSidebar } = useUsageInSidebar();
const serverId = useUsageHostId();
const reports = useUsageHostReports({
serverId: inSidebar ? serverId : null,
shouldPoll: active && inSidebar && isAppVisible,
});
const reports = useUsageHostReports(inSidebar ? serverId : null);
const { preferences } = useUsageDisplay(reports);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
+4 -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();
+16 -4
View File
@@ -2,12 +2,13 @@ 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 { useTopBandRow, type TopBandRow } from "./top-band";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
@@ -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 whose occlusions top-edge rows clear with `useTopBandRow()`.
*/
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 } });
+1
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBandRow, type TopBandRow } from "./top-band";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
+173 -6
View File
@@ -1,35 +1,202 @@
import { describe, expect, it } from "vitest";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import {
resolveSafeAreaInsets,
resolveTopBand,
resolveTopBandClearance,
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. The Live Activity width (202pt) is still an estimate from #7924's ActivityKit QA.
const duo = {
systemTop: 82,
topBar: { x: 16, y: 26, width: 509, height: 44 },
statusRegion: { x: 525, y: 0, width: 144, height: 82 },
liveActivityRegion: { x: 467, y: 0, width: 202, 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 occlusions when the band is in use", () => {
expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({
top: 26,
occlusions: [duo.statusRegion],
});
});
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();
});
});
describe("resolveTopBandClearance", () => {
// The full workspace header with the sidebar closed spans the window.
const fullHeader = { x: 0, y: duo.topBar.y, width: 669, height: 44 };
// A row beside a 320pt sidebar whose controls need more width than the status region leaves.
const narrowRow = { x: 320, y: duo.topBar.y + 8, width: 349, height: 56 };
const narrowMinimum = 300;
it("trims the trailing side to the status region", () => {
expect(
resolveTopBandClearance({
row: fullHeader,
occlusions: [duo.statusRegion],
minimumWidth: 300,
}),
).toEqual({ trailing: 144, top: 0 });
});
it("trims further when a Live Activity widens the status region", () => {
expect(
resolveTopBandClearance({
row: fullHeader,
occlusions: [duo.liveActivityRegion],
minimumWidth: 300,
}),
).toEqual({ trailing: 202, top: 0 });
});
it("starts below the status region when the controls no longer fit beside it", () => {
expect(
resolveTopBandClearance({
row: narrowRow,
occlusions: [duo.statusRegion],
minimumWidth: narrowMinimum,
}),
).toEqual({ trailing: 0, top: 48 });
});
it("starts below the lowest of several overlapping occlusions", () => {
expect(
resolveTopBandClearance({
row: narrowRow,
occlusions: [duo.statusRegion, { x: 600, y: 0, width: 69, height: 96 }],
minimumWidth: narrowMinimum,
}),
).toEqual({ trailing: 0, top: 62 });
});
it("leaves rows alone that do not overlap an occlusion", () => {
const sidebarRow = { x: 0, y: duo.topBar.y + 8, width: 320, height: 44 };
const rowBelow = { x: 0, y: 82, width: 669, height: 44 };
for (const row of [sidebarRow, rowBelow]) {
expect(
resolveTopBandClearance({ row, occlusions: [duo.statusRegion], minimumWidth: 0 }),
).toEqual({ trailing: 0, top: 0 });
}
});
it("rounds a fractional clearance up so controls never touch the occlusion", () => {
expect(
resolveTopBandClearance({
row: { x: 0, y: 10, width: 669.5, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 144, height: 60.5 }],
minimumWidth: Number.POSITIVE_INFINITY,
}),
).toEqual({ trailing: 0, top: 51 });
expect(
resolveTopBandClearance({
row: { x: 0, y: 10, width: 669.5, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 144, height: 60.5 }],
minimumWidth: 0,
}).trailing,
).toBe(145);
});
});
+69 -5
View File
@@ -14,18 +14,82 @@ 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 part of it. Content outside a top-edge row still keeps out of `occlusions`.
*/
export interface TopBand {
top: number;
occlusions: readonly WindowLayoutRect[];
}
/** 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;
return { top: Math.max(0, bar.y), occlusions: layout.occlusions ?? [] };
}
/**
* 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 TopBandClearance {
/** Points the row's trailing controls keep from its trailing edge. */
trailing: number;
/** Points the row moves down when its controls cannot fit beside the occlusions. */
top: number;
}
export const NO_TOP_BAND_CLEARANCE: TopBandClearance = { trailing: 0, top: 0 };
/**
* How a top-edge row keeps its controls out of the system occlusions it overlaps: trim its
* trailing side to the leftmost occlusion, or, when `minimumWidth` no longer fits, start
* below the occlusions. `row` is the row's frame without any clearance, in window points.
*/
export function resolveTopBandClearance(input: {
row: WindowLayoutRect;
occlusions: readonly WindowLayoutRect[];
minimumWidth: number;
}): TopBandClearance {
const { row, occlusions, minimumWidth } = input;
const overlapping = occlusions.filter((frame) => overlaps(frame, row));
if (overlapping.length === 0) return NO_TOP_BAND_CLEARANCE;
const trailing = Math.ceil(row.x + row.width - Math.min(...overlapping.map((frame) => frame.x)));
if (row.width - trailing >= minimumWidth) return { trailing, top: 0 };
const bottom = Math.max(...overlapping.map((frame) => frame.y + frame.height));
return { trailing: 0, top: Math.ceil(bottom - row.y) };
}
function overlaps(a: WindowLayoutRect, b: WindowLayoutRect): boolean {
return (
a.width > 0 &&
a.height > 0 &&
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
export interface DesktopDivisionLayout {
@@ -0,0 +1,78 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import type { View } from "react-native";
import { TopBandContext } from "./context";
import {
NO_TOP_BAND_CLEARANCE,
resolveTopBandClearance,
type TopBandClearance,
type WindowLayoutRect,
} from "./model";
export interface TopBandRow extends TopBandClearance {
/** Attach to the view whose top edge `offset` is measured from. */
ref: RefObject<View | null>;
onLayout: (() => void) | undefined;
}
/**
* Clearance for a row that can land in the window's top band. The measured view's frame
* must not depend on the clearance: apply `trailing` and `top` as padding inside it.
*
* - `offset`: points from the measured view's top to the row, without `top`.
* - `height`: the row's height.
* - `minimumWidth`: the narrowest row that still fits its controls.
* - `settled`: re-measures when it changes, for rows whose panel slides into place.
*/
export function useTopBandRow(input: {
offset: number;
height: number;
minimumWidth: number;
settled?: boolean;
}): TopBandRow {
const { offset, height, minimumWidth, settled } = input;
const band = useContext(TopBandContext);
const ref = useRef<View | null>(null);
const [frame, setFrame] = useState<WindowLayoutRect | null>(null);
const measure = useCallback(() => {
ref.current?.measureInWindow((x, y, width, measuredHeight) =>
setFrame((previous) =>
previous &&
previous.x === x &&
previous.y === y &&
previous.width === width &&
previous.height === measuredHeight
? previous
: { x, y, width, height: measuredHeight },
),
);
}, []);
const active = band !== null;
useEffect(() => {
if (active) measure();
}, [active, measure, settled]);
const clearance = useMemo(
() =>
band && frame
? resolveTopBandClearance({
row: { x: frame.x, y: frame.y + offset, width: frame.width, height },
occlusions: band.occlusions,
minimumWidth,
})
: NO_TOP_BAND_CLEARANCE,
[band, frame, offset, height, minimumWidth],
);
return {
ref,
onLayout: active ? measure : undefined,
trailing: clearance.trailing,
top: clearance.top,
};
}
-2
View File
@@ -4189,8 +4189,6 @@ export const WorkspaceGitHubRuntimePayloadSchema = z
export const WorkspaceDescriptorPayloadSchema = z
.object({
// Informational creation ancestry; independent of placement and lifecycle.
parentWorkspaceId: z.string().optional(),
id: z.string(),
projectId: z.string(),
projectDisplayName: z.string(),
@@ -634,26 +634,6 @@ describe("workspace message schemas", () => {
]);
});
test("preserves informational workspace parentage in directory updates", () => {
const workspace = {
id: "child",
parentWorkspaceId: "parent",
projectId: "project",
projectDisplayName: "repo",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "child",
status: "done",
activityAt: null,
};
expect(WorkspaceDescriptorPayloadSchema.parse(workspace).parentWorkspaceId).toBe("parent");
expect(
WorkspaceDescriptorPayloadSchema.parse({ ...workspace, parentWorkspaceId: undefined })
.parentWorkspaceId,
).toBeUndefined();
});
test("parses legacy workspace descriptors without workspaceDirectory", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
@@ -1171,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => {
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
}, 30_000);
});
test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => {
const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", {
@@ -1197,43 +1197,12 @@ describe("Suite E: Workspace Tools", () => {
});
ownedWorkspaces.push(str(inherited.workspaceId));
expect(inherited.background).toBe(true);
expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId);
const visible = await callToolStructured(scoped, "create_workspace", {
isolation: "local",
background: false,
});
ownedWorkspaces.push(str(visible.workspaceId));
expect(visible.background).toBe(false);
expect(visible.parentWorkspaceId).toBe(parentWorkspaceId);
const worktree = await callToolStructured(scoped, "create_workspace", {
isolation: "worktree",
branchName: "parentage-child",
baseBranch: "main",
background: false,
});
ownedWorkspaces.push(str(worktree.workspaceId));
expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` });
try {
await client.connect();
const created = await client.createWorkspace({
source: { kind: "directory", path: worktreeRepoCwd },
callerAgentId: str(parent.agentId),
background: false,
});
const workspaceId = str(created.workspace?.id);
ownedWorkspaces.push(workspaceId);
expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId);
const listed = await client.fetchWorkspaces();
expect(listed.entries).toContainEqual(
expect.objectContaining({
id: workspaceId,
parentWorkspaceId: parentWorkspaceId,
}),
);
} finally {
await client.close();
}
} finally {
await scoped?.close();
for (const workspaceId of ownedWorkspaces.toReversed())
@@ -1241,7 +1210,7 @@ describe("Suite E: Workspace Tools", () => {
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
}, 30_000);
});
test("a background agent's children and workspaces are background unless it overrides", async () => {
const visibleAgentId = await createTopLevelAgent();
@@ -188,7 +188,6 @@ interface ProviderSummary {
}
const WorkspaceAutomationSummarySchema = z.object({
parentWorkspaceId: z.string().optional(),
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
@@ -207,7 +206,6 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) {
kind: workspace.kind,
title: workspace.title,
background: workspace.background,
parentWorkspaceId: workspace.parentWorkspaceId,
};
}
-2
View File
@@ -5900,7 +5900,6 @@ export class Session {
pinnedAt: workspace.pinnedAt,
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
background: workspace.background,
parentWorkspaceId: workspace.parentWorkspaceId,
archivingAt: null,
status: "done",
statusEnteredAt: null,
@@ -5989,7 +5988,6 @@ export class Session {
projectKind: projectRecord?.kind ?? "git",
workspaceKind: result.workspace.kind,
background: result.workspace.background,
parentWorkspaceId: result.workspace.parentWorkspaceId,
name: resolveWorkspaceName({
title: result.workspace.title,
derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
@@ -205,82 +205,6 @@ test("fresh non-git directory creates a directory workspace at the exact path",
expect(workspace.cwd).toBe(dir);
});
test("workspace parentage survives reload and parent archive without changing placement", async () => {
const cwd = path.join(tmpDir, "plain");
const parent = await provisioning.createWorkspaceForDirectory(cwd);
const child = await provisioning.createWorkspaceForDirectory(cwd, null, undefined, {
callerWorkspaceId: parent.workspaceId,
background: false,
});
const grandchild = await provisioning.createWorkspaceForDirectory(
path.join(tmpDir, "other"),
null,
undefined,
{
callerWorkspaceId: child.workspaceId,
},
);
expect(parent.parentWorkspaceId).toBeUndefined();
expect(child).toMatchObject({ parentWorkspaceId: parent.workspaceId, cwd });
expect(grandchild).toMatchObject({ parentWorkspaceId: child.workspaceId });
await workspaceRegistry.archive(parent.workspaceId, ARCHIVED_AT);
const reloaded = new FileBackedWorkspaceRegistry(
path.join(tmpDir, "projects", "workspaces.json"),
logger,
);
await reloaded.initialize();
expect(await reloaded.get(child.workspaceId)).toMatchObject({
parentWorkspaceId: parent.workspaceId,
archivedAt: null,
});
expect(await reloaded.get(grandchild.workspaceId)).toMatchObject({
parentWorkspaceId: child.workspaceId,
archivedAt: null,
});
});
test("worktree workspace creation records its caller independently of its source project", async () => {
const sourceCwd = path.join(tmpDir, "repo");
gitRoots.add(sourceCwd);
const parent = await provisioning.createWorkspaceForDirectory(path.join(tmpDir, "other"));
const child = await provisioning.createWorkspaceForWorktree({
sourceCwd,
repoRoot: sourceCwd,
cwd: path.join(tmpDir, "worktree"),
worktreeRoot: path.join(tmpDir, "worktree"),
branch: "child",
baseBranch: "main",
title: null,
callerWorkspaceId: parent.workspaceId,
});
expect(child.parentWorkspaceId).toBe(parent.workspaceId);
expect(child.projectId).not.toBe(parent.projectId);
expect(await workspaceRegistry.get(child.workspaceId)).toMatchObject({
parentWorkspaceId: parent.workspaceId,
});
});
test("workspace creation rejects an unknown caller even with explicit visibility", async () => {
await expect(
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "plain"), null, undefined, {
callerWorkspaceId: "missing",
background: false,
}),
).rejects.toThrow("Caller workspace missing not found");
});
test("workspace creation cannot overwrite its caller with a self relationship", async () => {
const cwd = path.join(tmpDir, "plain");
const parent = await provisioning.createWorkspaceForDirectory(cwd);
await expect(
provisioning.createWorkspaceForDirectory(cwd, null, undefined, {
callerWorkspaceId: parent.workspaceId,
workspaceId: parent.workspaceId,
}),
).rejects.toThrow("A workspace cannot be its own parent");
expect(await workspaceRegistry.get(parent.workspaceId)).toEqual(parent);
});
test("background workspace creation preserves hooks, inheritance and durable visibility", async () => {
const dir = path.join(tmpDir, "plain");
const events: string[] = [];
@@ -227,21 +227,15 @@ export function createWorkspaceProvisioningService(deps: {
return project;
}
async function resolveCreationMetadata(input?: {
workspaceId?: string;
async function resolveBackground(input?: {
background?: boolean;
callerWorkspaceId?: string;
}): Promise<{ background: boolean; parentWorkspaceId?: string }> {
if (!input?.callerWorkspaceId) return { background: input?.background ?? false };
if (input.workspaceId === input.callerWorkspaceId) {
throw new Error("A workspace cannot be its own parent");
}
}): Promise<boolean> {
if (input?.background !== undefined) return input.background;
if (!input?.callerWorkspaceId) return false;
const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId);
if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`);
return {
background: input.background ?? callerWorkspace.background,
parentWorkspaceId: callerWorkspace.workspaceId,
};
return callerWorkspace.background;
}
async function createWorkspaceForDirectory(
@@ -275,7 +269,7 @@ export function createWorkspaceProvisioningService(deps: {
title: title?.trim() || null,
createdAt: timestamp,
updatedAt: timestamp,
...(await resolveCreationMetadata(context)),
background: await resolveBackground(context),
});
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: context?.expectsInitialAgent,
@@ -316,7 +310,7 @@ export function createWorkspaceProvisioningService(deps: {
createdAt: timestamp,
updatedAt: timestamp,
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
...(await resolveCreationMetadata(input)),
background: await resolveBackground(input),
});
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: input.expectsInitialAgent,
@@ -49,7 +49,6 @@ const PersistedProjectRecordSchema = z.object({
});
const PersistedWorkspaceRecordSchema = z.object({
parentWorkspaceId: z.string().optional(),
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
@@ -680,7 +679,6 @@ export function resolveProjectDisplayName(record: PersistedProjectRecord): strin
}
export function createPersistedWorkspaceRecord(input: {
parentWorkspaceId?: string;
workspaceId: string;
projectId: string;
cwd: string;
File diff suppressed because one or more lines are too long