Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 387be14a2e fix(usage): keep the sidebar usage summary current while it is shown
The summary fetched reports once on mount and never again: no interval,
window-focus and reconnect refetches are off app-wide, and the daemon does
not push report changes. It only updated when the Usage modal or Settings
mounted the same query and refetched it. It now re-reads every minute while
visible; the daemon answers from its five-minute cache, so source APIs are
still hit at most once per cache window.
2026-11-06 04:11:48 +01:00
70 changed files with 674 additions and 2288 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.
+2 -11
View File
@@ -179,17 +179,8 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
lists the content-position corrections and the React Native issues they work around.
The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native
scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still.
Ordinary scroll parents retain Android's handoff. Keep that identity in sync with
`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot
identify the timeline for this rule.
`VisibleContentPositionClampTest` mounts through React Native's own mounting manager;
`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent
handoff with the real compiled scroll views:
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
```bash
cd packages/app/android
+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).
@@ -235,7 +235,6 @@ async function beginTimelineRowStabilityCheck(
lastTop: initialRow.getBoundingClientRect().top,
largestDownwardShift: 0,
sawMissing: false,
sawHidden: false,
};
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionTimelineRowStability = state;
@@ -246,9 +245,6 @@ async function beginTimelineRowStabilityCheck(
requestAnimationFrame(checkFrame);
return;
}
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
state.sawHidden = true;
}
const top = initialRow.getBoundingClientRect().top;
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
state.lastTop = top;
@@ -261,24 +257,14 @@ async function beginTimelineRowStabilityCheck(
const result = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionTimelineRowStability as
| {
active: boolean;
largestDownwardShift: number;
sawMissing: boolean;
sawHidden: boolean;
}
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
| undefined;
if (!state) throw new Error("Timeline-row stability check was not started");
state.active = false;
delete windowState.__messageSubmissionTimelineRowStability;
return {
largestDownwardShift: state.largestDownwardShift,
sawMissing: state.sawMissing,
sawHidden: state.sawHidden,
};
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
});
expect(result.sawMissing).toBe(false);
expect(result.sawHidden).toBe(false);
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
};
}
@@ -1192,13 +1178,7 @@ test.describe("Agent message submission", () => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
page,
pending.userMessage,
);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
await finishTimelineRowStabilityCheck();
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
@@ -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()] });
@@ -575,3 +469,27 @@ test("visible usage summary requests reports on load", async ({ page }) => {
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary picks up changed reports without opening Usage", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeSessionAt(31), () => claudeSessionAt(80)],
});
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await page.clock.install();
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
await page.clock.fastForward(61_000);
await expectPinnedUsage(page, ["80% 5h", "54% wk", "7% 5h", "12% wk"]);
await expect(usageModal(page)).toHaveCount(0);
});
function claudeSessionAt(usedPct: number) {
const reports = claudeAndCodexReports();
const claude = reports[0]!;
if (claude.report.status === "available") claude.report.windows[0]!.usedPct = usedPct;
return reports;
}
+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);
}
}
@@ -1,158 +0,0 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.view.View
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.soloader.SoLoader
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */
@RunWith(AndroidJUnit4::class)
class PanelEdgeTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
private lateinit var manager: ReactScrollViewManager
@After fun finish() {
instrumentation.runOnMainSync { activity?.finish() }
}
@Test fun panelBottomEdgeLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
child.stopNestedScroll()
assertEquals("Panel stays at its bottom", 600, child.scrollY)
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
}
}
@Test fun panelTopEdgeLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 0)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, -100, null)
child.stopNestedScroll()
assertEquals("Panel stays at its top", 0, child.scrollY)
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
}
}
@Test fun panelFlingLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedFling(0f, 1500f, false)
child.stopNestedScroll()
}
SystemClock.sleep(500)
instrumentation.runOnMainSync {
assertEquals("Panel fling must leave timeline still", 400, parent.scrollY)
}
}
@Test fun disablingChildNestedScrollPreventsHandoff() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.isNestedScrollingEnabled = false
assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
assertEquals(400, parent.scrollY)
}
}
@Test fun ordinaryParentStillConsumesNestedOvershoot() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
manager.setNativeId(parent, "ordinary-scroll-parent")
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
child.stopNestedScroll()
assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY)
}
}
@Test fun ordinaryParentStillConsumesUnconsumedFling() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
manager.setNativeId(parent, "ordinary-scroll-parent")
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedFling(0f, 1500f, false)
child.stopNestedScroll()
}
SystemClock.sleep(500)
instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) }
}
@Test fun consumedPanelFlingDoesNotStartParentFling() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
assertEquals(false, child.dispatchNestedFling(0f, 1500f, true))
child.stopNestedScroll()
assertEquals(400, parent.scrollY)
}
}
@Test fun panelInteriorScrollingLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 100)
assertEquals(100, child.scrollY)
assertEquals(400, parent.scrollY)
parent.scrollTo(0, 500)
assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY)
}
}
private fun openPanel(): Pair<ReactScrollView, ReactScrollView> {
val started = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = started
lateinit var parent: ReactScrollView
lateinit var child: ReactScrollView
instrumentation.runOnMainSync {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
SoLoader.init(started, OpenSourceMergedSoMapping)
val react = ScrollTestContext(started.applicationContext)
val context = ThemedReactContext(react, started)
manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
parent = manager.createViewInstance(context)
manager.setNativeId(parent, "agent-chat-scroll-native-virtualized")
child = manager.createViewInstance(context)
child.isNestedScrollingEnabled = true
val content = ReactViewGroup(context)
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 })
parent.addView(content)
val childContent = ReactViewGroup(context)
child.addView(childContent)
started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) })
parent.layout(0, 0, 300, 400)
content.layout(0, 0, 300, 2000)
child.layout(0, 400, 300, 600)
childContent.layout(0, 0, 300, 800)
parent.scrollTo(0, 400)
}
return Pair(parent, child)
}
}
@@ -1,19 +0,0 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's own event sink for a context without JavaScript. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -12,13 +12,10 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with timeline corrections. Java, because the content-position
* React Native's vertical scroll view with two corrections. Java, because the content-position
* config type is internal to React Native's Kotlin module.
*/
final class PaseoScrollView extends ReactScrollView {
// The chat timeline, packages/app/src/agent-stream/strategy-native.tsx.
private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized";
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
@@ -35,26 +32,6 @@ final class PaseoScrollView extends ReactScrollView {
super(context);
}
// A panel owns its gesture through its edges; remaining child motion must not move the chat
// behind it. The timeline's native identity stays present when its end pin is removed for
// reading history. Ordinary parents retain Android's nested-scroll handoff.
@Override
public void onNestedScroll(
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
if (isTimeline()) return;
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
}
@Override
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
if (isTimeline() && !consumed) return true;
return super.onNestedFling(target, velocityX, velocityY, consumed);
}
private boolean isTimeline() {
return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id));
}
@Override
public void setMaintainVisibleContentPosition(
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
@@ -65,6 +65,7 @@ interface BottomAnchorControllerDriver {
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
@@ -479,6 +480,17 @@ function createBottomAnchorControllerDriver(
blockedReason,
};
},
resetForAgent() {
lastRouteRequestKey = null;
pendingRequest = null;
blockedReason = null;
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
applyRouteRequest(request) {
if (!request) {
return;
@@ -729,6 +741,10 @@ export function useBottomAnchorController(input: {
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
@@ -129,8 +129,7 @@ function mountList(heldRowId: string) {
}
return position - state.scroll;
};
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
return { render, scrollTo, heldRowTop, rowSize };
return { render, scrollTo, heldRowTop };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
@@ -170,21 +169,3 @@ it("anchors a size change on a held row whose top is above the viewport", async
expect(list.heldRowTop()).toBe(-20);
});
it("keeps measured row sizes while the list is not laid out", async () => {
// A hidden retained tab is display: none. Its rows still render, and a row measured there
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
// event, so a 0 recorded while hidden would stay and the next row would overlap it.
const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 }));
const list = mountList("row-0");
list.render(rows);
await wait(150);
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
host.style.display = "none";
await wait(150);
list.render(rows.map((row) => ({ ...row })));
await wait(150);
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
});
@@ -137,8 +137,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
const [isListPlaced, setIsListPlaced] = useState(false);
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
const isListPlaced = placedAgentId === agentId;
const isListPlacedRef = useRef(isListPlaced);
isListPlacedRef.current = isListPlaced;
const streamViewportMetricsRef = useRef({
@@ -383,6 +384,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
);
useEffect(() => {
streamViewportMetricsRef.current = {
containerKey: "native-virtualized",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
disclosureHold.cancel();
historyStartReadyRef.current = false;
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -393,7 +411,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -605,7 +623,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const handleListPlaced = useStableEvent(() => {
isListPlacedRef.current = true;
setIsListPlaced(true);
setPlacedAgentId(agentId);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
contentMeasuredForKey: "native-virtualized",
@@ -675,7 +693,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
extraData={renderHistoryMountedRow}
keyExtractor={keyExtractor}
testID="agent-chat-scroll"
// The Android scroll owner identifies the timeline to contain nested panel gestures.
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={historyStartContent}
ListFooterComponent={liveAuxiliaryContent ?? undefined}
@@ -694,6 +711,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
dataKey={agentId}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
+10 -2
View File
@@ -839,6 +839,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
useEffect(() => {
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
readingAnchor.reset();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -849,7 +853,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart]);
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -1391,7 +1395,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
return createStreamStrategy({
render: (renderInput) => (
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
<WebStreamViewport
key={renderInput.agentId}
{...renderInput}
isMobileBreakpoint={input.isMobileBreakpoint}
/>
),
orderTailReverse: false,
orderHeadReverse: false,
@@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => {
]).flat();
const { result } = renderHook(() =>
useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }),
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
);
expect(result.current.start).toBe(2);
@@ -35,6 +35,7 @@ describe("useStreamHistoryWindow", () => {
const loadRemoteOlder = vi.fn(async () => true);
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -66,7 +67,8 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -89,7 +91,8 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -105,7 +108,8 @@ describe("useStreamHistoryWindow", () => {
configurable: true,
});
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{
initialProps: {
history: [
@@ -138,6 +142,7 @@ describe("useStreamHistoryWindow", () => {
});
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -28,17 +28,22 @@ function findEarlierHistoryWindowStart(input: {
}
interface HistoryWindowBoundary {
agentId: string;
boundaryItemId: string | null;
initialized: boolean;
}
function reconcileHistoryWindow(input: {
current: HistoryWindowBoundary;
agentId: string;
boundaryIndex: number;
initialBoundaryItemId: string | null;
hasItems: boolean;
}): HistoryWindowBoundary {
const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input;
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (current.agentId !== agentId) {
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
}
if (!current.initialized && hasItems) {
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
}
@@ -49,26 +54,28 @@ function reconcileHistoryWindow(input: {
}
export function useStreamHistoryWindow(input: {
agentId: string;
items: StreamItem[];
loadRemoteOlder: () => boolean | Promise<boolean>;
}) {
const { items, loadRemoteOlder } = input;
const { agentId, items, loadRemoteOlder } = input;
const initialStart = useMemo(
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
[items],
);
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
const [window, setWindow] = useState(() => ({
agentId,
boundaryItemId: initialBoundaryItemId,
initialized: items.length > 0,
}));
const boundaryItemId = window.boundaryItemId;
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
const boundaryIndex = useMemo(
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
[boundaryItemId, items],
);
let start = initialStart;
if (window.initialized) {
if (window.agentId === agentId && window.initialized) {
if (window.boundaryItemId === null) {
start = 0;
} else if (boundaryIndex >= 0) {
@@ -80,12 +87,13 @@ export function useStreamHistoryWindow(input: {
setWindow((current) =>
reconcileHistoryWindow({
current,
agentId,
boundaryIndex,
initialBoundaryItemId,
hasItems: items.length > 0,
}),
);
}, [boundaryIndex, initialBoundaryItemId, items.length]);
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
@@ -98,12 +106,13 @@ export function useStreamHistoryWindow(input: {
return false;
}
setWindow({
agentId,
boundaryItemId: items[nextStart]?.id ?? null,
initialized: true,
});
return true;
},
[items, start],
[agentId, items, start],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
return revealLoadedHistory() || (await loadRemoteOlder());
+6 -1
View File
@@ -430,6 +430,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
useEffect(() => {
setExpandedInlineToolCallIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
@@ -472,7 +476,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
return () => inlinePathOpener.cancel();
}, [inlinePathOpener, isActive]);
}, [inlinePathOpener, isActive, agentId]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (isActive) void inlinePathOpener.open(target, disposition);
@@ -547,6 +551,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
revealLoadedHistory,
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: presentation.tail,
loadRemoteOlder,
});
+24 -79
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";
@@ -41,17 +40,6 @@ 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";
@@ -67,7 +55,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 +79,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 +410,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>
@@ -990,12 +937,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,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],
+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,
{
@@ -60,6 +60,17 @@ describe("useDraftAgentCreateFlow", () => {
expect(result.current.isSubmitting).toBe(true);
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
expect(result.current.submittedStreamItems).toEqual([
{
kind: "user_message",
id: "msg-prepared",
clientMessageId: "msg-prepared",
text: "build this",
timestamp: attempt.timestamp,
images: [image],
attachments: [attachment],
},
]);
expect(createRequest).not.toHaveBeenCalled();
await act(async () => {
+42 -1
View File
@@ -8,8 +8,15 @@ import {
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream";
import {
createUserMessage,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PendingMessageSubmission } from "@/composer/submission/model";
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
interface CreateAttempt {
clientMessageId: string;
@@ -146,6 +153,38 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const submittedStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
if (
!machine.attempt.text &&
(!machine.attempt.images || machine.attempt.images.length === 0) &&
(!machine.attempt.attachments || machine.attempt.attachments.length === 0)
) {
return EMPTY_STREAM_ITEMS;
}
return [
createUserMessage({
clientMessageId: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
images: machine.attempt.images,
attachments: machine.attempt.attachments,
}),
];
}, [machine]);
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
if (machine.tag !== "creating") return [];
return [
{
clientMessageId: machine.attempt.clientMessageId,
},
];
}, [machine]);
const draftAgent = machine.tag === "creating" ? machine.draftAgent : null;
const startCreateAttempt = useCallback(
(attempt: CreateAttempt) => {
@@ -326,6 +365,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
machine,
formErrorMessage,
isSubmitting,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import { DRAFT_CAPABILITIES, resolveDraftTimeline } from "./timeline";
const image: UserMessageImageAttachment = {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-key",
createdAt: 123,
};
const attachment = {
type: "review",
cwd: "/repo",
summary: "Review",
} as unknown as AgentAttachment;
function pendingAttempt(overrides: Partial<PendingCreateAttempt> = {}): PendingCreateAttempt {
return {
draftId: "draft-1",
serverId: "server-1",
agentId: null,
clientMessageId: "draft-1:initial-message",
text: "build this",
timestamp: Date.parse("2026-05-25T00:00:00.000Z"),
lifecycle: "active",
images: [image],
attachments: [attachment],
...overrides,
};
}
describe("resolveDraftTimeline", () => {
it("shows the sent prompt pending with the turn working", () => {
const timeline = resolveDraftTimeline({
pending: pendingAttempt(),
serverId: "server-1",
tabId: "tab-1",
cwd: "/repo",
});
expect(timeline?.agent).toEqual({
serverId: "server-1",
id: "tab-1",
status: "running",
cwd: "/repo",
capabilities: DRAFT_CAPABILITIES,
});
expect(timeline?.streamItems).toEqual([
{
kind: "user_message",
id: "draft-1:initial-message",
clientMessageId: "draft-1:initial-message",
text: "build this",
timestamp: new Date("2026-05-25T00:00:00.000Z"),
images: [image],
attachments: [attachment],
},
]);
expect(timeline?.pendingMessageSubmissions).toEqual([
{ clientMessageId: "draft-1:initial-message" },
]);
expect(timeline?.turnPresentation.isActive).toBe(true);
});
it("keeps the prompt once the agent is created, until the tab retargets", () => {
const timeline = resolveDraftTimeline({
pending: pendingAttempt({ lifecycle: "sent", agentId: "agent-1" }),
serverId: "server-1",
tabId: "tab-1",
cwd: "/repo",
});
expect(timeline?.streamItems).toHaveLength(1);
});
it("shows only the working turn for a prompt without content", () => {
const timeline = resolveDraftTimeline({
pending: pendingAttempt({ text: "", images: undefined, attachments: undefined }),
serverId: "server-1",
tabId: "tab-1",
cwd: "/repo",
});
expect(timeline?.streamItems).toEqual([]);
expect(timeline?.turnPresentation.isActive).toBe(true);
});
it("shows no timeline before a prompt is sent, after it fails, or on another host", () => {
const base = { serverId: "server-1", tabId: "tab-1", cwd: "/repo" };
expect(resolveDraftTimeline({ ...base, pending: undefined })).toBeNull();
expect(
resolveDraftTimeline({ ...base, pending: pendingAttempt({ lifecycle: "abandoned" }) }),
).toBeNull();
expect(
resolveDraftTimeline({ ...base, pending: pendingAttempt({ serverId: "server-2" }) }),
).toBeNull();
expect(resolveDraftTimeline({ ...base, cwd: null, pending: pendingAttempt() })).toBeNull();
});
});
-102
View File
@@ -1,102 +0,0 @@
import { useMemo } from "react";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import type { PendingMessageSubmission } from "@/composer/submission/model";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { type PendingCreateAttempt, useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import {
resolveTurnPresentation,
TURN_LIVENESS_IDLE,
type TurnPresentation,
} from "@/timeline/turn-liveness";
import { createUserMessage, type StreamItem } from "@/types/stream";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import { resolveDraftWorkingDirectory } from "@/composer/draft/workspace-tab-core";
export const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
export interface DraftConversation {
tabId: string;
workspaceId: string;
draftId: string;
setup?: WorkspaceDraftTabSetup;
}
/** The timeline a draft shows while its first prompt creates the agent. */
export interface DraftTimeline {
agent: AgentScreenAgent;
streamItems: StreamItem[];
pendingMessageSubmissions: readonly PendingMessageSubmission[];
turnPresentation: TurnPresentation;
}
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
/**
* The sent prompt, pending, with the turn working. The created agent's timeline carries the same
* row (same client message id), so the handoff only changes who owns it.
*/
export function resolveDraftTimeline(input: {
pending: PendingCreateAttempt | undefined;
serverId: string;
tabId: string;
cwd: string | null;
}): DraftTimeline | null {
const { pending, serverId, tabId, cwd } = input;
if (!pending || pending.serverId !== serverId || pending.lifecycle === "abandoned" || !cwd) {
return null;
}
const hasContent =
pending.text.length > 0 ||
(pending.images?.length ?? 0) > 0 ||
(pending.attachments?.length ?? 0) > 0;
return {
agent: { serverId, id: tabId, status: "running", cwd, capabilities: DRAFT_CAPABILITIES },
streamItems: hasContent
? [
createUserMessage({
clientMessageId: pending.clientMessageId,
text: pending.text,
timestamp: new Date(pending.timestamp),
images: pending.images,
attachments: pending.attachments,
}),
]
: EMPTY_STREAM_ITEMS,
pendingMessageSubmissions: [{ clientMessageId: pending.clientMessageId }],
turnPresentation: resolveTurnPresentation(TURN_LIVENESS_IDLE, true),
};
}
export function useDraftTimeline(input: {
serverId: string;
draft: DraftConversation | null;
}): DraftTimeline | null {
const { serverId, draft } = input;
const pending = useCreateFlowStore((state) =>
draft ? state.pendingByDraftId[draft.draftId] : undefined,
);
const workspaceDirectory = useWorkspaceFields(
serverId,
draft?.workspaceId ?? null,
(workspace) => workspace.workspaceDirectory,
);
const cwd = draft
? resolveDraftWorkingDirectory({
workspaceDirectory: workspaceDirectory || null,
initialSetup: draft.setup ?? null,
})
: null;
const tabId = draft?.tabId ?? "";
return useMemo(
() => resolveDraftTimeline({ pending, serverId, tabId, cwd }),
[cwd, pending, serverId, tabId],
);
}
@@ -1,5 +1,4 @@
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export interface WorkspaceDraftAutoSubmitConfig {
provider: string;
@@ -51,13 +50,3 @@ export function validateDraftSubmission(input: {
});
return readiness.ok ? null : (readiness.reason ?? null);
}
export function resolveDraftWorkingDirectory(input: {
workspaceDirectory: string | null;
initialSetup: WorkspaceDraftTabSetup | null;
}): string | null {
if (input.initialSetup) {
return input.initialSetup.cwd;
}
return input.workspaceDirectory;
}
+197 -123
View File
@@ -1,17 +1,21 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { ComposerDock } from "@/composer/dock";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
@@ -21,13 +25,13 @@ import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import {
resolveDraftWorkingDirectory,
shouldAllowEmptyDraftText,
validateDraftSubmission,
} from "@/composer/draft/workspace-tab-core";
import { DRAFT_CAPABILITIES } from "@/composer/draft/timeline";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
@@ -46,6 +50,16 @@ import {
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
interface AutoSubmitConfig {
provider: string;
modeId: string | null;
@@ -246,15 +260,25 @@ function buildDraftInitialValues(input: {
};
}
interface WorkspaceDraftComposerProps {
function resolveDraftWorkingDirectory(input: {
workspaceDirectory: string | null;
initialSetup: WorkspaceDraftTabSetup | null;
}): string | null {
if (input.initialSetup) {
return input.initialSetup.cwd;
}
return input.workspaceDirectory;
}
interface WorkspaceDraftAgentTabProps {
serverId: string;
workspaceId: string;
tabId: string;
draftId: string;
initialSetup?: WorkspaceDraftTabSetup;
isPaneFocused: boolean;
editingSession: ComposerEditingSession;
onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet?: () => void;
}
@@ -268,21 +292,17 @@ function resolveImportPillPress(
return onOpenImportSheet ?? null;
}
/**
* Creates the agent from the draft's first prompt. The conversation panel shows that prompt in
* the timeline from the create-flow store while the agent is created.
*/
export function WorkspaceDraftComposer({
export function WorkspaceDraftAgentTab({
serverId,
workspaceId,
tabId,
draftId,
initialSetup = undefined,
isPaneFocused,
editingSession,
onCreated,
onOpenWorkspaceFile,
onOpenImportSheet,
}: WorkspaceDraftComposerProps) {
}: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -367,6 +387,7 @@ export function WorkspaceDraftComposer({
}, [pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompactFormFactor = useIsCompactFormFactor();
const editingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(editingSession);
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_CONTENT_WIDTH,
@@ -400,71 +421,82 @@ export function WorkspaceDraftComposer({
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
);
const { formErrorMessage, isSubmitting, handleCreateFromInput, continueCreateFromAttempt } =
useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
draftId,
getPendingServerId: () => serverId,
initialAttempt: initialCreateAttempt,
allowEmptyText: allowsEmptyAutoSubmit,
validateBeforeSubmit: ({ text, attachments }) => {
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
allowsEmptyAutoSubmit,
attachments,
});
return validateDraftSubmission({
text,
allowsEmptyAutoSubmit: allowsEmptyDraftText,
composerState,
autoSubmitConfig,
workspaceDirectory: draftWorkingDirectory,
isConnected,
});
},
onBeforeSubmit: async () => {
await composerState.persistFormPreferences();
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
Keyboard.dismiss();
},
buildDraftAgent: (attempt) =>
buildDraftAgentSnapshot({
attempt,
serverId,
tabId,
workspaceDirectory: draftWorkingDirectory,
autoSubmitConfig,
composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
if (pendingAutoSubmit?.agentCreation) {
const result = await pendingAutoSubmit.agentCreation.result;
return { agentId: result.id, result };
}
return submitDraftCreateRequest({
draftId,
attempt,
text,
images,
attachments,
cwd,
client,
workspaceDirectory: draftWorkingDirectory,
workspaceId: workspaceFields?.id ?? null,
autoSubmitConfig,
composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
selectModelMessage: t("workspaceSetup.errors.selectModel"),
});
},
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
onCreated(result);
},
});
const {
formErrorMessage,
isSubmitting,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
draftId,
getPendingServerId: () => serverId,
initialAttempt: initialCreateAttempt,
allowEmptyText: allowsEmptyAutoSubmit,
validateBeforeSubmit: ({ text, attachments }) => {
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
allowsEmptyAutoSubmit,
attachments,
});
return validateDraftSubmission({
text,
allowsEmptyAutoSubmit: allowsEmptyDraftText,
composerState,
autoSubmitConfig,
workspaceDirectory: draftWorkingDirectory,
isConnected,
});
},
onBeforeSubmit: async () => {
await composerState.persistFormPreferences();
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
Keyboard.dismiss();
},
buildDraftAgent: (attempt) =>
buildDraftAgentSnapshot({
attempt,
serverId,
tabId,
workspaceDirectory: draftWorkingDirectory,
autoSubmitConfig,
composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
if (pendingAutoSubmit?.agentCreation) {
const result = await pendingAutoSubmit.agentCreation.result;
return { agentId: result.id, result };
}
return submitDraftCreateRequest({
draftId,
attempt,
text,
images,
attachments,
cwd,
client,
workspaceDirectory: draftWorkingDirectory,
workspaceId: workspaceFields?.id ?? null,
autoSubmitConfig,
composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
selectModelMessage: t("workspaceSetup.errors.selectModel"),
});
},
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
onCreated(result);
},
});
const turnPresentation = useMemo(
() => resolveTurnPresentation(TURN_LIVENESS_IDLE, pendingMessageSubmissions.length > 0),
[pendingMessageSubmissions],
);
useAgentControlCommandCenterActions({
sourceId: `draft:${serverId}:${tabId}`,
enabled: isPaneFocused && !isSubmitting,
@@ -568,49 +600,76 @@ export function WorkspaceDraftComposer({
}),
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
);
return (
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{formErrorMessage ? (
<View style={styles.formErrorRow}>
<View style={styles.formError}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
const dockContent = (
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
serverId={serverId}
context={draftAgent}
streamItems={submittedStreamItems}
pendingMessageSubmissions={pendingMessageSubmissions}
turnPresentation={turnPresentation}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</View>
) : null}
{importPillPress && !isComposerFullscreen ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
textSource={draftInput.textSource}
onChangeText={draftInput.editText}
textReplacement={draftInput.textReplacement}
attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
editingSession={editingSession}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraft={composerState.commandDraft}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
</ScrollView>
)}
</View>
);
return (
<FileDropZone style={styles.container}>
<ComposerDock fullscreen={isComposerFullscreen}>
{dockContent}
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{importPillPress && !isComposerFullscreen ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
textSource={draftInput.textSource}
onChangeText={draftInput.editText}
textReplacement={draftInput.textReplacement}
attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
editingSession={editingSession}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraft={composerState.commandDraft}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
</View>
</ComposerDock>
</FileDropZone>
);
}
const animatedStaticStyles = RNStyleSheet.create({
@@ -623,10 +682,27 @@ const animatedStaticStyles = RNStyleSheet.create({
});
const styles = StyleSheet.create((theme) => ({
formErrorRow: {
container: {
flex: 1,
width: "100%",
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flex: 1,
},
streamContainer: {
flex: 1,
},
scrollView: {
flex: 1,
},
configScrollContent: {
paddingHorizontal: theme.spacing[4],
alignItems: "center",
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
configSection: {
gap: theme.spacing[3],
},
importPillRow: {
width: "100%",
@@ -646,10 +722,8 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: theme.contentMaxWidth,
flexDirection: "row",
},
formError: {
width: "100%",
maxWidth: theme.contentMaxWidth,
marginBottom: theme.spacing[2],
errorContainer: {
marginTop: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
+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,
-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: "使用情况",
+94 -158
View File
@@ -78,8 +78,7 @@ import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { AgentTracks } from "@/panels/agent-tracks";
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -91,13 +90,14 @@ import {
useSessionStore,
} from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import type { StreamItem, TodoEntry } from "@/types/stream";
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
@@ -225,29 +225,6 @@ function AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: ()
);
}
/** A draft has no agent to wait for; an agent shows its loading and error states until ready. */
function renderConversationNonReadyView(args: {
draft: DraftConversation | null;
agentId: string | undefined;
cwd: string | null;
viewState: AgentScreenViewState;
effectiveAgent: AgentScreenAgent | null;
t: TFunction;
onRetryLoad: () => void;
}): React.ReactElement | null {
if (args.draft) {
return null;
}
const nonReadyView = renderChatAgentNonReadyView(args);
if (nonReadyView) {
return nonReadyView;
}
invariant(args.agentId, "agent id is defined when agent content is ready");
invariant(args.effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
invariant(args.cwd, "agent cwd is defined when agent content is ready");
return null;
}
function renderChatAgentNonReadyView(args: {
viewState: AgentScreenViewState;
effectiveAgent: AgentScreenAgent | null;
@@ -326,31 +303,17 @@ function storeFetchedAgentDetail(input: {
return hydrated;
}
type ConversationTarget = Extract<WorkspaceTabTarget, { kind: "agent" } | { kind: "draft" }>;
/** The agent a conversation shows; a draft has none until its first prompt creates one. */
function resolveConversationAgentId(target: ConversationTarget): string | undefined {
return target.kind === "agent" ? target.agentId.trim() || undefined : undefined;
}
/**
* Drafts and agents render through one chain so the timeline stays mounted when the draft's
* first prompt creates the agent and the tab retargets. The agent gates are idle for a draft.
*/
export function AgentConversationPanel() {
function AgentPanel() {
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(
target.kind === "agent" || target.kind === "draft",
"AgentConversationPanel requires an agent or draft target",
);
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<RetainedChatContent>
<AgentPanelContent
serverId={serverId}
workspaceId={workspaceId}
target={target}
agentId={target.agentId}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
/>
@@ -358,6 +321,55 @@ export function AgentConversationPanel() {
);
}
function DraftPanel() {
const {
serverId,
workspaceId,
tabId,
target,
openFileInWorkspace,
openImportSheet,
retargetCurrentTab,
} = usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(target.kind === "draft", "DraftPanel requires draft target");
const handleCreated = useCallback(
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
},
[retargetCurrentTab, serverId],
);
return (
<WorkspaceDraftAgentTab
serverId={serverId}
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
initialSetup={target.setup}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
onCreated={handleCreated}
onOpenImportSheet={openImportSheet}
/>
);
}
export function AgentConversationPanel() {
const { target } = usePaneContext();
if (target.kind === "draft") {
return <DraftPanel />;
}
if (target.kind === "agent") {
return <AgentPanel />;
}
invariant(false, "AgentConversationPanel requires an agent or draft target");
}
export const agentPanelRegistration = definePanel("agent", {
component: AgentConversationPanel,
useDescriptor: useAgentDescriptor,
@@ -432,18 +444,18 @@ type AgentLookupState =
function AgentPanelContent({
serverId,
workspaceId,
target,
agentId,
isPaneFocused,
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
target: ConversationTarget;
agentId: string;
isPaneFocused: boolean;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const resolvedAgentId = resolveConversationAgentId(target);
const resolvedAgentId = agentId.trim() || undefined;
const resolvedServerId = serverId.trim() || undefined;
const daemons = useHosts();
const runtimeServerId = resolvedServerId ?? "";
@@ -472,7 +484,7 @@ function AgentPanelContent({
: runtimeConnectionStatus;
const lastConnectionError = runtimeLastError;
if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) {
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
return (
<AgentSessionUnavailableState
serverLabel={serverLabel}
@@ -486,9 +498,9 @@ function AgentPanelContent({
return (
<AgentPanelBody
serverId={resolvedServerId ?? serverId}
serverId={resolvedServerId}
workspaceId={workspaceId}
target={target}
agentId={resolvedAgentId}
isPaneFocused={isPaneFocused}
client={runtimeClient}
isConnected={runtimeIsConnected}
@@ -500,7 +512,7 @@ function AgentPanelContent({
function AgentPanelBody({
serverId,
workspaceId,
target,
agentId,
isPaneFocused,
client,
isConnected,
@@ -508,14 +520,13 @@ function AgentPanelBody({
}: {
serverId: string;
workspaceId: string;
target: ConversationTarget;
agentId?: string;
isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const agentId = resolveConversationAgentId(target);
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const projectPlacement = useStoreWithEqualityFn(
@@ -630,7 +641,7 @@ function AgentPanelBody({
}
: null;
if (target.kind === "agent" && !agent) {
if (!agent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
@@ -644,7 +655,7 @@ function AgentPanelBody({
<ChatAgentContent
serverId={serverId}
workspaceId={workspaceId}
target={target}
agentId={agentId}
isPaneFocused={isPaneFocused}
client={client}
isConnected={isConnected}
@@ -656,7 +667,7 @@ function AgentPanelBody({
function ChatAgentContent({
serverId,
workspaceId,
target,
agentId,
isPaneFocused,
client,
isConnected,
@@ -664,15 +675,13 @@ function ChatAgentContent({
}: {
serverId: string;
workspaceId: string;
target: ConversationTarget;
agentId?: string;
isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const { tabId } = usePaneContext();
const agentId = resolveConversationAgentId(target);
const isPaneVisible = useRetainedPanelActive();
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
@@ -979,23 +988,17 @@ function ChatAgentContent({
visibilityCatchUpStatus,
]);
const draft = useMemo<DraftConversation | null>(
() =>
target.kind === "draft"
? { tabId, workspaceId, draftId: target.draftId, setup: target.setup }
: null,
[tabId, target, workspaceId],
);
const nonReadyView = renderConversationNonReadyView({
draft,
agentId,
cwd: agentState.cwd,
const nonReadyView = renderChatAgentNonReadyView({
viewState,
effectiveAgent,
t,
onRetryLoad: retryAgentLoad,
});
if (nonReadyView) return nonReadyView;
invariant(agentId, "agent id is defined when agent content is ready");
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
const agentCwd = agentState.cwd;
invariant(agentCwd, "agent cwd is defined when agent content is ready");
const showHistorySyncOverlay =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
@@ -1010,8 +1013,7 @@ function ChatAgentContent({
<ChatAgentReadyContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId ?? tabId}
draft={draft}
agentId={agentId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
agentState={agentState}
@@ -1029,7 +1031,7 @@ function ChatAgentContent({
showHistorySyncOverlay={showHistorySyncOverlay}
showHistorySyncError={showHistorySyncError}
isRetryingHistorySync={isRetryingHistorySync}
cwd={agentState.cwd ?? ""}
cwd={agentCwd}
retryTimelineSync={retryTimelineSync}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
@@ -1042,7 +1044,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId,
workspaceId,
agentId,
draft,
isPaneFocused,
isArchivingCurrentAgent,
agentState,
@@ -1068,13 +1069,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
}: {
serverId: string;
workspaceId: string;
/** The agent's id, or the tab id while a draft has no agent yet. */
agentId: string;
draft: DraftConversation | null;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
agentState: ChatAgentSelectedState;
effectiveAgent: AgentScreenAgent | null;
effectiveAgent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
toastApi: ToastApi;
@@ -1096,11 +1095,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
}) {
const { t } = useTranslation();
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
const paneHost = usePaneContext().host;
const showsComposer = draft !== null || paneHost !== "sheet";
const showsComposer = usePaneContext().host !== "sheet";
// The dock lays out fullscreen for the same session the composer edits in.
const composerEditingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
);
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const parentAgentId = useParentAgentId(serverId, agentId);
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
@@ -1154,7 +1161,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const parentCallout = parentAgentId ? (
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
) : null;
const agentComposerSection = showsComposer ? (
const composerSection = showsComposer ? (
<RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection
agentId={agentId}
@@ -1175,16 +1182,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : (
parentCallout
);
const composerSection = draft ? (
<DraftComposerSection
serverId={serverId}
draft={draft}
isPaneFocused={isPaneFocused}
editingSession={composerEditingSession}
/>
) : (
agentComposerSection
);
const streamContent = (
<View style={animatedStaticStyles.content}>
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
@@ -1194,7 +1191,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
draft={draft}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
hasActiveComposer={hasActiveComposer}
@@ -1202,12 +1198,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
{hasActiveComposer && !draft ? (
{hasActiveComposer ? (
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
parentAgentId={parentPillAgentId}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
/>
) : null}
</View>
@@ -1263,7 +1263,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
</RewindComposerRestoreProvider>
);
return wrapAgentCommandProvider({
enabled: showsComposer && hasActiveComposer && !draft,
enabled: showsComposer && hasActiveComposer,
serverId,
agentId,
editingSession: composerEditingSession,
@@ -1355,7 +1355,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
serverId,
agentId,
agent,
draft,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
hasActiveComposer,
@@ -1365,8 +1364,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
agentId?: string;
agent: AgentScreenAgent | null;
draft: DraftConversation | null;
agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
hasActiveComposer: boolean;
@@ -1424,31 +1422,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
}
return new Map(pendingPermissionList.map((permission) => [permission.key, permission]));
}, [pendingPermissionList]);
// A draft's timeline exists only while its first prompt creates the agent. The created agent's
// timeline then replaces this input in the same mounted view.
const draftTimeline = useDraftTimeline({ serverId, draft });
if (draftTimeline) {
return (
<AgentStreamView
ref={streamViewRef}
agentId={draftTimeline.agent.id}
serverId={serverId}
context={draftTimeline.agent}
streamItems={draftTimeline.streamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
bottomOverlayTailClearance={bottomOverlayTailClearance}
bottomOverlayControlClearance={bottomOverlayControlClearance}
toast={toast}
pendingMessageSubmissions={draftTimeline.pendingMessageSubmissions}
turnPresentation={draftTimeline.turnPresentation}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
if (!agent) {
return null;
}
return (
<AgentStreamView
ref={streamViewRef}
@@ -1469,44 +1443,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
);
});
/** The draft's composer, which creates the agent and retargets this tab to it. */
function DraftComposerSection({
serverId,
draft,
isPaneFocused,
editingSession,
}: {
serverId: string;
draft: DraftConversation;
isPaneFocused: boolean;
editingSession: ComposerEditingSession;
}) {
const { openImportSheet, retargetCurrentTab } = usePaneContext();
const handleCreated = useCallback(
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
},
[retargetCurrentTab, serverId],
);
return (
<WorkspaceDraftComposer
serverId={serverId}
workspaceId={draft.workspaceId}
tabId={draft.tabId}
draftId={draft.draftId}
initialSetup={draft.setup}
isPaneFocused={isPaneFocused}
editingSession={editingSession}
onCreated={handleCreated}
onOpenImportSheet={openImportSheet}
/>
);
}
const AgentComposerSection = memo(function AgentComposerSection({
agentId,
serverId,
+12 -13
View File
@@ -9,11 +9,11 @@ import { useSettings } from "@/hooks/use-settings";
import { PluginComposerPills } from "@/plugins";
import { useSessionStore } from "@/stores/session-store";
import {
useArchiveFinishedSubagents,
type ArchiveFinishedStatus,
useArchiveSubagent,
useDetachSubagent,
useSubagentSheetTab,
useSubagentsForParent,
type SubagentRow,
} from "@/subagents";
import { ParentAgentPill } from "@/subagents/parent";
import { SubagentsTrack } from "@/subagents/track";
@@ -35,21 +35,20 @@ export const AgentTracks = memo(function AgentTracks({
workspaceId,
agentId,
parentAgentId,
subagentRows,
tasks,
archiveFinishedStatus,
onArchiveFinished,
}: {
serverId: string;
workspaceId: string;
agentId: string;
parentAgentId: string | null;
subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void;
}): ReactElement {
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
);
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const { tabId } = usePaneContext();
const isCompact = useIsCompactFormFactor();
const openInSidePane = useSettings((settings) => settings.openInSidePane);
@@ -120,8 +119,8 @@ export const AgentTracks = memo(function AgentTracks({
onOpenProviderSubagent={handleOpenProviderSubagent}
sheetTabFor={sheetTabFor}
onArchiveSubagent={archiveSubagent}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={onArchiveFinished}
archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<PluginComposerPills
@@ -390,39 +390,4 @@ describe("AgentDirectoryReplica", () => {
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
store.clearSession(serverId);
});
it("keeps unchanged agent fields when an update changes only the status", () => {
const serverId = "agent-replica-unchanged-fields";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
);
const agentInStore = () => useSessionStore.getState().sessions[serverId]?.agents.get("agent");
replica.commitSnapshot([entry(payload("agent"))], []);
const before = agentInStore();
replica.applyDelta({
kind: "upsert",
agent: { ...payload("agent"), status: "running", updatedAt: "2026-07-17T00:02:00.000Z" },
project: entry(payload("agent")).project,
});
const afterDelta = agentInStore();
replica.commitSnapshot(
[entry({ ...payload("agent"), status: "idle", updatedAt: "2026-07-17T00:03:00.000Z" })],
[],
);
const afterSnapshot = agentInStore();
expect(afterDelta?.status).toBe("running");
expect(afterSnapshot?.status).toBe("idle");
for (const agent of [afterDelta, afterSnapshot]) {
expect(agent?.capabilities).toBe(before?.capabilities);
expect(agent?.availableModes).toBe(before?.availableModes);
expect(agent?.labels).toBe(before?.labels);
expect(agent?.projectPlacement).toBe(before?.projectPlacement);
}
store.clearSession(serverId);
});
});
@@ -21,26 +21,6 @@ function withoutAgent(agentId: string) {
};
}
/**
* Every update arrives as a whole new agent, so each nested value (capabilities, modes, labels) is
* a new object even when it did not change. Keep the stored value for every field that is equal,
* and the stored agent when nothing changed, so a reader of one field sees a new value only when
* that field changes.
*/
function keepUnchangedFields(stored: Agent, incoming: Agent): Agent {
const next = { ...incoming };
let changed = Object.keys(stored).length !== Object.keys(next).length;
for (const key of Object.keys(next) as (keyof Agent)[]) {
if (next[key] === stored[key]) continue;
if (key in stored && equal(next[key], stored[key])) {
Object.assign(next, { [key]: stored[key] });
} else {
changed = true;
}
}
return changed ? next : stored;
}
function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent {
if (!previous) return incoming;
const activeTurn =
@@ -79,8 +59,7 @@ export class AgentStoreProjection {
let accepted = agent;
useSessionStore.getState().setAgents(this.serverId, (current) => {
const previous = current.get(agent.id);
const winner = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
accepted = previous ? keepUnchangedFields(previous, winner) : winner;
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
if (accepted === previous) return current;
const next = new Map(current);
next.set(agent.id, accepted);
@@ -138,7 +117,7 @@ export class AgentStoreProjection {
for (const [agentId, fetchedAgent] of fetched) {
const existing = current.get(agentId);
const merged = mergeSnapshotTurn(existing, fetchedAgent);
agents.set(agentId, existing ? keepUnchangedFields(existing, merged) : merged);
agents.set(agentId, existing && equal(existing, merged) ? existing : merged);
if (fetchedAgent.archivedAt) {
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
}
@@ -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,
@@ -870,12 +870,8 @@ function WorkspaceDocumentTitleEffectSlot({
);
}
/**
* The header floats over a conversation's timeline. A draft and the agent it creates share one
* timeline, so their surfaces match and the timeline does not move when the tab retargets.
*/
function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean {
return supportsStreamTopOverlay && (activeTab?.kind === "agent" || activeTab?.kind === "draft");
return supportsStreamTopOverlay && activeTab?.kind === "agent";
}
/** A fullscreen composer covers the compact header; the column lays out without it. */
@@ -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,
+9 -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,12 @@ 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. Re-read every minute while the summary is shown, since the daemon never
* pushes report changes.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
@@ -183,7 +178,7 @@ export function useUsageHostReports({
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false,
refetchInterval: REPORTS_STALE_TIME_MS,
});
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]);
}
@@ -126,23 +126,6 @@ describe("agent directory reconciliation", () => {
}).toEqual({ title: "newer page", status: "running", projectName: "repo" });
});
it("takes the project of a newer buffered upsert", () => {
const snapshotEntry = entry("agent", "idle");
const newerProject = { ...snapshotEntry.project, projectName: "renamed project" };
const result = reconcileAgentDirectory({
snapshot: [snapshotEntry],
deltas: [
{
kind: "upsert",
agent: { ...snapshot("agent", "idle"), updatedAt: "2026-07-12T11:00:00.000Z" },
project: newerProject,
},
],
});
expect(result[0]?.project.projectName).toBe("renamed project");
});
it("clears a snapshot stop when a newer buffered upsert is running", () => {
const result = reconcileAgentDirectory({
snapshot: [entry("agent", "idle")],
-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;
@@ -270,16 +270,6 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => {
await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 });
expect(isAlive(unrelated.pid!)).toBe(true);
// Completion means the recorded process is gone, even if its PID is occupied.
await expect
.poll(
async () =>
!isAlive(original.pid) ||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)).status ===
"unrelated",
{ timeout: 10000 },
)
.toBe(true);
expect(existsSync(recordPath)).toBe(false);
expect(
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)),
@@ -867,18 +867,7 @@ it.each([false, true])(
writeFileSync(exitRequestPath, "");
await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000);
expect(existsSync(exitObservedPath)).toBe(true);
const originalEnding = record!.terminals.find((entry) => entry.id === ending.id)!;
// Exit delivery does not reserve the PID. Wait for the original process to
// disappear, allowing Windows to reuse its PID just as in the restart check below.
await expect
.poll(
async () =>
!isProcessAlive(endingPid) ||
classifyTerminalProgramProcess(originalEnding, await inspector.inspect(endingPid))
.status === "unrelated",
{ timeout: 10000 },
)
.toBe(true);
expect(isProcessAlive(endingPid)).toBe(false);
} finally {
if (recordFd !== null) closeSync(recordFd);
}
+20 -47
View File
@@ -25,9 +25,6 @@
# 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a
# data change already does, so a disclosure that only grows (a todo card) holds the cut-off
# row instead of the first row in view.
# 11. A 0 x 0 measurement is not a size (updateItemSizesBatch): rows of a hidden retained tab
# (display: none) still render and measure 0 x 0. Recording it collapsed the row, and a row
# whose frame was unchanged when the tab showed again got no layout event to correct it.
#
# Upgrading: drop each hunk whose fix the new version ships, regenerate with
# `npx patch-package @legendapp/list`, and copy this header back on top.
@@ -48,7 +45,7 @@ index 983e937..970ba51 100644
* Returns a stable item type used for pooling and size estimation.
*/
diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js
index eeb3fb0..5fe940d 100644
index eeb3fb0..b1689ad 100644
--- a/node_modules/@legendapp/list/react-native.js
+++ b/node_modules/@legendapp/list/react-native.js
@@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
@@ -208,19 +205,7 @@ index eeb3fb0..5fe940d 100644
}
// src/core/updateItemSizes.ts
@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) {
const result = {};
const previousTotalSize = state.totalSize;
for (const measurement of measurements) {
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
+ // to correct it, so the next row overlapped it.
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
if (ownsMeasuredItem) {
const index = state.indexByKey.get(measurement.itemKey);
@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
@@ -229,7 +214,7 @@ index eeb3fb0..5fe940d 100644
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
@@ -240,7 +225,7 @@ index eeb3fb0..5fe940d 100644
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
@@ -249,7 +234,7 @@ index eeb3fb0..5fe940d 100644
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
@@ -264,7 +249,7 @@ index eeb3fb0..5fe940d 100644
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
@@ -273,7 +258,7 @@ index eeb3fb0..5fe940d 100644
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
@@ -282,7 +267,7 @@ index eeb3fb0..5fe940d 100644
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
@@ -294,7 +279,7 @@ index eeb3fb0..5fe940d 100644
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
@@ -302,7 +287,7 @@ index eeb3fb0..5fe940d 100644
getItemType,
horizontal,
rtl,
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,
@@ -311,7 +296,7 @@ index eeb3fb0..5fe940d 100644
getItemType: useWrapIfItem(getItemType),
hasExternalScroll,
diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs
index fe5f415..7f1b9dd 100644
index fe5f415..e61945e 100644
--- a/node_modules/@legendapp/list/react-native.mjs
+++ b/node_modules/@legendapp/list/react-native.mjs
@@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
@@ -471,19 +456,7 @@ index fe5f415..7f1b9dd 100644
}
// src/core/updateItemSizes.ts
@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) {
const result = {};
const previousTotalSize = state.totalSize;
for (const measurement of measurements) {
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
+ // to correct it, so the next row overlapped it.
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
if (ownsMeasuredItem) {
const index = state.indexByKey.get(measurement.itemKey);
@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
@@ -492,7 +465,7 @@ index fe5f415..7f1b9dd 100644
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
@@ -503,7 +476,7 @@ index fe5f415..7f1b9dd 100644
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
@@ -512,7 +485,7 @@ index fe5f415..7f1b9dd 100644
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
@@ -527,7 +500,7 @@ index fe5f415..7f1b9dd 100644
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
@@ -536,7 +509,7 @@ index fe5f415..7f1b9dd 100644
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
@@ -545,7 +518,7 @@ index fe5f415..7f1b9dd 100644
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
@@ -557,7 +530,7 @@ index fe5f415..7f1b9dd 100644
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
@@ -565,7 +538,7 @@ index fe5f415..7f1b9dd 100644
getItemType,
horizontal,
rtl,
@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,