Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] f792ac8a4e chore: update panel scrolling branch from main 2026-11-06 07:49:20 +00:00
paseo-bot[bot] e8a9e2d699 chore(android): carry panel scrolling fix onto the current timeline 2026-11-06 07:48:53 +00:00
Mohamed Boudra e79e826e23 Stop re-rendering every chat message on each agent update (#8916)
* fix(app): keep unchanged agent fields when an agent update is accepted

* fix(app): share unchanged agent fields at the store write, not in the update policy
2026-11-06 14:48:10 +07:00
paseo-bot[bot]andluckxine 5feec6fa35 fix(android): contain nested panel motion in the current timeline
Identify the forward-order timeline by its existing native identity so panel edges stay still while ordinary parents retain Android handoff. Preserve the native content-position corrections.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-06 07:46:51 +00:00
Mohamed Boudra 4928500e1f fix(app): keep chat rows their size while their tab is hidden on Android (#8911) 2026-11-06 14:08:09 +07:00
Mohamed Boudra 1e2ab3d596 Keep the first prompt on screen when a new agent starts on Android (#8898)
* refactor(app): stop resetting the chat timeline when its agent id changes

A mounted timeline only ever shows one agent: every host keys its panel by tab id,
and a tab keeps its id across a target change only when a draft becomes the agent
it created. The per-agent resets inside the timeline therefore never had a different
agent to reset for. They only fired at that create handoff, where they hid and
re-placed a timeline that was already on screen.

Delete them: LegendList's dataKey and the placed agent id, the web viewport key,
the bottom anchor's resetForAgent, the history window's agent id, and the
expansion and path-opener resets in the stream view.

* fix(app): keep one chat timeline across the draft-to-agent handoff

A draft tab and the agent its first prompt creates rendered two different panels:
DraftPanel mounted its own dock and list, and the retarget swapped it for AgentPanel's.
On Android, with the LegendList timeline, the swap blanked the first turn and re-placed it.

Render both from the agent panel. A draft supplies its timeline from the pending create
in the create-flow store (composer/draft/timeline.ts) and its composer in the composer
slot. The retarget then re-renders the same stream view with the agent's timeline, so the
list instance and its measured rows stay mounted. Agent gating is unchanged.

Float the compact header over draft tabs as it already floats over agent tabs. The
header sat in flow above a draft and over an agent, so the list viewport grew 92 dp at
the retarget and the turn moved.

The handoff E2E now fails if the first row disappears, hides, or shifts down while the
draft becomes the agent.

* fix(app): load subagents and tasks only for an agent, not a draft tab
2026-11-06 13:38:10 +07:00
paseo-bot[bot] 2716bd96de test(terminal): observe original process completion after exit (#8892)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 06:27:04 +00:00
Mohamed Boudra 5abfdf7107 Record workspace creation parentage (#8890)
* feat(workspaces): record workspace creation parentage

Retain the caller workspace as informational ancestry for future sidebar nesting, independently of workspace placement and lifecycle.

* fix(app): preserve workspace parentage in replica cache
2026-11-06 10:55:56 +07:00
Mohamed Boudra 6355b339c8 fix(app): paint a smaller send and stop disc inside the touch hit frame (#8891)
Under touch sizing the composer's send and stop buttons filled their whole
44pt hit frame with color, so they outweighed every unpainted control in the
toolbar. The trigger keeps the 44pt frame and now paints a 36pt disc
(TOOLBAR_DISC_SIZE) centered inside it. Pointer sizing paints 28 inside 28, so
desktop is unchanged. The voice button's recording fill follows the same rule.
2026-11-06 10:28:42 +07:00
Mohamed Boudra 8b9c42c266 Keep every sidebar footer action reachable in a narrow sidebar (#8885)
* fix(app): move sidebar footer actions that don't fit behind a caret

Touch sizing makes each footer icon a 44pt target, so five of them plus
the row's padding need 236pt. A docked sidebar can be 200pt and a fold
column about as narrow, so Settings was cut off at the sidebar's edge.

The row now measures itself and keeps as many full-size buttons as fit;
the rest move behind a caret that opens a menu-engine popover. Help and
Usage leave first, Settings and Add project last, and Hosts and Help
open as pages of the overflow menu rather than second popovers.

* fix(app): keep the sidebar footer empty until it has measured

Laying the footer out before its first onLayout put all five buttons in
the row, so a narrow sidebar painted Settings clipped for a few frames
before the caret replaced it. The unmeasured row now places nothing and
holds its height, so the first painted row is already the measured one.

* fix(app): keep the footer overflow menu on a narrow, short screen

The overflow menu asked for 240pt, and edge clamping can move a surface
but not shrink it, so on a 220pt screen its right edge and submenu
arrows hung off the display. Anchored surfaces now cap their width to
the display inside the same edge padding that clamping uses.

The Hosts page lists every host, so the overflow menu is scrollable:
on a short screen the last hosts and Add host were below the fold with
no way to reach them.
2026-11-06 10:28:38 +07:00
Okan YILDIRIM a68dc55490 fix(app): refresh visible usage summaries periodically (#7806) 2026-11-06 03:03:57 +00:00
paseo-bot[bot]andluckxine 138d9d63ab fix(android): keep panel overscroll inside inverted timelines
Android forwards a nested panel’s unconsumed scroll and fling to its inverted parent, moving the timeline in the opposite visual direction. Keep that motion inside the panel while preserving ordinary parent scrolling and nested-scroll registration.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-01 14:32:01 +00:00
70 changed files with 2292 additions and 678 deletions
+6
View File
@@ -136,6 +136,12 @@ 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.
+11 -2
View File
@@ -179,8 +179,17 @@ 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 corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
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:
```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. `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. 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()`.
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.
+3 -2
View File
@@ -226,7 +226,8 @@ 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, 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.
- 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.
- 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,6 +235,7 @@ 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;
@@ -245,6 +246,9 @@ 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;
@@ -257,14 +261,24 @@ 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 }
| {
active: boolean;
largestDownwardShift: number;
sawMissing: boolean;
sawHidden: 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 };
return {
largestDownwardShift: state.largestDownwardShift,
sawMissing: state.sawMissing,
sawHidden: state.sawHidden,
};
});
expect(result.sawMissing).toBe(false);
expect(result.sawHidden).toBe(false);
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
};
}
@@ -1178,7 +1192,13 @@ 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");
});
@@ -0,0 +1,127 @@
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 { openSettingsHostSection } from "../support/helpers/settings";
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterItemSetting,
expectFooterSeparator,
@@ -42,6 +42,7 @@ 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 };
@@ -70,7 +71,112 @@ 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()] });
@@ -469,27 +575,3 @@ 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;
}
+25 -11
View File
@@ -22,15 +22,27 @@ export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
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,
}),
);
await page.evaluate(
({ host, keys, primaryLabel }) => {
({ hosts: added, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
@@ -40,20 +52,22 @@ export async function addOfflineHostAndReload(
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
for (const host of added) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
hosts: offlineHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
primaryLabel: options.primaryLabel,
},
);
@@ -0,0 +1,127 @@
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);
}
}
@@ -0,0 +1,158 @@
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)
}
}
@@ -0,0 +1,19 @@
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,10 +12,13 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with two corrections. Java, because the content-position
* React Native's vertical scroll view with timeline 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";
@@ -32,6 +35,26 @@ 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,7 +65,6 @@ interface BottomAnchorControllerDriver {
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
@@ -480,17 +479,6 @@ 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;
@@ -741,10 +729,6 @@ export function useBottomAnchorController(input: {
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
@@ -129,7 +129,8 @@ function mountList(heldRowId: string) {
}
return position - state.scroll;
};
return { render, scrollTo, heldRowTop };
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
return { render, scrollTo, heldRowTop, rowSize };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
@@ -169,3 +170,21 @@ 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,9 +137,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
const isListPlaced = placedAgentId === agentId;
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
const [isListPlaced, setIsListPlaced] = useState(false);
const isListPlacedRef = useRef(isListPlaced);
isListPlacedRef.current = isListPlaced;
const streamViewportMetricsRef = useRef({
@@ -384,23 +383,6 @@ 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();
@@ -411,7 +393,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -623,7 +605,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const handleListPlaced = useStableEvent(() => {
isListPlacedRef.current = true;
setPlacedAgentId(agentId);
setIsListPlaced(true);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
contentMeasuredForKey: "native-virtualized",
@@ -693,6 +675,7 @@ 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}
@@ -711,7 +694,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
dataKey={agentId}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
+2 -10
View File
@@ -839,10 +839,6 @@ 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();
@@ -853,7 +849,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
}, [evaluateHistoryStart]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -1395,11 +1391,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
return createStreamStrategy({
render: (renderInput) => (
<WebStreamViewport
key={renderInput.agentId}
{...renderInput}
isMobileBreakpoint={input.isMobileBreakpoint}
/>
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
),
orderTailReverse: false,
orderHeadReverse: false,
@@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => {
]).flat();
const { result } = renderHook(() =>
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }),
);
expect(result.current.start).toBe(2);
@@ -35,7 +35,6 @@ describe("useStreamHistoryWindow", () => {
const loadRemoteOlder = vi.fn(async () => true);
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -67,8 +66,7 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -91,8 +89,7 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -108,8 +105,7 @@ describe("useStreamHistoryWindow", () => {
configurable: true,
});
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
{
initialProps: {
history: [
@@ -142,7 +138,6 @@ describe("useStreamHistoryWindow", () => {
});
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -28,22 +28,17 @@ 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, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (current.agentId !== agentId) {
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
}
const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (!current.initialized && hasItems) {
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
}
@@ -54,28 +49,26 @@ function reconcileHistoryWindow(input: {
}
export function useStreamHistoryWindow(input: {
agentId: string;
items: StreamItem[];
loadRemoteOlder: () => boolean | Promise<boolean>;
}) {
const { agentId, items, loadRemoteOlder } = input;
const { 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.agentId === agentId ? window.boundaryItemId : null;
const boundaryItemId = window.boundaryItemId;
const boundaryIndex = useMemo(
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
[boundaryItemId, items],
);
let start = initialStart;
if (window.agentId === agentId && window.initialized) {
if (window.initialized) {
if (window.boundaryItemId === null) {
start = 0;
} else if (boundaryIndex >= 0) {
@@ -87,13 +80,12 @@ export function useStreamHistoryWindow(input: {
setWindow((current) =>
reconcileHistoryWindow({
current,
agentId,
boundaryIndex,
initialBoundaryItemId,
hasItems: items.length > 0,
}),
);
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
}, [boundaryIndex, initialBoundaryItemId, items.length]);
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
@@ -106,13 +98,12 @@ export function useStreamHistoryWindow(input: {
return false;
}
setWindow({
agentId,
boundaryItemId: items[nextStart]?.id ?? null,
initialized: true,
});
return true;
},
[agentId, items, start],
[items, start],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
return revealLoadedHistory() || (await loadRemoteOlder());
+1 -6
View File
@@ -430,10 +430,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
useEffect(() => {
setExpandedInlineToolCallIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
@@ -476,7 +472,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
return () => inlinePathOpener.cancel();
}, [inlinePathOpener, isActive, agentId]);
}, [inlinePathOpener, isActive]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (isActive) void inlinePathOpener.open(target, disposition);
@@ -551,7 +547,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
revealLoadedHistory,
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: presentation.tail,
loadRemoteOlder,
});
+79 -24
View File
@@ -17,6 +17,7 @@ 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";
@@ -40,6 +41,17 @@ 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";
@@ -55,6 +67,7 @@ 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";
@@ -79,6 +92,9 @@ 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 {
@@ -410,35 +426,72 @@ 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.
// 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.
return (
<UsageSidebarRoot>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<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.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.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
{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}
</View>
</View>
</UsageSidebarRoot>
@@ -937,10 +990,12 @@ 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",
paddingHorizontal: theme.spacing[2],
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
paddingHorizontal: FOOTER_ROW_PADDING,
paddingVertical: theme.spacing[1.5],
},
// Pushes Help and Settings to the end of the icon line.
@@ -0,0 +1,89 @@
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: [] });
}
});
});
@@ -0,0 +1,62 @@
/** 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)),
};
}
@@ -0,0 +1,69 @@
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,
},
}));
@@ -0,0 +1,245 @@
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 { Text, View } from "react-native";
import { 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,17 +12,13 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
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";
@@ -77,12 +73,32 @@ 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();
@@ -99,105 +115,65 @@ export function SidebarHelpMenu() {
}, []);
return (
<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}
<>
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenuItem
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
>
{t("sidebar.help.whatsNew")}
{t("sidebar.help.shortcuts")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
) : 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"
>
{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>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</>
);
}
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],
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
import { computePosition, fitSurfaceWidth, 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,3 +97,26 @@ 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,6 +120,27 @@ 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,6 +14,8 @@ 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";
@@ -90,6 +92,11 @@ 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,6 +93,14 @@ 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,6 +29,7 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
measureElement,
type Alignment,
@@ -283,11 +284,7 @@ export function AnchoredSurface({
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
return [
resolvedWidthStyle,
{
@@ -60,17 +60,6 @@ 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 () => {
+1 -42
View File
@@ -8,15 +8,8 @@ import {
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import {
createUserMessage,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
import { createUserMessage, 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;
@@ -153,38 +146,6 @@ 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) => {
@@ -365,8 +326,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
machine,
formErrorMessage,
isSubmitting,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
@@ -0,0 +1,103 @@
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
@@ -0,0 +1,102 @@
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,4 +1,5 @@
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export interface WorkspaceDraftAutoSubmitConfig {
provider: string;
@@ -50,3 +51,13 @@ 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;
}
+124 -198
View File
@@ -1,21 +1,17 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { Keyboard, 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 { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
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";
@@ -25,13 +21,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 type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import { DRAFT_CAPABILITIES } from "@/composer/draft/timeline";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
@@ -50,16 +46,6 @@ 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;
@@ -260,25 +246,15 @@ function buildDraftInitialValues(input: {
};
}
function resolveDraftWorkingDirectory(input: {
workspaceDirectory: string | null;
initialSetup: WorkspaceDraftTabSetup | null;
}): string | null {
if (input.initialSetup) {
return input.initialSetup.cwd;
}
return input.workspaceDirectory;
}
interface WorkspaceDraftAgentTabProps {
interface WorkspaceDraftComposerProps {
serverId: string;
workspaceId: string;
tabId: string;
draftId: string;
initialSetup?: WorkspaceDraftTabSetup;
isPaneFocused: boolean;
editingSession: ComposerEditingSession;
onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet?: () => void;
}
@@ -292,17 +268,21 @@ function resolveImportPillPress(
return onOpenImportSheet ?? null;
}
export function WorkspaceDraftAgentTab({
/**
* 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({
serverId,
workspaceId,
tabId,
draftId,
initialSetup = undefined,
isPaneFocused,
editingSession,
onCreated,
onOpenWorkspaceFile,
onOpenImportSheet,
}: WorkspaceDraftAgentTabProps) {
}: WorkspaceDraftComposerProps) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -387,7 +367,6 @@ export function WorkspaceDraftAgentTab({
}, [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,
@@ -421,82 +400,71 @@ export function WorkspaceDraftAgentTab({
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
);
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],
);
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);
},
});
useAgentControlCommandCenterActions({
sourceId: `draft:${serverId}:${tabId}`,
enabled: isPaneFocused && !isSubmitting,
@@ -600,75 +568,48 @@ export function WorkspaceDraftAgentTab({
}),
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
);
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>
) : (
<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>
</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 style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{formErrorMessage ? (
<View style={styles.formErrorRow}>
<View style={styles.formError}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
</View>
</ComposerDock>
</FileDropZone>
) : null}
{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>
);
}
@@ -682,27 +623,10 @@ const animatedStaticStyles = RNStyleSheet.create({
});
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
formErrorRow: {
width: "100%",
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flex: 1,
},
streamContainer: {
flex: 1,
},
scrollView: {
flex: 1,
},
configScrollContent: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
configSection: {
gap: theme.spacing[3],
alignItems: "center",
},
importPillRow: {
width: "100%",
@@ -722,8 +646,10 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: theme.contentMaxWidth,
flexDirection: "row",
},
errorContainer: {
marginTop: theme.spacing[2],
formError: {
width: "100%",
maxWidth: theme.contentMaxWidth,
marginBottom: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
+10 -2
View File
@@ -35,6 +35,7 @@ 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";
@@ -1117,7 +1118,7 @@ function ComposerCancelButton({
accessibilityRole="button"
style={cancelButtonStyle}
>
{icon}
<View style={styles.cancelButtonDisc}>{icon}</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -2595,14 +2596,21 @@ 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",
+41 -24
View File
@@ -33,6 +33,7 @@ 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";
@@ -349,7 +350,11 @@ function VoiceButtonIcon({
buttonIconSize: number;
}) {
if (isDictating) {
return <Square size={buttonIconSize} color="white" fill="white" />;
return (
<View style={styles.voiceButtonRecordingDisc}>
<Square size={buttonIconSize} color="white" fill="white" />
</View>
);
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) {
@@ -837,12 +842,14 @@ function SendButtonTooltip({
testID={submitButtonTestID}
style={sendButtonCombinedStyle}
>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
<View style={submitLabel ? styles.sendButtonDiscLabeled : styles.sendButtonDisc}>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
@@ -1925,7 +1932,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
styles.voiceButton,
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
!isDictationStartEnabled && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
],
[isDictating, isDictationStartEnabled],
);
@@ -1974,12 +1980,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
allowEmpty: allowEmptySubmit,
});
const sendButtonCombinedStyle = useMemo(
() => [
styles.sendButton,
submitLabel ? styles.sendButtonLabeled : undefined,
isPresentedSendDisabled && styles.buttonDisabled,
],
[isPresentedSendDisabled, submitLabel],
() => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled],
[isPresentedSendDisabled],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
@@ -2416,23 +2418,38 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
},
sendButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
voiceButtonRecordingDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
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,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
sendButtonLabeled: {
width: "auto",
minWidth: TOOLBAR_CONTROL_HEIGHT,
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,
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,6 +1236,7 @@ export const ar: TranslationResources = {
hosts: "المضيفون",
settings: "إعدادات",
closeSidebar: "إغلاق الشريط الجانبي",
moreActions: "المزيد من الإجراءات",
},
footer: {
usage: "الاستخدام",
+1
View File
@@ -1244,6 +1244,7 @@ export const en = {
hosts: "Hosts",
settings: "Settings",
closeSidebar: "Close sidebar",
moreActions: "More actions",
},
footer: {
usage: "Usage",
+1
View File
@@ -1272,6 +1272,7 @@ export const es: TranslationResources = {
hosts: "Hosts",
settings: "Ajustes",
closeSidebar: "Cerrar barra lateral",
moreActions: "Más acciones",
},
footer: {
usage: "Uso",
+1
View File
@@ -1266,6 +1266,7 @@ 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,6 +1250,7 @@ export const ja: TranslationResources = {
hosts: "ホスト",
settings: "設定",
closeSidebar: "サイドバーを閉じる",
moreActions: "その他のアクション",
},
footer: {
usage: "使用状況",
+1
View File
@@ -1243,6 +1243,7 @@ export const ko: TranslationResources = {
hosts: "호스트",
settings: "설정",
closeSidebar: "사이드바 닫기",
moreActions: "추가 작업",
},
footer: {
usage: "사용량",
+1
View File
@@ -1262,6 +1262,7 @@ export const ptBR: TranslationResources = {
hosts: "Hosts",
settings: "Configurações",
closeSidebar: "Fechar barra lateral",
moreActions: "Mais ações",
},
footer: {
usage: "Uso",
+1
View File
@@ -1254,6 +1254,7 @@ export const ru: TranslationResources = {
hosts: "Хосты",
settings: "Настройки",
closeSidebar: "Закрыть боковую панель",
moreActions: "Дополнительные действия",
},
footer: {
usage: "Использование",
+1
View File
@@ -1228,6 +1228,7 @@ export const zhCN: TranslationResources = {
hosts: "Hosts",
settings: "设置",
closeSidebar: "关闭侧边栏",
moreActions: "更多操作",
},
footer: {
usage: "使用情况",
+158 -94
View File
@@ -78,7 +78,8 @@ import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
import { AgentTracks } from "@/panels/agent-tracks";
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -90,14 +91,13 @@ import {
useSessionStore,
} from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } 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, TodoEntry } from "@/types/stream";
import type { StreamItem } 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,6 +225,29 @@ 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;
@@ -303,17 +326,31 @@ function storeFetchedAgentDetail(input: {
return hydrated;
}
function AgentPanel() {
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() {
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(target.kind === "agent", "AgentPanel requires agent target");
invariant(
target.kind === "agent" || target.kind === "draft",
"AgentConversationPanel requires an agent or draft target",
);
return (
<RetainedChatContent>
<AgentPanelContent
serverId={serverId}
workspaceId={workspaceId}
agentId={target.agentId}
target={target}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={openFileInWorkspace}
/>
@@ -321,55 +358,6 @@ function AgentPanel() {
);
}
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,
@@ -444,18 +432,18 @@ type AgentLookupState =
function AgentPanelContent({
serverId,
workspaceId,
agentId,
target,
isPaneFocused,
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId: string;
target: ConversationTarget;
isPaneFocused: boolean;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const resolvedAgentId = agentId.trim() || undefined;
const resolvedAgentId = resolveConversationAgentId(target);
const resolvedServerId = serverId.trim() || undefined;
const daemons = useHosts();
const runtimeServerId = resolvedServerId ?? "";
@@ -484,7 +472,7 @@ function AgentPanelContent({
: runtimeConnectionStatus;
const lastConnectionError = runtimeLastError;
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) {
return (
<AgentSessionUnavailableState
serverLabel={serverLabel}
@@ -498,9 +486,9 @@ function AgentPanelContent({
return (
<AgentPanelBody
serverId={resolvedServerId}
serverId={resolvedServerId ?? serverId}
workspaceId={workspaceId}
agentId={resolvedAgentId}
target={target}
isPaneFocused={isPaneFocused}
client={runtimeClient}
isConnected={runtimeIsConnected}
@@ -512,7 +500,7 @@ function AgentPanelContent({
function AgentPanelBody({
serverId,
workspaceId,
agentId,
target,
isPaneFocused,
client,
isConnected,
@@ -520,13 +508,14 @@ function AgentPanelBody({
}: {
serverId: string;
workspaceId: string;
agentId?: string;
target: ConversationTarget;
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(
@@ -641,7 +630,7 @@ function AgentPanelBody({
}
: null;
if (!agent) {
if (target.kind === "agent" && !agent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
@@ -655,7 +644,7 @@ function AgentPanelBody({
<ChatAgentContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
target={target}
isPaneFocused={isPaneFocused}
client={client}
isConnected={isConnected}
@@ -667,7 +656,7 @@ function AgentPanelBody({
function ChatAgentContent({
serverId,
workspaceId,
agentId,
target,
isPaneFocused,
client,
isConnected,
@@ -675,13 +664,15 @@ function ChatAgentContent({
}: {
serverId: string;
workspaceId: string;
agentId?: string;
target: ConversationTarget;
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();
@@ -988,17 +979,23 @@ function ChatAgentContent({
visibilityCatchUpStatus,
]);
const nonReadyView = renderChatAgentNonReadyView({
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,
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" &&
@@ -1013,7 +1010,8 @@ function ChatAgentContent({
<ChatAgentReadyContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
agentId={agentId ?? tabId}
draft={draft}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
agentState={agentState}
@@ -1031,7 +1029,7 @@ function ChatAgentContent({
showHistorySyncOverlay={showHistorySyncOverlay}
showHistorySyncError={showHistorySyncError}
isRetryingHistorySync={isRetryingHistorySync}
cwd={agentCwd}
cwd={agentState.cwd ?? ""}
retryTimelineSync={retryTimelineSync}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
@@ -1044,6 +1042,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId,
workspaceId,
agentId,
draft,
isPaneFocused,
isArchivingCurrentAgent,
agentState,
@@ -1069,11 +1068,13 @@ 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;
effectiveAgent: AgentScreenAgent | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
toastApi: ToastApi;
@@ -1095,19 +1096,11 @@ 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 showsComposer = usePaneContext().host !== "sheet";
const paneHost = usePaneContext().host;
const showsComposer = draft !== null || paneHost !== "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.
@@ -1161,7 +1154,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const parentCallout = parentAgentId ? (
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
) : null;
const composerSection = showsComposer ? (
const agentComposerSection = showsComposer ? (
<RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection
agentId={agentId}
@@ -1182,6 +1175,16 @@ 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} />}
@@ -1191,6 +1194,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
draft={draft}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
hasActiveComposer={hasActiveComposer}
@@ -1198,16 +1202,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
{hasActiveComposer ? (
{hasActiveComposer && !draft ? (
<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,
enabled: showsComposer && hasActiveComposer && !draft,
serverId,
agentId,
editingSession: composerEditingSession,
@@ -1355,6 +1355,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
serverId,
agentId,
agent,
draft,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
hasActiveComposer,
@@ -1364,7 +1365,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
agentId?: string;
agent: AgentScreenAgent;
agent: AgentScreenAgent | null;
draft: DraftConversation | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
hasActiveComposer: boolean;
@@ -1422,7 +1424,31 @@ 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}
@@ -1443,6 +1469,44 @@ 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,
+13 -12
View File
@@ -9,11 +9,11 @@ import { useSettings } from "@/hooks/use-settings";
import { PluginComposerPills } from "@/plugins";
import { useSessionStore } from "@/stores/session-store";
import {
type ArchiveFinishedStatus,
useArchiveFinishedSubagents,
useArchiveSubagent,
useDetachSubagent,
useSubagentSheetTab,
type SubagentRow,
useSubagentsForParent,
} from "@/subagents";
import { ParentAgentPill } from "@/subagents/parent";
import { SubagentsTrack } from "@/subagents/track";
@@ -35,20 +35,21 @@ 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);
@@ -119,8 +120,8 @@ export const AgentTracks = memo(function AgentTracks({
onOpenProviderSubagent={handleOpenProviderSubagent}
sheetTabFor={sheetTabFor}
onArchiveSubagent={archiveSubagent}
onArchiveFinished={onArchiveFinished}
archiveFinishedStatus={archiveFinishedStatus}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<PluginComposerPills
@@ -390,4 +390,39 @@ 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,6 +21,26 @@ 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 =
@@ -59,7 +79,8 @@ export class AgentStoreProjection {
let accepted = agent;
useSessionStore.getState().setAgents(this.serverId, (current) => {
const previous = current.get(agent.id);
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
const winner = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
accepted = previous ? keepUnchangedFields(previous, winner) : winner;
if (accepted === previous) return current;
const next = new Map(current);
next.set(agent.id, accepted);
@@ -117,7 +138,7 @@ export class AgentStoreProjection {
for (const [agentId, fetchedAgent] of fetched) {
const existing = current.get(agentId);
const merged = mergeSnapshotTurn(existing, fetchedAgent);
agents.set(agentId, existing && equal(existing, merged) ? existing : merged);
agents.set(agentId, existing ? keepUnchangedFields(existing, merged) : merged);
if (fetchedAgent.archivedAt) {
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
}
@@ -321,6 +321,26 @@ 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,6 +299,7 @@ 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(),
@@ -707,6 +708,7 @@ 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,8 +870,12 @@ 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";
return supportsStreamTopOverlay && (activeTab?.kind === "agent" || activeTab?.kind === "draft");
}
/** A fullscreen composer covers the compact header; the column lays out without it. */
@@ -557,6 +557,33 @@ 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,6 +105,7 @@ export interface Agent {
}
export interface WorkspaceDescriptor {
parentWorkspaceId?: string;
createdAt: Date | null;
lastUserMessageAt: Date | null;
id: string;
@@ -162,6 +163,7 @@ 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,
+14 -9
View File
@@ -33,9 +33,10 @@ import {
} from "./model";
import type { UsageReportEntry, UsageView } from "./types";
// 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.
// The daemon caches each report for five minutes, so re-reading it is cheap.
// An explicit refresh passes `forceRefresh` to bypass that cache.
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) {
@@ -164,12 +165,16 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const NO_REPORTS: 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[] {
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[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
@@ -178,7 +183,7 @@ export function useUsageHostReports(serverId: string | null): UsageReportEntry[]
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
refetchInterval: REPORTS_STALE_TIME_MS,
refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
+8 -1
View File
@@ -16,6 +16,8 @@ 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";
@@ -36,9 +38,14 @@ 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(inSidebar ? serverId : null);
const reports = useUsageHostReports({
serverId: inSidebar ? serverId : null,
shouldPoll: active && inSidebar && isAppVisible,
});
const { preferences } = useUsageDisplay(reports);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
@@ -126,6 +126,23 @@ 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,6 +4189,8 @@ 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,6 +634,26 @@ 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,12 +1197,43 @@ 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())
@@ -1210,7 +1241,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,6 +188,7 @@ interface ProviderSummary {
}
const WorkspaceAutomationSummarySchema = z.object({
parentWorkspaceId: z.string().optional(),
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
@@ -206,6 +207,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) {
kind: workspace.kind,
title: workspace.title,
background: workspace.background,
parentWorkspaceId: workspace.parentWorkspaceId,
};
}
+2
View File
@@ -5900,6 +5900,7 @@ 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,
@@ -5988,6 +5989,7 @@ 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,6 +205,82 @@ 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,15 +227,21 @@ export function createWorkspaceProvisioningService(deps: {
return project;
}
async function resolveBackground(input?: {
async function resolveCreationMetadata(input?: {
workspaceId?: string;
background?: boolean;
callerWorkspaceId?: string;
}): Promise<boolean> {
if (input?.background !== undefined) return input.background;
if (!input?.callerWorkspaceId) return false;
}): 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");
}
const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId);
if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`);
return callerWorkspace.background;
return {
background: input.background ?? callerWorkspace.background,
parentWorkspaceId: callerWorkspace.workspaceId,
};
}
async function createWorkspaceForDirectory(
@@ -269,7 +275,7 @@ export function createWorkspaceProvisioningService(deps: {
title: title?.trim() || null,
createdAt: timestamp,
updatedAt: timestamp,
background: await resolveBackground(context),
...(await resolveCreationMetadata(context)),
});
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: context?.expectsInitialAgent,
@@ -310,7 +316,7 @@ export function createWorkspaceProvisioningService(deps: {
createdAt: timestamp,
updatedAt: timestamp,
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
background: await resolveBackground(input),
...(await resolveCreationMetadata(input)),
});
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: input.expectsInitialAgent,
@@ -49,6 +49,7 @@ const PersistedProjectRecordSchema = z.object({
});
const PersistedWorkspaceRecordSchema = z.object({
parentWorkspaceId: z.string().optional(),
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
@@ -679,6 +680,7 @@ export function resolveProjectDisplayName(record: PersistedProjectRecord): strin
}
export function createPersistedWorkspaceRecord(input: {
parentWorkspaceId?: string;
workspaceId: string;
projectId: string;
cwd: string;
@@ -270,6 +270,16 @@ 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,7 +867,18 @@ it.each([false, true])(
writeFileSync(exitRequestPath, "");
await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000);
expect(existsSync(exitObservedPath)).toBe(true);
expect(isProcessAlive(endingPid)).toBe(false);
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);
} finally {
if (recordFd !== null) closeSync(recordFd);
}
+47 -20
View File
@@ -25,6 +25,9 @@
# 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.
@@ -45,7 +48,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..b1689ad 100644
index eeb3fb0..5fe940d 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
@@ -205,7 +208,19 @@ index eeb3fb0..b1689ad 100644
}
// src/core/updateItemSizes.ts
@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
@@ -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
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
@@ -214,7 +229,7 @@ index eeb3fb0..b1689ad 100644
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
@@ -225,7 +240,7 @@ index eeb3fb0..b1689ad 100644
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
@@ -234,7 +249,7 @@ index eeb3fb0..b1689ad 100644
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
@@ -249,7 +264,7 @@ index eeb3fb0..b1689ad 100644
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
@@ -258,7 +273,7 @@ index eeb3fb0..b1689ad 100644
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
@@ -267,7 +282,7 @@ index eeb3fb0..b1689ad 100644
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
@@ -279,7 +294,7 @@ index eeb3fb0..b1689ad 100644
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
@@ -287,7 +302,7 @@ index eeb3fb0..b1689ad 100644
getItemType,
horizontal,
rtl,
@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,
@@ -296,7 +311,7 @@ index eeb3fb0..b1689ad 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..e61945e 100644
index fe5f415..7f1b9dd 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
@@ -456,7 +471,19 @@ index fe5f415..e61945e 100644
}
// src/core/updateItemSizes.ts
@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
@@ -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
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
@@ -465,7 +492,7 @@ index fe5f415..e61945e 100644
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
@@ -476,7 +503,7 @@ index fe5f415..e61945e 100644
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
@@ -485,7 +512,7 @@ index fe5f415..e61945e 100644
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
@@ -500,7 +527,7 @@ index fe5f415..e61945e 100644
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
@@ -509,7 +536,7 @@ index fe5f415..e61945e 100644
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
@@ -518,7 +545,7 @@ index fe5f415..e61945e 100644
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
@@ -530,7 +557,7 @@ index fe5f415..e61945e 100644
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
@@ -538,7 +565,7 @@ index fe5f415..e61945e 100644
getItemType,
horizontal,
rtl,
@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,