Compare commits

...
Author SHA1 Message Date
Mohamed Boudra c4e5a0e739 feat(plugin): observe terminals across connected hosts
Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp.
2026-11-06 09:22:19 +01:00
Mohamed Boudra 62bd21214b feat(plugin): add sidebar view examples and ref actions
Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders.
2026-11-06 09:22:19 +01:00
Mohamed Boudra dc309586f0 feat(plugin): let users select a sidebar view
An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery.
2026-11-06 09:22:19 +01:00
Mohamed Boudra e4106b4e2d feat(plugin): expose stable sidebar entity data across hosts
Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase.
2026-11-06 09:22:19 +01: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
126 changed files with 5537 additions and 446 deletions
+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).
+7
View File
@@ -570,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Replace sidebar content
The sidebar view slot is global across hosts, while header and footer contributions retain their
host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns
its layout. This keeps plugin failures from removing navigation or recovery controls. The
[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
@@ -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();
});
});
@@ -0,0 +1,18 @@
import { test } from "../support/fixtures";
import { withSidebarViews } from "../support/helpers/sidebar-views";
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
page,
}) => {
test.setTimeout(150_000);
await withSidebarViews(page, async (views) => {
await views.expectDefault();
await views.chooseWorkspaces();
await views.exerciseExamples();
await views.exerciseTerminals();
await views.restoreDefault();
await views.recoverFromFailure();
await views.chooseWorkspaces();
await views.disableChosenView();
});
});
@@ -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()] });
+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,
},
);
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "createTerminal"
| "killTerminal"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
| "setWorkspaceLabel"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -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,191 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { copyPluginExample } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { gotoWorkspace } from "./launcher";
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
import { visibleTestId } from "./plugin-sidebar-items";
import { getServerId } from "./server-id";
export async function withSidebarViews(
page: Page,
run: (views: ReturnType<typeof sidebarViews>) => Promise<void>,
) {
const example = await copyPluginExample("sidebar-views");
const entry = path.join(example.directory, "index.client.tsx");
const original = await readFile(entry, "utf8");
await writeFile(
entry,
original.replace(
"const cleanups = [",
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
),
);
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-views-",
title: "Sidebar view workspace",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await run(sidebarViews(page, workspace.workspaceId, client));
} finally {
await client.removePlugin("sidebar-views");
await client.close();
await workspace.cleanup();
await example.cleanup();
}
}
function sidebarViews(
page: Page,
workspaceId: string,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
) {
const builtinRow = () =>
visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
async function choose(title: string) {
await openSidebarNavSettings(page);
await page.getByRole("button", { name: /^View:/ }).click();
await page.getByRole("menuitem", { name: title, exact: true }).click();
await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible();
await leaveSettings(page);
}
async function expectDefault() {
await expect(builtinRow()).toBeVisible();
}
async function expectPlugin() {
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", {
exact: true,
}),
).toBeVisible();
await expect(builtinRow()).toHaveCount(0);
await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible();
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
}
return {
expectDefault,
async chooseWorkspaces() {
await choose("Example workspaces");
await expectPlugin();
},
async exerciseExamples() {
await choose("Example agents");
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
).toBeVisible();
await choose("Example nested agents");
await expectPlugin();
await choose("Example label folders");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toContain("Inbox");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Later"]);
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Unlabelled", color: "red" },
assigned: true,
});
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Later", color: "red" },
assigned: false,
});
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Unlabelled"]);
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Inbox"]);
},
async exerciseTerminals() {
await choose("Example terminals");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
.click();
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
const workspace = (await client.fetchWorkspaces()).entries.find(
(item) => item.id === workspaceId,
)!;
const created = await client.createTerminal(
workspace.workspaceDirectory,
"Sidebar terminal",
undefined,
{ workspaceId },
);
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
try {
await expect(
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
).toBeVisible();
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
await expect
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
.not.toContain(created.terminal!.id);
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
} finally {
await client.killTerminal(created.terminal.id);
}
},
async restoreDefault() {
await choose("Default view");
await expectDefault();
},
async recoverFromFailure() {
await choose("Broken example");
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Use default view", exact: true }).click();
await expectDefault();
},
async disableChosenView() {
await client.disablePlugin("sidebar-views");
await expectDefault();
await openSidebarNavSettings(page);
await expect(
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
exact: true,
}),
).toBeVisible();
await leaveSettings(page);
},
};
}
+134 -74
View File
@@ -1,3 +1,4 @@
import { SidebarViewSlot } from "@/plugins/sidebar-view";
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
@@ -17,6 +18,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 +42,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 +68,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 +93,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 +427,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>
@@ -589,31 +643,33 @@ function MobileSidebar({
)}
<SidebarSeparator />
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarViewSlot dismiss={handleWorkspacePress}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
<SidebarFooter
handleOpenProject={handleOpenProject}
@@ -785,31 +841,33 @@ function DesktopSidebar({
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
</View>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarViewSlot dismiss={closeSidebar}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
<SidebarCalloutSlot />
@@ -937,10 +995,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,
{
+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,
+4 -4
View File
@@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
} {
const handlers: Record<RouterMessageType, RouterHandler[]> = {
providers_snapshot_update: [],
@@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
function on<K extends RouterMessageType>(
type: K,
+5 -2
View File
@@ -41,8 +41,11 @@ export function useReplicaQuery<
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input));
>(
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input), queryClient);
}
export function useFetchQuery<
+15
View File
@@ -1161,6 +1161,14 @@ export const ar: TranslationResources = {
},
},
sidebar: {
view: {
failed: "تعذر تحميل عرض الشريط الجانبي.",
useDefault: "استخدام العرض الافتراضي",
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
agentUnavailable: "لم يعد الوكيل متاحًا",
terminalUnavailable: "لم تعد الطرفية متاحة",
projectUnavailable: "لم يعد المشروع متاحًا",
},
statusGroupAccessibility: "مجموعة {{label}}",
statusBucket: {
needsInput: "تحتاج إدخالاً",
@@ -1236,6 +1244,7 @@ export const ar: TranslationResources = {
hosts: "المضيفون",
settings: "إعدادات",
closeSidebar: "إغلاق الشريط الجانبي",
moreActions: "المزيد من الإجراءات",
},
footer: {
usage: "الاستخدام",
@@ -2580,6 +2589,12 @@ export const ar: TranslationResources = {
description: "عرض مخطط للتنقل بين المطالبات",
},
sidebar: {
view: {
title: "عرض الشريط الجانبي",
label: "العرض",
default: "العرض الافتراضي",
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
},
header: {
title: "الرأس",
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
+15
View File
@@ -1169,6 +1169,14 @@ export const en = {
},
},
sidebar: {
view: {
failed: "Sidebar view failed to load.",
useDefault: "Use default view",
readFailed: "Could not load your sidebar view preference.",
agentUnavailable: "Agent is no longer available",
terminalUnavailable: "Terminal is no longer available",
projectUnavailable: "Project is no longer available",
},
statusGroupAccessibility: "{{label}} group",
statusBucket: {
needsInput: "Needs input",
@@ -1244,6 +1252,7 @@ export const en = {
hosts: "Hosts",
settings: "Settings",
closeSidebar: "Close sidebar",
moreActions: "More actions",
},
footer: {
usage: "Usage",
@@ -2671,6 +2680,12 @@ export const en = {
description: "Show an outline for jumping between prompts",
},
sidebar: {
view: {
title: "Sidebar view",
label: "View",
default: "Default view",
unavailable: "The selected view is unavailable or disabled. Showing Default view.",
},
header: {
title: "Header",
description: "Choose which items appear at the top of the sidebar and in what order",
+16
View File
@@ -1197,6 +1197,14 @@ export const es: TranslationResources = {
},
},
sidebar: {
view: {
failed: "No se pudo cargar la vista de la barra lateral.",
useDefault: "Usar vista predeterminada",
readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.",
agentUnavailable: "El agente ya no está disponible",
terminalUnavailable: "La terminal ya no está disponible",
projectUnavailable: "El proyecto ya no está disponible",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Necesita datos",
@@ -1272,6 +1280,7 @@ export const es: TranslationResources = {
hosts: "Hosts",
settings: "Ajustes",
closeSidebar: "Cerrar barra lateral",
moreActions: "Más acciones",
},
footer: {
usage: "Uso",
@@ -2600,6 +2609,13 @@ export const es: TranslationResources = {
description: "Muestra un esquema para saltar entre instrucciones",
},
sidebar: {
view: {
title: "Vista de la barra lateral",
label: "Vista",
default: "Vista predeterminada",
unavailable:
"La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.",
},
header: {
title: "Encabezado",
description:
+16
View File
@@ -1190,6 +1190,14 @@ export const fr: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Impossible de charger la vue de la barre latérale.",
useDefault: "Utiliser la vue par défaut",
readFailed: "Impossible de charger votre préférence de vue de la barre latérale.",
agentUnavailable: "Cet agent n’est plus disponible",
terminalUnavailable: "Ce terminal n’est plus disponible",
projectUnavailable: "Ce projet n’est plus disponible",
},
statusGroupAccessibility: "Groupe {{label}}",
statusBucket: {
needsInput: "Attend une réponse",
@@ -1266,6 +1274,7 @@ export const fr: TranslationResources = {
hosts: "Hôtes",
settings: "Paramètres",
closeSidebar: "Fermer la barre latérale",
moreActions: "Plus d’actions",
},
footer: {
usage: "Utilisation",
@@ -2601,6 +2610,13 @@ export const fr: TranslationResources = {
description: "Afficher un plan pour naviguer entre les prompts",
},
sidebar: {
view: {
title: "Vue de la barre latérale",
label: "Vue",
default: "Vue par défaut",
unavailable:
"La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.",
},
header: {
title: "En-tête",
description:
+16
View File
@@ -1174,6 +1174,14 @@ export const ja: TranslationResources = {
},
},
sidebar: {
view: {
failed: "サイドバーのビューを読み込めませんでした。",
useDefault: "デフォルトのビューを使用",
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
agentUnavailable: "このエージェントは利用できなくなりました",
terminalUnavailable: "このターミナルは利用できなくなりました",
projectUnavailable: "このプロジェクトは利用できなくなりました",
},
statusGroupAccessibility: "{{label}} グループ",
statusBucket: {
needsInput: "入力待ち",
@@ -1250,6 +1258,7 @@ export const ja: TranslationResources = {
hosts: "ホスト",
settings: "設定",
closeSidebar: "サイドバーを閉じる",
moreActions: "その他のアクション",
},
footer: {
usage: "使用状況",
@@ -2567,6 +2576,13 @@ export const ja: TranslationResources = {
description: "プロンプト間を移動するためのアウトラインを表示します",
},
sidebar: {
view: {
title: "サイドバーのビュー",
label: "ビュー",
default: "デフォルトのビュー",
unavailable:
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
},
header: {
title: "ヘッダー",
description: "サイドバー上部に表示する項目とその順序を選択します",
+16
View File
@@ -1168,6 +1168,14 @@ export const ko: TranslationResources = {
},
},
sidebar: {
view: {
failed: "사이드바 보기를 불러오지 못했습니다.",
useDefault: "기본 보기 사용",
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
},
statusGroupAccessibility: "{{label}} 그룹",
statusBucket: {
needsInput: "입력 필요",
@@ -1243,6 +1251,7 @@ export const ko: TranslationResources = {
hosts: "호스트",
settings: "설정",
closeSidebar: "사이드바 닫기",
moreActions: "추가 작업",
},
footer: {
usage: "사용량",
@@ -2558,6 +2567,13 @@ export const ko: TranslationResources = {
description: "프롬프트 사이를 이동하기 위한 개요 표시",
},
sidebar: {
view: {
title: "사이드바 보기",
label: "보기",
default: "기본 보기",
unavailable:
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
},
header: {
title: "헤더",
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
+16
View File
@@ -1187,6 +1187,14 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Não foi possível carregar a visualização da barra lateral.",
useDefault: "Usar visualização padrão",
readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.",
agentUnavailable: "O agente não está mais disponível",
terminalUnavailable: "O terminal não está mais disponível",
projectUnavailable: "O projeto não está mais disponível",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Precisa de resposta",
@@ -1262,6 +1270,7 @@ export const ptBR: TranslationResources = {
hosts: "Hosts",
settings: "Configurações",
closeSidebar: "Fechar barra lateral",
moreActions: "Mais ações",
},
footer: {
usage: "Uso",
@@ -2582,6 +2591,13 @@ export const ptBR: TranslationResources = {
description: "Mostrar uma estrutura para navegar entre prompts",
},
sidebar: {
view: {
title: "Visualização da barra lateral",
label: "Visualização",
default: "Visualização padrão",
unavailable:
"A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.",
},
header: {
title: "Cabeçalho",
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
+15
View File
@@ -1178,6 +1178,14 @@ export const ru: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Не удалось загрузить вид боковой панели.",
useDefault: "Использовать стандартный вид",
readFailed: "Не удалось загрузить настройку вида боковой панели.",
agentUnavailable: "Агент больше недоступен",
terminalUnavailable: "Терминал больше недоступен",
projectUnavailable: "Проект больше недоступен",
},
statusGroupAccessibility: "Группа «{{label}}»",
statusBucket: {
needsInput: "Ожидает ввода",
@@ -1254,6 +1262,7 @@ export const ru: TranslationResources = {
hosts: "Хосты",
settings: "Настройки",
closeSidebar: "Закрыть боковую панель",
moreActions: "Дополнительные действия",
},
footer: {
usage: "Использование",
@@ -2588,6 +2597,12 @@ export const ru: TranslationResources = {
description: "Показывать оглавление для перехода между запросами",
},
sidebar: {
view: {
title: "Вид боковой панели",
label: "Вид",
default: "Стандартный вид",
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
},
header: {
title: "Верх",
description:
+15
View File
@@ -1153,6 +1153,14 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
view: {
failed: "无法加载侧边栏视图。",
useDefault: "使用默认视图",
readFailed: "无法加载侧边栏视图偏好设置。",
agentUnavailable: "此智能体已不可用",
terminalUnavailable: "此终端已不可用",
projectUnavailable: "此项目已不可用",
},
statusGroupAccessibility: "{{label}} 分组",
statusBucket: {
needsInput: "需要输入",
@@ -1228,6 +1236,7 @@ export const zhCN: TranslationResources = {
hosts: "Hosts",
settings: "设置",
closeSidebar: "关闭侧边栏",
moreActions: "更多操作",
},
footer: {
usage: "使用情况",
@@ -2517,6 +2526,12 @@ export const zhCN: TranslationResources = {
description: "显示用于在提示词之间跳转的大纲",
},
sidebar: {
view: {
title: "侧边栏视图",
label: "视图",
default: "默认视图",
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
},
header: {
title: "顶部",
description: "选择侧边栏顶部显示的项目及其顺序",
+2 -1
View File
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
@@ -32,7 +33,7 @@ export function createPluginCapabilities(
navigation.openSurface(plugin.id, screenId, params);
}
return {
paseo: plugin.paseo,
paseo: getPluginClientApi(plugin).paseo,
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
openSettings(screenId) {
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import appPackage from "../../package.json";
import { afterEach, expect, it } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
@@ -139,7 +140,7 @@ function harness() {
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected runtime RPC");
+2 -3
View File
@@ -1,6 +1,5 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts";
import { getPluginClientApi } from "./client-state/api";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
@@ -27,7 +26,7 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
hosts: getPluginClientApi(installation).hosts,
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
@@ -0,0 +1,68 @@
import { createPluginReferenceApi } from "@getpaseo/plugin/client/host";
import { router } from "expo-router";
import { i18n } from "@/i18n/i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { createPluginHosts } from "../hosts";
import type { InstalledPlugin } from "../types";
import { createPluginClientStateSource } from "./source";
interface InstallationApi {
paseo: ReturnType<typeof createPluginReferenceApi>;
hosts: ReturnType<typeof createPluginHosts>;
}
// Registry presentation clones share the installation lifetime and its client pool.
const apis = new WeakMap<AbortSignal, InstallationApi>();
export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi {
const cached = apis.get(plugin.lifetime.signal);
if (cached) return cached;
const state = createPluginClientStateSource();
const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal);
const paseo = createPluginReferenceApi({
paseo: plugin.paseo,
resolveHost: hosts.getPaseoClient,
readProject: state.getProject,
navigation: {
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
},
openAgent(ref) {
const agent = state.getAgent(ref);
if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
},
openTerminal(ref) {
const terminal = state.getTerminal(ref);
if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
},
openProject(ref) {
const project = state.getProject(ref);
if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable"));
usePanelStore.getState().showMobileAgent();
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
},
},
});
const api = { paseo, hosts };
apis.set(plugin.lifetime.signal, api);
return api;
}
@@ -0,0 +1,72 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { PluginConnectedHostsSource } from "./source";
interface PluginHostsRuntime {
getHosts(): readonly { serverId: string }[];
getSnapshot(serverId: string): { connectionStatus: string } | null | undefined;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
acquireDirectoryDemand(serverId: string): () => void;
}
export function createConnectedPluginHosts(
getRuntime: () => PluginHostsRuntime,
): PluginConnectedHostsSource {
let snapshot: readonly string[] = [];
const listeners = new Set<() => void>();
let release: (() => void) | null = null;
const source: PluginConnectedHostsSource = {
getSnapshot() {
const runtime = getRuntime();
const next = runtime
.getHosts()
.filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online")
.map((host) => host.serverId);
if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index]))
snapshot = Object.freeze(next);
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
if (!release) {
const runtime = getRuntime();
const demands = new Map<string, () => void>();
const reconcile = () => {
const ids = new Set(source.getSnapshot());
for (const [id, releaseDemand] of demands) {
if (!ids.has(id)) {
demands.delete(id);
releaseDemand();
}
}
for (const id of ids) {
if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id));
}
};
const notify = () => {
reconcile();
for (const subscriber of listeners) subscriber();
};
const releaseRuntime = runtime.subscribeAll(notify);
const releaseHosts = runtime.subscribeHostList(notify);
reconcile();
release = () => {
releaseRuntime();
releaseHosts();
for (const releaseDemand of demands.values()) releaseDemand();
demands.clear();
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && release) {
release();
release = null;
}
};
},
};
return source;
}
export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore);
@@ -0,0 +1,53 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
export const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
export const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
@@ -0,0 +1,3 @@
import { createEntityRefs } from "@getpaseo/plugin/client/host";
export const entityRefs = createEntityRefs();
@@ -1,66 +1,20 @@
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import { describe, expect, it } from "vitest";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
createPluginAgentSnapshot,
createPluginWorkspaceSnapshot,
createPluginProjectSnapshot,
} from "./snapshots";
import { workspace, agent } from "./fixtures";
describe("plugin context snapshots", () => {
it("commits a deeply immutable normalized workspace snapshot synchronously", () => {
const snapshot = createPluginWorkspaceSnapshot(workspace);
expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot);
const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot);
expect(snapshot).toEqual({
expect(snapshot).toMatchObject({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -90,7 +44,7 @@ describe("plugin context snapshots", () => {
const snapshot = createPluginAgentSnapshot(agent, workspace.id);
expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot);
expect(snapshot).toEqual({
expect(snapshot).toMatchObject({
id: "agent-1",
workspaceId: "workspace-1",
provider: "codex",
@@ -119,3 +73,82 @@ describe("plugin context snapshots", () => {
expect(agent.labels).toEqual({ phase: "implementation" });
});
});
describe("sidebar entity snapshots", () => {
it("interns cross-host references and preserves identity across unrelated reads", () => {
const first = createPluginWorkspaceSnapshot(workspace, "host-1");
const second = createPluginWorkspaceSnapshot(workspace, "host-2");
expect(first.ref).not.toBe(second.ref);
expect(first.ref.hostId).toBe("host-1");
expect(first.project.id).toBe(workspace.projectId);
expect(Object.isFrozen(first.labels)).toBe(true);
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(first);
expect(createPluginAgentSnapshot(agent, workspace.id).workspace).toBe(first.ref);
});
it("invalidates a done workspace when its effective root-agent status changes", () => {
const idle: WorkspaceDescriptor = { ...workspace, status: "done" };
const before = createPluginWorkspaceSnapshot(idle, "host-1");
const activity = new Map([
[idle.id, { agentId: agent.id, status: "needs_input" as const, enteredAt: null }],
]);
const after = createPluginWorkspaceSnapshot(idle, "host-1", {
workspaceAgentActivity: activity,
});
expect(before.indicator).toBe("done");
expect(after.status).toBe("done");
expect(after.indicator).toBe("needs_input");
expect(after.indicatorLabel).toBe("Needs input");
expect(after.ref).toBe(before.ref);
expect(
createPluginWorkspaceSnapshot(idle, "host-1", { workspaceAgentActivity: activity }),
).toBe(after);
});
it("reads projects without requiring a workspace", () => {
const project = {
projectId: "empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git" as const,
};
const snapshot = createPluginProjectSnapshot(project, "host-1");
expect(createPluginProjectSnapshot(project, "host-1")).toBe(snapshot);
expect(snapshot.displayName).toBe("Empty");
expect(snapshot.ref.id).toBe("empty");
expect(Object.isFrozen(snapshot)).toBe(true);
});
});
it("uses the app status labels and invalidates a snapshot on language change", async () => {
i18n.addResourceBundle("fr", "translation", fr);
const previousLanguage = i18n.language;
try {
await i18n.changeLanguage("en");
const english = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(english.indicatorLabel).toBe("Working");
await i18n.changeLanguage("fr");
const french = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(french).not.toBe(english);
expect(french.ref).toBe(english.ref);
expect(french.indicatorLabel).toBe("En cours");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(french);
} finally {
await i18n.changeLanguage(previousLanguage);
}
});
it("preserves the host-qualified parent of a remotely delegated agent", () => {
const child = {
...agent,
serverId: "host-2",
parentAgentId: null,
labels: { "paseo.parent-server-id": "host-1", "paseo.parent-agent-id": "parent" },
};
expect(createPluginAgentSnapshot(child, workspace.id).parentAgent).toMatchObject({
hostId: "host-1",
id: "parent",
kind: "agent",
});
});
@@ -1,16 +1,57 @@
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import { i18n } from "@/i18n/i18next";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
} from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store";
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
import { entityRefs } from "./refs";
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
interface CachedWorkspace {
language: string;
snapshot: PluginWorkspaceSnapshot;
}
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, Map<string, CachedWorkspace>>();
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
export function createPluginWorkspaceSnapshot(
workspace: WorkspaceDescriptor,
hostId: string,
context: Omit<Parameters<typeof deriveEffectiveWorkspaceStatus>[0], "workspace" | "serverId"> & {
parentAgent?: PluginWorkspaceSnapshot["parentAgent"];
} = {},
): PluginWorkspaceSnapshot {
const cached = workspaceSnapshots.get(workspace);
if (cached) return cached;
const indicator = deriveEffectiveWorkspaceStatus({
...context,
workspace,
serverId: hostId,
}).status;
const parentAgent = context.parentAgent ?? null;
const cached = workspaceSnapshots.get(workspace)?.get(hostId);
if (
cached &&
cached.language === i18n.language &&
cached.snapshot.indicator === indicator &&
cached.snapshot.parentAgent === parentAgent
)
return cached.snapshot;
const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null;
const snapshot = Object.freeze({
ref: entityRefs.workspace(hostId, workspace.id),
hostId,
project: entityRefs.project(hostId, workspace.projectId),
pinnedAt: workspace.pinnedAt ?? null,
labels: Object.freeze([...(workspace.labels ?? [])]),
background: workspace.background ?? false,
branch: workspace.gitRuntime?.currentBranch ?? null,
createdAt: serializeTimestamp(workspace.createdAt),
lastPromptAt: serializeTimestamp(workspace.lastUserMessageAt),
parentAgent,
indicator,
indicatorLabel: i18n.t(indicatorLabelKeys[indicator]),
id: workspace.id,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
@@ -21,18 +62,25 @@ export function createPluginWorkspaceSnapshot(
name: workspace.name,
title: workspace.title ?? null,
status: workspace.status,
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt),
archivingAt: workspace.archivingAt,
diffStat,
});
workspaceSnapshots.set(workspace, snapshot);
const byHost = workspaceSnapshots.get(workspace) ?? new Map<string, CachedWorkspace>();
byHost.set(hostId, { language: i18n.language, snapshot });
workspaceSnapshots.set(workspace, byHost);
return snapshot;
}
export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot {
const cached = agentSnapshots.get(agent)?.get(workspaceId);
if (cached) return cached;
const parent = getParentAddressFromLabels(agent.labels, agent.serverId);
const snapshot = Object.freeze({
ref: entityRefs.agent(agent.serverId, agent.id),
hostId: agent.serverId,
workspace: entityRefs.workspace(agent.serverId, workspaceId),
parentAgent: parent ? entityRefs.agent(parent.serverId, parent.agentId) : null,
id: agent.id,
workspaceId,
provider: agent.provider,
@@ -55,3 +103,36 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
agentSnapshots.set(agent, byWorkspace);
return snapshot;
}
const indicatorLabelKeys = {
needs_input: "sidebar.statusBucket.needsInput",
failed: "sidebar.statusBucket.failed",
running: "sidebar.statusBucket.working",
attention: "sidebar.statusBucket.readyToReview",
done: "sidebar.statusBucket.done",
} as const;
const projectSnapshots = new WeakMap<ProjectDescriptor, Map<string, PluginProjectSnapshot>>();
export function createPluginProjectSnapshot(
project: ProjectDescriptor,
hostId: string,
): PluginProjectSnapshot {
const cached = projectSnapshots.get(project)?.get(hostId);
if (cached) return cached;
const snapshot = Object.freeze({
ref: entityRefs.project(hostId, project.projectId),
hostId,
displayName: project.projectCustomName ?? project.projectDisplayName,
rootPath: project.projectRootPath,
kind: project.projectKind,
iconRevision: project.projectCustomIconRevision ?? project.projectIconRevision ?? null,
});
const byHost = projectSnapshots.get(project) ?? new Map<string, PluginProjectSnapshot>();
byHost.set(hostId, snapshot);
projectSnapshots.set(project, byHost);
return snapshot;
}
function serializeTimestamp(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
@@ -0,0 +1,55 @@
import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import { setImmediate } from "node:timers/promises";
import { expect, it } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { createPluginClientStateSource } from "./source";
import { workspace } from "./fixtures";
setFlagsFromString("--expose_gc");
const collectGarbage: () => void = runInNewContext("gc");
setFlagsFromString("--no-expose_gc");
function observeHost() {
const hostId = "removed-plugin-host";
let hosts = [hostId];
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(hostId, new Map([[workspace.id, { ...workspace }]]));
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => hosts, subscribe: () => () => {} },
});
const release = source.subscribe(() => source.getSnapshot());
source.getSnapshot();
const reference = new WeakRef(useSessionStore.getState().sessions[hostId]!.workspaces);
const remove = () => {
hosts = [];
store.clearSession(hostId);
};
return { reference, release, remove };
}
it("releases removed hosts' projected data while other plugin readers remain", async () => {
const { reference, release, remove } = observeHost();
remove();
try {
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
} finally {
release();
}
});
it("releases previous projections when the last plugin reader leaves", async () => {
const { reference, release, remove } = observeHost();
release();
remove();
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
});
@@ -0,0 +1,420 @@
import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested";
import { lightTheme } from "@/styles/theme";
import { toPluginTheme } from "../theme";
import { createConnectedPluginHosts } from "./connected-hosts";
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import {
useAgents,
useWorkspaces,
useWorkspace,
useAgent,
type WorkspaceRef,
type AgentRef,
} from "@getpaseo/plugin/client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createPluginClientStateSource, type PluginConnectedHostsSource } from "./source";
import { agent, workspace } from "./fixtures";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
const renders = new Map<string, number>();
function count(key: string) {
renders.set(key, (renders.get(key) ?? 0) + 1);
}
function WorkspaceRow({ reference }: { reference: WorkspaceRef }) {
const item = useWorkspace(reference);
count(reference.key);
return (
<div>
{item?.name}:{item?.indicator}
</div>
);
}
function Workspaces() {
count("workspaces");
const items = useWorkspaces({ orderBy: "name" });
return (
<div>
{items.map((reference) => (
<WorkspaceRow key={reference.key} reference={reference} />
))}
</div>
);
}
function SelectedWorkspace({ reference }: { reference: WorkspaceRef }) {
const name = useWorkspace(reference, (item) => ({ name: item.name }));
const custom = useWorkspace(reference, undefined, (a, b) => a?.name === b?.name);
count("selected");
return (
<div>
{name?.name}:{custom?.name}
</div>
);
}
function AgentRow({ reference }: { reference: AgentRef }) {
const item = useAgent(reference);
return <div>{item?.title}</div>;
}
function AgentSection({ section }: { section: string }) {
const agents = useAgents({
where: (item) => (item.requiresAttention ? "waiting" : item.status) === section,
});
count(section);
return (
<div>
{agents.map((reference) => (
<AgentRow key={reference.key} reference={reference} />
))}
</div>
);
}
function Agents() {
return (
<div>
<AgentSection section="running" />
<AgentSection section="waiting" />
<AgentSection section="idle" />
</div>
);
}
function seedHost(hostId: string) {
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(
hostId,
new Map([
["workspace-1", { ...workspace, id: "workspace-1", status: "done", name: "First" }],
["workspace-2", { ...workspace, id: "workspace-2", status: "done", name: "Second" }],
]),
);
store.setProjects(hostId, [
{
projectId: "empty",
projectDisplayName: "Empty project",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git",
},
]);
store.setAgents(
hostId,
new Map<string, Agent>([
[
"agent-1",
{
...agent,
serverId: hostId,
id: "agent-1",
workspaceId: "workspace-1",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
[
"agent-2",
{
...agent,
serverId: hostId,
id: "agent-2",
workspaceId: "workspace-2",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
]),
);
}
it("keeps archived agents readable individually while excluding them from collections", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
const reference = source.getSnapshot().agents[0]!.ref;
useSessionStore.getState().setAgents(
"host-1",
new Map([
[
agent.id,
{
...agent,
archivedAt: new Date("2026-11-01T00:00:00.000Z"),
},
],
]),
);
expect(source.getSnapshot().agents).toEqual([]);
expect(source.getAgent(reference)?.title).toBe(agent.title);
expect(source.getAgent(agent.id)).toBe(source.getAgent(reference));
});
it("demands directories for connected hosts and releases them with its last reader", () => {
const hostId = "plugin-demand-host";
let online = true;
let demands = 0;
const listeners = new Set<() => void>();
const hosts = createConnectedPluginHosts(() => ({
getHosts: () => [{ serverId: hostId }],
getSnapshot: () => ({ connectionStatus: online ? "online" : "offline" }),
subscribeAll: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
subscribeHostList: () => () => {},
acquireDirectoryDemand: () => {
demands++;
seedHost(hostId);
return () => {
demands--;
};
},
}));
const source = createPluginClientStateSource(undefined, { hosts });
const first = source.subscribe(() => {});
const second = source.subscribe(() => {});
expect(demands).toBe(1);
expect(source.getSnapshot().workspaces.map((item) => item.hostId)).toEqual([hostId, hostId]);
useSessionStore
.getState()
.setWorkspaces(
hostId,
(previous) =>
new Map([...previous].map(([id, item]) => [id, { ...item, name: "Live update" }])),
);
expect(source.getSnapshot().workspaces.map((item) => item.name)).toEqual([
"Live update",
"Live update",
]);
online = false;
for (const listener of listeners) listener();
expect(demands).toBe(0);
expect(source.getSnapshot().workspaces).toEqual([]);
online = true;
for (const listener of listeners) listener();
expect(demands).toBe(1);
first();
expect(demands).toBe(1);
second();
expect(demands).toBe(0);
expect(listeners.size).toBe(0);
});
function waitForInput() {
useSessionStore.getState().setAgents("host-1", (previous) => {
const current = previous.get("agent-1");
if (!current) throw new Error("Missing test agent");
const next = new Map(previous);
next.set(current.id, {
...current,
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
requiresAttention: true,
attentionReason: "permission",
});
return next;
});
}
class ConnectedHosts implements PluginConnectedHostsSource {
private ids: readonly string[] = ["host-1"];
private listeners = new Set<() => void>();
getSnapshot = () => this.ids;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
connect(id: string) {
this.ids = [...this.ids, id];
for (const listener of this.listeners) listener();
}
}
function mount(children: React.ReactNode, hosts = new ConnectedHosts()) {
const source = createPluginClientStateSource("host-1", { hosts });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root?.render(<PluginClientStateProvider source={source}>{children}</PluginClientStateProvider>),
);
return source;
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
useSessionStore.getState().clearSession("host-1");
useSessionStore.getState().clearSession("host-2");
renders.clear();
});
it("shows a restored child in the nested example while its cached parent remains archived", () => {
seedHost("host-1");
const parent = {
...agent,
id: "archived-parent",
title: "Archived parent",
archivedAt: new Date("2026-11-01T00:00:00Z"),
};
const child = {
...agent,
id: "restored-child",
title: "Restored child",
parentAgentId: parent.id,
labels: { "paseo.parent-agent-id": parent.id },
archivedAt: parent.archivedAt,
};
const store = useSessionStore.getState();
store.setProjects("host-1", [
{
projectId: workspace.projectId,
projectDisplayName: "Project",
projectCustomName: null,
projectRootPath: workspace.projectRootPath,
projectKind: "git",
},
]);
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, child],
]),
);
const view = {
theme: toPluginTheme(lightTheme),
layout: { compact: false, platform: "web" as const },
active: { workspace: null, agent: null },
navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} },
dismiss() {},
};
const source = mount(<Nested {...view} />);
expect(source.getAgent(parent.id)?.title).toBe(parent.title);
expect(container?.textContent).not.toContain(child.title);
act(() =>
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, { ...child, archivedAt: null }],
]),
),
);
expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull();
expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull();
});
describe("sidebar data hooks through the app store and plugin provider", () => {
it("renders only one workspace row when its root agent starts waiting", () => {
seedHost("host-1");
const source = mount(<Workspaces />);
const first = source.getSnapshot().workspaces[0]!;
const second = source.getSnapshot().workspaces[1]!;
const before = source.getSnapshot();
act(waitForInput);
expect(renders.get("workspaces")).toBe(1);
expect(renders.get(first.ref.key)).toBe(2);
expect(renders.get(second.ref.key)).toBe(1);
expect(source.getSnapshot().workspaces[0]?.indicator).toBe("needs_input");
expect(source.getSnapshot().workspaces[1]).toBe(before.workspaces[1]);
});
it("moves the agent and renders only its two affected sections without memo", () => {
seedHost("host-1");
mount(<Agents />);
act(waitForInput);
expect(renders.get("running")).toBe(2);
expect(renders.get("waiting")).toBe(2);
expect(renders.get("idle")).toBe(1);
});
it("uses shallow selected equality and custom equality without a selector", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
mount(<SelectedWorkspace reference={source.getSnapshot().workspaces[0]!.ref} />);
act(waitForInput);
expect(renders.get("selected")).toBe(1);
});
it("refreshes translated labels through the subscribed source when language changes", async () => {
seedHost("host-1");
i18n.addResourceBundle("fr", "translation", fr);
const source = mount(<Workspaces />);
const before = source.getSnapshot().workspaces[0]!;
const previousLanguage = i18n.language;
try {
await act(async () => {
await i18n.changeLanguage("fr");
});
const after = source.getSnapshot().workspaces[0]!;
expect(after).not.toBe(before);
expect(after.indicatorLabel).toBe("En cours");
expect(after.ref).toBe(before.ref);
expect(renders.get(before.ref.key)).toBe(2);
expect(renders.get("workspaces")).toBe(1);
} finally {
await act(async () => {
await i18n.changeLanguage(previousLanguage);
});
}
});
it("adds a newly connected host and exposes projects with no workspaces", () => {
seedHost("host-1");
seedHost("host-2");
const hosts = new ConnectedHosts();
const source = mount(<Workspaces />, hosts);
expect(source.getSnapshot().workspaces).toHaveLength(2);
expect(source.getSnapshot().projects[0]?.displayName).toBe("Empty project");
act(() => hosts.connect("host-2"));
expect(source.getSnapshot().workspaces).toHaveLength(4);
expect(source.getSnapshot().projects).toHaveLength(2);
expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4);
});
});
it("observes terminals only while a terminal reader is subscribed", () => {
let active = 0;
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => {
active++;
return () => {
active--;
};
},
},
});
const releaseWorkspace = source.subscribe(() => {});
expect(active).toBe(0);
const releaseTerminal = source.subscribe(() => {}, { terminals: true });
const releaseSecond = source.subscribe(() => {}, { terminals: true });
expect(active).toBe(1);
releaseTerminal();
expect(active).toBe(1);
releaseSecond();
expect(active).toBe(0);
releaseWorkspace();
});
it("terminal row reads propagate only the referenced host's observation error", () => {
const error = new Error("Update the host");
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => () => {},
getErrors: () => new Map([["unsupported", error]]),
},
});
const refs = createEntityRefs();
expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error);
expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull();
});
+242 -15
View File
@@ -1,21 +1,248 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import { useSessionStore } from "@/stores/session-store";
import { selectWorkspace } from "@/stores/session-store-hooks/selectors";
import { i18n } from "@/i18n/i18next";
import type { PluginClientSnapshot, PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
import { connectedPluginHosts } from "./connected-hosts";
import { pluginTerminals } from "./terminals";
import { entityRefs } from "./refs";
import {
createPluginAgentSnapshot,
createPluginProjectSnapshot,
createPluginWorkspaceSnapshot,
} from "./snapshots";
export function createPluginClientStateSource(serverId: string): PluginClientStateSource {
return {
subscribe: useSessionStore.subscribe,
getWorkspace(workspaceId) {
const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId);
return workspace ? createPluginWorkspaceSnapshot(workspace) : null;
export interface PluginConnectedHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly string[];
}
export interface PluginTerminalsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly PluginTerminalSnapshot[];
getErrors?(): ReadonlyMap<string, Error>;
}
const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]);
const listeners = new Set<() => void>();
let releaseStores: (() => void) | null = null;
// Every plugin host shares one subscription to each app store.
function subscribeStores(listener: () => void): () => void {
if (!releaseStores) {
const notify = () => {
for (const subscriber of listeners) subscriber();
};
i18n.on("languageChanged", notify);
const releaseSession = useSessionStore.subscribe((state) => {
for (const hostId of hostProjections.keys()) {
if (!state.sessions[hostId]) hostProjections.delete(hostId);
}
notify();
});
const releaseCreate = useCreateFlowStore.subscribe(notify);
releaseStores = () => {
i18n.off("languageChanged", notify);
releaseSession();
releaseCreate();
};
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0 && releaseStores) {
releaseStores();
releaseStores = null;
hostProjections.clear();
}
};
}
interface HostProjection {
language: string;
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
projects: SessionState["projects"];
activity: SessionState["workspaceAgentActivity"];
pending: ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
snapshot: Omit<PluginClientSnapshot, "terminals">;
workspaceById: Map<string, PluginClientSnapshot["workspaces"][number]>;
agentById: Map<string, PluginClientSnapshot["agents"][number]>;
projectById: Map<string, PluginClientSnapshot["projects"][number]>;
}
const hostProjections = new Map<string, HostProjection>();
function projectHost(hostId: string): HostProjection | null {
const session = useSessionStore.getState().sessions[hostId];
if (!session) {
hostProjections.delete(hostId);
return null;
}
const pending = useCreateFlowStore.getState().pendingByDraftId;
const previous = hostProjections.get(hostId);
const unchanged =
previous &&
previous.language === i18n.language &&
previous.workspaces === session.workspaces &&
previous.agents === session.agents &&
previous.projects === session.projects &&
previous.activity === session.workspaceAgentActivity &&
previous.pending === pending;
if (unchanged) return previous;
const workspaces = Array.from(session.workspaces.values(), (workspace) => {
const activity = session.workspaceAgentActivity.get(workspace.id);
const root = activity ? session.agents.get(activity.agentId) : undefined;
return createPluginWorkspaceSnapshot(workspace, hostId, {
pendingCreateAttempts: pending,
workspaceAgentActivity: session.workspaceAgentActivity,
parentAgent: root ? createPluginAgentSnapshot(root, workspace.id).parentAgent : null,
});
});
const agents: PluginClientSnapshot["agents"][number][] = [];
for (const agent of session.agents.values()) {
const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (!workspaceId || agent.archivedAt) continue;
agents.push(createPluginAgentSnapshot(agent, workspaceId));
}
const projects = Array.from(session.projects.values(), (project) =>
createPluginProjectSnapshot(project, hostId),
);
const projection: HostProjection = {
language: i18n.language,
workspaces: session.workspaces,
agents: session.agents,
projects: session.projects,
activity: session.workspaceAgentActivity,
pending,
snapshot: {
workspaces: Object.freeze(workspaces),
agents: Object.freeze(agents),
projects: Object.freeze(projects),
},
getAgent(agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId);
return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null;
workspaceById: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
agentById: new Map(agents.map((agent) => [agent.id, agent])),
projectById: new Map(projects.map((project) => [project.ref.id, project])),
};
hostProjections.set(hostId, projection);
return projection;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/** A host scope only resolves legacy string ids. Ref lookups and collections span hosts. */
export function createPluginClientStateSource(
serverId?: string,
options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {},
): PluginClientStateSource {
const terminals = options.terminals ?? pluginTerminals;
const hosts = options.hosts ?? connectedPluginHosts;
const subscribers = new Set<() => void>();
const terminalSubscribers = new Set<() => void>();
let releaseTerminals: (() => void) | null = null;
let release: (() => void) | null = null;
let previousProjections: readonly HostProjection[] = [];
let previousTerminals = emptyTerminals;
let previousTerminalErrors: ReadonlyMap<string, Error> | undefined;
let snapshot: PluginClientSnapshot = {
workspaces: [],
agents: [],
projects: [],
terminals: emptyTerminals,
};
function getSnapshot(): PluginClientSnapshot {
const ids = hosts.getSnapshot();
const projections: HostProjection[] = [];
for (const id of ids) {
const projection = projectHost(id);
if (projection) projections.push(projection);
}
const terminalSnapshots = terminals.getSnapshot();
const terminalErrors = terminals.getErrors?.();
if (
sameSequence(previousProjections, projections) &&
previousTerminals === terminalSnapshots &&
previousTerminalErrors === terminalErrors
)
return snapshot;
previousProjections = projections;
previousTerminals = terminalSnapshots;
previousTerminalErrors = terminalErrors;
snapshot = Object.freeze({
workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)),
agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)),
projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)),
terminals: terminalSnapshots,
terminalErrors,
});
return snapshot;
}
return {
subscribe(listener, subscriptionOptions) {
subscribers.add(listener);
if (subscriptionOptions?.terminals) {
terminalSubscribers.add(listener);
releaseTerminals ??= terminals.subscribe(() => {
for (const subscriber of terminalSubscribers) subscriber();
});
}
if (!release) {
const notify = () => {
for (const subscriber of subscribers) subscriber();
};
const releaseStore = subscribeStores(notify);
const releaseHosts = hosts.subscribe(notify);
release = () => {
releaseStore();
releaseHosts();
};
}
return () => {
subscribers.delete(listener);
terminalSubscribers.delete(listener);
if (terminalSubscribers.size === 0 && releaseTerminals) {
releaseTerminals();
releaseTerminals = null;
}
if (subscribers.size === 0 && release) {
release();
release = null;
previousProjections = [];
previousTerminals = emptyTerminals;
previousTerminalErrors = undefined;
snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals };
}
};
},
getSnapshot,
getWorkspace(reference) {
const ref =
typeof reference === "string" && serverId
? entityRefs.workspace(serverId, reference)
: reference;
if (typeof ref === "string") return null;
return projectHost(ref.hostId)?.workspaceById.get(ref.id) ?? null;
},
getAgent(reference) {
const ref =
typeof reference === "string" && serverId
? entityRefs.agent(serverId, reference)
: reference;
if (typeof ref === "string") return null;
const projected = projectHost(ref.hostId)?.agentById.get(ref.id);
if (projected) return projected;
const session = useSessionStore.getState().sessions[ref.hostId];
const detail = session?.agents.get(ref.id) ?? session?.agentDetails.get(ref.id);
const workspaceId = normalizeWorkspaceOpaqueId(detail?.workspaceId);
return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null;
},
getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null,
getTerminal(ref) {
const error = terminals.getErrors?.().get(ref.hostId);
if (error) throw error;
return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null;
},
};
}
@@ -0,0 +1,154 @@
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TerminalsChanged } from "@getpaseo/protocol/messages";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { entityRefs } from "./refs";
import type { PluginTerminalsSource } from "./source";
interface TerminalHost {
serverId: string;
client: DaemonClient;
epoch: number;
}
export interface TerminalHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly TerminalHost[];
}
interface Observation {
client: DaemonClient;
epoch: number;
items: readonly PluginTerminalSnapshot[];
release(): void;
}
/** One owned observation per connected host, shared by every plugin surface. */
export function createPluginTerminalsSource(hosts: TerminalHostsSource): PluginTerminalsSource {
const observations = new Map<string, Observation>();
const listeners = new Set<() => void>();
let snapshot: readonly PluginTerminalSnapshot[] = Object.freeze([]);
let errors: ReadonlyMap<string, Error> = new Map();
let releaseHosts: (() => void) | null = null;
function publish() {
const next = [...observations.values()].flatMap((item) => item.items);
if (next.length !== snapshot.length || next.some((item, index) => item !== snapshot[index]))
snapshot = Object.freeze(next);
for (const listener of listeners) listener();
}
function receive(hostId: string, observation: Observation, payload: TerminalsChanged["payload"]) {
if (observations.get(hostId) !== observation) return;
const previous = new Map(observation.items.map((item) => [item.ref.id, item]));
observation.items = Object.freeze(
payload.terminals.flatMap((terminal) => {
// Host-wide observation includes unowned legacy terminals. Sidebar navigation needs a workspace.
if (!terminal.workspaceId) return [];
const ref = entityRefs.terminal(hostId, terminal.id);
const workspace = entityRefs.workspace(hostId, terminal.workspaceId);
const title = terminal.title ?? terminal.name;
const createdAt = terminal.createdAt ?? null;
const isScript = terminal.isScript === true;
const cached = previous.get(terminal.id);
if (
cached &&
cached.workspace === workspace &&
cached.title === title &&
cached.createdAt === createdAt &&
cached.isScript === isScript
)
return [cached];
return [Object.freeze({ ref, hostId, workspace, title, createdAt, isScript })];
}),
);
errors = new Map([...errors].filter(([id]) => id !== hostId));
publish();
}
function reconcile() {
const active = new Map(hosts.getSnapshot().map((host) => [host.serverId, host]));
for (const [id, observation] of observations) {
if (
active.get(id)?.client !== observation.client ||
active.get(id)?.epoch !== observation.epoch
) {
observations.delete(id);
observation.release();
}
}
errors = new Map([...errors].filter(([id]) => active.has(id)));
for (const [hostId, { client, epoch }] of active) {
if (observations.has(hostId)) continue;
const observation: Observation = { client, epoch, items: [], release() {} };
observations.set(hostId, observation);
try {
// The SDK owns the capability gate; a failed host is reported only to terminal readers.
const subscription = client.observeTerminals({});
const unsubscribe = subscription.subscribe({
snapshot: (payload) => receive(hostId, observation, payload),
update: (message) => {
if (message.type === "terminals_changed") receive(hostId, observation, message.payload);
},
error: (error) => {
if (observations.get(hostId) !== observation) return;
errors = new Map(errors).set(
hostId,
error instanceof Error ? error : new Error(String(error)),
);
publish();
},
});
void subscription.ready.catch(() => {}); // Observer owns error delivery.
observation.release = () => {
unsubscribe();
void subscription.release().catch(() => {});
};
} catch (error) {
errors = new Map(errors).set(
hostId,
error instanceof Error ? error : new Error(String(error)),
);
}
}
publish();
}
return {
getSnapshot: () => snapshot,
getErrors: () => errors,
subscribe(listener) {
listeners.add(listener);
if (!releaseHosts) {
releaseHosts = hosts.subscribe(reconcile);
reconcile();
}
return () => {
listeners.delete(listener);
if (!listeners.size && releaseHosts) {
releaseHosts();
releaseHosts = null;
for (const observation of observations.values()) observation.release();
observations.clear();
snapshot = Object.freeze([]);
errors = new Map();
}
};
},
};
}
export const pluginTerminals = createPluginTerminalsSource({
getSnapshot() {
const runtime = getHostRuntimeStore();
return runtime.getHosts().flatMap((host) => {
const state = runtime.getSnapshot(host.serverId);
return state?.connectionStatus === "online" && state.client
? [{ serverId: host.serverId, client: state.client, epoch: state.connectionEpoch }]
: [];
});
},
subscribe(listener) {
const runtime = getHostRuntimeStore();
const releaseRuntime = runtime.subscribeAll(listener);
const releaseHosts = runtime.subscribeHostList(listener);
return () => {
releaseRuntime();
releaseHosts();
};
},
});
@@ -13,7 +13,20 @@ import { z } from "zod";
import type { InstalledPlugin } from "../types";
import { buildPluginCommandCenterContributions } from "./contributions";
import { entityRefs } from "../client-state/refs";
const workspace: PluginWorkspaceSnapshot = {
ref: entityRefs.workspace("host-1", "workspace-1"),
hostId: "host-1",
project: entityRefs.project("host-1", "project-1"),
pinnedAt: null,
labels: [],
background: false,
branch: null,
createdAt: null,
lastPromptAt: null,
parentAgent: null,
indicator: "running",
indicatorLabel: "Running",
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -30,6 +43,10 @@ const workspace: PluginWorkspaceSnapshot = {
};
const agent: PluginAgentSnapshot = {
ref: entityRefs.agent("host-1", "agent-1"),
hostId: "host-1",
workspace: workspace.ref,
parentAgent: null,
id: "agent-1",
workspaceId: workspace.id,
provider: "codex",
@@ -82,6 +99,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
cleanup: () => {},
settingsScreens: [],
surfaces: [{ id: "main", title: "Main", Component: () => null }],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -131,6 +149,15 @@ function stateSource() {
subscribe() {
return () => {};
},
getSnapshot() {
return { workspaces: [workspace], agents: [agent], projects: [], terminals: [] };
},
getProject() {
return null;
},
getTerminal() {
return null;
},
getWorkspace(id: string) {
return id === workspace.id ? workspace : null;
},
+23
View File
@@ -55,6 +55,29 @@ describe("evaluatePluginClientBundle", () => {
expect(active).toBe(0);
});
it("registers sidebar views without selecting one and removes them on cleanup", async () => {
const plugin = evaluatePluginClientBundle(
"sidebar",
bundle(`
plugin.addSidebarView({ id: "workspaces", title: "Workspaces", Component() { return null; } });
`),
);
expect(plugin.sidebarViews.map((view) => view.id)).toEqual(["workspaces"]);
await plugin.cleanup();
expect(plugin.sidebarViews).toEqual([]);
});
it("rejects duplicate sidebar view ids", () => {
expect(() =>
evaluatePluginClientBundle(
"sidebar",
bundle(`
const view = { id: "workspaces", title: "Workspaces", Component() { return null; } };
plugin.addSidebarView(view); plugin.addSidebarView(view);
`),
),
).toThrow("Duplicate sidebar view");
});
it("accepts memoized settings screens", () => {
const plugin = evaluatePluginClientBundle(
"settings",
+17 -1
View File
@@ -129,6 +129,7 @@ export function runPluginClientBundle(
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
surfaces: [],
settingsScreens: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -141,6 +142,7 @@ export function runPluginClientBundle(
timelineRenderers: [],
};
const surfaceIds = new Set<string>();
const sidebarViewIds = new Set<string>();
const settingsScreenIds = new Set<string>();
const sidebarItemIds: Record<PluginSidebarSection, Set<string>> = {
header: new Set(),
@@ -270,6 +272,18 @@ export function runPluginClientBundle(
() => surfaceIds.delete(normalizedId),
);
},
addSidebarView(contribution) {
const normalizedId = requireId(contribution.id, "sidebar view id");
if (sidebarViewIds.has(normalizedId))
throw new Error(`Duplicate sidebar view: ${normalizedId}`);
if (!isComponentType(contribution.Component))
throw new Error(`Sidebar view ${normalizedId} is not a component`);
const title = requirePluginText(contribution.title);
sidebarViewIds.add(normalizedId);
return register(collector.sidebarViews, { ...contribution, id: normalizedId, title }, () =>
sidebarViewIds.delete(normalizedId),
);
},
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("header", contribution);
},
@@ -518,7 +532,8 @@ export function runPluginClientBundle(
if (name === "@getpaseo/plugin/client")
return {
...pluginClientRuntime,
useSettings,
useSettings: (definition: import("@getpaseo/plugin").SettingsDefinition) =>
useSettings(definition, id),
openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
useHosts: () =>
@@ -583,6 +598,7 @@ export function runPluginClientBundle(
cleanup,
surfaces: collector.surfaces,
settingsScreens: collector.settingsScreens,
sidebarViews: collector.sidebarViews,
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
filePanels: collector.filePanels,
@@ -1,7 +1,8 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
import { defineRpc } from "@getpaseo/plugin";
import type { PaseoApi } from "@getpaseo/client";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { QueryClient } from "@tanstack/react-query";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -24,7 +25,13 @@ function installation(invoked: string[]): InstalledPlugin {
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
paseo: { dispose: async () => undefined } as PaseoApi,
paseo: createPaseoApi(
new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "installation-provider",
reconnect: { enabled: false },
}),
),
invoke: async (method) => {
invoked.push(method);
return { state: "green" };
@@ -36,6 +43,7 @@ function installation(invoked: string[]): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -75,4 +83,6 @@ it("gives every surface the installation's client and RPCs on its first frame",
expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true);
await expect(rpc!({})).resolves.toEqual({ state: "green" });
expect(invoked).toEqual(["deploys.status"]);
plugin.lifetime.abort();
await plugin.paseo.dispose();
});
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { QueryClientProvider } from "@tanstack/react-query";
import {
PaseoApiProvider,
@@ -17,7 +18,7 @@ export function PluginInstallationProvider({
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PaseoApiProvider paseo={getPluginClientApi(plugin).paseo}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider>
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { filePanelTitle } from "@/file-panels/choice/presentation";
import { i18n } from "@/i18n/i18next";
import { createPluginHosts } from "./hosts";
@@ -50,7 +51,7 @@ function registry(
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected plugin RPC");
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { createPluginHosts } from "./hosts";
import { afterEach, expect, it } from "vitest";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -25,7 +26,7 @@ function registry(version: string) {
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("No RPC in this plugin");
+1
View File
@@ -122,6 +122,7 @@ export class PluginRegistry {
cleanup: () => undefined,
surfaces: [],
settingsScreens: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
import { createDeviceSettings } from "./device";
function device(records = new Map<string, string>()) {
return createDeviceSettings({
getItem: async (key) => records.get(key) ?? null,
setItem: async (key, value) => {
records.set(key, value);
},
});
}
const definition = defineSettings({
id: "layout",
scope: "device",
version: 1,
schema: z.object({ collapsed: z.array(z.string()).default([]) }),
});
describe("device plugin settings", () => {
it("shares one installation across hosts and keeps other installations separate", async () => {
const settings = device();
const first = settings("plugin", definition);
const second = settings("plugin", definition);
const before = await first.read();
expect(before).toEqual({ status: "ready", revision: "missing", values: { collapsed: [] } });
const saved = await first.write({ revision: before.revision, values: { collapsed: ["work"] } });
expect(saved.status).toBe("saved");
expect(await second.read()).toMatchObject({ status: "ready", values: { collapsed: ["work"] } });
expect(await settings("other", definition).read()).toEqual(before);
});
it("serializes competing writes and resets through the same revision contract", async () => {
const settings = device()("plugin", definition);
const initial = await settings.read();
const results = await Promise.all([
settings.write({ revision: initial.revision, values: { collapsed: ["a"] } }),
settings.write({ revision: initial.revision, values: { collapsed: ["b"] } }),
]);
expect(results.map((result) => result.status)).toEqual(["saved", "conflict"]);
const current = await settings.read();
expect(await settings.reset({ revision: current.revision })).toMatchObject({
status: "saved",
values: { collapsed: [] },
});
});
it("validates writes and migrates saved layout values", async () => {
const settings = device();
const first = settings("plugin", definition);
expect(await first.write({ revision: "missing", values: { collapsed: 3 } })).toMatchObject({
status: "invalid",
});
await first.write({ revision: "missing", values: { collapsed: ["a"] } });
const upgraded = settings(
"plugin",
defineSettings({
...definition,
version: 2,
migrate: (_values, from) => ({ collapsed: [`v${from}`] }),
}),
);
expect(await upgraded.read()).toMatchObject({ status: "ready", values: { collapsed: ["v1"] } });
});
});
it("persists a validated migration once and returns the persisted revision across restarts", async () => {
const records = new Map<string, string>();
const settings = device(records);
await settings("plugin", definition).write({ revision: "missing", values: { collapsed: ["a"] } });
let migrations = 0;
const upgraded = defineSettings({
...definition,
version: 2,
migrate: () => {
migrations++;
return { collapsed: ["migrated"] };
},
});
const first = await settings("plugin", upgraded).read();
const restarted = device(records)("plugin", upgraded);
expect(await restarted.read()).toEqual(first);
expect(migrations).toBe(1);
expect(first).toEqual({
status: "ready",
revision: JSON.stringify({ version: 2, values: { collapsed: ["migrated"] } }),
values: { collapsed: ["migrated"] },
});
expect(
await restarted.write({
revision: JSON.stringify({ version: 1, values: { collapsed: ["a"] } }),
values: { collapsed: [] },
}),
).toMatchObject({ status: "conflict" });
expect(
await restarted.write({ revision: first.revision, values: { collapsed: [] } }),
).toMatchObject({ status: "saved" });
});
@@ -0,0 +1,80 @@
import {
settingsRpc,
type SettingsDefinition,
type RpcOutput,
type RpcInput,
} from "@getpaseo/plugin";
import { z } from "zod";
interface DeviceStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
type ReadResult = RpcOutput<ReturnType<typeof settingsRpc>["read"]>;
type WriteResult = RpcOutput<ReturnType<typeof settingsRpc>["write"]>;
type WriteInput = RpcInput<ReturnType<typeof settingsRpc>["write"]>;
const envelope = z.object({ version: z.number().int().positive(), values: z.json() });
/** One writer per device; installations with the same id share their layout settings across hosts. */
export function createDeviceSettings(storage: DeviceStorage) {
let queue: Promise<unknown> = Promise.resolve();
function serial<T>(work: () => Promise<T>): Promise<T> {
const result = queue.then(work);
queue = result.catch(() => undefined);
return result;
}
return function settings(installationId: string, definition: SettingsDefinition) {
const key = `@paseo:plugin-device-settings:${installationId}/${definition.id}`;
async function read(): Promise<ReadResult> {
const raw = await storage.getItem(key);
let revision = raw ?? "missing";
try {
let values: unknown = {};
let migrated = false;
if (raw !== null) {
const stored = envelope.parse(JSON.parse(raw));
if (stored.version > definition.version)
throw new Error("Update this plugin to read its newer settings");
values = stored.values;
if (stored.version < definition.version) {
if (!definition.migrate)
throw new Error("This plugin has no migration for its saved settings");
values = await definition.migrate(values, stored.version);
migrated = true;
}
}
const parsed = z.json().parse(await definition.schema.parseAsync(values));
if (migrated) {
const serialized = JSON.stringify({ version: definition.version, values: parsed });
await storage.setItem(key, serialized);
revision = serialized;
}
return { status: "ready", revision, values: parsed };
} catch (error) {
return {
status: "invalid",
revision,
error: error instanceof Error ? error.message : String(error),
};
}
}
function write(input: WriteInput): Promise<WriteResult> {
return serial(async () => {
const raw = await storage.getItem(key);
if ((raw ?? "missing") !== input.revision)
return { status: "conflict", error: "Settings changed. Reload before saving." };
const parsed = await definition.schema.safeParseAsync(input.values);
if (!parsed.success) return { status: "invalid", error: parsed.error.message };
const values = z.json().parse(parsed.data);
const serialized = JSON.stringify({ version: definition.version, values });
await storage.setItem(key, serialized);
return { status: "saved", revision: serialized, values };
});
}
return {
read: () => serial(read),
write,
reset: (input: { revision: string }) => write({ revision: input.revision, values: {} }),
};
};
}
@@ -1,3 +1,6 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { queryClient as appQueryClient } from "@/data/query-client";
import { createDeviceSettings } from "./device";
import type { SettingsState } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -8,6 +11,8 @@ import { useReplicaQuery } from "@/data/query";
import { z, type ZodType } from "zod";
import { pluginSettingsKey } from "./query";
const deviceSettings = createDeviceSettings(AsyncStorage);
function message(error: unknown): string {
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
return error instanceof Error ? error.message : String(error);
@@ -15,34 +20,49 @@ function message(error: unknown): string {
export function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
installationId: string,
): SettingsState<Schema> {
const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]);
const read = useRpc(rpc.read);
const write = useRpc(rpc.write);
const reset = useRpc(rpc.reset);
const client = useQueryClient();
const key = pluginSettingsKey(definition.id);
const query = useReplicaQuery({
queryKey: key,
pushEvent: "plugin_settings_changed",
queryFn: () => read({}),
retry: false,
});
const mutation = useMutation({
mutationFn: async (
input: { revision: string; values: z.output<Schema> } | { revision: string },
) => {
const result =
"values" in input
? await write({
revision: input.revision,
values: z.json().parse(await definition.schema.parseAsync(input.values)),
})
: await reset(input);
if (result.status !== "saved") throw new Error(result.error);
client.setQueryData(key, { ...result, status: "ready" });
const installationClient = useQueryClient();
const device = definition.scope === "device";
const client = device ? appQueryClient : installationClient;
const key = device
? ["plugin-device-settings", installationId, definition.id, definition.version]
: pluginSettingsKey(definition.id);
const local = useMemo(
() => deviceSettings(installationId, definition),
[installationId, definition],
);
const query = useReplicaQuery(
{
queryKey: key,
pushEvent: device ? "plugin_device_settings_saved" : "plugin_settings_changed",
queryFn: () => (device ? local.read() : read({})),
retry: false,
},
});
client,
);
const mutation = useMutation(
{
mutationFn: async (
input: { revision: string; values: z.output<Schema> } | { revision: string },
) => {
const result =
"values" in input
? await (device ? local.write : write)({
revision: input.revision,
values: z.json().parse(await definition.schema.parseAsync(input.values)),
})
: await (device ? local.reset : reset)(input);
if (result.status !== "saved") throw new Error(result.error);
client.setQueryData(key, { ...result, status: "ready" });
},
},
client,
);
async function save(values: z.output<Schema>, revision: string) {
try {
await mutation.mutateAsync({ values, revision });
@@ -31,6 +31,7 @@ function installed(
},
settingsScreens: [],
surfaces: [{ id: "surface", title: "Surface", Component: () => null }],
sidebarViews: [],
sidebarItems: {
header: [{ id: contributionId, title: "Example", Component: () => null }],
footer: [{ id: "status", title: "Status", Component: () => null }],
@@ -0,0 +1,25 @@
import type { InstalledPlugin } from "../types";
export interface SidebarViewEntry {
id: string;
title: string;
plugin: InstalledPlugin;
view: InstalledPlugin["sidebarViews"][number];
}
export function sidebarViews(plugins: readonly InstalledPlugin[]): SidebarViewEntry[] {
const entries = new Map<string, SidebarViewEntry>();
for (const plugin of plugins) {
for (const view of plugin.sidebarViews) {
const id = `${plugin.id}/${view.id}`;
if (!entries.has(id))
entries.set(id, {
id,
title: plugin.i18n.resolve(view.title, plugin.locale),
plugin,
view,
});
}
}
return [...entries.values()];
}
@@ -0,0 +1,69 @@
import React, { act, useMemo } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, expect, it } from "vitest";
import { SidebarViewFrame } from "./frame";
import { useSidebarViewSelection, type SidebarViewStorage } from "./selection";
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let defaultRenders = 0;
function DefaultView() {
defaultRenders++;
return <div>Default sidebar</div>;
}
function PluginView() {
return <div>Stored plugin sidebar</div>;
}
function Sidebar({ storage }: { storage: SidebarViewStorage }) {
const selection = useSidebarViewSelection(storage);
const fallback = useMemo(() => <DefaultView />, []);
const selected =
selection.state.status === "ready" && selection.state.selection === "example/workspaces";
const view = selected ? <PluginView /> : null;
return (
<SidebarViewFrame state={selection.state} fallback={fallback} reload={selection.reload}>
{view}
</SidebarViewFrame>
);
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
defaultRenders = 0;
client.clear();
});
function deferredStorage() {
let finishRead: (value: string) => void = () => {
throw new Error("Read not started");
};
const read = new Promise<string>((resolve) => {
finishRead = resolve;
});
const storage: SidebarViewStorage = { getItem: () => read, setItem: async () => {} };
return { storage, finishRead };
}
it("never renders the default before a stored plugin selection is known", async () => {
const { storage, finishRead } = deferredStorage();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () =>
root?.render(
<QueryClientProvider client={client}>
<Sidebar storage={storage} />
</QueryClientProvider>,
),
);
expect(defaultRenders).toBe(0);
await act(async () => {
finishRead("example/workspaces");
});
await expect.poll(() => container?.textContent).toContain("Stored plugin sidebar");
expect(defaultRenders).toBe(0);
});
@@ -0,0 +1,34 @@
import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton";
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import type { SidebarViewSelectionState } from "./selection";
export function SidebarViewFrame({
state,
fallback,
children,
reload,
}: {
state: SidebarViewSelectionState;
fallback: ReactNode;
children: ReactNode;
reload(): void;
}) {
const { t } = useTranslation();
if (state.status === "loading") return <SidebarAgentListSkeleton />;
if (state.status === "error")
return (
<View style={styles.failure}>
<Text style={styles.text}>{t("sidebar.view.readFailed")}</Text>
<Button onPress={reload}>{t("common.actions.retry")}</Button>
</View>
);
return children ?? fallback;
}
const styles = StyleSheet.create((theme) => ({
failure: { padding: theme.spacing[4], gap: theme.spacing[3] },
text: { color: theme.colors.foreground },
}));
@@ -0,0 +1,173 @@
import { useTranslation } from "react-i18next";
import { SidebarViewFrame } from "./frame";
import type { PluginSidebarViewProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { router } from "expo-router";
import { useMemo, useCallback, type ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { entityRefs } from "../client-state/refs";
import { PluginEnvironmentProvider } from "../popover";
import { resolvePluginPlatform } from "../platform";
import { useInstalledPlugins } from "../registry";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
import { sidebarViews, type SidebarViewEntry } from "./catalog";
import { useSidebarViewSelection } from "./selection";
const source = createPluginClientStateSource();
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function SidebarViewSlot({ children, dismiss }: { children: ReactNode; dismiss(): void }) {
const { t } = useTranslation();
const installed = useInstalledPlugins();
const { state, reload, setSelection, pending, error } = useSidebarViewSelection();
const restoreDefault = useCallback(() => setSelection("default"), [setSelection]);
const renderError = useCallback(
() => (
<View style={styles.placeholder}>
<Text style={styles.text}>{t("sidebar.view.failed")}</Text>
<Button onPress={restoreDefault} disabled={pending}>
{t("sidebar.view.useDefault")}
</Button>
{error ? <Text style={styles.text}>{error.message}</Text> : null}
</View>
),
[restoreDefault, pending, error, t],
);
const entry =
state.status === "ready"
? sidebarViews(installed).find((view) => view.id === state.selection)
: undefined;
const view = entry ? (
<View style={styles.content} testID="plugin-sidebar-view">
<SurfaceErrorBoundary
installation={entry.plugin}
Surface={entry.view.Component}
renderError={renderError}
>
<ThemedSidebarView entry={entry} dismiss={dismiss} uniProps={themeMapping} />
</SurfaceErrorBoundary>
</View>
) : null;
return (
<SidebarViewFrame state={state} fallback={children} reload={reload}>
{view}
</SidebarViewFrame>
);
}
function SidebarView({
entry,
dismiss,
theme,
}: {
entry: SidebarViewEntry;
dismiss(): void;
theme: PluginTheme;
}) {
const { t } = useTranslation();
const toast = useToast();
const compact = useIsCompactFormFactor();
const selection = useActiveWorkspaceSelection();
const focusedAgentId = useSessionStore((state) =>
selection ? (state.sessions[selection.serverId]?.focusedAgentId ?? null) : null,
);
const activeWorkspace = selection
? entityRefs.workspace(selection.serverId, selection.workspaceId)
: null;
const activeAgent =
selection && focusedAgentId ? entityRefs.agent(selection.serverId, focusedAgentId) : null;
const environment = useMemo(
() => ({ installation: entry.plugin, state: source, toast }),
[entry.plugin, toast],
);
const navigation = useMemo<PluginSidebarViewProps["navigation"]>(
() => ({
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
dismiss();
},
openAgent(ref) {
const agent = source.getAgent(ref);
if (!agent) {
toast.error(t("sidebar.view.agentUnavailable"));
return;
}
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
dismiss();
},
openTerminal(ref) {
const terminal = source.getTerminal(ref);
if (!terminal) {
toast.error(t("sidebar.view.terminalUnavailable"));
return;
}
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
dismiss();
},
openProject(ref) {
const project = source.getProject(ref);
if (!project) {
toast.error(t("sidebar.view.projectUnavailable"));
return;
}
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
dismiss();
},
}),
[dismiss, toast, t],
);
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
const active = useMemo(
() => ({ workspace: activeWorkspace, agent: activeAgent }),
[activeWorkspace, activeAgent],
);
const Component = entry.view.Component;
return (
<PluginEnvironmentProvider environment={environment}>
<Component
theme={theme}
layout={layout}
active={active}
navigation={navigation}
dismiss={dismiss}
/>
</PluginEnvironmentProvider>
);
}
const ThemedSidebarView = withUnistyles(SidebarView);
const styles = StyleSheet.create((theme) => ({
content: { flex: 1 },
placeholder: { padding: theme.spacing[4], gap: theme.spacing[3] },
text: { color: theme.colors.foreground },
}));
export { SidebarViewPicker } from "./picker";
@@ -0,0 +1,58 @@
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { SettingsCard, SettingsRow, SettingsSection, SettingsSelect } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { useInstalledPlugins } from "../registry";
import { sidebarViews } from "./catalog";
import { useSidebarViewSelection } from "./selection";
export function SidebarViewPicker() {
const { t } = useTranslation();
const views = sidebarViews(useInstalledPlugins());
const { state, reload, setSelection, pending, error } = useSidebarViewSelection();
let content;
if (state.status === "loading") {
content = (
<SettingsRow label={t("settings.appearance.sidebar.view.label")}>
<Text style={styles.message}>{t("common.loading")}</Text>
</SettingsRow>
);
} else if (state.status === "error") {
content = (
<SettingsRow
label={t("settings.appearance.sidebar.view.label")}
hint={t("sidebar.view.readFailed")}
>
<Button onPress={reload}>{t("common.actions.retry")}</Button>
</SettingsRow>
);
} else {
const missing =
state.selection !== "default" && !views.some((view) => view.id === state.selection);
const value = missing ? "default" : state.selection;
let hint: string | undefined;
if (missing) hint = t("settings.appearance.sidebar.view.unavailable");
if (error) hint = error.message;
const options = [
{ value: "default", label: t("settings.appearance.sidebar.view.default") },
...views.map((view) => ({ value: view.id, label: view.title })),
];
content = (
<SettingsSelect
label={t("settings.appearance.sidebar.view.label")}
value={value}
options={options}
onValueChange={setSelection}
disabled={pending}
hint={hint}
/>
);
}
return (
<SettingsSection title={t("settings.appearance.sidebar.view.title")}>
<SettingsCard>{content}</SettingsCard>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
@@ -0,0 +1,47 @@
import { useCallback } from "react";
import { useFetchQuery } from "@/data/query";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQueryClient } from "@tanstack/react-query";
const key = "sidebar.view";
const queryKey = [key];
export interface SidebarViewStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
export type SidebarViewSelectionState =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "ready"; selection: string };
export function useSidebarViewSelection(storage: SidebarViewStorage = AsyncStorage) {
const client = useQueryClient();
const query = useFetchQuery({
queryKey,
queryFn: async () => (await storage.getItem(key)) ?? "default",
dataShape: "value",
immutableWhen: () => true,
});
const save = useMutation({
mutationFn: (selection: string) => storage.setItem(key, selection),
onSuccess: (_result, selection) => {
client.setQueryData(queryKey, selection);
},
});
let state: SidebarViewSelectionState;
if (query.isPending) state = { status: "loading" };
else if (query.isError) state = { status: "error", error: query.error };
else state = { status: "ready", selection: query.data };
const { refetch } = query;
const reload = useCallback(() => {
void refetch();
}, [refetch]);
return {
state,
reload,
setSelection: save.mutate,
pending: save.isPending,
error: save.error,
};
}
@@ -49,6 +49,7 @@ function installation(
cleanup: () => undefined,
settingsScreens: [],
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: sidebarItems.map((item) => ({
...item,
+1
View File
@@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -24,6 +24,7 @@ function plugin(input: {
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null }));
const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null }));
vi.mock("@/runtime/host-runtime", () => ({
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient),
useHosts: () => [{ serverId: "host-1", label: "Local" }],
}));
+2
View File
@@ -13,6 +13,7 @@ import type {
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
PluginComposerPillContribution as SDKPluginComposerPillContribution,
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarViewContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
PluginScreenContribution as SDKPluginScreenContribution,
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
@@ -48,6 +49,7 @@ export interface EvaluatedPlugin {
cleanup: PluginCleanup;
surfaces: PluginScreenContribution[];
settingsScreens: PluginSettingsScreenContribution[];
sidebarViews: PluginSidebarViewContribution<PluginText>[];
sidebarItems: Record<PluginSidebarSection, PluginSidebarItemContribution[]>;
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
@@ -21,6 +21,7 @@ function installed(): InstalledPlugin {
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -20,7 +20,8 @@ const runtime = vi.hoisted(() => ({
client: null as DaemonClient | null,
}));
vi.mock("@/runtime/host-runtime", () => ({
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
useHostRuntimeClient: () => runtime.client,
useHostRuntimeIsConnected: () => runtime.connected,
}));
@@ -1,3 +1,4 @@
import { SidebarViewPicker } from "@/plugins/sidebar-view";
import { useCallback, useMemo, type ComponentType, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -183,6 +184,7 @@ function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElemen
export function SidebarNavSection(): ReactElement {
return (
<>
<SidebarViewPicker />
<SidebarItemsCard section="header" />
<SidebarItemsCard section="footer" />
</>
+14 -7
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,11 +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.
*/
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({
@@ -177,6 +183,7 @@ export function useUsageHostReports(serverId: string | null): UsageReportEntry[]
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
+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]);
}
+7 -1
View File
@@ -6506,10 +6506,16 @@ export class DaemonClient {
// ============================================================================
observeTerminals(input: {
cwd: string;
cwd?: string;
workspaceId?: string;
signal?: AbortSignal;
}): OwnedSubscription<CorrelatedResponsePayload<"terminals_changed">> {
// COMPAT(terminalCollection): added in v0.16, remove after 2027-06.
if (
input.cwd === undefined &&
this.getLastServerInfoMessage()?.features?.terminalCollection !== true
)
throw new Error("Update the host to list terminals across workspaces.");
const { signal, ...query } = input;
return this.observe(
"terminals_changed",
+36
View File
@@ -1,3 +1,4 @@
import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels";
export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
@@ -189,6 +190,8 @@ export interface PaseoWorkspaceHandle {
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
setPinned(pinned: boolean): Promise<{ pinnedAt: string | null }>;
setLabel(name: string, options: { assigned: boolean }): Promise<{ labels: string[] }>;
setBaseBranch(options: {
baseRef: string;
requestId?: string;
@@ -866,6 +869,14 @@ function createWorkspaceHandleFactory(
current: () => current,
refresh,
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
setPinned: (pinned) => daemonClient.setWorkspacePinned(id, pinned),
setLabel: (name, options) =>
assignWorkspaceLabel({
client: daemonClient,
workspaceId: id,
name,
assigned: options.assigned,
}),
setBaseBranch: (options) =>
daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }),
archive: async (requestId) => {
@@ -1212,3 +1223,28 @@ function createGeneratedClientId(): string {
: Math.random().toString(36).slice(2);
return `paseo-sdk-${randomId}`;
}
interface WorkspaceLabelAssignment {
client: DaemonClient;
workspaceId: string;
name: string;
assigned: boolean;
}
async function assignWorkspaceLabel(
input: WorkspaceLabelAssignment,
): Promise<{ labels: string[] }> {
input.client.ensureConnected();
// COMPAT(workspaceLabels): added in v0.5.0, remove gate after 2027-08-14.
if (input.client.getLastServerInfoMessage()?.features?.workspaceLabels !== true)
throw new Error("Update the host to assign workspace labels.");
const catalog = await input.client.listWorkspaceLabels();
const key = workspaceLabelKey(input.name);
const existing = catalog.labels.find((label) => workspaceLabelKey(label.name) === key);
const label = existing ?? { name: input.name, color: "violet" as const };
const result = await input.client.setWorkspaceLabel({
workspaceId: input.workspaceId,
label,
assigned: input.assigned,
});
return { labels: result.workspaceLabels };
}
+42
View File
@@ -388,3 +388,45 @@ describe("plugin subscription SDK boundary", () => {
).toEqual([]);
});
});
describe("sidebar data SDK boundary", () => {
it("accepts references and preserves string-id panel consumers", () => {
expect(
typeErrors(`
import { useWorkspace, useAgent, usePaseo, type PluginClientContext, type PluginSidebarViewProps, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client";
import type { PluginWorkspaceSnapshot } from "@getpaseo/plugin";
function Panel({ workspace, agent }: { workspace: WorkspaceRef; agent: AgentRef }) {
const snapshot: PluginWorkspaceSnapshot | null = useWorkspace(workspace);
const title: string | null = useWorkspace("workspace", item => item.name);
const status = useAgent(agent, item => ({ status: item.status }), (a, b) => a?.status === b?.status);
const agentSnapshot = useAgent(agent);
const custom = useWorkspace(workspace, undefined, (a, b) => a?.name === b?.name);
const workspaces: readonly WorkspaceRef[] = useWorkspaces({ orderBy: "name" });
const groups: readonly Group<AgentRef>[] = useAgents({ groupBy: item => item.status });
const projects: readonly ProjectRef[] = useProjects({ hosts: [workspace.hostId] });
const terminals: readonly TerminalRef[] = useTerminals();
const paseo = usePaseo();
void paseo.workspaces.ref(workspace).setPinned(true);
void paseo.workspaces.ref(workspace).setLabel("Later", { assigned: true });
void paseo.workspaces.ref("old-id").setTitle("Title");
if (projects[0]) { paseo.projects.ref(projects[0]).open(); void paseo.projects.ref(projects[0]).createWorkspace(); }
if (terminals[0]) { paseo.terminals.ref(terminals[0]).focus(); void paseo.terminals.ref(terminals[0]).kill(); }
void paseo.agents.ref(agent);
if (projects[0]) useProject(projects[0], item => item.displayName);
if (terminals[0]) useTerminal(terminals[0]);
function View(props: PluginSidebarViewProps) {
props.navigation.openWorkspace(workspace);
props.navigation.openAgent(agent);
props.dismiss();
return null;
}
function contribute(client: PluginClientContext) {
client.addSidebarView({ id: "workspaces", title: "Workspaces", Component: View });
}
const query: Query<PluginWorkspaceSnapshot> = { hosts: [workspace.hostId], orderBy: ["name", "desc"] };
return null;
}
`),
).toEqual([]);
});
});
+122 -35
View File
@@ -1,12 +1,30 @@
import { createContext, useContext, type ReactNode } from "react";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js";
import type { AgentRef, WorkspaceRef, ProjectRef, TerminalRef } from "../refs.js";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
} from "../contracts.js";
import { createCollectionQuery, type Query, type Group } from "./query.js";
import { shallowEqual } from "./shallow.js";
export interface PluginClientStateSource {
subscribe(listener: () => void): () => void;
getWorkspace(id: string): PluginWorkspaceSnapshot | null;
getAgent(id: string): PluginAgentSnapshot | null;
subscribe(listener: () => void, options?: { terminals?: boolean }): () => void;
getWorkspace(id: string | WorkspaceRef): PluginWorkspaceSnapshot | null;
getAgent(id: string | AgentRef): PluginAgentSnapshot | null;
getProject(ref: ProjectRef): PluginProjectSnapshot | null;
getTerminal(ref: TerminalRef): PluginTerminalSnapshot | null;
getSnapshot(): PluginClientSnapshot;
}
export interface PluginClientSnapshot {
readonly workspaces: readonly PluginWorkspaceSnapshot[];
readonly agents: readonly PluginAgentSnapshot[];
readonly projects: readonly PluginProjectSnapshot[];
readonly terminals: readonly PluginTerminalSnapshot[];
readonly terminalErrors?: ReadonlyMap<string, Error>;
}
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
@@ -15,44 +33,113 @@ export function usePluginClientStateSource(): PluginClientStateSource | null {
return useContext(PluginClientStateContext);
}
function usePluginEntity<Entity, Selection>(input: {
id: string;
read(source: PluginClientStateSource, id: string): Entity | null;
selector(entity: Entity): Selection;
}): Selection | null {
/** Shared selection semantics for host surfaces and plugin entities. */
export function usePaseoSelector<Entity, Selection>(
source: { subscribe(listener: () => void): () => void },
read: () => Entity,
selector: (entity: Entity) => Selection,
equals: (left: Selection, right: Selection) => boolean = shallowEqual,
): Selection {
return useSyncExternalStoreWithSelector(source.subscribe, read, read, selector, equals);
}
type Equality<T> = { equals(left: T, right: T): boolean }["equals"];
function useStateSource(terminals: boolean) {
const source = usePluginClientStateSource();
if (!source) throw new Error("Plugin state hooks must run inside a workspace panel");
return useSyncExternalStoreWithSelector(
source.subscribe,
() => input.read(source, input.id),
() => input.read(source, input.id),
(entity) => (entity ? input.selector(entity) : null),
shallowEqual,
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
return useMemo(
() =>
terminals
? {
...source,
subscribe: (listener: () => void) => source.subscribe(listener, { terminals: true }),
}
: source,
[source, terminals],
);
}
export function useWorkspace<Selection>(
workspaceId: string,
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: workspaceId,
read: (source, id) => source.getWorkspace(id),
selector,
});
function createEntityHook<Ref, Entity>(
read: (source: PluginClientStateSource, ref: Ref) => Entity | null,
terminals = false,
) {
function useEntity(
ref: Ref,
selector?: undefined,
equals?: Equality<Entity | null>,
): Entity | null;
function useEntity<Selection>(
ref: Ref,
selector: (entity: Entity) => Selection,
equals?: Equality<Selection | null>,
): Selection | null;
function useEntity<Selection>(
ref: Ref,
selector?: (entity: Entity) => Selection,
equals?: Equality<Entity | Selection | null>,
): Entity | Selection | null {
const source = useStateSource(terminals);
function select(entity: Entity | null): Entity | Selection | null {
return entity !== null && selector ? selector(entity) : entity;
}
const equality = equals ?? (selector ? shallowEqual : Object.is);
return usePaseoSelector(source, () => read(source, ref), select, equality);
}
return useEntity;
}
export function useAgent<Selection>(
agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: agentId,
read: (source, id) => source.getAgent(id),
selector,
});
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
export const useWorkspace = createEntityHook((source, ref: WorkspaceRef | string) =>
source.getWorkspace(ref),
);
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
export const useAgent = createEntityHook((source, ref: AgentRef | string) => source.getAgent(ref));
export const useProject = createEntityHook((source, ref: ProjectRef) => source.getProject(ref));
export const useTerminal = createEntityHook(
(source, ref: TerminalRef) => source.getTerminal(ref),
true,
);
function createCollectionHook<Snapshot extends { readonly ref: unknown; readonly hostId: string }>(
read: (source: PluginClientStateSource, query: Query<Snapshot>) => readonly Snapshot[],
terminals = false,
) {
function useCollection(
query: Query<Snapshot> & { groupBy: NonNullable<Query<Snapshot>["groupBy"]> },
): readonly Group<Snapshot["ref"]>[];
function useCollection(
query?: Query<Snapshot> & { groupBy?: undefined },
): readonly Snapshot["ref"][];
function useCollection(
query: Query<Snapshot>,
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[];
function useCollection(
query: Query<Snapshot> = {},
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[] {
const source = useStateSource(terminals);
const project = useMemo(() => createCollectionQuery<Snapshot>(), []);
return usePaseoSelector(
source,
() => read(source, query),
(snapshots) => project(snapshots, query),
Object.is,
);
}
return useCollection;
}
export const useWorkspaces = createCollectionHook((source) => source.getSnapshot().workspaces);
export const useAgents = createCollectionHook((source) => source.getSnapshot().agents);
export const useProjects = createCollectionHook((source) => source.getSnapshot().projects);
export const useTerminals = createCollectionHook<PluginTerminalSnapshot>((source, query) => {
const snapshot = source.getSnapshot();
for (const [hostId, error] of snapshot.terminalErrors ?? []) {
if (!query.hosts || query.hosts.includes(hostId)) throw error;
}
return snapshot.terminals;
}, true);
export function PluginClientStateProvider({
children,
source,
+22 -2
View File
@@ -1,6 +1,7 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
import type { PluginText } from "../text.js";
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { PluginPaseoApi } from "./reference-api.js";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
@@ -96,6 +97,24 @@ export interface PluginSidebarItemContribution<Text extends PluginText = string>
Component: ComponentType<PluginSidebarItemProps>;
}
export interface PluginSidebarViewProps extends Pick<PluginHostProps, "theme" | "layout"> {
active: { workspace: WorkspaceRef | null; agent: AgentRef | null };
navigation: {
openWorkspace(ref: WorkspaceRef): void;
openAgent(ref: AgentRef): void;
openTerminal(ref: TerminalRef): void;
openProject(ref: ProjectRef): void;
};
dismiss(): void;
}
export interface PluginSidebarViewContribution<Text extends PluginText = string> {
id: string;
title: Text;
description?: Text;
Component: ComponentType<PluginSidebarViewProps>;
}
/**
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
* and color; buttons widen the props with their host and target context.
@@ -185,6 +204,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
playAudio(source: { base64: string; mimeType: string }): Promise<void>;
addSettingsScreen(contribution: PluginSettingsScreenContribution<PluginText>): PluginCleanup;
addScreen(contribution: PluginScreenContribution<PluginText>): PluginCleanup;
addSidebarView(contribution: PluginSidebarViewContribution<PluginText>): PluginCleanup;
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
@@ -308,7 +328,7 @@ export interface PluginTimelineRendererContribution<Schema extends ZodType = Zod
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
paseo: PluginPaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
+10 -1
View File
@@ -1,6 +1,11 @@
import type { PluginText } from "../text.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
PluginClientStateProvider,
usePaseoSelector,
type PluginClientStateSource,
type PluginClientSnapshot,
} from "./client-state.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
@@ -31,3 +36,7 @@ export {
isPluginMessage,
} from "./translations.js";
export type { PluginLocaleSource } from "./translations.js";
export { createEntityRefs } from "./refs.js";
export { createPluginReferenceApi } from "./reference-api.js";
+17 -1
View File
@@ -15,6 +15,8 @@ export type {
PluginOpenScreenInput,
PluginScreenLocation,
PluginSidebarItemProps,
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginIcon,
PluginPanelLocation,
@@ -70,7 +72,16 @@ export {
useFileUrl,
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export {
useAgent,
useWorkspace,
useProject,
useTerminal,
useWorkspaces,
useAgents,
useProjects,
useTerminals,
} from "./client-state.js";
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js";
export type {
PluginSubscribeOptions,
@@ -102,3 +113,8 @@ export type { PluginI18n } from "./translations.js";
export type { PluginText, PluginMessage, PluginTranslations } from "../text.js";
export { useTabState } from "./tab.js";
export type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
export type { Query, Group, GroupKey } from "./query.js";
export type { PluginPaseoApi, PluginProjectHandle, PluginTerminalHandle } from "./reference-api.js";
+11 -5
View File
@@ -1,17 +1,23 @@
import type { PaseoApi } from "@getpaseo/client";
import type { PluginPaseoApi } from "./reference-api.js";
import { createContext, useContext, type ReactNode } from "react";
const PaseoApiContext = createContext<PaseoApi | null>(null);
const PaseoApiContext = createContext<PluginPaseoApi | null>(null);
export function usePaseoContextValue(): PaseoApi | null {
export function usePaseoContextValue(): PluginPaseoApi | null {
return useContext(PaseoApiContext);
}
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
export function PaseoApiProvider({
children,
paseo,
}: {
children: ReactNode;
paseo: PluginPaseoApi;
}) {
return <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
}
export function usePaseo(): PaseoApi {
export function usePaseo(): PluginPaseoApi {
const paseo = usePaseoContextValue();
if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface");
return paseo;
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { createCollectionQuery, type GroupKey } from "./query.js";
const first = Object.freeze({ key: "a" });
const second = Object.freeze({ key: "b" });
const work: GroupKey = "work";
const snapshots = [
{ ref: first, hostId: "one", title: "A", status: "running", labels: [work, work] },
{ ref: second, hostId: "two", title: "B", status: "idle", labels: ["home"] },
];
describe("collection query structural sharing", () => {
it("preserves collection identity when a row changes without changing membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const before = project(snapshots);
const after = project([{ ...snapshots[0]!, status: "waiting" }, snapshots[1]!]);
expect(after).toBe(before);
expect(after).toEqual([first, second]);
});
it("shares unaffected groups and deduplicates membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const query = { groupBy: (item: (typeof snapshots)[number]) => item.labels };
const before = project(snapshots, query);
const after = project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query);
expect(after[1]).toBe(before[1]);
expect(before[0]).toEqual({ key: "work", title: null, color: null, items: [first] });
expect(after[0]).toEqual({ key: "new", title: null, color: null, items: [first] });
expect(project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query)).toBe(after);
});
it("filters hosts and predicates before sorting", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
expect(project(snapshots, { hosts: ["two"] })).toEqual([second]);
expect(project(snapshots, { where: (item) => item.status === "running" })).toEqual([first]);
expect(project(snapshots, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(project(snapshots, { orderBy: (a, b) => a.title.localeCompare(b.title) })).toEqual([
first,
second,
]);
});
it("keeps nulls last in either direction and metadata from the first group", () => {
const project = createCollectionQuery<{
ref: typeof first;
hostId: string;
title: string | null;
}>();
const items = [
{ ref: first, hostId: "one", title: null },
{ ref: second, hostId: "one", title: "Z" },
];
expect(project(items, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(
project(items, { groupBy: (item) => ({ key: "all", title: item.title ?? "First" }) }),
).toEqual([{ key: "all", title: "First", color: null, items: [first, second] }]);
});
});
+115
View File
@@ -0,0 +1,115 @@
interface GroupDescriptor {
readonly key: string;
readonly title?: string;
readonly color?: string;
}
export type GroupKey = string | GroupDescriptor;
export interface Query<Snapshot> {
readonly hosts?: readonly string[];
readonly where?: (item: Snapshot) => boolean;
readonly orderBy?:
| keyof Snapshot
| readonly [keyof Snapshot, "asc" | "desc"]
| ((a: Snapshot, b: Snapshot) => number);
readonly groupBy?: (item: Snapshot) => GroupKey | readonly GroupKey[];
}
export interface Group<Ref> {
readonly key: string;
readonly title: string | null;
readonly color: string | null;
readonly items: readonly Ref[];
}
function groupKeys(value: GroupKey | readonly GroupKey[]): readonly GroupKey[] {
if (typeof value === "string") return [value];
if ("key" in value) return [value];
return value;
}
interface Referenced {
readonly hostId: string;
readonly ref: unknown;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((item, index) => item === right[index]);
}
function compareValues(left: unknown, right: unknown, direction: number): number {
if (Object.is(left, right)) return 0;
if (left === null || left === undefined) return 1;
if (right === null || right === undefined) return -1;
if (typeof left === "number" && typeof right === "number") return (left - right) * direction;
return String(left).localeCompare(String(right)) * direction;
}
/** A hook owns one projection; sharing survives both source and query changes. */
export function createCollectionQuery<Snapshot extends Referenced>() {
type Ref = Snapshot["ref"];
let previousItems: readonly Ref[] = Object.freeze([]);
let previousGroups: readonly Group<Ref>[] = Object.freeze([]);
return function project(
snapshots: readonly Snapshot[],
query: Query<Snapshot> = {},
): readonly Ref[] | readonly Group<Ref>[] {
const items = snapshots.filter((item) => {
if (query.hosts && !query.hosts.includes(item.hostId)) return false;
return !query.where || query.where(item);
});
const order = query.orderBy;
if (typeof order === "function") items.sort(order);
else if (order !== undefined) {
const field = typeof order === "object" ? order[0] : order;
const direction = typeof order === "object" && order[1] === "desc" ? -1 : 1;
items.sort((a, b) => compareValues(a[field], b[field], direction));
}
if (!query.groupBy) {
const refs = items.map((item) => item.ref);
if (!sameSequence(previousItems, refs)) previousItems = Object.freeze(refs);
return previousItems;
}
const groups = new Map<
string,
{ key: string; title: string | null; color: string | null; items: Ref[] }
>();
for (const item of items) {
const grouping = query.groupBy(item);
const keys = groupKeys(grouping);
const seen = new Set<string>();
for (const value of keys) {
const descriptor = typeof value === "string" ? { key: value } : value;
if (seen.has(descriptor.key)) continue;
seen.add(descriptor.key);
let group = groups.get(descriptor.key);
if (!group) {
group = {
key: descriptor.key,
title: descriptor.title ?? null,
color: descriptor.color ?? null,
items: [],
};
groups.set(descriptor.key, group);
}
group.items.push(item.ref);
}
}
const oldGroups = new Map(previousGroups.map((group) => [group.key, group]));
const nextGroups = Array.from(groups.values(), (group) => {
const previous = oldGroups.get(group.key);
if (
previous &&
previous.title === group.title &&
previous.color === group.color &&
sameSequence(previous.items, group.items)
)
return previous;
return Object.freeze({ ...group, items: Object.freeze(group.items) });
});
if (!sameSequence(previousGroups, nextGroups)) previousGroups = Object.freeze(nextGroups);
return previousGroups;
};
}
@@ -0,0 +1,88 @@
import type {
PaseoApi,
PaseoAgent,
PaseoAgentHandle,
PaseoWorkspace,
PaseoWorkspaceHandle,
PaseoWorkspaceCreateOptions,
} from "@getpaseo/client";
import type { PaseoTerminal, PaseoTerminalHandle } from "@getpaseo/client";
import type { PluginProjectSnapshot } from "../contracts.js";
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
import type { PluginSidebarViewProps } from "./contracts.js";
export interface PluginProjectHandle {
readonly ref: ProjectRef;
open(): void;
createWorkspace(
input?: Omit<PaseoWorkspaceCreateOptions, "source"> & {
source?: PaseoWorkspaceCreateOptions["source"];
},
): Promise<PaseoWorkspaceHandle>;
}
export interface PluginTerminalHandle extends PaseoTerminalHandle {
focus(): void;
}
export interface PluginPaseoApi extends PaseoApi {
workspaces: Omit<PaseoApi["workspaces"], "ref"> & {
ref(workspace: string | PaseoWorkspace | WorkspaceRef): PaseoWorkspaceHandle;
};
agents: Omit<PaseoApi["agents"], "ref"> & {
ref(agent: string | PaseoAgent | AgentRef): PaseoAgentHandle;
};
projects: PaseoApi["projects"] & { ref(project: ProjectRef): PluginProjectHandle };
terminals: Omit<PaseoApi["terminals"], "ref"> & {
ref(terminal: TerminalRef): PluginTerminalHandle;
ref(terminal: string | PaseoTerminal): PaseoTerminalHandle;
};
}
/** Host adapter; the installation retains its single API identity across every surface. */
export function createPluginReferenceApi(input: {
paseo: PaseoApi;
resolveHost(hostId: string): PaseoApi;
readProject(ref: ProjectRef): PluginProjectSnapshot | null;
navigation: PluginSidebarViewProps["navigation"];
}): PluginPaseoApi {
const { paseo } = input;
const workspaceRef = paseo.workspaces.ref;
const agentRef = paseo.agents.ref;
const terminalRef = paseo.terminals.ref;
function refWorkspace(workspace: string | PaseoWorkspace | WorkspaceRef) {
if (typeof workspace !== "string" && "hostId" in workspace)
return input.resolveHost(workspace.hostId).workspaces.ref(workspace.id);
return workspaceRef(workspace);
}
function refAgent(agent: string | PaseoAgent | AgentRef) {
if (typeof agent !== "string" && "hostId" in agent)
return input.resolveHost(agent.hostId).agents.ref(agent.id);
return agentRef(agent);
}
function terminal(ref: TerminalRef): PluginTerminalHandle;
function terminal(ref: string | PaseoTerminal): PaseoTerminalHandle;
function terminal(ref: TerminalRef | string | PaseoTerminal): PaseoTerminalHandle {
if (typeof ref !== "string" && "hostId" in ref) {
const handle = input.resolveHost(ref.hostId).terminals.ref(ref.id);
return Object.assign(handle, { focus: () => input.navigation.openTerminal(ref) });
}
return terminalRef(ref);
}
function project(ref: ProjectRef): PluginProjectHandle {
return {
ref,
open: () => input.navigation.openProject(ref),
async createWorkspace(options = {}) {
const snapshot = input.readProject(ref);
if (!snapshot) throw new Error("Project is no longer available");
const source = options.source ?? { kind: "directory", path: snapshot.rootPath };
return input.resolveHost(ref.hostId).workspaces.create({ ...options, source });
},
};
}
return Object.assign(paseo, {
workspaces: Object.assign(paseo.workspaces, { ref: refWorkspace }),
agents: Object.assign(paseo.agents, { ref: refAgent }),
projects: Object.assign(paseo.projects, { ref: project }),
terminals: Object.assign(paseo.terminals, { ref: terminal }),
});
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createEntityRefs } from "./refs.js";
describe("session entity references", () => {
it("interns frozen references separately by kind, host and entity", () => {
const refs = createEntityRefs();
for (const reference of [refs.workspace, refs.agent, refs.project, refs.terminal]) {
const first = reference("host", "entity");
expect(reference("host", "entity")).toBe(first);
expect(Object.isFrozen(first)).toBe(true);
expect(reference("other", "entity")).not.toBe(first);
expect(reference("host", "other")).not.toBe(first);
}
expect(refs.workspace("host", "entity")).not.toBe(refs.agent("host", "entity"));
});
it("keeps opaque ids unambiguous and interning scoped to one app session", () => {
const refs = createEntityRefs();
expect(refs.workspace("a:b", "c").key).not.toBe(refs.workspace("a", "b:c").key);
expect(createEntityRefs().workspace("host", "entity")).not.toBe(
refs.workspace("host", "entity"),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
/** Owned by the app session, shared by every plugin installation. */
export function createEntityRefs() {
function intern<Kind extends string>(kind: Kind) {
const references = new Map<
string,
Readonly<{ kind: Kind; key: string; hostId: string; id: string }>
>();
return function reference(hostId: string, id: string) {
const key = JSON.stringify([hostId, id]);
let ref = references.get(key);
if (!ref) {
ref = Object.freeze({ kind, key, hostId, id });
references.set(key, ref);
}
return ref;
};
}
return {
workspace: intern("workspace") satisfies (hostId: string, id: string) => WorkspaceRef,
agent: intern("agent") satisfies (hostId: string, id: string) => AgentRef,
project: intern("project") satisfies (hostId: string, id: string) => ProjectRef,
terminal: intern("terminal") satisfies (hostId: string, id: string) => TerminalRef,
};
}
+40
View File
@@ -1,3 +1,4 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "./refs.js";
import type { PluginText } from "./text.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginSerializableIcon } from "./icons.js";
@@ -19,7 +20,22 @@ export interface PluginTheme {
};
}
export type WorkspaceIndicator = "needs_input" | "failed" | "running" | "attention" | "done";
export interface PluginWorkspaceSnapshot {
readonly ref: WorkspaceRef;
readonly hostId: string;
readonly project: ProjectRef;
readonly pinnedAt: string | null;
readonly labels: readonly string[];
readonly background: boolean;
readonly branch: string | null;
/** Null when an older host has not supplied the workspace creation time. */
readonly createdAt: string | null;
readonly lastPromptAt: string | null;
readonly parentAgent: AgentRef | null;
readonly indicator: WorkspaceIndicator;
readonly indicatorLabel: string | null;
readonly id: string;
readonly projectId: string;
readonly projectDisplayName: string;
@@ -29,6 +45,7 @@ export interface PluginWorkspaceSnapshot {
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
readonly name: string;
readonly title: string | null;
/** Raw daemon status; prefer indicator for presentation. */
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
readonly statusEnteredAt: string | null;
readonly archivingAt: string | null;
@@ -36,6 +53,10 @@ export interface PluginWorkspaceSnapshot {
}
export interface PluginAgentSnapshot {
readonly ref: AgentRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly parentAgent: AgentRef | null;
readonly id: string;
readonly workspaceId: string;
readonly provider: string;
@@ -96,3 +117,22 @@ export interface PluginTimelineTransformResult {
}
export type PluginCleanup = () => void | Promise<void>;
export interface PluginProjectSnapshot {
readonly ref: ProjectRef;
readonly hostId: string;
readonly displayName: string;
readonly rootPath: string;
readonly kind: PluginWorkspaceSnapshot["projectKind"];
readonly iconRevision: string | null;
}
export interface PluginTerminalSnapshot {
readonly ref: TerminalRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly title: string;
readonly isScript: boolean;
/** Null for terminals owned by workers started before timestamps were tracked. */
readonly createdAt: string | null;
}
+3
View File
@@ -3,6 +3,9 @@ export type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
WorkspaceIndicator,
PluginThemeColors,
PluginThemeContribution,
PluginAttachmentSourceContribution,
+11
View File
@@ -0,0 +1,11 @@
interface EntityRef<Kind extends string> {
readonly kind: Kind;
readonly key: string;
readonly hostId: string;
readonly id: string;
}
export interface WorkspaceRef extends EntityRef<"workspace"> {}
export interface AgentRef extends EntityRef<"agent"> {}
export interface ProjectRef extends EntityRef<"project"> {}
export interface TerminalRef extends EntityRef<"terminal"> {}
+4 -3
View File
@@ -4,10 +4,10 @@ import { defineRpc } from "./rpc.js";
export interface SettingsDefinition<Schema extends ZodType = ZodType> {
id: string;
scope: "host";
scope: "host" | "device";
version: number;
schema: Schema;
/** Convert a previous stored version to the current schema. Runs on the host. */
/** Convert a previous stored version to the current schema. Runs in the selected scope. */
migrate?: (values: unknown, fromVersion: number) => unknown | Promise<unknown>;
}
@@ -15,7 +15,8 @@ export function defineSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsDefinition<Schema> {
if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID");
if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported");
if (definition.scope !== "host" && definition.scope !== "device")
throw new Error("Invalid settings scope");
if (!Number.isSafeInteger(definition.version) || definition.version < 1)
throw new Error("Invalid settings version");
return definition;
+40
View File
@@ -571,3 +571,43 @@ test.each(["all", "base", "uncommitted"] as const)(
expect(parsed.attachments).toEqual([attachment]);
},
);
describe("terminal collection protocol compatibility", () => {
test("accepts released directory observations and their snapshots", () => {
expect(
SessionInboundMessageSchema.parse({
type: "subscribe_terminals_request",
cwd: "/repo",
requestId: "old",
}),
).toMatchObject({ cwd: "/repo" });
expect(
SessionOutboundMessageSchema.parse({
type: "terminals_changed",
payload: { cwd: "/repo", terminals: [{ id: "terminal", name: "Shell" }] },
}),
).toMatchObject({ payload: { cwd: "/repo" } });
});
test("accepts capability-gated host observations and additive metadata", () => {
expect(
SessionInboundMessageSchema.parse({ type: "subscribe_terminals_request", requestId: "host" }),
).toMatchObject({ requestId: "host" });
expect(
SessionOutboundMessageSchema.parse({
type: "terminals_changed",
payload: {
terminals: [
{
id: "terminal",
name: "Shell",
cwd: "/repo",
workspaceId: "workspace",
createdAt: null,
isScript: true,
},
],
},
}),
).toMatchObject({ payload: { terminals: [{ isScript: true, createdAt: null }] } });
});
});
+7 -4
View File
@@ -3059,13 +3059,13 @@ export const ListTerminalsRequestSchema = z.object({
export const SubscribeTerminalsRequestSchema = z.object({
type: z.literal("subscribe_terminals_request"),
requestId: z.string().optional(),
cwd: z.string(),
cwd: z.string().optional(),
workspaceId: z.string().optional(),
});
export const UnsubscribeTerminalsRequestSchema = z.object({
type: z.literal("unsubscribe_terminals_request"),
cwd: z.string(),
cwd: z.string().optional(),
workspaceId: z.string().optional(),
});
@@ -3732,6 +3732,7 @@ export const ServerInfoStatusPayloadSchema = z
directorySync: z.boolean().optional(),
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
workspaceLabels: z.boolean().optional(),
terminalCollection: z.boolean().optional(),
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
workspaceBaseBranch: z.boolean().optional(),
// COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02.
@@ -6591,6 +6592,8 @@ export const ListCommandsResponseSchema = z.object({
// ============================================================================
const TerminalInfoSchema = z.object({
createdAt: z.string().nullable().optional(),
isScript: z.boolean().optional(),
id: z.string(),
name: z.string(),
cwd: z.string(),
@@ -6654,8 +6657,8 @@ export const TerminalsChangedSchema = z.object({
subscriptionId: z.string().optional(),
requestId: z.string().optional(),
workspaceId: z.string().optional(),
cwd: z.string(),
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
cwd: z.string().optional(),
terminals: z.array(TerminalInfoSchema.partial({ cwd: true })),
}),
});
@@ -3,6 +3,11 @@
// and tear down its own live subscription without disturbing the other. An
// absent workspaceId (old clients) keys to the cwd on its own. The `::`
// separator cannot collide with a workspace id, which is always `wks_<hex>`.
export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string {
export function terminalSubscriptionKey(
cwd: string | undefined,
workspaceId: string | undefined,
): string {
if (cwd === undefined)
return workspaceId === undefined ? "terminal-host" : `${workspaceId}::terminal-host`;
return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`;
}
@@ -1,5 +1,5 @@
import { resolveDaemonVersion } from "../daemon-version.js";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
@@ -269,3 +269,132 @@ test.runIf(process.platform !== "win32")(
expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]);
},
);
test("host-wide terminal observation tracks every workspace, metadata and release", async () => {
const first = await createWorkspace("Observe first");
const second = await createWorkspace("Observe second");
expect(client.getLastServerInfoMessage()?.features?.terminalCollection).toBe(true);
const observation = client.observeTerminals({});
let terminals = (await observation.ready).terminals;
const update = observation.subscribe({
snapshot: (value) => {
terminals = value.terminals;
},
update: (message) => {
if (message.type === "terminals_changed") terminals = message.payload.terminals;
},
});
try {
expect(terminals).toEqual([]);
const scoped = client.observeTerminals({ workspaceId: first });
const scopedSnapshot = await scoped.ready;
let owned = scopedSnapshot.terminals;
const releaseScoped = scoped.subscribe({
snapshot: (value) => {
owned = value.terminals;
},
update: (message) => {
if (message.type === "terminals_changed") owned = message.payload.terminals;
},
});
// Use a controlled process: interactive shells publish their own OSC title on startup.
const command = {
command: process.execPath,
args: ["-e", "console.log('READY'); process.stdin.resume()"],
};
const a = await sdk.terminals.create({
workspaceId: first,
name: "First terminal",
...command,
});
const b = await sdk.terminals.create({
workspaceId: second,
name: "Second terminal",
...command,
});
await expect
.poll(async () => (await a.capture({ stripAnsi: true })).lines.join("\n"))
.toContain("READY");
await expect
.poll(async () => (await b.capture({ stripAnsi: true })).lines.join("\n"))
.toContain("READY");
await expect.poll(() => terminals.map((item) => item.id).sort()).toEqual([a.id, b.id].sort());
expect(terminals.every((item) => item.cwd === cwd && item.createdAt && !item.isScript)).toBe(
true,
);
expect(terminals.map((item) => item.workspaceId).sort()).toEqual([first, second].sort());
await expect.poll(() => owned.map((item) => item.id)).toEqual([a.id]);
releaseScoped();
await scoped.release();
await client.renameTerminal({ terminalId: a.id, title: "Renamed terminal" });
await expect
.poll(() => terminals.find((item) => item.id === a.id)?.title)
.toBe("Renamed terminal");
await b.kill();
await expect.poll(() => terminals.map((item) => item.id)).toEqual([a.id]);
await a.kill();
await expect.poll(() => terminals).toEqual([]);
} finally {
update();
await observation.release();
}
}, 60_000);
test("terminal collection reports workspace script ownership after runtime registration", async () => {
await writeFile(
path.join(cwd, "paseo.json"),
JSON.stringify({
scripts: {
sleeper: {
type: "script",
command: `node -e "const fs = require('node:fs'); fs.writeFileSync('sleeper.pid', String(process.pid)); setInterval(() => { if (fs.existsSync('finish-sleeper')) process.exit(0); }, 50)"`,
},
},
}),
);
const workspaceId = await createWorkspace("Script observation");
const observation = client.observeTerminals({});
let terminals = (await observation.ready).terminals;
const release = observation.subscribe({
snapshot: (value) => {
terminals = value.terminals;
},
update: (message) => {
if (message.type === "terminals_changed") terminals = message.payload.terminals;
},
});
try {
expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull();
await expect
.poll(() =>
terminals.some((terminal) => terminal.workspaceId === workspaceId && terminal.isScript),
)
.toBe(true);
const terminalId = terminals.find((terminal) => terminal.workspaceId === workspaceId)!.id;
const pidFile = path.join(cwd, "sleeper.pid");
await expect
.poll(async () => Number(await readFile(pidFile, "utf8")), { timeout: 10_000 })
.toBeGreaterThan(0);
const pid = Number(await readFile(pidFile, "utf8"));
await writeFile(path.join(cwd, "finish-sleeper"), "done");
await expect.poll(() => isFixtureProcessRunning(pid), { timeout: 10_000 }).toBe(false);
// The program has exited; now close its interactive shell as well.
await sdk.terminals.ref(terminalId).kill();
await expect
.poll(() => terminals.filter((terminal) => terminal.workspaceId === workspaceId))
.toEqual([]);
} finally {
release();
await observation.release();
}
}, 60_000);
function isFixtureProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
throw error;
}
}
@@ -2122,7 +2122,7 @@ it.each(examples)("builds and loads the %s plugin example", async (example) => {
const runtime = createTestRuntime(
{ settingsDirectory },
undefined,
resolveDaemonVersion(import.meta.url),
example === "sidebar-views" ? "0.16.0" : resolveDaemonVersion(import.meta.url),
);
try {
await runtime.startPlugin(example, path.join(examplesDirectory, example));
@@ -175,3 +175,11 @@ test("installation namespaces and definition IDs remain separate", async () => {
expect(() => store.register(definition)).toThrow("Duplicate settings");
expect(() => store.register({ ...definition, id: "../escape" })).toThrow("Invalid settings ID");
});
test("device-scoped settings are owned by the client", async () => {
const { directory } = await setup();
const store = new PluginSettingsStore(directory, () => {});
expect(() => store.register(defineSettings({ ...definition, scope: "device" }))).toThrow(
"Device settings belong to the client",
);
});
@@ -45,6 +45,7 @@ export class PluginSettingsStore {
register<Schema extends ZodType>(definition: SettingsDefinition<Schema>) {
defineSettings(definition);
if (definition.scope !== "host") throw new Error("Device settings belong to the client");
if (this.definitions.has(definition.id))
throw new Error(`Duplicate settings: ${definition.id}`);
this.definitions.set(definition.id, definition);

Some files were not shown because too many files have changed in this diff Show More