mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e5a0e739 | ||
|
|
62bd21214b | ||
|
|
dc309586f0 | ||
|
|
e4106b4e2d | ||
|
|
6355b339c8 | ||
|
|
8b9c42c266 | ||
|
|
a68dc55490 |
+1
-1
@@ -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
@@ -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).
|
||||
|
||||
@@ -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()] });
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
|
||||
@@ -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: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "选择侧边栏顶部显示的项目及其顺序",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }],
|
||||
}));
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] }]);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,6 +3,9 @@ export type {
|
||||
PluginTheme,
|
||||
PluginWorkspaceSnapshot,
|
||||
PluginAgentSnapshot,
|
||||
PluginProjectSnapshot,
|
||||
PluginTerminalSnapshot,
|
||||
WorkspaceIndicator,
|
||||
PluginThemeColors,
|
||||
PluginThemeContribution,
|
||||
PluginAttachmentSourceContribution,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 }] } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user