mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72a7d4084 | ||
|
|
dd244c076d | ||
|
|
1c8c68f93e | ||
|
|
19749baeec | ||
|
|
e760764b37 | ||
|
|
b6dde61a7f | ||
|
|
1e0e56c721 | ||
|
|
c575464e8f | ||
|
|
9a0b95aa10 | ||
|
|
9be1547741 |
+27
-9
@@ -240,8 +240,9 @@ Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
|
||||
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
|
||||
so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
|
||||
[host UI contract](../public-docs/plugins/reference.md#host-ui).
|
||||
@@ -332,7 +333,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
|
||||
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
|
||||
errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
|
||||
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
|
||||
@@ -400,6 +401,25 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
### Plugin icons
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
|
||||
`icon` is a Lucide name, `{ svg }`, or a component.
|
||||
|
||||
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
|
||||
The daemon applies it to provider and usage-source icon files; the app applies it to inline
|
||||
icons. Change the rules there, never at a call site.
|
||||
- The app validates each icon once, at registration, through `parsePluginIcon` in
|
||||
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
|
||||
- That module is the only code that branches on the icon form. Surfaces draw with
|
||||
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
|
||||
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
|
||||
component icon never reach an SVG element.
|
||||
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
|
||||
each attachment, and a component cannot be stored.
|
||||
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
|
||||
markup string.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
@@ -491,15 +511,13 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
|
||||
owns author workflow, lifecycle, testing, and distribution guidance.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
|
||||
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
|
||||
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
|
||||
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
|
||||
string is never inline SVG or a URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -74,6 +74,14 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re
|
||||
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
|
||||
for parsing compatibility; modern requests cannot select their subscription ID.
|
||||
|
||||
Workspace setup details negotiate `workspace_setup_observation` with
|
||||
`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through
|
||||
the legacy session event category. The setup panel owns a workspace-scoped observation while
|
||||
active and visible; directory metadata signals failures without requesting output. Older clients
|
||||
retain their legacy event delivery. A new app makes no setup event demand on older hosts and
|
||||
requires a host update to view details. Subscription-capable older hosts honor that absence of
|
||||
demand; broadcast-era hosts require an update before wire silence is guaranteed.
|
||||
|
||||
## Every shim is tagged and dated
|
||||
|
||||
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
|
||||
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
|
||||
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const task of cleanupTasks.splice(0)) {
|
||||
await task();
|
||||
}
|
||||
});
|
||||
|
||||
async function createRemoteRepo(prefix: string): Promise<string> {
|
||||
const repo = await createTempGitRepo(prefix, {
|
||||
withRemote: true,
|
||||
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
|
||||
});
|
||||
cleanupTasks.push(repo.cleanup);
|
||||
// The helper puts the bare remote inside the checkout; keep it out of the status.
|
||||
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
|
||||
return repo.path;
|
||||
}
|
||||
|
||||
async function registerWorkspace(repoPath: string): Promise<string> {
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push(async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
return created.workspace.id;
|
||||
}
|
||||
|
||||
async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId("changes-primary-cta-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
}
|
||||
|
||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", repoPath]);
|
||||
});
|
||||
|
||||
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
|
||||
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", other]);
|
||||
});
|
||||
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
|
||||
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const workspaceId = await registerWorkspace(repoPath);
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Pull",
|
||||
"Push",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-push-");
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", repoPath]);
|
||||
});
|
||||
const workspaceId = await registerWorkspace(repoPath);
|
||||
|
||||
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
|
||||
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Push",
|
||||
"Pull",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "plugin-icons-e2e";
|
||||
const SVG_PATH = "M4 12l5 5L20 6";
|
||||
|
||||
const pluginClientSource = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
|
||||
|
||||
function BadgeIcon({ size, color }) {
|
||||
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
return <View><Text>Plugin icon panel</Text></View>;
|
||||
}
|
||||
|
||||
const panels = [
|
||||
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
|
||||
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
|
||||
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
|
||||
];
|
||||
|
||||
export default function contribute(client) {
|
||||
for (const panel of panels) {
|
||||
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
|
||||
client.addCommandCenterItem({
|
||||
id: panel.id,
|
||||
title: "Open " + panel.title,
|
||||
icon: panel.icon,
|
||||
context: "workspace",
|
||||
onSelect({ openPanel }) { openPanel(panel.id); },
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
function pluginTab(page: Page, panelId: string): Locator {
|
||||
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
|
||||
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const screenshot = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test.describe("plugin icons", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
|
||||
await waitForWorkspaceInSidebar(page, target);
|
||||
await switchWorkspaceViaSidebar({ page, ...target });
|
||||
|
||||
await test.step("Command Center rows draw each icon form", async () => {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("icon panel");
|
||||
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
|
||||
await expect(
|
||||
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
panel
|
||||
.getByRole("button", { name: "Open SVG icon panel", exact: true })
|
||||
.locator(`path[d="${SVG_PATH}"]`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel
|
||||
.getByRole("button", { name: "Open Component icon panel", exact: true })
|
||||
.getByTestId("plugin-component-icon"),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, "command-center-icons");
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await test.step("workspace panel tabs draw each icon form", async () => {
|
||||
await runCommand(page, "Open Lucide icon panel");
|
||||
await runCommand(page, "Open SVG icon panel");
|
||||
await runCommand(page, "Open Component icon panel");
|
||||
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
|
||||
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
|
||||
await expect(
|
||||
pluginTab(page, "component").getByTestId("plugin-component-icon"),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, "workspace-tab-icons");
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await workspace.cleanup();
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
closeWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
observeSetupPanelTraffic,
|
||||
} from "../support/helpers/workspace-setup";
|
||||
|
||||
interface WorkspaceScriptStarter {
|
||||
@@ -116,6 +117,45 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({
|
||||
page,
|
||||
}) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-membership-", {
|
||||
paseoConfig: { worktree: { setup: ["echo setup complete"] } },
|
||||
});
|
||||
const traffic = observeSetupPanelTraffic(page);
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed",
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "setup-membership",
|
||||
});
|
||||
await completed;
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
await traffic.expectNoDemand();
|
||||
await expectSetupPanel(page);
|
||||
await traffic.expectSubscribed(workspace.id, 1);
|
||||
await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete");
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await traffic.expectReleased(0);
|
||||
await expectSetupPanel(page);
|
||||
await traffic.expectSubscribed(workspace.id, 2);
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await traffic.expectReleased(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Run against an Android dev client serving this checkout, with a real software keyboard.
|
||||
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> <server-id> [app-id]
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv;
|
||||
assert(
|
||||
session && metroUrl && serverId,
|
||||
"Pass the agent-device session, Metro URL and connected host ID",
|
||||
);
|
||||
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
|
||||
}
|
||||
|
||||
// Read the mounted app's public React Native APIs through Metro. Accessibility
|
||||
// snapshots with Gboard open can block while waiting for an idle hierarchy.
|
||||
const reactNative = `Array.from(__r.getModules().values()).find(
|
||||
m => m.verboseName?.endsWith("/react-native/index.js")
|
||||
).publicModule.exports`;
|
||||
|
||||
function measureFocusedInput() {
|
||||
evaluate(`(() => {
|
||||
const rn = ${reactNative};
|
||||
globalThis.__appearanceKeyboardMeasurement = null;
|
||||
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
|
||||
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
|
||||
x, y, width, height,
|
||||
statusBarHeight: rn.StatusBar.currentHeight,
|
||||
scale: rn.PixelRatio.get(),
|
||||
keyboard: rn.Keyboard.metrics(),
|
||||
});
|
||||
});
|
||||
})()`);
|
||||
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async function waitForFocusedInput() {
|
||||
let measurement;
|
||||
let previous;
|
||||
let stable = 0;
|
||||
const deadline = Date.now() + 30_000;
|
||||
do {
|
||||
measurement = measureFocusedInput();
|
||||
const current = measurement?.keyboard
|
||||
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
|
||||
: null;
|
||||
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
|
||||
previous = current;
|
||||
if (stable >= 3) break;
|
||||
await delay(150);
|
||||
} while (Date.now() < deadline);
|
||||
|
||||
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
|
||||
return measurement;
|
||||
}
|
||||
|
||||
ad("open", appId, "--platform", "android", "--no-test-ime");
|
||||
const targets = ad("cdp", "target", "list", "--url", metroUrl)
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(appId));
|
||||
assert.equal(targets.length, 1, "Connect only the test app to this Metro server");
|
||||
ad("cdp", "target", "select", targets[0].split(/\s+/)[0]);
|
||||
|
||||
try {
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
|
||||
ad("wait", "text", "Appearance", "5000");
|
||||
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
|
||||
ad("wait", "text", "Code size", "5000");
|
||||
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const numericFields = snapshot.data.nodes.filter(
|
||||
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
|
||||
);
|
||||
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
|
||||
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
|
||||
// Appearance orders Interface size, Content size, then Code size.
|
||||
ad("press", `@${numericFields[2].ref}`);
|
||||
|
||||
const measurement = await waitForFocusedInput();
|
||||
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
|
||||
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
|
||||
const keyboardTop = measurement.keyboard.screenY;
|
||||
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
|
||||
ad("screenshot", resolve(artifacts, "keyboard.png"));
|
||||
assert(
|
||||
inputBottom <= keyboardTop + 1 / measurement.scale,
|
||||
"Code size must be fully visible above the number keyboard (within one physical pixel)",
|
||||
);
|
||||
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
|
||||
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
ad(
|
||||
"open",
|
||||
appId,
|
||||
`paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`,
|
||||
"--platform",
|
||||
"android",
|
||||
"--no-test-ime",
|
||||
);
|
||||
ad("wait", "text", "Plugin source", "5000");
|
||||
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
|
||||
assert(source, "The connected host must support installing plugins from source");
|
||||
ad("press", `@${source.ref}`);
|
||||
ad("type", "qa-source");
|
||||
const pluginMeasurement = await waitForFocusedInput();
|
||||
// A FormTextInput draws its border and padding on the native input's parent.
|
||||
// Measure that visible frame, rather than accepting only the text as visible.
|
||||
const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused);
|
||||
assert(focused, "Plugin source must remain focused after typing");
|
||||
assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field");
|
||||
const frame = hierarchy.find((node) => node.index === focused.parentIndex);
|
||||
assert(frame, "The source input's visible frame must be present in the native hierarchy");
|
||||
assert(
|
||||
frame.rect.height > focused.rect.height,
|
||||
"Measure the surrounding frame, including padding",
|
||||
);
|
||||
const frameBottom = frame.rect.y + frame.rect.height;
|
||||
const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale;
|
||||
console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect }));
|
||||
ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png"));
|
||||
assert(
|
||||
frameBottom <= pluginKeyboardTop + 1,
|
||||
"Plugin source frame must be fully visible above the keyboard (within one physical pixel)",
|
||||
);
|
||||
console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard");
|
||||
} finally {
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
|
||||
}
|
||||
@@ -24,6 +24,7 @@ interface E2EDaemonClientConfig {
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveDaemonWsUrl(port?: number): string {
|
||||
@@ -34,6 +35,7 @@ export interface ConnectDaemonClientOptions {
|
||||
clientIdPrefix: string;
|
||||
appVersion?: string;
|
||||
port?: number;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion ?? loadAppVersion(),
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
capabilities: options.capabilities,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
|
||||
@@ -35,6 +35,8 @@ export type { WorkspaceSetupDaemonClient };
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const client = await connectDaemonClient<WorkspaceSetupDaemonClient>({
|
||||
clientIdPrefix: "workspace-setup",
|
||||
// COMPAT(workspaceSetupObservation): legacy streaming coverage; remove after 2027-04-19.
|
||||
capabilities: { workspace_setup_observation: false },
|
||||
});
|
||||
// Establish demand before a caller can launch setup. Client close releases it.
|
||||
try {
|
||||
@@ -326,3 +328,51 @@ export async function waitForWorkspaceSetupProgress(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Observe the browser's actual setup membership without requesting any output. */
|
||||
export function observeSetupPanelTraffic(page: Page) {
|
||||
const subscriptions: Array<{ workspaceId: string; subscriptionId: string }> = [];
|
||||
const releases = new Set<string>();
|
||||
const requests: string[] = [];
|
||||
let legacySetupDemand = false;
|
||||
const outputWorkspaces: string[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const frame = JSON.parse(String(payload));
|
||||
if (frame.type !== "session") return;
|
||||
if (frame.message?.type === "subscription.release.request")
|
||||
releases.add(frame.message.subscriptionId);
|
||||
if (frame.message?.type === "workspace.setup.subscribe.request")
|
||||
requests.push(frame.message.workspaceId);
|
||||
if (
|
||||
frame.message?.type === "session.events.set_subscription.request" &&
|
||||
frame.message.events.includes("workspace_setup_progress")
|
||||
)
|
||||
legacySetupDemand = true;
|
||||
});
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const frame = JSON.parse(String(payload));
|
||||
if (frame.type !== "session") return;
|
||||
if (frame.message?.type === "workspace.setup.subscribe.response")
|
||||
subscriptions.push(frame.message.payload);
|
||||
if (frame.message?.type === "workspace_setup_progress")
|
||||
outputWorkspaces.push(frame.message.payload.workspaceId);
|
||||
});
|
||||
});
|
||||
return {
|
||||
async expectNoDemand() {
|
||||
expect(requests).toEqual([]);
|
||||
expect(legacySetupDemand).toBe(false);
|
||||
expect(subscriptions).toEqual([]);
|
||||
expect(outputWorkspaces).toEqual([]);
|
||||
},
|
||||
async expectSubscribed(workspaceId: string, count: number) {
|
||||
await expect.poll(() => subscriptions.length).toBe(count);
|
||||
expect(subscriptions[count - 1].workspaceId).toBe(workspaceId);
|
||||
expect(requests).toEqual(Array(count).fill(workspaceId));
|
||||
},
|
||||
async expectReleased(index: number) {
|
||||
await expect.poll(() => releases.has(subscriptions[index].subscriptionId)).toBe(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type {
|
||||
WorkspacePanelPlacement,
|
||||
@@ -581,9 +581,7 @@ export function buildWorkspaceCommandCenterContributions(
|
||||
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||
];
|
||||
|
||||
const primary = source.gitActions.primary;
|
||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||
if (action.id === primary?.id) continue;
|
||||
for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) {
|
||||
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { shouldShowWorkspaceSetup } from "@/stores/workspace-setup-store";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import {
|
||||
@@ -188,6 +188,7 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
workspaceDirectory: workspace.workspaceDirectory ?? null,
|
||||
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
|
||||
pinnedAt: workspace.pinnedAt ?? null,
|
||||
setupStatus: workspace.setupStatus ?? null,
|
||||
labels: workspace.labels ?? [],
|
||||
}));
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
@@ -195,12 +196,10 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
const isPinned = fields?.pinnedAt != null;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const canPin = useHostFeature(serverId, "workspacePinning");
|
||||
const persistenceKey =
|
||||
serverId && fields
|
||||
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
|
||||
: null;
|
||||
const canShowSetup = useWorkspaceSetupStore((state) =>
|
||||
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
|
||||
const supportsSetupObservation = useHostFeature(serverId, "workspaceSetupObservation");
|
||||
const canShowSetup = shouldShowWorkspaceSetup(
|
||||
fields?.setupStatus ?? null,
|
||||
supportsSetupObservation,
|
||||
);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const { gitActions, isGit } = useGitActions({
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
export type FilePreviewRenderKind = "markdown" | "html";
|
||||
|
||||
export function isRenderedMarkdownFile(filePath: string): boolean {
|
||||
const normalizedPath = filePath.trim().toLowerCase();
|
||||
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
|
||||
}
|
||||
|
||||
function isRenderedHtmlFile(filePath: string): boolean {
|
||||
const normalizedPath = filePath.trim().toLowerCase();
|
||||
return normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm");
|
||||
}
|
||||
|
||||
export function filePreviewRenderKind(filePath: string): FilePreviewRenderKind | null {
|
||||
if (isRenderedMarkdownFile(filePath)) return "markdown";
|
||||
if (isRenderedHtmlFile(filePath)) return "html";
|
||||
return null;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
const PageScrollView = Platform.OS === "android" ? KeyboardAwareScrollView : ScrollView;
|
||||
|
||||
interface PageLayoutProps {
|
||||
title?: string;
|
||||
@@ -42,7 +46,13 @@ export function PageLayout({
|
||||
) : (
|
||||
<MenuHeader borderless />
|
||||
)}
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
|
||||
<PageScrollView
|
||||
// Leave room for field borders and padding around the focused native input.
|
||||
{...(Platform.OS === "android" ? { bottomOffset: SPACING[4] } : {})}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={scrollContentStyle}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{showTitle ? (
|
||||
<View style={styles.titleRow} testID={titleTestID}>
|
||||
@@ -54,7 +64,7 @@ export function PageLayout({
|
||||
) : null}
|
||||
{children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</PageScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
useSessionStore,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||
import {
|
||||
@@ -168,11 +167,6 @@ function resolveAgentAttentionNotification(
|
||||
});
|
||||
}
|
||||
|
||||
type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
function notifyVoiceAbortFailure(
|
||||
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
|
||||
notifyError: (message: string) => void,
|
||||
@@ -225,7 +219,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
|
||||
// Track focused agent for heartbeat
|
||||
const focusedAgentId = useSessionStore(
|
||||
@@ -365,13 +358,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
|
||||
|
||||
const applyWorkspaceSetupProgress = useCallback(
|
||||
(payload: WorkspaceSetupProgressPayload) => {
|
||||
upsertWorkspaceSetupProgress({ serverId, payload });
|
||||
},
|
||||
[serverId, upsertWorkspaceSetupProgress],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
|
||||
const onStream = (message: SessionOutboundMessage) => {
|
||||
@@ -508,7 +494,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
"agent_permission_resolved",
|
||||
"agent.provider_subagents.update",
|
||||
"checkout_status_update",
|
||||
"workspace_setup_progress",
|
||||
"status.server_info",
|
||||
],
|
||||
{ notifications: true },
|
||||
@@ -547,11 +532,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message });
|
||||
});
|
||||
|
||||
const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => {
|
||||
if (message.type !== "workspace_setup_progress") return;
|
||||
applyWorkspaceSetupProgress(message.payload);
|
||||
});
|
||||
|
||||
const unsubStatus = onFeed("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
@@ -710,7 +690,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubProviderSubagentUpdate();
|
||||
unsubAgentAttention();
|
||||
unsubCheckoutStatusUpdate();
|
||||
unsubWorkspaceSetupProgress();
|
||||
unsubStatus();
|
||||
unsubPermissionRequest();
|
||||
unsubPermissionResolved();
|
||||
@@ -732,7 +711,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
notifyAgentAttention,
|
||||
applyWorkspaceSetupProgress,
|
||||
updateSessionServerInfo,
|
||||
toast,
|
||||
voiceRuntime,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isRenderedMarkdownFile } from "@/file-panels";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ScrollView as RNScrollView, Text, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
||||
import { FilePanelPreview, resolveFilePreviewPanel, type FilePanel } from "@/file-panels";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { getFileNameFromPath } from "@/attachments/utils";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
@@ -28,8 +21,6 @@ import { useLiveFile } from "./live-file/hook";
|
||||
import { useFilePreview } from "./preview-lifecycle/hook";
|
||||
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
|
||||
import { FilePanelBar } from "./bar";
|
||||
import { FileHtmlPreview } from "./html-preview";
|
||||
import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||
import { createFileObservationSource } from "./editor/observation-source";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
@@ -39,7 +30,6 @@ import type { LiveFileModel } from "./live-file/model";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -47,6 +37,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
});
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
panel: FilePanel | null;
|
||||
preview: ExplorerFile | null;
|
||||
mode?: "preview" | "source";
|
||||
isLoading: boolean;
|
||||
@@ -131,6 +122,7 @@ function TooLargeSource({ size }: { size?: number }) {
|
||||
}
|
||||
|
||||
function FilePreviewBody({
|
||||
panel,
|
||||
preview,
|
||||
mode,
|
||||
isLoading,
|
||||
@@ -144,14 +136,6 @@ function FilePreviewBody({
|
||||
}: FilePreviewBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
// A line target means the caller wants to land on that line, so fall back to
|
||||
// the highlighted source view even for renderable files.
|
||||
const renderKind =
|
||||
preview?.kind === "text" && !location.lineStart && mode !== "source"
|
||||
? filePreviewRenderKind(filePath)
|
||||
: null;
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
@@ -170,36 +154,21 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source");
|
||||
if (panel && !showSource) {
|
||||
return (
|
||||
<FilePanelPreview
|
||||
panel={panel}
|
||||
content={preview.content ?? ""}
|
||||
filePath={filePath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (preview.kind === "text") {
|
||||
if (renderKind === "html") {
|
||||
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<FileHtmlPreview html={preview.content ?? ""} testID="file-html-preview" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderKind === "markdown") {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<FileMarkdownPreview
|
||||
source={preview.content ?? ""}
|
||||
filePath={filePath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReadonlySource
|
||||
preview={preview}
|
||||
@@ -210,19 +179,6 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "image") {
|
||||
if (!imagePreviewUri) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
@@ -243,6 +199,7 @@ export function FilePane({
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useAppSettings();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
|
||||
|
||||
@@ -292,7 +249,12 @@ export function FilePane({
|
||||
|
||||
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
|
||||
const isRenderable = isRenderablePreview(preview, location.path);
|
||||
const panel = resolveFilePreviewPanel({
|
||||
preview,
|
||||
path: location.path,
|
||||
associations: settings.filePanelAssociations,
|
||||
});
|
||||
const isRenderable = preview?.kind === "text" && panel !== null;
|
||||
const editable = isEditableTextFile({
|
||||
preview,
|
||||
supportsEditing,
|
||||
@@ -308,6 +270,7 @@ export function FilePane({
|
||||
|
||||
return (
|
||||
<FilePanePresentation
|
||||
panel={panel}
|
||||
serverId={serverId}
|
||||
workspaceRoot={normalizedWorkspaceRoot}
|
||||
client={client}
|
||||
@@ -333,10 +296,6 @@ export function FilePane({
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderablePreview(preview: ExplorerFile | null, path: string): boolean {
|
||||
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
|
||||
}
|
||||
|
||||
function isEditableTextFile(input: {
|
||||
preview: ExplorerFile | null;
|
||||
supportsEditing: boolean;
|
||||
@@ -350,6 +309,7 @@ function isEditableTextFile(input: {
|
||||
}
|
||||
|
||||
function FilePanePresentation({
|
||||
panel,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
@@ -372,6 +332,7 @@ function FilePanePresentation({
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
panel: FilePanel | null;
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
@@ -407,6 +368,7 @@ function FilePanePresentation({
|
||||
if (editable && client && readTarget && preview?.kind === "text") {
|
||||
return (
|
||||
<EditableFilePane
|
||||
panel={panel}
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
@@ -459,6 +421,7 @@ function FilePanePresentation({
|
||||
/>
|
||||
) : null}
|
||||
<FilePreviewBody
|
||||
panel={panel}
|
||||
preview={preview}
|
||||
mode={previewMode}
|
||||
isLoading={isLoading}
|
||||
@@ -475,6 +438,7 @@ function FilePanePresentation({
|
||||
}
|
||||
|
||||
function EditableFilePane({
|
||||
panel,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
@@ -492,6 +456,7 @@ function EditableFilePane({
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
panel: FilePanel | null;
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient;
|
||||
@@ -639,6 +604,7 @@ function EditableFilePane({
|
||||
/>
|
||||
) : (
|
||||
<FilePreviewBody
|
||||
panel={panel}
|
||||
preview={renderedPreview}
|
||||
mode={mode}
|
||||
isLoading={isLoading}
|
||||
@@ -708,15 +674,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
previewScrollContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
previewContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
previewCodeScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FileHtmlPreview } from "./html-preview";
|
||||
import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import type { builtinFilePanels } from "./catalog";
|
||||
|
||||
interface FilePanelPreviewProps {
|
||||
content: string;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
function MarkdownPanel(props: FilePanelPreviewProps) {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<ScrollView style={styles.previewContent} showsVerticalScrollIndicator>
|
||||
<FileMarkdownPreview
|
||||
source={props.content}
|
||||
filePath={props.filePath}
|
||||
workspaceRoot={props.workspaceRoot}
|
||||
client={props.client}
|
||||
serverId={props.serverId}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HtmlPanel({ content }: FilePanelPreviewProps) {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<FileHtmlPreview html={content} testID="file-html-preview" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!imagePreviewUri) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
|
||||
}
|
||||
|
||||
const components = {
|
||||
"builtin:markdown": MarkdownPanel,
|
||||
"builtin:html": HtmlPanel,
|
||||
"builtin:image": ImagePanel,
|
||||
};
|
||||
|
||||
export function FilePanelPreview({
|
||||
panel,
|
||||
...props
|
||||
}: FilePanelPreviewProps & {
|
||||
panel: (typeof builtinFilePanels)[number];
|
||||
}) {
|
||||
const Component = components[panel.id];
|
||||
return <Component {...props} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
previewScrollContainer: { flex: 1, minHeight: 0 },
|
||||
previewContent: { flex: 1, minHeight: 0 },
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
+47
-1
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filePreviewRenderKind, isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
|
||||
import { resolveFilePanel } from "./resolve";
|
||||
|
||||
function filePreviewRenderKind(path: string) {
|
||||
const { panel } = resolveFilePanel({
|
||||
file: { path, kind: "text", size: 100, hasBytes: false },
|
||||
candidates: builtinFilePanels,
|
||||
associations: {},
|
||||
});
|
||||
return panel ? panel.id.slice("builtin:".length) : null;
|
||||
}
|
||||
|
||||
describe("isRenderedMarkdownFile", () => {
|
||||
it("detects .md files", () => {
|
||||
@@ -38,3 +48,39 @@ describe("filePreviewRenderKind", () => {
|
||||
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in file panels", () => {
|
||||
it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])(
|
||||
"registers the image extension %s",
|
||||
(extension) => {
|
||||
const resolution = resolveFilePanel({
|
||||
file: {
|
||||
path: `/capture.${extension.toUpperCase()}`,
|
||||
kind: "image",
|
||||
size: 512,
|
||||
hasBytes: true,
|
||||
},
|
||||
candidates: builtinFilePanels,
|
||||
associations: { [`ext:${extension}`]: "builtin:markdown" },
|
||||
});
|
||||
expect(resolution.panel?.id).toBe("builtin:image");
|
||||
expect(
|
||||
resolveFilePanel({
|
||||
file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false },
|
||||
candidates: builtinFilePanels,
|
||||
associations: {},
|
||||
}).panel,
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not impose a new preview size limit on built-in text panels", () => {
|
||||
expect(
|
||||
resolveFilePanel({
|
||||
file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false },
|
||||
candidates: builtinFilePanels,
|
||||
associations: {},
|
||||
}).panel?.id,
|
||||
).toBe("builtin:markdown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { filePanelExtension, type FilePanelCandidate } from "./resolve";
|
||||
|
||||
const markdownPanel = {
|
||||
id: "builtin:markdown",
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
document: "text",
|
||||
maxBytes: Infinity,
|
||||
} as const satisfies FilePanelCandidate;
|
||||
|
||||
export const builtinFilePanels = [
|
||||
markdownPanel,
|
||||
{
|
||||
id: "builtin:html",
|
||||
files: { extensions: ["html", "htm"] },
|
||||
document: "text",
|
||||
maxBytes: Infinity,
|
||||
},
|
||||
{
|
||||
id: "builtin:image",
|
||||
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
|
||||
document: "bytes",
|
||||
maxBytes: Infinity,
|
||||
},
|
||||
] as const satisfies readonly FilePanelCandidate[];
|
||||
|
||||
export function isRenderedMarkdownFile(path: string): boolean {
|
||||
return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path));
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withPreviewCsp } from "@/file-pane/html-preview-csp";
|
||||
import { withPreviewCsp } from "./html-preview-csp";
|
||||
|
||||
describe("withPreviewCsp", () => {
|
||||
it("places the policy before the complete source document", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { htmlPreviewNavigationKind } from "@/file-pane/html-preview-navigation";
|
||||
import { htmlPreviewNavigationKind } from "./html-preview-navigation";
|
||||
|
||||
describe("htmlPreviewNavigationKind", () => {
|
||||
it("allows the WebView's initial document URLs", () => {
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ExplorerFile } from "@/stores/session-store";
|
||||
import { builtinFilePanels } from "./catalog";
|
||||
import { resolveFilePanel } from "./resolve";
|
||||
|
||||
export { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
|
||||
export { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
|
||||
export { FilePanelPreview } from "./builtin";
|
||||
|
||||
export type FilePanel = (typeof builtinFilePanels)[number];
|
||||
|
||||
export function resolveFilePreviewPanel(input: {
|
||||
preview: ExplorerFile | null;
|
||||
path: string;
|
||||
associations: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
const { preview, path, associations } = input;
|
||||
if (!preview) return null;
|
||||
return resolveFilePanel({
|
||||
file: { path, kind: preview.kind, size: preview.size, hasBytes: preview.kind === "image" },
|
||||
candidates: builtinFilePanels,
|
||||
associations,
|
||||
}).panel;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
|
||||
|
||||
const markdown: FilePanelCandidate = {
|
||||
id: "builtin:markdown",
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
document: "text",
|
||||
maxBytes: Infinity,
|
||||
};
|
||||
const alternative: FilePanelCandidate = { ...markdown, id: "plugin:notes/markdown" };
|
||||
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true };
|
||||
|
||||
function resolve(
|
||||
overrides: Partial<Parameters<typeof resolveFilePanel<FilePanelCandidate>>[0]> = {},
|
||||
) {
|
||||
return resolveFilePanel({
|
||||
file,
|
||||
candidates: [markdown, alternative],
|
||||
associations: {},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveFilePanel", () => {
|
||||
it("uses the associated candidate before catalog order", () => {
|
||||
expect(resolve({ associations: { "ext:md": alternative.id } })).toEqual({
|
||||
panel: alternative,
|
||||
candidates: [markdown, alternative],
|
||||
selectorKey: "ext:md",
|
||||
});
|
||||
});
|
||||
it("ignores an absent association without deleting it", () => {
|
||||
const associations = { "ext:md": "plugin:absent/markdown" };
|
||||
expect(resolve({ associations }).panel).toBe(markdown);
|
||||
expect(associations).toEqual({ "ext:md": "plugin:absent/markdown" });
|
||||
});
|
||||
it("uses a single candidate and preserves catalog order for multiple candidates", () => {
|
||||
expect(resolve({ candidates: [alternative] }).panel).toBe(alternative);
|
||||
expect(resolve().panel).toBe(markdown);
|
||||
expect(resolve({ candidates: [alternative, markdown] }).panel).toBe(alternative);
|
||||
});
|
||||
it("returns the floor when no selector matches", () => {
|
||||
expect(resolve({ file: { ...file, path: "index.ts" } })).toEqual({
|
||||
panel: null,
|
||||
candidates: [],
|
||||
selectorKey: "ext:ts",
|
||||
});
|
||||
});
|
||||
it("filters text panels for binary files even with an association", () => {
|
||||
expect(
|
||||
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
|
||||
).toBeNull();
|
||||
});
|
||||
it("requires bytes for bytes panels and accepts binary files with bytes", () => {
|
||||
const bytes: FilePanelCandidate = { ...alternative, document: "bytes" };
|
||||
expect(resolve({ file: { ...file, hasBytes: false }, candidates: [bytes] }).panel).toBeNull();
|
||||
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [bytes] }).panel).toBe(bytes);
|
||||
});
|
||||
it("includes the maxBytes boundary and skips oversized associated panels", () => {
|
||||
const limited = { ...alternative, maxBytes: 100 };
|
||||
expect(resolve({ candidates: [limited] }).panel).toBe(limited);
|
||||
expect(
|
||||
resolve({
|
||||
file: { ...file, size: 101 },
|
||||
candidates: [limited, markdown],
|
||||
associations: { "ext:md": limited.id },
|
||||
}).panel,
|
||||
).toBe(markdown);
|
||||
});
|
||||
it("uses exact basename keys when an eligible candidate matches by filename", () => {
|
||||
const named = { ...alternative, files: { extensions: [], filenames: ["README.MD"] } };
|
||||
expect(
|
||||
resolve({ candidates: [markdown, named], associations: { "name:README.MD": named.id } }),
|
||||
).toEqual({
|
||||
panel: named,
|
||||
candidates: [markdown, named],
|
||||
selectorKey: "name:README.MD",
|
||||
});
|
||||
expect(resolve({ file: { ...file, path: "README.md" }, candidates: [named] }).panel).toBeNull();
|
||||
});
|
||||
it("uses extension keys when filename candidates were filtered out", () => {
|
||||
const named = {
|
||||
...alternative,
|
||||
maxBytes: 1,
|
||||
files: { extensions: [], filenames: ["README.MD"] },
|
||||
};
|
||||
expect(resolve({ candidates: [named, markdown] }).selectorKey).toBe("ext:md");
|
||||
});
|
||||
it.each(["/src/Dockerfile", "C:\\src\\Dockerfile"])(
|
||||
"matches an extensionless exact filename: %s",
|
||||
(path) => {
|
||||
const named = { ...alternative, files: { extensions: [], filenames: ["Dockerfile"] } };
|
||||
expect(resolve({ file: { ...file, path }, candidates: [named] })).toEqual({
|
||||
panel: named,
|
||||
candidates: [named],
|
||||
selectorKey: "name:Dockerfile",
|
||||
});
|
||||
},
|
||||
);
|
||||
it("normalizes extension selectors and keeps extensionless files distinct", () => {
|
||||
const upper = { ...alternative, files: { extensions: ["MD"] } };
|
||||
expect(resolve({ candidates: [upper] }).panel).toBe(upper);
|
||||
expect(resolve({ file: { ...file, path: "README" } }).selectorKey).toBe("ext:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface FilePanelFile {
|
||||
path: string;
|
||||
kind: "text" | "image" | "binary";
|
||||
size: number;
|
||||
hasBytes: boolean;
|
||||
}
|
||||
|
||||
export interface FilePanelSelector {
|
||||
extensions: readonly string[];
|
||||
filenames?: readonly string[];
|
||||
}
|
||||
|
||||
export interface FilePanelCandidate {
|
||||
id: string;
|
||||
files: FilePanelSelector;
|
||||
document: "text" | "bytes";
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export interface FilePanelResolution<Candidate> {
|
||||
panel: Candidate | null;
|
||||
candidates: readonly Candidate[];
|
||||
selectorKey: string;
|
||||
}
|
||||
|
||||
export function filePanelExtension(path: string): string {
|
||||
const name = filePanelBasename(path);
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot < 0 ? "" : name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function filePanelBasename(path: string): string {
|
||||
return path.trim().split(/[\\/]/).pop() ?? "";
|
||||
}
|
||||
|
||||
function matchesSelector(file: FilePanelFile, selector: FilePanelSelector): boolean {
|
||||
if (selector.filenames?.includes(filePanelBasename(file.path))) return true;
|
||||
const extension = filePanelExtension(file.path);
|
||||
return selector.extensions.some((entry) => entry.toLowerCase() === extension);
|
||||
}
|
||||
|
||||
export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
|
||||
file: FilePanelFile;
|
||||
candidates: readonly Candidate[];
|
||||
associations: Readonly<Record<string, string>>;
|
||||
}): FilePanelResolution<Candidate> {
|
||||
const { file, associations } = input;
|
||||
const candidates = input.candidates.filter((candidate) => {
|
||||
const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes;
|
||||
return (
|
||||
documentAvailable && file.size <= candidate.maxBytes && matchesSelector(file, candidate.files)
|
||||
);
|
||||
});
|
||||
const filename = filePanelBasename(file.path);
|
||||
const matchedFilename = candidates.some((candidate) =>
|
||||
candidate.files.filenames?.includes(filename),
|
||||
);
|
||||
const selectorKey = matchedFilename ? `name:${filename}` : `ext:${filePanelExtension(file.path)}`;
|
||||
const association = associations[selectorKey];
|
||||
const panel =
|
||||
candidates.find((candidate) => candidate.id === association) ?? candidates[0] ?? null;
|
||||
return { panel, candidates, selectorKey };
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
|
||||
import { useGitActionRunner } from "@/git/use-actions";
|
||||
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
|
||||
@@ -129,14 +129,10 @@ export function GitActionsSplitButton({
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
|
||||
const menuOnlyActions = useMemo(
|
||||
() => [
|
||||
...(gitActions.primary ? [gitActions.primary] : []),
|
||||
...gitActions.secondary,
|
||||
...gitActions.menu,
|
||||
],
|
||||
[gitActions.menu, gitActions.primary, gitActions.secondary],
|
||||
);
|
||||
const menuOnlyActions = [
|
||||
...(gitActions.primary ? [gitActions.primary] : []),
|
||||
...actionsWithoutPrimary(gitActions),
|
||||
];
|
||||
|
||||
if (menuOnly) {
|
||||
if (menuOnlyActions.length === 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
|
||||
|
||||
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
|
||||
@@ -190,6 +190,59 @@ describe("git-actions-policy", () => {
|
||||
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
|
||||
});
|
||||
|
||||
it("drops a promoted remote action from the remaining menu actions", () => {
|
||||
const pull = buildGitActions(createInput({ hasRemote: true, behindOfOrigin: 2 }));
|
||||
expect(pull.primary?.id).toBe("pull");
|
||||
expect(actionsWithoutPrimary(pull).map((action) => action.id)).toEqual([
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"archive-workspace",
|
||||
]);
|
||||
|
||||
const push = buildGitActions(createInput({ hasRemote: true, aheadOfOrigin: 1 }));
|
||||
expect(push.primary?.id).toBe("push");
|
||||
expect(actionsWithoutPrimary(push).map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"pull-and-push",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a promoted merge action from the remaining menu actions", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus({
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
}),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({ id: "merge-pr-merge", label: "Merge PR (merge)" });
|
||||
expect(actionsWithoutPrimary(actions).map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps push clickable with a clearer message when the branch diverged", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
|
||||
@@ -312,6 +312,18 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions a flattened list shows after the primary. `secondary` keeps the actions the policy
|
||||
* can promote to `primary`, and the translator rebuilds those objects, so a flattening consumer
|
||||
* has to drop the promoted one by id or it renders it twice.
|
||||
*/
|
||||
export function actionsWithoutPrimary(gitActions: GitActions): GitAction[] {
|
||||
const primaryActionId = gitActions.primary?.id;
|
||||
return [...gitActions.secondary, ...gitActions.menu].filter(
|
||||
(action) => action.id !== primaryActionId,
|
||||
);
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shouldPromoteArchive) {
|
||||
return "archive-workspace";
|
||||
|
||||
@@ -44,6 +44,33 @@ function makeDeps(
|
||||
}
|
||||
|
||||
describe("loadAppSettingsFromStorage", () => {
|
||||
it("persists file panel associations, including panels absent from this host", async () => {
|
||||
const deps = makeDeps();
|
||||
const queryClient = new QueryClient();
|
||||
const filePanelAssociations = {
|
||||
"ext:md": "builtin:markdown",
|
||||
"name:Dockerfile": "plugin:absent/container",
|
||||
};
|
||||
await saveAppSettings({ queryClient, deps, updates: { filePanelAssociations } });
|
||||
expect((await loadAppSettingsFromStorage(deps)).filePanelAssociations).toEqual(
|
||||
filePanelAssociations,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, null, [], "invalid", { "ext:md": 42 }])(
|
||||
"defaults malformed file panel associations without discarding other settings: %j",
|
||||
async (filePanelAssociations) => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ filePanelAssociations, vimKeybindings: true }),
|
||||
}),
|
||||
});
|
||||
const settings = await loadAppSettingsFromStorage(deps);
|
||||
expect(settings.filePanelAssociations).toEqual({});
|
||||
expect(settings.vimKeybindings).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a persisted steer send behavior", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
|
||||
@@ -104,6 +104,7 @@ export interface AppSettings {
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatOutlineEnabled: boolean;
|
||||
vimKeybindings: boolean;
|
||||
filePanelAssociations: Record<string, string>;
|
||||
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
|
||||
openInSidePane: OpenInSidePanePreferences;
|
||||
pullRequestOpenLocation: PullRequestOpenLocation;
|
||||
@@ -160,6 +161,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatOutlineEnabled: true,
|
||||
vimKeybindings: false,
|
||||
filePanelAssociations: {},
|
||||
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
|
||||
pullRequestOpenLocation: "explorer",
|
||||
};
|
||||
@@ -261,6 +263,7 @@ const StoredAppSettingsSchema = z
|
||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||
chatOutlineEnabled: z.boolean().catch(true),
|
||||
vimKeybindings: z.boolean().catch(false),
|
||||
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
|
||||
openInSidePane: z
|
||||
.object({
|
||||
explorerFiles: z.boolean().catch(false),
|
||||
|
||||
@@ -538,6 +538,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost: "حدّث المضيف لعرض تفاصيل إعداد مساحة العمل",
|
||||
descriptor: {
|
||||
label: "يثبت",
|
||||
completed: "اكتمل الإعداد",
|
||||
@@ -2333,6 +2334,7 @@ export const ar: TranslationResources = {
|
||||
searchPlaceholder: "البحث في الاختصارات",
|
||||
unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب",
|
||||
capturePrompt: "اضغط على الاختصار...",
|
||||
captureIndexPrompt: "اختم برقم من 1 إلى 9.",
|
||||
unassigned: "غير معين",
|
||||
actions: {
|
||||
menu: "إجراءات {{name}}",
|
||||
|
||||
@@ -535,6 +535,7 @@ export const en = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost: "Update the host to view workspace setup details",
|
||||
descriptor: {
|
||||
label: "Setup",
|
||||
completed: "Setup completed",
|
||||
@@ -2456,6 +2457,7 @@ export const en = {
|
||||
searchPlaceholder: "Search shortcuts",
|
||||
unavailableOnMobile: "Keyboard shortcuts are only available on desktop",
|
||||
capturePrompt: "Press shortcut...",
|
||||
captureIndexPrompt: "End with a number from 1 to 9.",
|
||||
unassigned: "Not set",
|
||||
actions: {
|
||||
menu: "Actions for {{name}}",
|
||||
|
||||
@@ -544,6 +544,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost: "Actualiza el host para ver los detalles de configuración del espacio de trabajo",
|
||||
descriptor: {
|
||||
label: "Configuración",
|
||||
completed: "Configuración completada",
|
||||
@@ -2387,6 +2388,7 @@ export const es: TranslationResources = {
|
||||
searchPlaceholder: "Buscar atajos",
|
||||
unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio",
|
||||
capturePrompt: "Presione el acceso directo...",
|
||||
captureIndexPrompt: "Termina con un número del 1 al 9.",
|
||||
unassigned: "Sin asignar",
|
||||
actions: {
|
||||
menu: "Acciones para {{name}}",
|
||||
|
||||
@@ -543,6 +543,8 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost:
|
||||
"Mettez à jour l’hôte pour afficher les détails de configuration de l’espace de travail",
|
||||
descriptor: {
|
||||
label: "Configuration",
|
||||
completed: "Configuration terminée",
|
||||
@@ -2387,6 +2389,7 @@ export const fr: TranslationResources = {
|
||||
searchPlaceholder: "Rechercher des raccourcis",
|
||||
unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur l’app de bureau",
|
||||
capturePrompt: "Appuyez sur le raccourci…",
|
||||
captureIndexPrompt: "Terminez par un chiffre de 1 à 9.",
|
||||
unassigned: "Non attribué",
|
||||
actions: {
|
||||
menu: "Actions pour {{name}}",
|
||||
|
||||
@@ -544,6 +544,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost: "ワークスペースのセットアップ詳細を表示するにはホストを更新してください",
|
||||
descriptor: {
|
||||
label: "セットアップ",
|
||||
completed: "セットアップ完了",
|
||||
@@ -2354,6 +2355,7 @@ export const ja: TranslationResources = {
|
||||
searchPlaceholder: "ショートカットを検索",
|
||||
unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます",
|
||||
capturePrompt: "ショートカットを押してください...",
|
||||
captureIndexPrompt: "最後に1〜9の数字を入力してください。",
|
||||
unassigned: "未設定",
|
||||
actions: {
|
||||
menu: "{{name}} のアクション",
|
||||
|
||||
@@ -539,6 +539,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost: "작업 공간 설정 세부 정보를 보려면 호스트를 업데이트하세요",
|
||||
descriptor: {
|
||||
label: "설정",
|
||||
completed: "설정 완료",
|
||||
@@ -2343,6 +2344,7 @@ export const ko: TranslationResources = {
|
||||
searchPlaceholder: "검색 단축키",
|
||||
unavailableOnMobile: "키보드 단축키는 데스크톱에서만 사용할 수 있습니다",
|
||||
capturePrompt: "단축키를 누르세요...",
|
||||
captureIndexPrompt: "마지막에 1부터 9까지의 숫자를 입력하세요.",
|
||||
unassigned: "설정되지 않음",
|
||||
actions: {
|
||||
menu: "{{name}} 작업",
|
||||
|
||||
@@ -543,6 +543,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost: "Atualize o host para ver os detalhes da configuração do espaço de trabalho",
|
||||
descriptor: {
|
||||
label: "Configuração",
|
||||
completed: "Configuração concluída",
|
||||
@@ -2368,6 +2369,7 @@ export const ptBR: TranslationResources = {
|
||||
searchPlaceholder: "Pesquisar atalhos",
|
||||
unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop",
|
||||
capturePrompt: "Pressione o atalho...",
|
||||
captureIndexPrompt: "Termine com um número de 1 a 9.",
|
||||
unassigned: "Não definido",
|
||||
actions: {
|
||||
menu: "Ações para {{name}}",
|
||||
|
||||
@@ -543,6 +543,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost: "Обновите хост, чтобы просмотреть сведения о настройке рабочего пространства",
|
||||
descriptor: {
|
||||
label: "Настройка",
|
||||
completed: "Настройка завершена",
|
||||
@@ -2373,6 +2374,7 @@ export const ru: TranslationResources = {
|
||||
searchPlaceholder: "Поиск сочетаний клавиш",
|
||||
unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.",
|
||||
capturePrompt: "Нажмите сочетание клавиш...",
|
||||
captureIndexPrompt: "Завершите цифрой от 1 до 9.",
|
||||
unassigned: "Не задано",
|
||||
actions: {
|
||||
menu: "Действия для {{name}}",
|
||||
|
||||
@@ -538,6 +538,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
updateHost: "更新主机以查看工作区设置详情",
|
||||
descriptor: {
|
||||
label: "Setup",
|
||||
completed: "Setup 已完成",
|
||||
@@ -2305,6 +2306,7 @@ export const zhCN: TranslationResources = {
|
||||
searchPlaceholder: "搜索快捷键",
|
||||
unavailableOnMobile: "键盘快捷键仅在桌面端可用",
|
||||
capturePrompt: "按下快捷键...",
|
||||
captureIndexPrompt: "请以 1 到 9 的数字结尾。",
|
||||
unassigned: "未设置",
|
||||
actions: {
|
||||
menu: "{{name}} 的操作",
|
||||
|
||||
@@ -4,7 +4,9 @@ import { keyboardEventToComboString } from "./shortcut-string";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
buildEffectiveBindings,
|
||||
DEFAULT_BINDINGS,
|
||||
normalizeCapturedShortcutCombo,
|
||||
isShortcutCaptureComplete,
|
||||
getBindingIdForAction,
|
||||
getDefaultKeysForAction,
|
||||
getWorkspaceIndexJumpModifierKey,
|
||||
@@ -1390,3 +1392,22 @@ describe("number-group shortcut capture", () => {
|
||||
expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+9")).toBe("Ctrl+Digit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcut capture completion", () => {
|
||||
const indexBindings = DEFAULT_BINDINGS.filter((binding) => binding.payload?.type === "index");
|
||||
it.each(indexBindings)("requires a final 1–9 number for $id", ({ id }) => {
|
||||
for (const combos of [[], ["Ctrl+K"], ["Ctrl+0"], ["Ctrl+Digit", "Ctrl+K"]]) {
|
||||
expect(isShortcutCaptureComplete(id, combos)).toBe(false);
|
||||
}
|
||||
for (let index = 1; index <= 9; index++) {
|
||||
const combo = normalizeCapturedShortcutCombo(id, `Ctrl+${index}`);
|
||||
expect(isShortcutCaptureComplete(id, [combo])).toBe(true);
|
||||
expect(isShortcutCaptureComplete(id, ["Ctrl+K", combo])).toBe(true);
|
||||
}
|
||||
});
|
||||
it("keeps ordinary shortcuts available without a number", () => {
|
||||
expect(isShortcutCaptureComplete("show-shortcuts", [])).toBe(false);
|
||||
expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+K"])).toBe(true);
|
||||
expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+0"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1217,6 +1217,14 @@ export function normalizeCapturedShortcutCombo(bindingId: string, combo: string)
|
||||
return combo.replace(/(^|\+)[1-9]$/, "$1Digit");
|
||||
}
|
||||
|
||||
/** Index navigation needs a final number; earlier combos may be any chord prefix. */
|
||||
export function isShortcutCaptureComplete(bindingId: string, combos: readonly string[]): boolean {
|
||||
const lastCombo = combos.at(-1);
|
||||
if (!lastCombo) return false;
|
||||
const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId);
|
||||
return binding?.payload?.type !== "index" || /(^|\+)(Digit|[1-9])$/.test(lastCombo);
|
||||
}
|
||||
|
||||
export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShortcutBinding[] {
|
||||
return DEFAULT_BINDINGS.map(function (binding) {
|
||||
const override = overrides[binding.id];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
type WorkspaceSetupSnapshot,
|
||||
} from "@/stores/workspace-setup-store";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
|
||||
function useSetupPanelDescriptor(
|
||||
@@ -24,13 +27,11 @@ function useSetupPanelDescriptor(
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: context.serverId,
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
|
||||
const fields = useWorkspaceFields(context.serverId, target.workspaceId, (workspace) => ({
|
||||
status: workspace.setupStatus ?? null,
|
||||
}));
|
||||
|
||||
if (snapshot?.status === "completed") {
|
||||
if (fields?.status === "completed") {
|
||||
return {
|
||||
label: t("workspace.setup.descriptor.label"),
|
||||
subtitle: t("workspace.setup.descriptor.completed"),
|
||||
@@ -41,7 +42,7 @@ function useSetupPanelDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot?.status === "failed") {
|
||||
if (fields?.status === "failed") {
|
||||
return {
|
||||
label: t("workspace.setup.descriptor.label"),
|
||||
subtitle: t("workspace.setup.descriptor.failed"),
|
||||
@@ -52,7 +53,7 @@ function useSetupPanelDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot?.status === "blocked") {
|
||||
if (fields?.status === "blocked") {
|
||||
return {
|
||||
label: t("workspace.setup.descriptor.label"),
|
||||
subtitle: t("workspace.setup.descriptor.blocked"),
|
||||
@@ -69,7 +70,7 @@ function useSetupPanelDescriptor(
|
||||
tooltip: t("workspace.setup.descriptor.workspace"),
|
||||
titleState: "ready",
|
||||
icon: SquareTerminal,
|
||||
statusBucket: snapshot?.status === "running" ? "running" : null,
|
||||
statusBucket: fields?.status === "running" ? "running" : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,33 +217,34 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
|
||||
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
|
||||
}
|
||||
|
||||
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
|
||||
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string, enabled: boolean) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
|
||||
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
const requestedRef = useRef(false);
|
||||
|
||||
const applySnapshot = useWorkspaceSetupStore((state) => state.applySnapshot);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (snapshot || requestedRef.current || !client) return;
|
||||
requestedRef.current = true;
|
||||
client
|
||||
.fetchWorkspaceSetupStatus(workspaceId)
|
||||
.then((response) => {
|
||||
if (response.snapshot) {
|
||||
upsertProgress({
|
||||
serverId,
|
||||
payload: { workspaceId: response.workspaceId, ...response.snapshot },
|
||||
});
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
// Server may not support this yet — ignore.
|
||||
});
|
||||
}, [client, snapshot, serverId, upsertProgress, workspaceId]);
|
||||
|
||||
return snapshot;
|
||||
if (!client || !enabled) return;
|
||||
setError(null);
|
||||
const subscription = client.observeWorkspaceSetup(workspaceId);
|
||||
const detach = subscription.subscribe({
|
||||
snapshot: (response) => {
|
||||
applySnapshot({ serverId, workspaceId, snapshot: response.snapshot });
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type === "workspace_setup_progress")
|
||||
applySnapshot({ serverId, workspaceId, snapshot: message.payload });
|
||||
},
|
||||
error: (failure) => setError(failure instanceof Error ? failure.message : String(failure)),
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
void subscription
|
||||
.release()
|
||||
.catch((failure) => console.warn("Failed to release setup observation", failure));
|
||||
};
|
||||
}, [client, enabled, serverId, applySnapshot, workspaceId]);
|
||||
return { snapshot, error };
|
||||
}
|
||||
|
||||
function useExpandedSetupCommands() {
|
||||
@@ -273,7 +275,14 @@ function SetupPanel() {
|
||||
const { serverId, target } = usePaneContext();
|
||||
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||
|
||||
const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isVisible = useAppActivelyVisible();
|
||||
const supportsObservation = useHostFeature(serverId, "workspaceSetupObservation");
|
||||
const { snapshot, error: observationError } = useWorkspaceSetupSnapshot(
|
||||
serverId,
|
||||
target.workspaceId,
|
||||
isActive && isVisible && supportsObservation,
|
||||
);
|
||||
|
||||
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
|
||||
const log = snapshot?.detail.log ?? "";
|
||||
@@ -306,19 +315,23 @@ function SetupPanel() {
|
||||
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
|
||||
{statusLabel}
|
||||
</Text>
|
||||
<SetupPanelBody
|
||||
serverId={serverId}
|
||||
workspaceId={target.workspaceId}
|
||||
snapshot={snapshot}
|
||||
commands={commands}
|
||||
log={log}
|
||||
isWaiting={isWaiting}
|
||||
hasNoSetupCommands={hasNoSetupCommands}
|
||||
autoExpandIndex={autoExpandIndex}
|
||||
expandedIndices={expandedIndices}
|
||||
manuallyCollapsed={manuallyCollapsed}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
{!supportsObservation && <Alert variant="warning" title={t("workspace.setup.updateHost")} />}
|
||||
{observationError && <Alert variant="error" title={observationError} />}
|
||||
{supportsObservation && !observationError && (
|
||||
<SetupPanelBody
|
||||
serverId={serverId}
|
||||
workspaceId={target.workspaceId}
|
||||
snapshot={snapshot}
|
||||
commands={commands}
|
||||
log={log}
|
||||
isWaiting={isWaiting}
|
||||
hasNoSetupCommands={hasNoSetupCommands}
|
||||
autoExpandIndex={autoExpandIndex}
|
||||
expandedIndices={expandedIndices}
|
||||
manuallyCollapsed={manuallyCollapsed}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@getpaseo/plugin";
|
||||
import {
|
||||
PluginAttachmentItemSchema,
|
||||
type PluginAttachmentItem,
|
||||
type PluginSerializableIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
|
||||
export const PluginResourceComposerAttachmentSchema = z.object({
|
||||
@@ -8,7 +12,8 @@ export const PluginResourceComposerAttachmentSchema = z.object({
|
||||
pluginId: z.string().min(1),
|
||||
sourceId: z.string().min(1),
|
||||
sourceTitle: z.string().min(1),
|
||||
sourceIcon: z.string().min(1),
|
||||
// Validated when the source registered; a draft keeps the icon its source had then.
|
||||
sourceIcon: z.union([z.string().min(1), z.object({ svg: z.string().min(1) })]),
|
||||
item: PluginAttachmentItemSchema,
|
||||
});
|
||||
|
||||
@@ -20,7 +25,7 @@ export interface PluginResourceSourceIdentity {
|
||||
pluginId: string;
|
||||
sourceId: string;
|
||||
sourceTitle: string;
|
||||
sourceIcon: string;
|
||||
sourceIcon: PluginSerializableIcon;
|
||||
}
|
||||
|
||||
export function createPluginResourceAttachment(
|
||||
|
||||
@@ -2,15 +2,18 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro
|
||||
import { View } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin";
|
||||
import type {
|
||||
PluginAttachmentItem,
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginSerializableIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AttachmentMenuItem } from "@/composer/input/input";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { PluginIconView } from "../icons";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
|
||||
@@ -19,8 +22,8 @@ const SEARCH_STALE_TIME_MS = 30_000;
|
||||
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
|
||||
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
return <Icon size={ICON_SIZE.md} color={color} />;
|
||||
function SourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
|
||||
return <PluginIconView icon={icon} size={ICON_SIZE.md} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedSourceIcon = withUnistyles(SourceIcon);
|
||||
@@ -145,15 +148,12 @@ export function usePluginAttachmentPicker(
|
||||
);
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
sources.map(({ key, source }) => {
|
||||
const Icon = resolvePluginIcon(source.icon);
|
||||
return {
|
||||
id: `plugin:${key}`,
|
||||
label: `Attach ${source.title}`,
|
||||
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
|
||||
onSelect: () => setActiveKey(key),
|
||||
};
|
||||
}),
|
||||
sources.map(({ key, source }) => ({
|
||||
id: `plugin:${key}`,
|
||||
label: `Attach ${source.title}`,
|
||||
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
|
||||
onSelect: () => setActiveKey(key),
|
||||
})),
|
||||
[sources],
|
||||
);
|
||||
if (!active) return { menuItems, picker: null };
|
||||
|
||||
@@ -3,19 +3,14 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import type { PluginSerializableIcon } from "@getpaseo/plugin";
|
||||
import { PluginIconView } from "../icons";
|
||||
import type { PluginResourceComposerAttachment } from "./model";
|
||||
|
||||
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function ResourceIcon({
|
||||
Icon,
|
||||
color = "",
|
||||
}: {
|
||||
Icon: ReturnType<typeof resolvePluginIcon>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={ICON_SIZE.sm} color={color} />;
|
||||
function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
|
||||
return <PluginIconView icon={icon} size={ICON_SIZE.sm} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedResourceIcon = withUnistyles(ResourceIcon);
|
||||
@@ -39,12 +34,11 @@ export function PluginResourceAttachmentPill({
|
||||
openLabel,
|
||||
removeLabel,
|
||||
}: PluginResourceAttachmentPillProps) {
|
||||
const Icon = resolvePluginIcon(attachment.sourceIcon);
|
||||
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
|
||||
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
|
||||
const icon = useMemo(
|
||||
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
|
||||
[Icon],
|
||||
() => <ThemedResourceIcon icon={attachment.sourceIcon} uniProps={iconColorMapping} />,
|
||||
[attachment.sourceIcon],
|
||||
);
|
||||
return (
|
||||
<AttachmentPill
|
||||
|
||||
@@ -30,8 +30,9 @@ function installation(): InstalledPlugin {
|
||||
|
||||
function store() {
|
||||
return new PluginButtonStore({
|
||||
validateIconName(name) {
|
||||
if (name !== "Scan") throw new Error("Unknown icon");
|
||||
parseIcon(icon) {
|
||||
if (icon !== "Scan") throw new Error("Unknown icon");
|
||||
return icon;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { parsePluginIcon } from "../icons";
|
||||
import { PluginButtonStore } from "./model";
|
||||
|
||||
export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon });
|
||||
export const pluginButtonStore = new PluginButtonStore({
|
||||
parseIcon: (icon) => parsePluginIcon("Plugin button", icon),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
|
||||
|
||||
export interface ButtonValidation {
|
||||
validateIconName(name: string): void;
|
||||
/** Returns the normalized icon, or throws naming why the icon is unusable. */
|
||||
parseIcon(icon: PluginButtonIcon): PluginButtonIcon;
|
||||
}
|
||||
|
||||
export function requireButtonId(value: string): string {
|
||||
@@ -29,12 +30,6 @@ function isComponent(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void {
|
||||
if (typeof icon === "string") validation.validateIconName(icon);
|
||||
else if (!isComponent(icon))
|
||||
throw new Error("Plugin button icon must be a Lucide name or component");
|
||||
}
|
||||
|
||||
function validateBehavior(
|
||||
behavior: PluginButtonBehavior,
|
||||
validation: ButtonValidation,
|
||||
@@ -57,9 +52,9 @@ function validateBehavior(
|
||||
ids.add(item.id);
|
||||
if (item.kind === "separator") return { ...item };
|
||||
if (item.kind !== "item") throw new Error("Invalid plugin button menu entry");
|
||||
if (item.icon !== undefined) validateIcon(item.icon, validation);
|
||||
return {
|
||||
...item,
|
||||
icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon),
|
||||
title: requireText(item.title, "menu item title"),
|
||||
visible: optionalBoolean(item.visible, true),
|
||||
disabled: optionalBoolean(item.disabled, false),
|
||||
@@ -83,9 +78,9 @@ export function validateButton(
|
||||
button: PluginButton,
|
||||
validation: ButtonValidation,
|
||||
): ResolvedPluginButton {
|
||||
validateIcon(button.icon, validation);
|
||||
return {
|
||||
...button,
|
||||
icon: validation.parseIcon(button.icon),
|
||||
title: requireText(button.title, "title"),
|
||||
label: button.label === undefined ? undefined : requireText(button.label, "label"),
|
||||
visible: optionalBoolean(button.visible, true),
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { Icon } from "../icons";
|
||||
import { pluginIconComponent } from "../icons";
|
||||
import {
|
||||
PluginEnvironmentProvider,
|
||||
PluginPopoverContent,
|
||||
@@ -79,6 +79,7 @@ function ButtonEnvironment({ view, children }: { view: ButtonView; children: Rea
|
||||
function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) {
|
||||
const color = view.props.theme.colors.foregroundMuted;
|
||||
const size = view.entry.placement === "composer" ? 14 : 16;
|
||||
const Icon = pluginIconComponent(icon);
|
||||
return (
|
||||
<View
|
||||
style={styles.icon}
|
||||
@@ -87,24 +88,11 @@ function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
{typeof icon === "string" ? (
|
||||
<Icon name={icon} size={size} color={color} />
|
||||
) : (
|
||||
renderCustomIcon(icon, view, size, color)
|
||||
)}
|
||||
<Icon {...view.props} size={size} color={color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCustomIcon(
|
||||
CustomIcon: Exclude<PluginButtonIcon, string>,
|
||||
view: ButtonView,
|
||||
size: number,
|
||||
color: string,
|
||||
) {
|
||||
return <CustomIcon {...view.props} size={size} color={color} />;
|
||||
}
|
||||
|
||||
function pressButton(view: ButtonView, path: readonly string[]) {
|
||||
void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => {
|
||||
view.toast.error(error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
|
||||
import type { CommandCenterContribution } from "@/command-center/contributions";
|
||||
import { getCommandCenterIcon } from "@/command-center/icon";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { pluginIconComponent } from "../icons";
|
||||
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginCapabilities, type PluginNavigation } from "../actions";
|
||||
@@ -83,7 +83,7 @@ export function buildPluginCommandCenterContributions(
|
||||
kind: "action",
|
||||
title: item.title,
|
||||
sectionTitle: plugin.id,
|
||||
icon: getCommandCenterIcon(resolvePluginIcon(item.icon)),
|
||||
icon: getCommandCenterIcon(pluginIconComponent(item.icon)),
|
||||
},
|
||||
run,
|
||||
});
|
||||
|
||||
@@ -381,6 +381,72 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]);
|
||||
});
|
||||
|
||||
it("registers inline SVG and component icons", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function ReviewPanel() { return null; }
|
||||
function BadgeIcon() { return null; }
|
||||
plugin.addWorkspacePanel({
|
||||
id: "review",
|
||||
title: "Review",
|
||||
icon: { svg: ${JSON.stringify(` ${svg}\n`)} },
|
||||
context: "workspace",
|
||||
Component: ReviewPanel,
|
||||
});
|
||||
plugin.addCommandCenterItem({
|
||||
id: "open-review",
|
||||
title: "Open review",
|
||||
icon: BadgeIcon,
|
||||
context: "global",
|
||||
onSelect() {},
|
||||
});
|
||||
plugin.addAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Issues",
|
||||
icon: { svg: ${JSON.stringify(svg)} },
|
||||
pickerTitle: "Attach issue",
|
||||
searchPlaceholder: "Search",
|
||||
search: { name: "issues.search", input: {}, output: {} },
|
||||
});
|
||||
`),
|
||||
);
|
||||
|
||||
expect(plugin.workspacePanels[0]?.icon).toEqual({ svg });
|
||||
expect(plugin.commandCenterItems[0]?.icon).toHaveProperty("name", "BadgeIcon");
|
||||
expect(plugin.attachmentSources[0]?.icon).toEqual({ svg });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"workspace panel",
|
||||
`plugin.addWorkspacePanel({ id: "review", title: "Review", icon: ICON, context: "workspace", Component() { return null; } })`,
|
||||
"Workspace panel review has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
[
|
||||
"Command Center item",
|
||||
`plugin.addCommandCenterItem({ id: "review", title: "Review", icon: ICON, context: "global", onSelect() {} })`,
|
||||
"Command Center item review has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
[
|
||||
"settings screen",
|
||||
`plugin.addSettingsScreen({ id: "review", title: "Review", icon: ICON, Component() { return null; } })`,
|
||||
"Settings screen review has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
[
|
||||
"attachment source",
|
||||
`plugin.addAttachmentSource({ id: "review", title: "Review", icon: ICON, pickerTitle: "Pick", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } })`,
|
||||
"Attachment source review has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
])("fails the plugin when a %s registers an unsafe SVG", (_kind, registration, message) => {
|
||||
const icon = JSON.stringify({ svg: "<svg><script>run()</script></svg>" });
|
||||
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle("review", bundle(registration.replace("ICON", icon))),
|
||||
).toThrow(message);
|
||||
});
|
||||
|
||||
it("normalizes and validates workspace panel locations", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
|
||||
import { pluginReactNativeRuntime } from "./react-native/runtime";
|
||||
import { parsePluginThemeContribution } from "./themes";
|
||||
|
||||
@@ -199,9 +199,9 @@ export function runPluginClientBundle(
|
||||
if (settingsScreenIds.has(screenId))
|
||||
throw new Error(`Duplicate settings screen: ${screenId}`);
|
||||
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
|
||||
resolvePluginIcon(contribution.icon);
|
||||
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
|
||||
settingsScreenIds.add(screenId);
|
||||
return register(collector.settingsScreens, { ...contribution, id: screenId }, () =>
|
||||
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
|
||||
settingsScreenIds.delete(screenId),
|
||||
);
|
||||
},
|
||||
@@ -241,12 +241,10 @@ export function runPluginClientBundle(
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
addSidebarItem(contribution: PluginSidebarContribution) {
|
||||
const normalizedId = requireId(contribution.id, "sidebar item id");
|
||||
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
|
||||
resolvePluginIcon(contribution.icon.trim());
|
||||
const legacy = {
|
||||
id: normalizedId,
|
||||
title: contribution.title.trim(),
|
||||
icon: contribution.icon.trim(),
|
||||
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
|
||||
surface: requireId(contribution.surface, "sidebar surface id"),
|
||||
};
|
||||
// Shares the header's ids: the sidebar orders both kinds of item under one key.
|
||||
@@ -262,16 +260,14 @@ export function runPluginClientBundle(
|
||||
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const icon = contribution.icon.trim();
|
||||
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
|
||||
if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`);
|
||||
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
|
||||
if (contribution.context !== "workspace" && contribution.context !== "agent") {
|
||||
throw new Error(`Workspace panel ${normalizedId} has invalid context`);
|
||||
}
|
||||
if (typeof contribution.Component !== "function") {
|
||||
throw new Error(`Workspace panel ${normalizedId} is not a component`);
|
||||
}
|
||||
resolvePluginIcon(icon);
|
||||
const locations = normalizePanelLocations(normalizedId, contribution.locations);
|
||||
workspacePanelIds.add(normalizedId);
|
||||
return register(
|
||||
@@ -292,9 +288,8 @@ export function runPluginClientBundle(
|
||||
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const icon = contribution.icon.trim();
|
||||
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
|
||||
if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`);
|
||||
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
|
||||
if (
|
||||
contribution.context !== "global" &&
|
||||
contribution.context !== "workspace" &&
|
||||
@@ -305,7 +300,6 @@ export function runPluginClientBundle(
|
||||
if (typeof contribution.onSelect !== "function") {
|
||||
throw new Error(`Command Center item ${normalizedId} has no callback`);
|
||||
}
|
||||
resolvePluginIcon(icon);
|
||||
commandCenterItemIds.add(normalizedId);
|
||||
return register(
|
||||
collector.commandCenterItems,
|
||||
@@ -350,18 +344,19 @@ export function runPluginClientBundle(
|
||||
throw new Error(`Duplicate attachment source: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const icon = contribution.icon.trim();
|
||||
const pickerTitle = contribution.pickerTitle.trim();
|
||||
const searchPlaceholder = contribution.searchPlaceholder.trim();
|
||||
const method = contribution.search.name.trim();
|
||||
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
|
||||
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
|
||||
const icon = parseSerializablePluginIcon(
|
||||
`Attachment source ${normalizedId}`,
|
||||
contribution.icon,
|
||||
);
|
||||
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
|
||||
if (!searchPlaceholder) {
|
||||
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
|
||||
}
|
||||
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
|
||||
resolvePluginIcon(icon);
|
||||
attachmentSourceIds.add(normalizedId);
|
||||
return register(
|
||||
collector.attachmentSources,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Settings } from "lucide-react-native";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Icon } from "./icons";
|
||||
|
||||
describe("Icon", () => {
|
||||
it("renders a host Lucide icon with the requested presentation", () => {
|
||||
expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
|
||||
type: Settings,
|
||||
props: { size: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing for an unknown icon name", () => {
|
||||
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
|
||||
expect(Icon({ name: "Icon" })).toBeNull();
|
||||
expect(Icon({ name: "createLucideIcon" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Settings } from "lucide-react-native";
|
||||
import React, { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginIconProps } from "@getpaseo/plugin";
|
||||
import { Icon, parsePluginIcon, parseSerializablePluginIcon, PluginIconView } from "./icons";
|
||||
|
||||
const SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
|
||||
const SVG_ICON = { svg: SVG };
|
||||
|
||||
const GLYPHS = new Set<unknown>([Settings, SvgXml]);
|
||||
|
||||
/** Expands function components down to what reaches the screen: a Lucide glyph, SvgXml, or DOM. */
|
||||
function draw(node: ReactNode): ReactElement | null {
|
||||
if (!isValidElement(node)) return null;
|
||||
const { type } = node;
|
||||
if (typeof type !== "function" || GLYPHS.has(type)) return node;
|
||||
return draw((type as (props: unknown) => ReactNode)(node.props));
|
||||
}
|
||||
|
||||
function BadgeIcon({ size, color }: PluginIconProps) {
|
||||
return <span data-size={size} data-color={color} />;
|
||||
}
|
||||
|
||||
describe("PluginIconView", () => {
|
||||
it("draws a Lucide name as that Lucide icon", () => {
|
||||
expect(draw(<PluginIconView icon="Settings" size={18} color="#123456" />)).toMatchObject({
|
||||
type: Settings,
|
||||
props: { size: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("draws inline SVG sized to the surface, with color reaching currentColor", () => {
|
||||
expect(draw(<PluginIconView icon={SVG_ICON} size={18} color="#123456" />)).toMatchObject({
|
||||
type: SvgXml,
|
||||
props: { xml: SVG, width: 18, height: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("draws a component with the surface's size and color", () => {
|
||||
expect(draw(<PluginIconView icon={BadgeIcon} size={18} color="#123456" />)).toMatchObject({
|
||||
type: "span",
|
||||
props: { "data-size": 18, "data-color": "#123456" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginIcon", () => {
|
||||
it("normalizes each icon form", () => {
|
||||
expect(parsePluginIcon("Workspace panel files", " Settings ")).toBe("Settings");
|
||||
expect(parsePluginIcon("Workspace panel files", { svg: `\n${SVG}\n` })).toEqual({ svg: SVG });
|
||||
expect(parsePluginIcon("Workspace panel files", BadgeIcon)).toBe(BadgeIcon);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "Workspace panel files has no icon"],
|
||||
[
|
||||
"NotALucideIcon",
|
||||
'Workspace panel files has an invalid icon: unknown Lucide icon "NotALucideIcon"',
|
||||
],
|
||||
[
|
||||
{ svg: "<svg><script>run()</script></svg>" },
|
||||
"Workspace panel files has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
[{ svg: "hello" }, "Workspace panel files has an invalid icon: not an SVG document"],
|
||||
[
|
||||
{ path: "icon.svg" },
|
||||
"Workspace panel files has an invalid icon: expected a Lucide icon name, { svg }, or a component",
|
||||
],
|
||||
])("rejects %j", (icon, message) => {
|
||||
expect(() => parsePluginIcon("Workspace panel files", icon as never)).toThrow(message);
|
||||
});
|
||||
|
||||
it("refuses a component where the icon is stored as data", () => {
|
||||
expect(() =>
|
||||
parseSerializablePluginIcon("Attachment source issues", BadgeIcon as never),
|
||||
).toThrow(
|
||||
"Attachment source issues has an invalid icon: expected a Lucide icon name or { svg }",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Icon", () => {
|
||||
it("renders a host Lucide icon by name with the requested presentation", () => {
|
||||
expect(draw(Icon({ name: "Settings", size: 18, color: "#123456" }))).toMatchObject({
|
||||
type: Settings,
|
||||
props: { size: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a plugin's own SVG through the icon prop", () => {
|
||||
expect(draw(Icon({ icon: { svg: SVG }, size: 18, color: "#123456" }))).toMatchObject({
|
||||
type: SvgXml,
|
||||
props: { xml: SVG, width: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing for an unusable icon", () => {
|
||||
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
|
||||
expect(Icon({ name: "Icon" })).toBeNull();
|
||||
expect(Icon({ name: "createLucideIcon" })).toBeNull();
|
||||
expect(Icon({ icon: { svg: '<svg onload="run()"></svg>' } })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,150 @@
|
||||
import { createElement, type ReactElement } from "react";
|
||||
import { createElement, type ComponentType, type ReactElement } from "react";
|
||||
import * as LucideIcons from "lucide-react-native";
|
||||
import type { PluginIconProps } from "@getpaseo/plugin/client";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import {
|
||||
findSvgIconProblem,
|
||||
type PluginIconProps,
|
||||
type PluginSerializableIcon,
|
||||
type PluginSvgIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
import type { IconProps } from "@getpaseo/plugin/client/react-native";
|
||||
import { SvgIcon } from "@/components/provider-icons";
|
||||
|
||||
function findPluginIcon(name: string): LucideIcon | null {
|
||||
/** Host surfaces may thin a Lucide stroke; SVG and plugin components ignore it. */
|
||||
type HostIconProps = PluginIconProps & { strokeWidth?: number };
|
||||
/** A host-drawn icon. A plain function, so it stands in for any plugin icon component type. */
|
||||
type HostIcon = (props: HostIconProps) => ReactElement | null;
|
||||
|
||||
const lucideIconComponents = new Map<string, HostIcon>();
|
||||
const svgIconComponents = new Map<string, HostIcon>();
|
||||
|
||||
function findLucideIcon(name: string): LucideIcon | null {
|
||||
const candidate = Reflect.get(LucideIcons, name);
|
||||
if (candidate === LucideIcons.Icon || candidate === LucideIcons.createLucideIcon) return null;
|
||||
const isComponent =
|
||||
typeof candidate === "function" ||
|
||||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
|
||||
return isComponent ? (candidate as LucideIcon) : null;
|
||||
return isComponent(candidate) ? (candidate as LucideIcon) : null;
|
||||
}
|
||||
|
||||
export function resolvePluginIcon(name: string): LucideIcon {
|
||||
const icon = findPluginIcon(name);
|
||||
if (!icon) throw new Error(`Unknown Lucide icon: ${name}`);
|
||||
function isComponent(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "function" ||
|
||||
(typeof value === "object" && value !== null && "$$typeof" in value)
|
||||
);
|
||||
}
|
||||
|
||||
function isSvgIcon(icon: unknown): icon is PluginSvgIcon {
|
||||
return (
|
||||
typeof icon === "object" &&
|
||||
icon !== null &&
|
||||
!("$$typeof" in icon) &&
|
||||
typeof Reflect.get(icon, "svg") === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isComponentIcon<Props extends PluginIconProps>(
|
||||
icon: PluginIcon<Props>,
|
||||
): icon is ComponentType<Props> {
|
||||
return isComponent(icon);
|
||||
}
|
||||
|
||||
function invalidIcon(owner: string, reason: string): Error {
|
||||
return new Error(`${owner} has an invalid icon: ${reason}`);
|
||||
}
|
||||
|
||||
/** Validates an icon that is stored with the data it labels. Returns the normalized icon. */
|
||||
export function parseSerializablePluginIcon(
|
||||
owner: string,
|
||||
icon: PluginSerializableIcon,
|
||||
): PluginSerializableIcon {
|
||||
if (typeof icon === "string") {
|
||||
const name = icon.trim();
|
||||
if (!name) throw new Error(`${owner} has no icon`);
|
||||
if (!findLucideIcon(name)) throw invalidIcon(owner, `unknown Lucide icon "${name}"`);
|
||||
return name;
|
||||
}
|
||||
if (!isSvgIcon(icon)) throw invalidIcon(owner, "expected a Lucide icon name or { svg }");
|
||||
const svg = icon.svg.trim();
|
||||
const problem = findSvgIconProblem(svg);
|
||||
if (problem) throw invalidIcon(owner, problem);
|
||||
return { svg };
|
||||
}
|
||||
|
||||
/** Validates a contribution's icon once, at registration. Rendering trusts the result. */
|
||||
export function parsePluginIcon<Props extends PluginIconProps>(
|
||||
owner: string,
|
||||
icon: PluginIcon<Props>,
|
||||
): PluginIcon<Props> {
|
||||
if (isComponentIcon(icon)) return icon;
|
||||
if (typeof icon !== "string" && !isSvgIcon(icon)) {
|
||||
throw invalidIcon(owner, "expected a Lucide icon name, { svg }, or a component");
|
||||
}
|
||||
return parseSerializablePluginIcon(owner, icon);
|
||||
}
|
||||
|
||||
function MissingIcon(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function createLucideIcon(name: string, Lucide: LucideIcon): HostIcon {
|
||||
function PluginLucideIcon({ size, color, strokeWidth }: HostIconProps) {
|
||||
return createElement(Lucide, { size, color, strokeWidth });
|
||||
}
|
||||
PluginLucideIcon.displayName = `PluginLucideIcon(${name})`;
|
||||
return PluginLucideIcon;
|
||||
}
|
||||
|
||||
function lucideIconComponent(name: string): HostIcon {
|
||||
const cached = lucideIconComponents.get(name);
|
||||
if (cached) return cached;
|
||||
const Lucide = findLucideIcon(name);
|
||||
if (!Lucide) return MissingIcon;
|
||||
const icon = createLucideIcon(name, Lucide);
|
||||
lucideIconComponents.set(name, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function Icon({ name, size, color }: PluginIconProps): ReactElement | null {
|
||||
const icon = findPluginIcon(name);
|
||||
return icon ? createElement(icon, { size, color }) : null;
|
||||
function svgIconComponent(svg: string): HostIcon {
|
||||
const cached = svgIconComponents.get(svg);
|
||||
if (cached) return cached;
|
||||
function PluginSvgIcon({ size, color }: PluginIconProps) {
|
||||
return createElement(SvgIcon, { svg, size, color });
|
||||
}
|
||||
svgIconComponents.set(svg, PluginSvgIcon);
|
||||
return PluginSvgIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable component for a registered icon, for surfaces that take an icon component. Lucide and
|
||||
* SVG icons draw from `size` and `color` only; a plugin component receives every prop.
|
||||
*/
|
||||
export function pluginIconComponent<Props extends PluginIconProps>(
|
||||
icon: PluginIcon<Props>,
|
||||
): ComponentType<Props> {
|
||||
if (typeof icon === "string") return lucideIconComponent(icon);
|
||||
if (isSvgIcon(icon)) return svgIconComponent(icon.svg);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export interface PluginIconViewProps extends PluginIconProps {
|
||||
icon: PluginIcon;
|
||||
}
|
||||
|
||||
/** Draws a registered plugin icon. `color` tints Lucide icons and `currentColor` in SVG. */
|
||||
export function PluginIconView({ icon, size, color }: PluginIconViewProps): ReactElement {
|
||||
return createElement(pluginIconComponent(icon), { size, color });
|
||||
}
|
||||
|
||||
function isRenderable(icon: PluginIcon): boolean {
|
||||
if (typeof icon === "string") return findLucideIcon(icon) !== null;
|
||||
if (isSvgIcon(icon)) return findSvgIconProblem(icon.svg) === null;
|
||||
return isComponent(icon);
|
||||
}
|
||||
|
||||
/** The kit `Icon`. Plugin code passes it anything, so an unusable icon renders nothing. */
|
||||
export function Icon(props: IconProps): ReactElement | null {
|
||||
const icon = props.icon === undefined ? props.name : props.icon;
|
||||
if (!isRenderable(icon)) return null;
|
||||
const size = props.size ?? 24;
|
||||
const color = props.color ?? "currentColor";
|
||||
return createElement(PluginIconView, { icon, size, color });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PluginSidebarItemContribution,
|
||||
PluginSidebarSection,
|
||||
} from "./types";
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
|
||||
export interface PluginSidebarTarget {
|
||||
plugin: InstalledPlugin;
|
||||
@@ -36,7 +37,7 @@ export type PluginSidebarGroup =
|
||||
| (PluginSidebarGroupBase & { kind: "item"; targets: PluginSidebarTarget[] })
|
||||
| (PluginSidebarGroupBase & {
|
||||
kind: "legacy";
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
targets: PluginLegacySidebarTarget[];
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { SidebarIcon, SidebarRowProps } from "@getpaseo/plugin/client/ui";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { SidebarRowProps } from "@getpaseo/plugin/client/ui";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator as AppSidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { parsePluginIcon, pluginIconComponent } from "../icons";
|
||||
import { useSidebarItemFrame } from "./frame";
|
||||
|
||||
function resolveIcon(icon: SidebarIcon) {
|
||||
return typeof icon === "string" ? resolvePluginIcon(icon) : icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* An item may render several rows. Any press inside a row, on the row or on its trailing content,
|
||||
* makes that row the popover anchor: the capture-phase responder check runs before the pressed
|
||||
@@ -29,6 +25,11 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
|
||||
anchorTo(rowRef.current);
|
||||
return false;
|
||||
}, [anchorTo]);
|
||||
// Rows get their icon at render, not registration, so an invalid icon throws to the item's boundary.
|
||||
const iconComponent = useMemo(
|
||||
() => (icon ? pluginIconComponent(parsePluginIcon("Sidebar row", icon)) : null),
|
||||
[icon],
|
||||
);
|
||||
// Keyboard activation skips the responder system, so the row's own press anchors as well.
|
||||
const handlePress = useCallback(() => {
|
||||
anchorTo(rowRef.current);
|
||||
@@ -37,7 +38,7 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
|
||||
return (
|
||||
<View ref={rowRef} collapsable={false} onStartShouldSetResponderCapture={anchorHere}>
|
||||
<SidebarHeaderRow
|
||||
icon={icon ? resolveIcon(icon) : null}
|
||||
icon={iconComponent}
|
||||
label={label ?? frame.title}
|
||||
onPress={handlePress}
|
||||
isActive={active}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { router, usePathname } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { rememberPluginContributionHost } from "../contribution-host";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { pluginIconComponent } from "../icons";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
|
||||
import { selectPluginSidebarTarget, type PluginSidebarGroup } from "../sidebar-groups";
|
||||
|
||||
@@ -33,7 +33,7 @@ export function LegacyPluginSidebarRow({
|
||||
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={resolvePluginIcon(group.icon)}
|
||||
icon={pluginIconComponent(group.icon)}
|
||||
label={group.title}
|
||||
onPress={navigate}
|
||||
isActive={isActive}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type { PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginIcon, PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useCallback, useMemo, type ComponentType } from "react";
|
||||
@@ -17,7 +17,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { usePluginHostNavigation } from "./host-navigation";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { PluginIconView } from "./icons";
|
||||
import { toPluginTheme } from "./theme";
|
||||
import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
||||
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
|
||||
@@ -47,14 +47,8 @@ function routeParam(value: string | string[] | undefined): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function PluginHeaderIcon({
|
||||
Icon,
|
||||
color = "",
|
||||
}: {
|
||||
Icon: ReturnType<typeof resolvePluginIcon>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={16} color={color} />;
|
||||
function PluginHeaderIcon({ icon, color = "" }: { icon: PluginIcon; color?: string }) {
|
||||
return <PluginIconView icon={icon} size={16} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
|
||||
@@ -162,7 +156,7 @@ export function PluginSurfaceScreen() {
|
||||
: (sidebarItem?.title ?? (pluginId || "Plugin")),
|
||||
[params, pluginId, sidebarItem, surface],
|
||||
);
|
||||
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
|
||||
const icon = sidebarItem?.icon ?? null;
|
||||
const close = useCallback(() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
else router.replace(`/h/${encodeURIComponent(serverId)}`);
|
||||
@@ -172,15 +166,15 @@ export function PluginSurfaceScreen() {
|
||||
const headerLeft = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{Icon ? (
|
||||
{icon ? (
|
||||
<HeaderIconBadge>
|
||||
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
|
||||
<ThemedPluginHeaderIcon icon={icon} uniProps={mutedColorMapping} />
|
||||
</HeaderIconBadge>
|
||||
) : null}
|
||||
<ScreenTitle testID="plugin-surface-title">{title}</ScreenTitle>
|
||||
</>
|
||||
),
|
||||
[Icon, title],
|
||||
[icon, title],
|
||||
);
|
||||
const hostFilter = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("../client-runtime", () => ({
|
||||
}));
|
||||
vi.mock("../icons", () => ({
|
||||
Icon: () => null,
|
||||
resolvePluginIcon: () => () => null,
|
||||
pluginIconComponent: () => () => null,
|
||||
}));
|
||||
|
||||
import { pluginRegistry } from "../registry";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { usePluginHostNavigation } from "../host-navigation";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { pluginIconComponent } from "../icons";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import { PluginInstallationProvider } from "../installation-provider";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
@@ -144,7 +144,7 @@ function usePluginPanelDescriptor(
|
||||
subtitle: target.pluginId,
|
||||
tooltip: panel.title,
|
||||
titleState: "ready",
|
||||
icon: resolvePluginIcon(panel.icon),
|
||||
icon: pluginIconComponent(panel.icon),
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { WorkspaceDirectoryReplica } from "./workspace-replica";
|
||||
|
||||
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
|
||||
@@ -230,3 +231,38 @@ it("does not restore a targeted cached workspace while its archive is pending",
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
it("setup metadata from directory snapshots and transitions resets failure surfacing without details", () => {
|
||||
const serverId = "setup-metadata";
|
||||
useSessionStore.getState().initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const identity = { serverId, workspaceId: "setup" };
|
||||
const store = useWorkspaceSetupStore.getState();
|
||||
replica.commitSnapshot(
|
||||
{
|
||||
workspaces: new Map([
|
||||
["setup", normalizeWorkspaceDescriptor({ ...workspace("setup"), setupStatus: "blocked" })],
|
||||
]),
|
||||
projects: new Map(),
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(useSessionStore.getState().sessions[serverId].workspaces.get("setup")?.setupStatus).toBe(
|
||||
"blocked",
|
||||
);
|
||||
expect(store.claimFailedSetupSurface({ ...identity, status: "blocked" })).toBe(true);
|
||||
replica.applyDelta({
|
||||
kind: "upsert",
|
||||
workspace: { ...workspace("setup"), setupStatus: "running" },
|
||||
});
|
||||
replica.applyDelta({
|
||||
kind: "upsert",
|
||||
workspace: { ...workspace("setup"), setupStatus: "failed" },
|
||||
});
|
||||
expect(useSessionStore.getState().sessions[serverId].workspaces.get("setup")?.setupStatus).toBe(
|
||||
"failed",
|
||||
);
|
||||
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(true);
|
||||
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false);
|
||||
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,7 @@ export class WorkspaceDirectoryReplica {
|
||||
workspaces.set(workspaceId, workspace);
|
||||
}
|
||||
}
|
||||
for (const workspace of workspaces.values()) this.applySetupStatus(workspace);
|
||||
this.workspaces = workspaces;
|
||||
this.projects = new Map(snapshot.projects);
|
||||
this.rebuildProjectIndex();
|
||||
@@ -198,7 +199,16 @@ export class WorkspaceDirectoryReplica {
|
||||
return [{ kind: "workspace", type: "upsert", id: workspace.id, value: workspace }];
|
||||
}
|
||||
|
||||
private applySetupStatus(workspace: WorkspaceDescriptor): void {
|
||||
useWorkspaceSetupStore.getState().applySetupStatus({
|
||||
serverId: this.serverId,
|
||||
workspaceId: workspace.id,
|
||||
status: workspace.setupStatus ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private setWorkspace(workspace: WorkspaceDescriptor): void {
|
||||
this.applySetupStatus(workspace);
|
||||
const previous = this.workspaces.get(workspace.id);
|
||||
if (previous?.projectId !== workspace.projectId) {
|
||||
this.workspaceIdsByProject.get(previous?.projectId ?? "")?.delete(workspace.id);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
getBindingIdForAction,
|
||||
normalizeCapturedShortcutCombo,
|
||||
isShortcutCaptureComplete,
|
||||
getDefaultKeysForAction,
|
||||
resolveShortcutKeysForAction,
|
||||
type KeyboardShortcutHelpRow,
|
||||
@@ -269,6 +270,8 @@ function ShortcutRow({
|
||||
|
||||
const isBindable = bindingId !== null;
|
||||
const showDone = isCapturing && capturedCombos.length > 0;
|
||||
const captureComplete =
|
||||
bindingId !== null && isShortcutCaptureComplete(bindingId, capturedCombos);
|
||||
const showClear = displayChord !== null;
|
||||
// Reset restores the default, so it is only meaningful when there is a
|
||||
// default to restore. A binding that ships without one would otherwise show a
|
||||
@@ -288,11 +291,14 @@ function ShortcutRow({
|
||||
capturedCombos={capturedCombos}
|
||||
heldModifiers={heldModifiers}
|
||||
/>
|
||||
{showDone && !captureComplete && (
|
||||
<Text style={styles.capturingText}>{t("settings.shortcuts.captureIndexPrompt")}</Text>
|
||||
)}
|
||||
</View>
|
||||
{isCapturing ? (
|
||||
<>
|
||||
{showDone && (
|
||||
<Button variant="ghost" size="sm" onPress={onDone}>
|
||||
<Button variant="ghost" size="sm" disabled={!captureComplete} onPress={onDone}>
|
||||
{t("settings.shortcuts.actions.done")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -357,7 +363,10 @@ export function KeyboardShortcutsSection() {
|
||||
);
|
||||
|
||||
const saveCapture = useCallback(() => {
|
||||
if (capturingBindingId === null || capturedCombos.length === 0) {
|
||||
if (
|
||||
capturingBindingId === null ||
|
||||
!isShortcutCaptureComplete(capturingBindingId, capturedCombos)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void setOverride(capturingBindingId, capturedCombos.join(" "));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, type ComponentType, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type SidebarNavItem,
|
||||
type SidebarSection,
|
||||
} from "@/sidebar-nav/model";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { pluginIconComponent } from "@/plugins/icons";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -50,15 +50,17 @@ const BUILTIN_ICONS: Record<BuiltinSidebarItemId, LucideIcon> = {
|
||||
/** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */
|
||||
const PLUGIN_ICON = Blocks;
|
||||
|
||||
function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
type NavIconComponent = ComponentType<{ size: number; color: string }>;
|
||||
|
||||
function NavIcon({ Icon, color = "" }: { Icon: NavIconComponent; color?: string }) {
|
||||
return <Icon size={ICON_SIZE.md} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedNavIcon = withUnistyles(NavIcon);
|
||||
|
||||
function navItemIcon(item: SidebarNavItem): LucideIcon {
|
||||
function navItemIcon(item: SidebarNavItem): NavIconComponent {
|
||||
if (item.kind === "builtin") return BUILTIN_ICONS[item.id];
|
||||
return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON;
|
||||
return item.group.kind === "legacy" ? pluginIconComponent(item.group.icon) : PLUGIN_ICON;
|
||||
}
|
||||
|
||||
function navItemLabel(t: TFunction, item: SidebarNavItem): string {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
createInlinePathOpener,
|
||||
@@ -223,6 +224,10 @@ const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
function getWorkspaceSetupStatus(workspace: WorkspaceDescriptor | null | undefined) {
|
||||
return workspace?.setupStatus ?? null;
|
||||
}
|
||||
|
||||
function getWorkspaceScripts(
|
||||
workspaceDescriptor: WorkspaceDescriptor | null | undefined,
|
||||
): WorkspaceDescriptor["scripts"] {
|
||||
@@ -1313,12 +1318,13 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
const lastMainPaneId = lastMainPaneRef.current.paneId;
|
||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
|
||||
persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
|
||||
);
|
||||
const ensureWorkspaceSetupStatus = useWorkspaceSetupStore((state) => state.ensureSetupStatus);
|
||||
const workspaceSetupStatus = getWorkspaceSetupStatus(workspaceDescriptor);
|
||||
const claimFailedSetupSurface = useWorkspaceSetupStore((state) => state.claimFailedSetupSurface);
|
||||
const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot);
|
||||
const supportsSetupObservation = useHostFeature(normalizedServerId, "workspaceSetupObservation");
|
||||
const showWorkspaceSetup = shouldShowWorkspaceSetup(
|
||||
workspaceSetupStatus,
|
||||
supportsSetupObservation,
|
||||
);
|
||||
const uiTabs = useMemo(
|
||||
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
||||
[workspaceLayout],
|
||||
@@ -1624,7 +1630,7 @@ function WorkspaceScreenContent({
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (!shouldSeedWorkspaceSetupTab(workspaceSetupSnapshot)) {
|
||||
if (!shouldSeedWorkspaceSetupTab(workspaceSetupStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1639,6 +1645,7 @@ function WorkspaceScreenContent({
|
||||
!claimFailedSetupSurface({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
status: workspaceSetupStatus,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
@@ -1659,24 +1666,7 @@ function WorkspaceScreenContent({
|
||||
normalizedServerId,
|
||||
openWorkspaceTabInBackground,
|
||||
persistenceKey,
|
||||
workspaceSetupSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
ensureWorkspaceSetupStatus({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
client,
|
||||
});
|
||||
}, [
|
||||
client,
|
||||
ensureWorkspaceSetupStatus,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceSetupStatus,
|
||||
]);
|
||||
|
||||
const handleOpenFileFromChat = useCallback(
|
||||
|
||||
@@ -63,3 +63,66 @@ describe("createValidatedPersistStorage", () => {
|
||||
expect(backing.values.has("settings")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips repeated immutable states only when requested", async () => {
|
||||
let writes = 0;
|
||||
const backing: StateStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
writes += 1;
|
||||
},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const value = { state: { enabled: true }, version: 1 };
|
||||
const regular = createValidatedPersistStorage(backing, StateSchema);
|
||||
await regular.setItem("regular", value);
|
||||
await regular.setItem("regular", value);
|
||||
expect(writes).toBe(2);
|
||||
|
||||
const deduplicated = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true });
|
||||
await deduplicated.setItem("first", value);
|
||||
await deduplicated.setItem("first", { ...value });
|
||||
expect(writes).toBe(3);
|
||||
await deduplicated.setItem("second", value);
|
||||
expect(writes).toBe(4);
|
||||
await deduplicated.setItem("first", { ...value, version: 2 });
|
||||
expect(writes).toBe(5);
|
||||
});
|
||||
|
||||
it("retries an unchanged state after a failed save", async () => {
|
||||
let attempts = 0;
|
||||
const backing = new MemoryStorage();
|
||||
const storage = createValidatedPersistStorage(
|
||||
{
|
||||
getItem: (name) => backing.getItem(name),
|
||||
setItem: (name, value) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new Error("storage unavailable"));
|
||||
backing.setItem(name, value);
|
||||
},
|
||||
removeItem: (name) => backing.removeItem(name),
|
||||
},
|
||||
StateSchema,
|
||||
{ skipUnchanged: true },
|
||||
);
|
||||
const value = { state: { enabled: true }, version: 1 };
|
||||
await expect(storage.setItem("layout", value)).rejects.toThrow("storage unavailable");
|
||||
await storage.setItem("layout", value);
|
||||
expect(attempts).toBe(2);
|
||||
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
|
||||
});
|
||||
|
||||
it("saves the same state again after removal or rehydration clears invalid storage", async () => {
|
||||
const backing = new MemoryStorage();
|
||||
const storage = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true });
|
||||
const value = { state: { enabled: true }, version: 1 };
|
||||
await storage.setItem("layout", value);
|
||||
await storage.removeItem("layout");
|
||||
await storage.setItem("layout", value);
|
||||
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
|
||||
|
||||
backing.values.set("layout", "{");
|
||||
await expect(storage.getItem("layout")).resolves.toBeNull();
|
||||
await storage.setItem("layout", value);
|
||||
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
|
||||
});
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import type { PersistStorage, StateStorage } from "zustand/middleware";
|
||||
import type { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
|
||||
|
||||
export function createValidatedPersistStorage<State>(
|
||||
backingStorage: StateStorage,
|
||||
stateSchema: z.ZodType<State>,
|
||||
// Opt in only when callers preserve state references until values change.
|
||||
options: { skipUnchanged?: boolean } = {},
|
||||
): PersistStorage<State> {
|
||||
const envelopeSchema = z.strictObject({
|
||||
state: stateSchema,
|
||||
version: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const accepted = new Map<string, StorageValue<State>>();
|
||||
|
||||
return {
|
||||
getItem: async (name) => {
|
||||
accepted.delete(name);
|
||||
const raw = await backingStorage.getItem(name);
|
||||
if (raw === null) return null;
|
||||
|
||||
@@ -31,13 +36,31 @@ export function createValidatedPersistStorage<State>(
|
||||
return result.data;
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
const previous = accepted.get(name);
|
||||
if (
|
||||
options.skipUnchanged &&
|
||||
previous &&
|
||||
previous.state === value.state &&
|
||||
previous.version === value.version
|
||||
)
|
||||
return;
|
||||
const result = envelopeSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
accepted.delete(name);
|
||||
await backingStorage.removeItem(name);
|
||||
return;
|
||||
}
|
||||
await backingStorage.setItem(name, JSON.stringify(result.data));
|
||||
if (options.skipUnchanged) accepted.set(name, value);
|
||||
try {
|
||||
await backingStorage.setItem(name, JSON.stringify(result.data));
|
||||
} catch (error) {
|
||||
if (accepted.get(name) === value) accepted.delete(name);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
removeItem: (name) => {
|
||||
accepted.delete(name);
|
||||
return backingStorage.removeItem(name);
|
||||
},
|
||||
removeItem: (name) => backingStorage.removeItem(name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ export interface WorkspaceDescriptor {
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"];
|
||||
setupStatus?: WorkspaceDescriptorPayload["setupStatus"];
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
||||
background?: boolean;
|
||||
@@ -149,6 +150,8 @@ export function normalizeWorkspaceDescriptor(
|
||||
// usable directory" (older daemons may omit it; the wire field is optional).
|
||||
workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "",
|
||||
worktreeSlug: payload.worktreeSlug,
|
||||
// COMPAT(workspaceSetupObservation): added in v0.12.0, old hosts omit metadata; remove after 2027-04-19.
|
||||
setupStatus: payload.setupStatus ?? null,
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
background: payload.background ?? false,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
|
||||
import { expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import { createWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
|
||||
Object.assign(globalThis, { indexedDB, IDBKeyRange });
|
||||
|
||||
it("skips unchanged reconciliations and immediately saves real layout changes", async () => {
|
||||
const values = new Map<string, string>();
|
||||
let writes = 0;
|
||||
const storage: StateStorage = {
|
||||
getItem: (name) => values.get(name) ?? null,
|
||||
setItem: (name, value) => {
|
||||
writes += 1;
|
||||
values.set(name, value);
|
||||
},
|
||||
removeItem: (name) => {
|
||||
values.delete(name);
|
||||
},
|
||||
};
|
||||
const store = createWorkspaceLayoutStore(undefined, storage);
|
||||
await store.persist.rehydrate();
|
||||
const workspaceKey = "server:workspace";
|
||||
const openAgent = (agentId: string) =>
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId },
|
||||
intent: "reveal",
|
||||
});
|
||||
openAgent("agent-1");
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
const writesBefore = writes;
|
||||
expect(writesBefore).toBe(1);
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
store.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
autoOpenAgentIds: ["agent-1"],
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
}
|
||||
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layout);
|
||||
expect(writes).toBe(writesBefore);
|
||||
|
||||
openAgent("agent-2");
|
||||
expect(writes).toBe(writesBefore + 1);
|
||||
const restored = createWorkspaceLayoutStore(undefined, storage);
|
||||
await restored.persist.rehydrate();
|
||||
expect(restored.getState().layoutByWorkspace[workspaceKey]).toEqual(
|
||||
store.getState().layoutByWorkspace[workspaceKey],
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { persist, type StateStorage } from "zustand/middleware";
|
||||
import type { z } from "zod";
|
||||
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
@@ -224,6 +224,7 @@ const MAX_TREE_DEPTH = 5;
|
||||
|
||||
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
|
||||
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
|
||||
type WorkspaceLayoutPersistedState = z.infer<typeof WorkspaceLayoutPersistedStateSchema>;
|
||||
|
||||
function convertLegacyExplorerSidebarRatios(
|
||||
ratiosByWorkspace: Record<string, number>,
|
||||
@@ -939,8 +940,57 @@ function createExplorerSidebarPane(
|
||||
return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null;
|
||||
}
|
||||
|
||||
const PERSISTED_SLICES = [
|
||||
"layoutByWorkspace",
|
||||
"pinnedAgentIdsByWorkspace",
|
||||
"splitSizesByWorkspace",
|
||||
"explorerSidebarWidthByWorkspace",
|
||||
"explorerSidebarPaneIdByWorkspace",
|
||||
"sidePaneIdByWorkspace",
|
||||
"pullRequestTabAutoOpenedByWorkspace",
|
||||
] as const;
|
||||
|
||||
// Zustand persist re-runs partialize after every set, including ones that return the state
|
||||
// untouched. Reusing the previous projection while every persisted slice is the same
|
||||
// reference lets validated storage skip unchanged writes before validation and serialization.
|
||||
function createPersistedStateProjection() {
|
||||
let previous: { source: WorkspaceLayoutStore; state: WorkspaceLayoutPersistedState } | null =
|
||||
null;
|
||||
return (source: WorkspaceLayoutStore): WorkspaceLayoutPersistedState => {
|
||||
const last = previous;
|
||||
if (last && PERSISTED_SLICES.every((slice) => last.source[slice] === source[slice])) {
|
||||
return last.state;
|
||||
}
|
||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||
for (const key in source.layoutByWorkspace) {
|
||||
// Strip ephemeral (commit diff) tabs before persisting so they are
|
||||
// dropped on reload rather than restored pointing at a rebased SHA.
|
||||
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
|
||||
normalizeLayout(source.layoutByWorkspace[key]),
|
||||
);
|
||||
}
|
||||
const state: WorkspaceLayoutPersistedState = {
|
||||
layoutByWorkspace,
|
||||
pinnedAgentIdsByWorkspace: Object.fromEntries(
|
||||
Object.entries(source.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
|
||||
key,
|
||||
Array.from(agentIds),
|
||||
]),
|
||||
),
|
||||
splitSizesByWorkspace: source.splitSizesByWorkspace,
|
||||
explorerSidebarWidthByWorkspace: source.explorerSidebarWidthByWorkspace,
|
||||
explorerPaneIdByWorkspace: source.explorerSidebarPaneIdByWorkspace,
|
||||
sidePaneIdByWorkspace: source.sidePaneIdByWorkspace,
|
||||
pullRequestTabAutoOpenedByWorkspace: source.pullRequestTabAutoOpenedByWorkspace,
|
||||
};
|
||||
previous = { source, state };
|
||||
return state;
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkspaceLayoutStore(
|
||||
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
||||
storage: StateStorage = AsyncStorage,
|
||||
) {
|
||||
return create<WorkspaceLayoutStore>()(
|
||||
persist(
|
||||
@@ -2117,33 +2167,12 @@ export function createWorkspaceLayoutStore(
|
||||
{
|
||||
name: "workspace-layout-state",
|
||||
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
|
||||
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
|
||||
storage: createValidatedPersistStorage(storage, WorkspaceLayoutPersistedStateSchema, {
|
||||
skipUnchanged: true,
|
||||
}),
|
||||
migrate: (persistedState, version) =>
|
||||
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
|
||||
partialize: (state) => {
|
||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||
for (const key in state.layoutByWorkspace) {
|
||||
// Strip ephemeral (commit diff) tabs before persisting so they are
|
||||
// dropped on reload rather than restored pointing at a rebased SHA.
|
||||
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
|
||||
normalizeLayout(state.layoutByWorkspace[key]),
|
||||
);
|
||||
}
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
pinnedAgentIdsByWorkspace: Object.fromEntries(
|
||||
Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
|
||||
key,
|
||||
Array.from(agentIds),
|
||||
]),
|
||||
),
|
||||
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
||||
explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace,
|
||||
explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace,
|
||||
sidePaneIdByWorkspace: state.sidePaneIdByWorkspace,
|
||||
pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace,
|
||||
};
|
||||
},
|
||||
partialize: createPersistedStateProjection(),
|
||||
merge: (persistedState, currentState) => {
|
||||
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -3,129 +3,13 @@ import {
|
||||
shouldSeedWorkspaceSetupTab,
|
||||
shouldShowWorkspaceSetup,
|
||||
useWorkspaceSetupStore,
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WorkspaceSetupStatusClient,
|
||||
type WorkspaceSetupStatusResult,
|
||||
} from "./workspace-setup-store";
|
||||
|
||||
function makeSnapshot(
|
||||
status: WorkspaceSetupSnapshot["status"],
|
||||
error: string | null = null,
|
||||
): WorkspaceSetupSnapshot {
|
||||
return {
|
||||
workspaceId: "workspace-1",
|
||||
status,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/Users/test/project",
|
||||
branchName: "main",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
error,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SNAPSHOT: WorkspaceSetupStatusResult["snapshot"] = {
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/Users/test/project",
|
||||
branchName: "main",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
function setupResult(
|
||||
workspaceId: string,
|
||||
snapshot: WorkspaceSetupStatusResult["snapshot"] = DEFAULT_SNAPSHOT,
|
||||
): WorkspaceSetupStatusResult {
|
||||
return { requestId: "req-1", workspaceId, snapshot };
|
||||
}
|
||||
|
||||
function makeClient(handler: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>) {
|
||||
const calls: string[] = [];
|
||||
const client: WorkspaceSetupStatusClient = {
|
||||
fetchWorkspaceSetupStatus: (workspaceId) => {
|
||||
calls.push(workspaceId);
|
||||
return handler(workspaceId);
|
||||
},
|
||||
};
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function storedSnapshots() {
|
||||
return Object.values(useWorkspaceSetupStore.getState().snapshots);
|
||||
}
|
||||
|
||||
function resolveDefault(workspaceId: string): Promise<WorkspaceSetupStatusResult> {
|
||||
return Promise.resolve(setupResult(workspaceId));
|
||||
}
|
||||
|
||||
function rejectThenResolve() {
|
||||
let attempt = 0;
|
||||
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
return Promise.reject(new Error("boom"));
|
||||
}
|
||||
return resolveDefault(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
function nullThenResolve() {
|
||||
let attempt = 0;
|
||||
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
return Promise.resolve(setupResult(workspaceId, null));
|
||||
}
|
||||
return resolveDefault(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
function mismatchThenResolve() {
|
||||
let attempt = 0;
|
||||
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
return Promise.resolve(setupResult("999"));
|
||||
}
|
||||
return resolveDefault(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSetupStatus(client: WorkspaceSetupStatusClient) {
|
||||
useWorkspaceSetupStore.getState().ensureSetupStatus({
|
||||
serverId: "server-1",
|
||||
workspaceId: "42",
|
||||
client,
|
||||
});
|
||||
}
|
||||
|
||||
describe("workspace-setup-store", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceSetupStore.setState({
|
||||
pendingWorkspaceSetup: null,
|
||||
snapshots: {},
|
||||
requestedKeys: new Set(),
|
||||
surfacedFailedSetupKeys: new Set(),
|
||||
});
|
||||
});
|
||||
@@ -162,213 +46,39 @@ describe("workspace-setup-store", () => {
|
||||
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull();
|
||||
});
|
||||
|
||||
it("hides empty successful setup snapshots", () => {
|
||||
expect(
|
||||
shouldShowWorkspaceSetup({
|
||||
workspaceId: "workspace-1",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/Users/test/project",
|
||||
branchName: "main",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows setup snapshots with commands or errors", () => {
|
||||
expect(
|
||||
shouldShowWorkspaceSetup({
|
||||
workspaceId: "workspace-1",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/Users/test/project",
|
||||
branchName: "main",
|
||||
log: "done\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/Users/test/project",
|
||||
log: "done\n",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldShowWorkspaceSetup({
|
||||
workspaceId: "workspace-1",
|
||||
status: "failed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/Users/test/project",
|
||||
branchName: "main",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
error: "Failed to parse paseo.json",
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("seeds a setup tab for failed or blocked setup", () => {
|
||||
expect(shouldSeedWorkspaceSetupTab(null)).toBe(false);
|
||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("running"))).toBe(false);
|
||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("completed"))).toBe(false);
|
||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("failed", "Setup failed"))).toBe(true);
|
||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("blocked"))).toBe(true);
|
||||
});
|
||||
|
||||
it("claims one failed setup surface until a later setup lifecycle begins", () => {
|
||||
const store = useWorkspaceSetupStore.getState();
|
||||
const identity = { serverId: "server-1", workspaceId: "42" };
|
||||
|
||||
store.upsertProgress({
|
||||
serverId: identity.serverId,
|
||||
payload: { ...makeSnapshot("failed", "Setup failed"), workspaceId: identity.workspaceId },
|
||||
});
|
||||
|
||||
expect(store.claimFailedSetupSurface(identity)).toBe(true);
|
||||
it("uses directory metadata to surface failures without requesting setup details", () => {
|
||||
for (const status of [null, "running", "completed", "failed", "blocked"] as const) {
|
||||
expect(shouldShowWorkspaceSetup(status)).toBe(status !== null);
|
||||
expect(shouldSeedWorkspaceSetupTab(status)).toBe(status === "failed" || status === "blocked");
|
||||
}
|
||||
expect(shouldShowWorkspaceSetup(null, false)).toBe(true);
|
||||
const identity = { serverId: "server-1", workspaceId: "42", status: "failed" as const };
|
||||
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
|
||||
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true);
|
||||
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(false);
|
||||
|
||||
useWorkspaceSetupStore.getState().upsertProgress({
|
||||
serverId: identity.serverId,
|
||||
payload: { ...makeSnapshot("running"), workspaceId: identity.workspaceId },
|
||||
});
|
||||
useWorkspaceSetupStore.getState().upsertProgress({
|
||||
serverId: identity.serverId,
|
||||
payload: {
|
||||
...makeSnapshot("failed", "Setup failed again"),
|
||||
workspaceId: identity.workspaceId,
|
||||
useWorkspaceSetupStore.getState().applySetupStatus({ ...identity, status: "running" });
|
||||
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true);
|
||||
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
|
||||
});
|
||||
it("replaces cached setup details with an empty reconnect snapshot", () => {
|
||||
const identity = { serverId: "server", workspaceId: "workspace" };
|
||||
const store = useWorkspaceSetupStore.getState();
|
||||
store.applySnapshot({
|
||||
...identity,
|
||||
snapshot: {
|
||||
status: "failed",
|
||||
error: "previous setup failed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/workspace",
|
||||
branchName: "main",
|
||||
commands: [],
|
||||
log: "previous output",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus fetches setup status once and stores the snapshot", async () => {
|
||||
const { client, calls } = makeClient(resolveDefault);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
|
||||
expect(calls).toEqual(["42"]);
|
||||
expect(storedSnapshots()).toEqual([
|
||||
expect.objectContaining({ workspaceId: "42", status: "running" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus does not refetch while a request is in flight", async () => {
|
||||
const deferred = createDeferred<WorkspaceSetupStatusResult>();
|
||||
const { client, calls } = makeClient(() => deferred.promise);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
ensureSetupStatus(client);
|
||||
|
||||
expect(calls).toEqual(["42"]);
|
||||
|
||||
deferred.resolve(setupResult("42"));
|
||||
await flush();
|
||||
|
||||
ensureSetupStatus(client);
|
||||
expect(calls).toEqual(["42"]);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus skips fetching when a snapshot already exists", () => {
|
||||
useWorkspaceSetupStore.getState().upsertProgress({
|
||||
serverId: "server-1",
|
||||
payload: { workspaceId: "42", ...DEFAULT_SNAPSHOT },
|
||||
});
|
||||
const { client, calls } = makeClient(resolveDefault);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus ignores a response for a different workspace", async () => {
|
||||
const { client } = makeClient(() => Promise.resolve(setupResult("999")));
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus does not store a snapshot when the response snapshot is null", async () => {
|
||||
const { client } = makeClient((workspaceId) => Promise.resolve(setupResult(workspaceId, null)));
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus retries after a null-snapshot response", async () => {
|
||||
const { client, calls } = makeClient(nullThenResolve());
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42"]);
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42", "42"]);
|
||||
expect(storedSnapshots()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus retries after a mismatched-workspace response", async () => {
|
||||
const { client, calls } = makeClient(mismatchThenResolve());
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42"]);
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42", "42"]);
|
||||
expect(storedSnapshots()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus clears the in-flight marker on error so a later call retries", async () => {
|
||||
const { client, calls } = makeClient(rejectThenResolve());
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42"]);
|
||||
expect(storedSnapshots()).toHaveLength(0);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42", "42"]);
|
||||
expect(storedSnapshots()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ensureSetupStatus retries after the workspace is removed", async () => {
|
||||
const { client, calls } = makeClient(resolveDefault);
|
||||
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
expect(calls).toEqual(["42"]);
|
||||
|
||||
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: "server-1", workspaceId: "42" });
|
||||
ensureSetupStatus(client);
|
||||
await flush();
|
||||
|
||||
expect(calls).toEqual(["42", "42"]);
|
||||
expect(Object.values(useWorkspaceSetupStore.getState().snapshots)).toHaveLength(1);
|
||||
store.applySnapshot({ ...identity, snapshot: null });
|
||||
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
WorkspaceSetupSnapshot as SetupDetailSnapshot,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
@@ -19,30 +22,18 @@ export type WorkspaceSetupProgressPayload = Extract<
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
export type WorkspaceSetupStatusResult = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_status_response" }
|
||||
>["payload"];
|
||||
|
||||
export interface WorkspaceSetupStatusClient {
|
||||
fetchWorkspaceSetupStatus: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>;
|
||||
}
|
||||
|
||||
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null): boolean {
|
||||
if (!snapshot) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
snapshot.status === "blocked" || snapshot.error !== null || snapshot.detail.commands.length > 0
|
||||
);
|
||||
export type WorkspaceSetupStatus = WorkspaceSetupSnapshot["status"] | null;
|
||||
|
||||
export function shouldShowWorkspaceSetup(status: WorkspaceSetupStatus, supported = true): boolean {
|
||||
return !supported || status !== null;
|
||||
}
|
||||
|
||||
export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | null): boolean {
|
||||
return snapshot?.status === "failed" || snapshot?.status === "blocked";
|
||||
export function shouldSeedWorkspaceSetupTab(status: WorkspaceSetupStatus): boolean {
|
||||
return status === "failed" || status === "blocked";
|
||||
}
|
||||
|
||||
interface WorkspaceSetupStoreState {
|
||||
@@ -50,17 +41,24 @@ interface WorkspaceSetupStoreState {
|
||||
| (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string })
|
||||
| null;
|
||||
snapshots: Record<string, WorkspaceSetupSnapshot>;
|
||||
requestedKeys: Set<string>;
|
||||
surfacedFailedSetupKeys: Set<string>;
|
||||
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
|
||||
clearWorkspaceSetup: () => void;
|
||||
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
|
||||
claimFailedSetupSurface: (input: { serverId: string; workspaceId: string }) => boolean;
|
||||
ensureSetupStatus: (input: {
|
||||
applySnapshot: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
client: WorkspaceSetupStatusClient;
|
||||
snapshot: SetupDetailSnapshot | null;
|
||||
}) => void;
|
||||
applySetupStatus: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
status: WorkspaceSetupStatus;
|
||||
}) => void;
|
||||
claimFailedSetupSurface: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
status: WorkspaceSetupStatus;
|
||||
}) => boolean;
|
||||
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
|
||||
clearServer: (serverId: string) => void;
|
||||
}
|
||||
@@ -69,10 +67,9 @@ function buildWorkspaceSetupKey(input: { serverId: string; workspaceId: string }
|
||||
return buildWorkspaceTabPersistenceKey(input);
|
||||
}
|
||||
|
||||
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, get) => ({
|
||||
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
|
||||
pendingWorkspaceSetup: null,
|
||||
snapshots: {},
|
||||
requestedKeys: new Set(),
|
||||
surfacedFailedSetupKeys: new Set(),
|
||||
beginWorkspaceSetup: (value) => {
|
||||
set({
|
||||
@@ -86,30 +83,28 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
|
||||
clearWorkspaceSetup: () => {
|
||||
set({ pendingWorkspaceSetup: null });
|
||||
},
|
||||
upsertProgress: ({ serverId, payload }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
applySnapshot: ({ serverId, workspaceId, snapshot }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys);
|
||||
if (payload.status !== "failed" && payload.status !== "blocked") {
|
||||
surfacedFailedSetupKeys.delete(key);
|
||||
}
|
||||
return {
|
||||
snapshots: {
|
||||
...state.snapshots,
|
||||
[key]: {
|
||||
...payload,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
surfacedFailedSetupKeys,
|
||||
};
|
||||
const snapshots = { ...state.snapshots };
|
||||
if (snapshot) snapshots[key] = { ...snapshot, workspaceId, updatedAt: Date.now() };
|
||||
else delete snapshots[key];
|
||||
return { snapshots };
|
||||
});
|
||||
},
|
||||
claimFailedSetupSurface: ({ serverId, workspaceId }) => {
|
||||
applySetupStatus: ({ serverId, workspaceId, status }) => {
|
||||
if (shouldSeedWorkspaceSetupTab(status)) return;
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
if (!state.surfacedFailedSetupKeys.has(key)) return state;
|
||||
const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys);
|
||||
surfacedFailedSetupKeys.delete(key);
|
||||
return { surfacedFailedSetupKeys };
|
||||
});
|
||||
},
|
||||
claimFailedSetupSurface: ({ serverId, workspaceId, status }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return false;
|
||||
@@ -117,10 +112,7 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
|
||||
|
||||
let claimed = false;
|
||||
set((state) => {
|
||||
if (
|
||||
!["failed", "blocked"].includes(state.snapshots[key]?.status ?? "") ||
|
||||
state.surfacedFailedSetupKeys.has(key)
|
||||
) {
|
||||
if (!shouldSeedWorkspaceSetupTab(status) || state.surfacedFailedSetupKeys.has(key)) {
|
||||
return state;
|
||||
}
|
||||
claimed = true;
|
||||
@@ -128,40 +120,6 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
|
||||
});
|
||||
return claimed;
|
||||
},
|
||||
ensureSetupStatus: async ({ serverId, workspaceId, client }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const state = get();
|
||||
if (state.snapshots[key] || state.requestedKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// requestedKeys is a pure in-flight marker: it dedupes concurrent fetches and is
|
||||
// released once the request settles. A settle that stored no snapshot (null snapshot,
|
||||
// mismatched workspace, or error) leaves no marker, so a later call can retry; once a
|
||||
// snapshot lands, the snapshots[key] guard above prevents redundant refetches.
|
||||
set((current) => ({ requestedKeys: new Set(current.requestedKeys).add(key) }));
|
||||
|
||||
try {
|
||||
const response = await client.fetchWorkspaceSetupStatus(workspaceId);
|
||||
if (response.workspaceId === workspaceId && response.snapshot) {
|
||||
get().upsertProgress({
|
||||
serverId,
|
||||
payload: { workspaceId: response.workspaceId, ...response.snapshot },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Swallowed: the finally clears the in-flight marker so a later call retries.
|
||||
} finally {
|
||||
set((current) => {
|
||||
const next = new Set(current.requestedKeys);
|
||||
next.delete(key);
|
||||
return { requestedKeys: next };
|
||||
});
|
||||
}
|
||||
},
|
||||
removeWorkspace: ({ serverId, workspaceId }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Globe, SquarePen, SquareTerminal } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { pluginIconComponent } from "@/plugins/icons";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { pluginPanelSupportsLocation } from "@/plugins/workspace-panels/locations";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
@@ -212,7 +212,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
pluginItems.push({
|
||||
id: `plugin:${plugin.id}:${panel.id}`,
|
||||
label: panel.title,
|
||||
Icon: resolvePluginIcon(panel.icon),
|
||||
Icon: pluginIconComponent(panel.icon),
|
||||
disabled: false,
|
||||
panelKind: "plugin",
|
||||
launch: launchSelection(selection),
|
||||
|
||||
@@ -141,6 +141,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.timelineNotifications]: true,
|
||||
[CLIENT_CAPS.pluginTimelineItems]: true,
|
||||
[CLIENT_CAPS.workspaceSetupBlocked]: true,
|
||||
[CLIENT_CAPS.workspaceSetupObservation]: true,
|
||||
[CLIENT_CAPS.explicitEventSubscriptions]: true,
|
||||
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
|
||||
|
||||
|
||||
@@ -1038,6 +1038,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
timeline_notifications: true,
|
||||
plugin_timeline_items: true,
|
||||
workspace_setup_blocked: true,
|
||||
workspace_setup_observation: true,
|
||||
hello_rejection: true,
|
||||
browser_host: {
|
||||
supportedCommands: ["list_tabs"],
|
||||
|
||||
@@ -12,7 +12,7 @@ import { CreationClient } from "./creation/index.js";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import type { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
@@ -2313,6 +2313,8 @@ export class DaemonClient {
|
||||
message,
|
||||
async (query, accept) => {
|
||||
resetSource = false;
|
||||
if (query.type === "workspace.setup.subscribe.request")
|
||||
this.requireWorkspaceSetupObservation();
|
||||
const requestId = this.createRequestId(options?.requestId);
|
||||
const request = SessionInboundMessageSchema.parse({ ...query, requestId });
|
||||
try {
|
||||
@@ -2781,6 +2783,27 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
observeWorkspaceSetup(
|
||||
workspaceId: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): OwnedSubscription<CorrelatedResponsePayload<"workspace.setup.subscribe.response">> {
|
||||
return this.observe(
|
||||
"workspace.setup.subscribe.response",
|
||||
{ type: "workspace.setup.subscribe.request", workspaceId },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private requireWorkspaceSetupObservation(): void {
|
||||
if (
|
||||
this.lastServerInfoMessage?.features?.workspaceSetupObservation !== true ||
|
||||
this.lastServerInfoMessage.features.ownedSubscriptions !== true ||
|
||||
this.config.capabilities?.[CLIENT_CAPS.workspaceSetupObservation] === false ||
|
||||
this.config.capabilities?.[CLIENT_CAPS.ownedSubscriptions] === false
|
||||
)
|
||||
throw new Error("Update the host to view workspace setup details");
|
||||
}
|
||||
|
||||
async fetchWorkspaceSetupStatus(
|
||||
workspaceId: string,
|
||||
requestId?: string,
|
||||
|
||||
@@ -261,3 +261,35 @@ test("rebinding Jump to tab preserves the number group after reload", async ({
|
||||
await openSettingsSection(page, "shortcuts");
|
||||
await expectTabNumberRange(page);
|
||||
});
|
||||
|
||||
async function expectNumberRequiredBeforeSaving(page: Page, label: string) {
|
||||
const row = page.getByText(label, { exact: true }).locator("..");
|
||||
await expect(row.getByRole("button", { name: "Done", exact: true })).toBeDisabled();
|
||||
await expect(row.getByText("End with a number from 1 to 9.", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function completeNumberShortcut(page: Page, label: string) {
|
||||
const row = page.getByText(label, { exact: true }).locator("..");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.press("Control+2");
|
||||
await expect(row.getByRole("button", { name: "Done", exact: true })).toBeEnabled();
|
||||
await row.getByRole("button", { name: "Done", exact: true }).click();
|
||||
await expect(row.getByText("⌃K", { exact: true })).toBeVisible();
|
||||
await expect(row.getByText("⌃1-9", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const { label, action } of [
|
||||
{ label: "Jump to tab", action: "workspace-tab-jump-index" },
|
||||
{ label: "Jump to workspace", action: "workspace-jump-index" },
|
||||
]) {
|
||||
test(`${label} requires a final number before a recorded shortcut can be saved`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openShortcutsSettings(page);
|
||||
await captureShortcut(page, label, action, "Control+K");
|
||||
await expectNumberRequiredBeforeSaving(page, label);
|
||||
await page.keyboard.press("Control+0");
|
||||
await expectNumberRequiredBeforeSaving(page, label);
|
||||
await completeNumberShortcut(page, label);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginHostProps } from "./contracts.js";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginHostProps, PluginIcon } from "./contracts.js";
|
||||
|
||||
export type PluginButtonContext =
|
||||
| { context: "workspace"; workspaceId: string }
|
||||
| { context: "agent"; workspaceId: string; agentId: string };
|
||||
|
||||
export type PluginButtonIconProps = PluginHostProps &
|
||||
PluginButtonContext & { size: number; color: string };
|
||||
export type PluginButtonIconProps = PluginHostProps & PluginButtonContext & PluginIconProps;
|
||||
|
||||
export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void };
|
||||
|
||||
export type PluginButtonIcon = string | ComponentType<PluginButtonIconProps>;
|
||||
export type PluginButtonIcon = PluginIcon<PluginButtonIconProps>;
|
||||
|
||||
export type PluginButtonBehavior =
|
||||
| { kind: "action"; onPress(): void | Promise<void> }
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PluginTimelineTransformResult,
|
||||
PluginCleanup,
|
||||
} from "../contracts.js";
|
||||
import type { PluginIconProps, PluginSerializableIcon } from "../icons.js";
|
||||
|
||||
export interface PluginHostProps {
|
||||
theme: PluginTheme;
|
||||
@@ -87,11 +88,13 @@ export interface PluginSidebarItemContribution {
|
||||
Component: ComponentType<PluginSidebarItemProps>;
|
||||
}
|
||||
|
||||
export interface PluginIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type PluginIcon<Props extends PluginIconProps = PluginIconProps> =
|
||||
| PluginSerializableIcon
|
||||
| ComponentType<Props>;
|
||||
|
||||
export type PluginPanelLocation = "workspace" | "explorer";
|
||||
|
||||
@@ -102,7 +105,7 @@ export interface PluginOpenPanelOptions {
|
||||
interface PluginWorkspacePanelBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
locations?: readonly PluginPanelLocation[];
|
||||
}
|
||||
|
||||
@@ -168,7 +171,7 @@ export type PluginWorkspacePanelContribution =
|
||||
export interface PluginSettingsScreenContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
Component: ComponentType<PluginSurfaceProps>;
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ export interface PluginSurfaceContribution {
|
||||
export interface PluginSidebarContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
@@ -259,7 +262,7 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities {
|
||||
interface PluginCommandCenterItemBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export type {
|
||||
PluginScreenLocation,
|
||||
PluginSidebarItemProps,
|
||||
PluginSidebarItemContribution,
|
||||
PluginIconProps,
|
||||
PluginIcon,
|
||||
PluginPanelLocation,
|
||||
PluginOpenPanelOptions,
|
||||
PluginWorkspacePanelProps,
|
||||
@@ -45,6 +45,7 @@ export type {
|
||||
PluginComposerPillContribution,
|
||||
PluginHeaderButtonContribution,
|
||||
} from "./buttons.js";
|
||||
export type { PluginIconProps, PluginSerializableIcon, PluginSvgIcon } from "../icons.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
|
||||
@@ -17,7 +17,8 @@ import type {
|
||||
TextInput as NativeTextInput,
|
||||
TextInputProps,
|
||||
} from "react-native";
|
||||
import type { PluginIconProps } from "./contracts.js";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginIcon } from "./contracts.js";
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
@@ -53,12 +54,16 @@ export interface ToastApi {
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export declare const Icon: ComponentType<PluginIconProps>;
|
||||
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
|
||||
export type IconProps = Partial<PluginIconProps> &
|
||||
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
|
||||
|
||||
export declare const Icon: ComponentType<IconProps>;
|
||||
export declare const Modal: ModalComponent;
|
||||
export declare function useToast(): ToastApi;
|
||||
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
|
||||
|
||||
export type { PluginIconProps } from "./contracts.js";
|
||||
export type { PluginIconProps } from "../icons.js";
|
||||
|
||||
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
|
||||
export declare const ScrollView: ForwardRefExoticComponent<
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType, ReactNode, Ref } from "react";
|
||||
import type { PluginIcon } from "./contracts.js";
|
||||
|
||||
export interface SettingsSectionProps {
|
||||
title: string;
|
||||
@@ -64,14 +65,14 @@ export interface ExternalLinkProps {
|
||||
}
|
||||
export declare const ExternalLink: ComponentType<ExternalLinkProps>;
|
||||
|
||||
export type SidebarIcon = string | ComponentType<{ size: number; color: string }>;
|
||||
export type SidebarIcon = PluginIcon;
|
||||
export interface SidebarRowProps {
|
||||
/**
|
||||
* Tells rows of one item apart when the item renders several: "bot-2". Unique within the item.
|
||||
* Omit it when the item renders one row.
|
||||
*/
|
||||
id?: string;
|
||||
/** A Lucide icon name or a component. */
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon?: SidebarIcon;
|
||||
/** Defaults to the item's registered title. */
|
||||
label?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginSerializableIcon } from "./icons.js";
|
||||
import type { PluginRpcContract } from "./rpc.js";
|
||||
|
||||
export interface PluginTheme {
|
||||
@@ -73,7 +74,7 @@ export interface PluginThemeContribution {
|
||||
export interface PluginAttachmentSourceContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginSerializableIcon;
|
||||
pickerTitle: string;
|
||||
searchPlaceholder: string;
|
||||
search: PluginRpcContract;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findSvgIconProblem } from "./icons.js";
|
||||
|
||||
describe("findSvgIconProblem", () => {
|
||||
it.each([
|
||||
["a plain SVG", '<svg viewBox="0 0 24 24"><path d="M0 0h24v24z" /></svg>'],
|
||||
["leading whitespace", '\n <svg viewBox="0 0 24 24"></svg>'],
|
||||
[
|
||||
"a fragment reference",
|
||||
'<svg><defs><path id="mark" d="M0 0h1v1z" /></defs><use href="#mark" /></svg>',
|
||||
],
|
||||
["currentColor fills", '<svg fill="currentColor"><circle r="4" /></svg>'],
|
||||
])("accepts %s", (_name, svg) => {
|
||||
expect(findSvgIconProblem(svg)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-SVG document", "hello", "not an SVG document"],
|
||||
["an <svgx> element", "<svgx></svgx>", "not an SVG document"],
|
||||
["a script", "<svg><script /></svg>", "script elements are not allowed"],
|
||||
["a foreign object", "<svg><foreignObject /></svg>", "foreignObject elements are not allowed"],
|
||||
["a style", "<svg><style /></svg>", "style elements are not allowed"],
|
||||
[
|
||||
"an event handler",
|
||||
'<svg><path onclick="run()" /></svg>',
|
||||
"event-handler attributes are not allowed",
|
||||
],
|
||||
[
|
||||
"a javascript URL",
|
||||
'<svg><a href="javascript:run()" /></svg>',
|
||||
"javascript URLs are not allowed",
|
||||
],
|
||||
[
|
||||
"an external href",
|
||||
'<svg><use href="https://example.com/icon.svg" /></svg>',
|
||||
"external href references are not allowed",
|
||||
],
|
||||
[
|
||||
"an external xlink:href",
|
||||
"<svg><image xlink:href=icon.png /></svg>",
|
||||
"external href references are not allowed",
|
||||
],
|
||||
["markup over 64 KiB", `<svg>${" ".repeat(64 * 1024)}</svg>`, "exceeds 64 KiB"],
|
||||
])("rejects %s", (_name, svg, reason) => {
|
||||
expect(findSvgIconProblem(svg)).toBe(reason);
|
||||
});
|
||||
|
||||
it("measures the size in UTF-8 bytes", () => {
|
||||
const multibyte = "é".repeat(32 * 1024);
|
||||
|
||||
expect(findSvgIconProblem(`<svg>${multibyte}</svg>`)).toBe("exceeds 64 KiB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/** What a component icon receives from the surface that draws it. */
|
||||
export interface PluginIconProps {
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Inline SVG markup. Paseo validates it when the contribution registers. */
|
||||
export interface PluginSvgIcon {
|
||||
svg: string;
|
||||
}
|
||||
|
||||
/** An icon that can be stored or sent: a Lucide icon name, or inline SVG. */
|
||||
export type PluginSerializableIcon = string | PluginSvgIcon;
|
||||
|
||||
export const PLUGIN_SVG_ICON_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const EXTERNAL_HREF = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
|
||||
|
||||
/**
|
||||
* Why Paseo refuses to render this SVG, or null when it is a self-contained document.
|
||||
* The daemon applies it to provider and usage-source icon files; the app to inline plugin icons.
|
||||
*/
|
||||
export function findSvgIconProblem(svg: string): string | null {
|
||||
if (new TextEncoder().encode(svg).byteLength > PLUGIN_SVG_ICON_MAX_BYTES) {
|
||||
return "exceeds 64 KiB";
|
||||
}
|
||||
if (!/^\s*<svg(?:\s|>)/i.test(svg)) return "not an SVG document";
|
||||
if (/<script(?:\s|>)/i.test(svg)) return "script elements are not allowed";
|
||||
if (/<foreignObject(?:\s|>)/i.test(svg)) return "foreignObject elements are not allowed";
|
||||
if (/<style(?:\s|>)/i.test(svg)) return "style elements are not allowed";
|
||||
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) return "event-handler attributes are not allowed";
|
||||
if (/javascript\s*:/i.test(svg)) return "javascript URLs are not allowed";
|
||||
for (const match of svg.matchAll(EXTERNAL_HREF)) {
|
||||
const href = match[2] ?? match[3] ?? "";
|
||||
if (!href.startsWith("#")) return "external href references are not allowed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -20,3 +20,10 @@ export {
|
||||
type PluginAttachmentSearchPayload,
|
||||
} from "./attachments.js";
|
||||
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
|
||||
export {
|
||||
findSvgIconProblem,
|
||||
PLUGIN_SVG_ICON_MAX_BYTES,
|
||||
type PluginIconProps,
|
||||
type PluginSerializableIcon,
|
||||
type PluginSvgIcon,
|
||||
} from "./icons.js";
|
||||
|
||||
@@ -43,6 +43,7 @@ export const CLIENT_CAPS = {
|
||||
timelineNotifications: "timeline_notifications",
|
||||
// COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
||||
pluginTimelineItems: "plugin_timeline_items",
|
||||
workspaceSetupObservation: "workspace_setup_observation",
|
||||
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
||||
workspaceSetupBlocked: "workspace_setup_blocked",
|
||||
browserHost: "browser_host",
|
||||
|
||||
@@ -2556,6 +2556,12 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupSubscribeRequestSchema = z.object({
|
||||
type: z.literal("workspace.setup.subscribe.request"),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupRunRequestSchema = z.object({
|
||||
type: z.literal("workspace.setup.run.request"),
|
||||
workspaceId: z.string(),
|
||||
@@ -3335,6 +3341,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
WorkspaceSetupStatusRequestSchema,
|
||||
WorkspaceSetupSubscribeRequestSchema,
|
||||
WorkspaceSetupRunRequestSchema,
|
||||
LegacyListAvailableEditorsRequestSchema,
|
||||
LegacyOpenInEditorRequestSchema,
|
||||
@@ -3725,6 +3732,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
selectiveAgentTimeline: z.boolean().optional(),
|
||||
explicitEventSubscriptions: z.boolean().optional(),
|
||||
ownedSubscriptions: z.boolean().optional(),
|
||||
workspaceSetupObservation: z.boolean().optional(),
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30.
|
||||
canonicalSubmittedPrompts: z.boolean().optional(),
|
||||
agentMessageProvenance: z.boolean().optional(),
|
||||
@@ -4035,6 +4043,8 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
projectCustomIconRevision: z.string().nullable().optional(),
|
||||
projectRootPath: z.string(),
|
||||
workspaceDirectory: z.string().optional(),
|
||||
// COMPAT(workspaceSetupObservation): added in v0.12.0, remove optional after 2027-04-19.
|
||||
setupStatus: z.enum(["running", "completed", "failed", "blocked"]).nullable().optional(),
|
||||
// COMPAT(worktreeSlug): added in v0.2.6, remove optional after 2027-01-31.
|
||||
// Present only for Paseo-owned worktrees; this is the basename of their root directory.
|
||||
worktreeSlug: z.string().optional(),
|
||||
@@ -4422,6 +4432,16 @@ export const WorkspaceSetupSnapshotSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupSubscribeResponseSchema = z.object({
|
||||
type: z.literal("workspace.setup.subscribe.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
subscriptionId: z.string().optional(),
|
||||
workspaceId: z.string(),
|
||||
snapshot: WorkspaceSetupSnapshotSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupRunResponseMessageSchema = z.object({
|
||||
type: z.literal("workspace.setup.run.response"),
|
||||
payload: z.object({
|
||||
@@ -6909,6 +6929,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ScriptStatusUpdateMessageSchema,
|
||||
WorkspaceSetupProgressMessageSchema,
|
||||
WorkspaceSetupStatusResponseMessageSchema,
|
||||
WorkspaceSetupSubscribeResponseSchema,
|
||||
WorkspaceSetupRunResponseMessageSchema,
|
||||
AgentStreamMessageSchema,
|
||||
AgentStatusMessageSchema,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { test, expect } from "vitest";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerClient } from "./app-server-transport.js";
|
||||
|
||||
test("a provider that closes stdin rejects initialization without an uncaught stream error", async () => {
|
||||
const child = spawn("/bin/sh", ["-c", "exec 0<&-; printf READY; sleep 0.2; exit 86"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
const ready = once(child.stdout, "data");
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
try {
|
||||
await ready;
|
||||
await expect(client.request("initialize", {})).rejects.toThrow(/86/);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"provenance": {
|
||||
"package": "@gotgenes/pi-subagents",
|
||||
"version": "23.4.0",
|
||||
"sourceCommit": "63476ff869efd8ea339040f496fa734048b89e70",
|
||||
"piVersion": "0.84.4",
|
||||
"captureDate": "2026-10-19"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "tool_execution_start",
|
||||
"toolCallId": "resume-call",
|
||||
"toolName": "subagent",
|
||||
"args": {
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "resume",
|
||||
"prompt": "Continue",
|
||||
"resume": "expired-child",
|
||||
"run_in_background": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_execution_end",
|
||||
"toolCallId": "resume-call",
|
||||
"toolName": "subagent",
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Agent \"expired-child\" had its session released after its retention window; resume is unavailable, but its result is still retrievable via get_subagent_result."
|
||||
}
|
||||
]
|
||||
},
|
||||
"isError": false
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "toolCall",
|
||||
"id": "resume-call",
|
||||
"name": "subagent",
|
||||
"arguments": {
|
||||
"subagent_type": "general-purpose",
|
||||
"description": "resume",
|
||||
"prompt": "Continue",
|
||||
"resume": "expired-child",
|
||||
"run_in_background": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "toolResult",
|
||||
"toolCallId": "resume-call",
|
||||
"toolName": "subagent",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Agent \"expired-child\" had its session released after its retention window; resume is unavailable, but its result is still retrievable via get_subagent_result."
|
||||
}
|
||||
],
|
||||
"isError": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+26
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ProviderSubagentStore } from "../../../../provider-subagents/store.js";
|
||||
import { createPiExtensionHost } from "../index.js";
|
||||
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
|
||||
import { streamPiHistory } from "../../history-mapper.js";
|
||||
@@ -25,6 +26,31 @@ function publishService(agents: Array<{ id: string; outputFile?: string }>): voi
|
||||
}
|
||||
|
||||
describe("@gotgenes/pi-subagents adapter", () => {
|
||||
test.each([true, false])(
|
||||
"removes a refused resume from live and replayed subagents (background=%s)",
|
||||
async (background) => {
|
||||
// The result came from 23.4.0's real AgentTool and SubagentManager after releaseSession().
|
||||
// The child session was a fake; releaseSession replaced waiting for the retention sweep.
|
||||
const fixture = readSubagentFixture(
|
||||
new URL("./fixtures/refused-resume.json", import.meta.url),
|
||||
);
|
||||
const start = fixture.events[0]!;
|
||||
if (start.type !== "tool_execution_start") throw new Error("Expected a tool start");
|
||||
start.args = { ...(start.args as object), run_in_background: background };
|
||||
const assistant = fixture.messages[0]!;
|
||||
if (assistant.role !== "assistant") throw new Error("Expected an assistant tool call");
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "toolCall") block.arguments = start.args;
|
||||
}
|
||||
const events = await verifySubagentFixture(fixture);
|
||||
const store = new ProviderSubagentStore();
|
||||
store.apply("parent", "pi", { type: "upsert", id: "previous-child", status: "completed" });
|
||||
for (const event of events) store.apply("parent", "pi", event.event);
|
||||
expect(events.map(({ event }) => event.type)).toEqual(["upsert", "remove"]);
|
||||
expect(store.list("parent")).toMatchObject([{ id: "previous-child", status: "completed" }]);
|
||||
},
|
||||
);
|
||||
|
||||
test("reports a child's file after the child republishes the gotgenes service", async () => {
|
||||
const notifications: string[] = [];
|
||||
const emit = loadRuntimeBridge();
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
detail,
|
||||
subagents: [{ type: "upsert" as const, id: call.callId, status: "failed" as const }],
|
||||
}
|
||||
: { detail };
|
||||
: { detail, subagents: [{ type: "remove" as const, id: call.callId }] };
|
||||
callsByAgent.set(details.data.agentId, call.callId);
|
||||
const file =
|
||||
outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId);
|
||||
|
||||
@@ -210,6 +210,7 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.script.stop.request": "workspace.write",
|
||||
"workspace.title.set.request": ["workspace.manage", "hub.execute"],
|
||||
workspace_setup_status_request: "workspace.read",
|
||||
"workspace.setup.subscribe.request": "workspace.read",
|
||||
"workspace.setup.run.request": "workspace.write",
|
||||
write_project_config_request: "workspace.write",
|
||||
} as const satisfies Record<InboundOperation, PermissionRequirement>;
|
||||
@@ -436,6 +437,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.title.set.response": ["workspace.manage", "hub.execute"],
|
||||
workspace_setup_progress: "workspace.read",
|
||||
workspace_setup_status_response: "workspace.read",
|
||||
"workspace.setup.subscribe.response": "workspace.read",
|
||||
"workspace.setup.run.response": "workspace.write",
|
||||
workspace_update: ["workspace.read", "hub.execute"],
|
||||
write_project_config_response: "workspace.write",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user