mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3fd36e2e9 | ||
|
|
23debf3af1 | ||
|
|
25eb322ef7 | ||
|
|
deb846eed6 | ||
|
|
a1ed5a8bad | ||
|
|
3d69b0f458 | ||
|
|
51a435d3cf | ||
|
|
fb67d2cdc8 | ||
|
|
fe803f6e70 | ||
|
|
a501e1e051 | ||
|
|
1fc5250544 |
@@ -218,3 +218,8 @@ are stale, run `npm run build:server`.
|
||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
||||
until GitLab reports a terminal status.
|
||||
- GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained
|
||||
targets per host, batch their reads, and stop until GitHub's reset time when
|
||||
the reserve is exhausted. Never add a per-target GitHub request to the poll
|
||||
path. Resolve fork PRs through their parent repository without abandoning the
|
||||
shared batch.
|
||||
|
||||
+54
-8
@@ -1,7 +1,7 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||
Paseo does not sandbox it.
|
||||
|
||||
@@ -121,17 +121,20 @@ code lives behind filename boundaries:
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks and the
|
||||
host-provided `Icon` component from `@getpaseo/plugin`. `Icon` resolves a Lucide name using the
|
||||
client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/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.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`.
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | host UI components, hooks, and UI types |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
|
||||
The compiler removes client registrations and imports from the server entry point, and server
|
||||
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||
@@ -202,6 +205,49 @@ They use typed plugin RPC only for plugin-specific backend work. Navigation is l
|
||||
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
|
||||
workspace-layout store access.
|
||||
|
||||
## Contribute composer pills
|
||||
|
||||
Register a headless client entrypoint, then add and remove targeted pills from that client
|
||||
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
|
||||
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
|
||||
client state without mounting a panel or surface.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
|
||||
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
the host, or unloading the app tears down the contribution.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-og2UgicET2crFwEazRlBA4Av/IPWR7wtyP8CIqTLCcE=
|
||||
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// The plan card has to pass its own markdown parser. Drop that prop and
|
||||
// react-native-markdown-display silently supplies one with `typographer: true`
|
||||
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
|
||||
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
|
||||
// No unit test can catch that, because the defect lives in the JSX.
|
||||
//
|
||||
// Nothing here is provider-specific: the mock provider drives it, and the
|
||||
// behavior is the same for every agent.
|
||||
test.describe("Plan card markdown", () => {
|
||||
test("renders plan text verbatim", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "plan-card-markdown-",
|
||||
title: "Plan card markdown e2e",
|
||||
initialPrompt: "Emit synthetic plan approval.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard).toContainText("(c)");
|
||||
await expect(planCard).toContainText('--name="my repo"');
|
||||
await expect(planCard).toContainText("---buzz");
|
||||
await expect(planCard).not.toContainText("©");
|
||||
await expect(planCard).not.toContainText("—buzz");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
function ModalBody({ onSaved }) {
|
||||
usePaseo();
|
||||
useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
function save() {
|
||||
toast.show("Issue saved", { variant: "success" });
|
||||
onSaved();
|
||||
}
|
||||
|
||||
return <View>
|
||||
<Text>Plugin modal contexts ready</Text>
|
||||
<Pressable accessibilityRole="button" onPress={save}>
|
||||
<Text>Save issue</Text>
|
||||
</Pressable>
|
||||
</View>;
|
||||
}
|
||||
|
||||
function Surface() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <View>
|
||||
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<Icon name="Pencil" size={18} />
|
||||
<Text>Open plugin modal</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Modal
|
||||
title="Edit plugin issue"
|
||||
icon={<Icon name="Pencil" size={18} />}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Modal.Content>
|
||||
<ModalBody onSaved={() => setOpen(false)} />
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</View>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Host UI",
|
||||
icon: "PanelsTopLeft",
|
||||
surface: "main",
|
||||
});
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function openHostUiPlugin(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function useNonCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
}
|
||||
|
||||
async function useCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
|
||||
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openPluginModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectCenteredPluginDialog(page: Page): Promise<void> {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeCenteredPluginDialog(page: Page): Promise<void> {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactPluginSheet(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function savePluginIssue(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save issue", exact: true }).click();
|
||||
await expect(page.getByText("Issue saved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("plugin modal adapts its presentation and preserves host contexts", async ({ page }) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await useNonCompactLayout(page);
|
||||
await openHostUiPlugin(page);
|
||||
|
||||
await test.step("non-compact layouts use a centered dialog", async () => {
|
||||
await openPluginModal(page);
|
||||
await expectCenteredPluginDialog(page);
|
||||
await closeCenteredPluginDialog(page);
|
||||
});
|
||||
|
||||
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
|
||||
await useCompactLayout(page);
|
||||
await reopenHostUiPluginFromCompactSidebar(page);
|
||||
await openPluginModal(page);
|
||||
await expectCompactPluginSheet(page);
|
||||
await savePluginIssue(page);
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { buildAgentRoute } from "../support/helpers/mock-agent";
|
||||
@@ -31,7 +32,15 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
function pluginSource(): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const recordComposerOpen = defineRpc({
|
||||
name: "composer.open",
|
||||
input: z.object({ workspaceId: z.string() }),
|
||||
output: z.object({ opened: z.boolean() }),
|
||||
});
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
||||
@@ -54,7 +63,56 @@ function SidebarCollisionSurface() {
|
||||
return <View><Text>Sidebar collision surface</Text></View>;
|
||||
}
|
||||
|
||||
function ComposerPill({ theme, workspaceId, agentId }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
|
||||
const agent = useAgent(agentId, (value) => ({ title: value.title }));
|
||||
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
|
||||
}
|
||||
|
||||
function contributeClient(client) {
|
||||
const pills = new Map();
|
||||
const remove = (agentId) => {
|
||||
pills.get(agentId)?.();
|
||||
pills.delete(agentId);
|
||||
};
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind === "remove") {
|
||||
remove(update.agentId);
|
||||
return;
|
||||
}
|
||||
const agent = update.agent;
|
||||
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
|
||||
remove(agent.id);
|
||||
let removePill = () => {};
|
||||
removePill = client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open composer review",
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
Component: ComposerPill,
|
||||
async onPress() {
|
||||
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
|
||||
removePill();
|
||||
client.openPanel("agent", {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, removePill);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const removePill of pills.values()) removePill();
|
||||
pills.clear();
|
||||
};
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
||||
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
|
||||
return { opened: true };
|
||||
});
|
||||
plugin.addSurface("collision", DirectCollisionSurface);
|
||||
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
|
||||
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
||||
@@ -64,6 +122,7 @@ export default function contribute(plugin) {
|
||||
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
||||
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
||||
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
||||
plugin.addClientSide(contributeClient);
|
||||
return () => {};
|
||||
}`;
|
||||
}
|
||||
@@ -207,7 +266,29 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
});
|
||||
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
await submitMessage(page, "emit 1 agent stream updates");
|
||||
await expect(page.getByRole("button", { name: "1/1 tasks" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const composerPill = page.getByRole("button", { name: "Open composer review" });
|
||||
await expect(composerPill).toContainText(
|
||||
"Review Unrelated title update:Plugin panel context agent",
|
||||
);
|
||||
await capture(page, testInfo, "plugin-composer-pill-wide");
|
||||
await page.setViewportSize(COMPACT_VIEWPORT);
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-composer-pill-compact");
|
||||
await page.getByRole("button", { name: "Open composer review" }).click();
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Opened from composer pill",
|
||||
);
|
||||
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
|
||||
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-agent-panel-compact");
|
||||
|
||||
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
||||
await openCompactSidebar(page);
|
||||
await runCommand(page, "Open plugin agent");
|
||||
await closeMobileAgentSidebar(page);
|
||||
@@ -218,7 +299,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-agent-panel-compact");
|
||||
});
|
||||
|
||||
await test.step("removing the active plugin renders the panel unavailable", async () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForWorkspaceInReplicaCache } from "../support/helpers/replica-cache-storage";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectWorkspaceAbsentFromSidebar,
|
||||
selectWorkspaceInSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function archiveWorkspaceOutsideTheApp(workspace: SeededWorkspace): Promise<void> {
|
||||
const result = await workspace.client.archiveWorkspace(workspace.workspaceId);
|
||||
expect(result.error).toBeNull();
|
||||
}
|
||||
|
||||
test.describe("Workspace archive cache coherence", () => {
|
||||
test("an archived selected workspace cannot return from the durable cache", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-cache-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await waitForWorkspaceInReplicaCache(page, workspace.workspaceId);
|
||||
|
||||
await archiveWorkspaceOutsideTheApp(workspace);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const DATABASE_NAME = "paseo-replica-row-store";
|
||||
const STORE_NAME = "rows";
|
||||
@@ -116,6 +116,23 @@ export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord |
|
||||
return rows.length > 0 ? assembleCache(rows) : null;
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceInReplicaCache(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
return cache?.hosts?.some((host) =>
|
||||
host.workspaces.some((workspace) => workspace.id === workspaceId),
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
||||
const rows: ReplicaRowRecord[] = [];
|
||||
for (const host of value.hosts ?? []) {
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface SeedDaemonClient {
|
||||
workspace: SeedWorkspaceDescriptor | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
archiveWorkspace(workspaceId: string): Promise<{ error: string | null }>;
|
||||
/**
|
||||
* Force the daemon to recompute its git snapshot and diff for a checkout,
|
||||
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
|
||||
|
||||
@@ -56,6 +56,7 @@ import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useCommandCenterContributions } from "./provider";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
@@ -121,10 +122,6 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
|
||||
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||
}
|
||||
|
||||
function workspaceSearchFields(result: CommandCenterWorkspaceResult): CommandCenterSearchFields {
|
||||
return { visible: [result.title, result.subtitle], hidden: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
|
||||
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
|
||||
@@ -158,6 +155,7 @@ function useBuiltInRows(open: boolean): {
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||
@@ -208,12 +206,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: t("shell.commandCenter.workspaces"),
|
||||
results: filterAndRankBuiltInResults(
|
||||
rows.workspaces,
|
||||
query,
|
||||
workspaceSearchFields,
|
||||
compareWorkspacesByTitle,
|
||||
),
|
||||
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
|
||||
@@ -102,7 +102,14 @@ function autoModeChoice(): CommandCenterContribution {
|
||||
}
|
||||
|
||||
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
|
||||
return { kind: "workspace", id, title, subtitle, run: () => undefined };
|
||||
return {
|
||||
kind: "workspace",
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
changeRequestNumber: null,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface CommandCenterWorkspaceResult {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** PR/MR number backing this workspace, so a number query can match it exactly. */
|
||||
changeRequestNumber: number | null;
|
||||
run(): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterWorkspaceResult } from "./results";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
changeRequestNumber?: number | null;
|
||||
}): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
id: input.id,
|
||||
title: input.title ?? input.id,
|
||||
subtitle: input.subtitle ?? "",
|
||||
changeRequestNumber: input.changeRequestNumber ?? null,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
|
||||
return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map(
|
||||
(row) => row.id,
|
||||
);
|
||||
}
|
||||
|
||||
describe("filterAndRankWorkspaces", () => {
|
||||
const subject = workspace({
|
||||
id: "payments",
|
||||
title: "Payments refactor",
|
||||
subtitle: "Primary host · feature/checkout",
|
||||
changeRequestNumber: 42,
|
||||
});
|
||||
|
||||
it("matches everything when the query is empty", () => {
|
||||
expect(ids([subject], " ")).toEqual(["payments"]);
|
||||
});
|
||||
|
||||
it("matches its own change-request number in every spelling", () => {
|
||||
const spellings = ["42", " 42 ", "#42", "!42", "pr 42", "mr 42", "PR #42", "mr!42", "pr42"];
|
||||
for (const query of spellings) {
|
||||
expect(ids([subject], query)).toEqual(["payments"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not take the number path for text that merely contains digits", () => {
|
||||
const numbered = workspace({ id: "unrelated workspace", changeRequestNumber: 42 });
|
||||
for (const query of ["fix-42-retries", "42x", "v4.2", "pr"]) {
|
||||
expect(ids([numbered], query)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match a different change-request number that shares digits", () => {
|
||||
const other = workspace({ id: "unrelated", changeRequestNumber: 142 });
|
||||
expect(ids([other], "42")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still matches on title and branch text", () => {
|
||||
expect(ids([subject], "checkout")).toEqual(["payments"]);
|
||||
expect(ids([subject], "payments")).toEqual(["payments"]);
|
||||
});
|
||||
|
||||
it("leaves a workspace without a change request unaffected", () => {
|
||||
const noPr = workspace({ id: "docs workspace" });
|
||||
expect(ids([noPr], "42")).toEqual([]);
|
||||
expect(ids([noPr], "docs")).toEqual(["docs workspace"]);
|
||||
});
|
||||
|
||||
it("falls back to text matching when the number does not match", () => {
|
||||
const textual = workspace({
|
||||
id: "text",
|
||||
subtitle: "branch fix-42-retries",
|
||||
changeRequestNumber: 7,
|
||||
});
|
||||
expect(ids([textual], "42")).toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("ranks an exact change request above an ordinary text match", () => {
|
||||
const text = workspace({ id: "a-text", title: "Migration 42" });
|
||||
expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
compareCommandCenterScores,
|
||||
filterAndRankBuiltInResults,
|
||||
scoreSearchFields,
|
||||
type CommandCenterScore,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
|
||||
/** A change-request query: an optional `pr`/`mr` word, an optional `#`/`!`, a number. */
|
||||
const CHANGE_REQUEST_QUERY = /^(?:(?:pr|mr)\s*)?[#!]?(\d+)$/i;
|
||||
|
||||
/**
|
||||
* Parse a change-request-shaped query into its number.
|
||||
*
|
||||
* Accepts `42`, `#42`, `!42`, `pr 42`, `pr42`, `PR #42`, `mr!42`, and so on.
|
||||
* Returns null for anything else, including text queries that merely contain
|
||||
* digits (`fix-42-retries`), so those fall through to normal text matching.
|
||||
*/
|
||||
function parseChangeRequestQuery(query: string): number | null {
|
||||
const match = CHANGE_REQUEST_QUERY.exec(query.trim());
|
||||
if (!match) return null;
|
||||
const number = Number.parseInt(match[1], 10);
|
||||
return Number.isSafeInteger(number) ? number : null;
|
||||
}
|
||||
|
||||
interface ScoredWorkspace {
|
||||
workspace: CommandCenterWorkspaceResult;
|
||||
score: CommandCenterScore | null;
|
||||
changeRequestHit: boolean;
|
||||
}
|
||||
|
||||
function searchFields(workspace: CommandCenterWorkspaceResult) {
|
||||
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
|
||||
}
|
||||
|
||||
function compareScoredWorkspaces(
|
||||
left: ScoredWorkspace,
|
||||
right: ScoredWorkspace,
|
||||
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
|
||||
): number {
|
||||
if (left.changeRequestHit !== right.changeRequestHit) {
|
||||
return left.changeRequestHit ? -1 : 1;
|
||||
}
|
||||
if (left.score && right.score) {
|
||||
const scoreDelta = compareCommandCenterScores(left.score, right.score);
|
||||
if (scoreDelta !== 0) return scoreDelta;
|
||||
}
|
||||
return tiebreak(left.workspace, right.workspace);
|
||||
}
|
||||
|
||||
export function filterAndRankWorkspaces(
|
||||
workspaces: readonly CommandCenterWorkspaceResult[],
|
||||
query: string,
|
||||
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
|
||||
): CommandCenterWorkspaceResult[] {
|
||||
if (!query.trim()) {
|
||||
return filterAndRankBuiltInResults(workspaces, query, searchFields, tiebreak);
|
||||
}
|
||||
|
||||
const changeRequestNumber = parseChangeRequestQuery(query);
|
||||
const matches: ScoredWorkspace[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
const score = scoreSearchFields(query, searchFields(workspace));
|
||||
const changeRequestHit =
|
||||
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
|
||||
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
|
||||
}
|
||||
matches.sort((left, right) => compareScoredWorkspaces(left, right, tiebreak));
|
||||
return matches.map((match) => match.workspace);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
type ContextBridge,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
@@ -450,6 +451,8 @@ export interface AdaptiveModalSheetProps {
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
sizeContentToCurrentSnapPoint?: boolean;
|
||||
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -467,6 +470,7 @@ export function AdaptiveModalSheet({
|
||||
presentation,
|
||||
contentStyle,
|
||||
sizeContentToCurrentSnapPoint = false,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -634,7 +638,7 @@ export function AdaptiveModalSheet({
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
contextBridge={contextBridge}
|
||||
snapPoints={resolvedSnapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-t
|
||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -65,7 +66,9 @@ function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRe
|
||||
return { style: createCompactMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
const defaultMarkdownParser = MarkdownIt({ typographer: true, linkify: true });
|
||||
// Serves PR comment bodies and the markdown file preview; agent chat passes its
|
||||
// own parser. The preview has to show the bytes on disk, so no typographer.
|
||||
const defaultMarkdownParser = createMarkdownParser({ linkify: true });
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
export interface MarkdownRendererProps {
|
||||
|
||||
@@ -5,6 +5,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
|
||||
// Without this prop react-native-markdown-display builds its own parser with
|
||||
// `typographer: true`, which would render a plan's literal `(c)` as ©. Its
|
||||
// default also leaves linkify off, so this one keeps bare URLs as plain text.
|
||||
const planMarkdownParser = createMarkdownParser({ linkify: false });
|
||||
|
||||
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
@@ -219,7 +225,7 @@ export function PlanCard({
|
||||
<View testID={testID} style={containerStyle}>
|
||||
<Text style={titleStyle}>{resolvedTitle}</Text>
|
||||
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
<Markdown style={markdownStyles} rules={markdownRules} markdownit={planMarkdownParser}>
|
||||
{text}
|
||||
</Markdown>
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
|
||||
@@ -19,12 +19,12 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
|
||||
|
||||
/**
|
||||
* The strip of pills where a pane's ambient trackers live — subagents and tasks today.
|
||||
* The strip of pills where a pane's ambient trackers and plugin actions live.
|
||||
*
|
||||
* Everything in it is a pill: a count you can read without opening anything, and a panel behind
|
||||
* it for the detail. Trackers used to be stacked cards, so every one of them pushed the composer
|
||||
* further down the pane. The bar has at most one subagent pill and one task pill, so it remains
|
||||
* one line and has deterministic geometry on every platform.
|
||||
* Trackers expose a count and a detail panel; plugin actions expose their own icon and text.
|
||||
* Trackers used to be stacked cards, so every one of them pushed the composer further down the
|
||||
* pane. Built-in and contributed pills share this one-line rail and its deterministic geometry
|
||||
* on every platform.
|
||||
*
|
||||
* The bar floats over the transcript with no background, so content remains visible underneath.
|
||||
* Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const value = useContext(ToastContext);
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ToastApiProvider({ api, children }: { api: ToastApi; children: ReactNode }) {
|
||||
return <ToastContext.Provider value={api}>{children}</ToastContext.Provider>;
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host";
|
||||
import type { ReactNode } from "react";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { ToastApiProvider } from "./toast-api-context";
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const value = useContext(ToastContext);
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export { ToastApiProvider, useToast } from "./toast-api-context";
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const { api, toast, dismiss } = useToastHost();
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
<ToastApiProvider api={api}>
|
||||
{children}
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
</ToastApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useHasPluginComposerPills } from "@/plugins";
|
||||
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
@@ -1288,11 +1289,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
hasPluginComposerPills,
|
||||
});
|
||||
const rawAgentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
@@ -1379,11 +1382,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
<AgentTracks
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
hasPluginComposerPills={hasPluginComposerPills}
|
||||
/>
|
||||
) : null}
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ComposerTrackBar } from "@/composer/tracks";
|
||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { PluginComposerPills } from "@/plugins";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
type ArchiveFinishedStatus,
|
||||
@@ -30,19 +31,23 @@ import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
cwd,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
onArchiveFinished,
|
||||
hasPluginComposerPills,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
onArchiveFinished: () => void;
|
||||
hasPluginComposerPills: boolean;
|
||||
}): ReactElement | null {
|
||||
const { tabId, openTab } = usePaneContext();
|
||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||
@@ -107,7 +112,15 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
}, [cwd, isCompact, openInSidePane, serverId, workspaceKey]);
|
||||
|
||||
if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -123,6 +136,12 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
archiveFinishedStatus={archiveFinishedStatus}
|
||||
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
||||
/>
|
||||
<PluginComposerPills
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
compact={isCompact}
|
||||
/>
|
||||
<WorkspaceDiffStatPill
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
@@ -136,10 +155,17 @@ export function hasAgentTracks({
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return subagentRows.length > 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle";
|
||||
return (
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
hasPluginComposerPills
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { callPluginRpc } from "@getpaseo/plugin/host";
|
||||
import type {
|
||||
PluginAgentCommandContext,
|
||||
PluginCommandCapabilities,
|
||||
PluginPanelLocation,
|
||||
PluginWorkspaceCommandContext,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
|
||||
import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations";
|
||||
import type { PluginSurfaceRuntime } from "./surface-runtime";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
export interface PluginNavigation {
|
||||
openSurface(pluginId: string, surfaceId: string): void;
|
||||
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
|
||||
openAgentPanel(
|
||||
pluginId: string,
|
||||
panelId: string,
|
||||
agentId: string,
|
||||
location: PluginPanelLocation,
|
||||
): void;
|
||||
}
|
||||
|
||||
export function createPluginCapabilities(
|
||||
plugin: InstalledPlugin,
|
||||
runtime: PluginSurfaceRuntime,
|
||||
navigation: PluginNavigation,
|
||||
): PluginCommandCapabilities {
|
||||
return {
|
||||
paseo: runtime.paseo,
|
||||
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
|
||||
openSurface(surfaceId) {
|
||||
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
|
||||
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
|
||||
}
|
||||
navigation.openSurface(plugin.id, surfaceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginAgentActionContext(input: {
|
||||
plugin: InstalledPlugin;
|
||||
runtime: PluginSurfaceRuntime;
|
||||
navigation: PluginNavigation;
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}): PluginAgentCommandContext | null {
|
||||
const { plugin, runtime, navigation, state, workspaceId, agentId } = input;
|
||||
const workspace = state.getWorkspace(workspaceId);
|
||||
const agent = state.getAgent(agentId);
|
||||
if (!workspace || !agent || agent.workspaceId !== workspace.id) return null;
|
||||
return {
|
||||
context: "agent",
|
||||
...createPluginCapabilities(plugin, runtime, navigation),
|
||||
workspace,
|
||||
agent,
|
||||
openPanel(panelId, options) {
|
||||
const panel = plugin.workspacePanels.find((candidate) => candidate.id === panelId);
|
||||
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
|
||||
const location = resolvePluginPanelOpenLocation(panel, options?.location);
|
||||
if (panel.context === "workspace") {
|
||||
navigation.openWorkspacePanel(plugin.id, panelId, location);
|
||||
return;
|
||||
}
|
||||
navigation.openAgentPanel(plugin.id, panelId, agent.id, location);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginWorkspaceActionContext(input: {
|
||||
plugin: InstalledPlugin;
|
||||
runtime: PluginSurfaceRuntime;
|
||||
navigation: PluginNavigation;
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string;
|
||||
}): PluginWorkspaceCommandContext | null {
|
||||
const { plugin, runtime, navigation, state, workspaceId } = input;
|
||||
const workspace = state.getWorkspace(workspaceId);
|
||||
if (!workspace) return null;
|
||||
return {
|
||||
context: "workspace",
|
||||
...createPluginCapabilities(plugin, runtime, navigation),
|
||||
workspace,
|
||||
openPanel(panelId, options) {
|
||||
const panel = plugin.workspacePanels.find(
|
||||
(candidate) => candidate.id === panelId && candidate.context === "workspace",
|
||||
);
|
||||
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
|
||||
const location = resolvePluginPanelOpenLocation(panel, options?.location);
|
||||
navigation.openWorkspacePanel(plugin.id, panelId, location);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
const connection = vi.hoisted(() => ({ connected: true, supported: true }));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeIsConnected: () => connection.connected,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: () => connection.supported,
|
||||
}));
|
||||
|
||||
import { PluginCatalogSync } from "./catalog-sync";
|
||||
import { pluginRegistry } from "./registry";
|
||||
|
||||
function bundle(): string {
|
||||
return `(function() { return { default: function(plugin) {
|
||||
plugin.addSurface("main", function() { return null; });
|
||||
return function() { globalThis.__catalogSyncCleanups = (globalThis.__catalogSyncCleanups || 0) + 1; };
|
||||
} }; })`;
|
||||
}
|
||||
|
||||
const unsubscribe = vi.fn();
|
||||
const catalogClient = {
|
||||
getPluginCatalog: vi.fn(async () => [{ id: "example", clientBundle: bundle() }]),
|
||||
on: vi.fn(() => unsubscribe),
|
||||
} as unknown as DaemonClient;
|
||||
|
||||
describe("PluginCatalogSync", () => {
|
||||
let container: HTMLElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
connection.connected = true;
|
||||
connection.supported = true;
|
||||
unsubscribe.mockReset();
|
||||
vi.mocked(catalogClient.getPluginCatalog).mockClear();
|
||||
vi.mocked(catalogClient.on).mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
pluginRegistry.removeHost("host-a");
|
||||
container.remove();
|
||||
Reflect.deleteProperty(globalThis, "__catalogSyncCleanups");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderSync(): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<PluginCatalogSync serverId="host-a" client={catalogClient} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("tears down through disconnect and unmount boundaries exactly once each", async () => {
|
||||
await renderSync();
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
expect(first?.id).toBe("example");
|
||||
first?.queryClient.setQueryData(["owned"], "state");
|
||||
|
||||
connection.connected = false;
|
||||
await renderSync();
|
||||
|
||||
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(1);
|
||||
expect(first?.queryClient.getQueryCache().getAll()).toEqual([]);
|
||||
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
||||
|
||||
connection.connected = true;
|
||||
await renderSync();
|
||||
expect(pluginRegistry.getSnapshot()).toHaveLength(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(2);
|
||||
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ export function PluginCatalogSync({
|
||||
if (!cancelled) {
|
||||
const timelineChanged = pluginRegistry.installCatalog(serverId, catalog, {
|
||||
replacePluginId,
|
||||
client,
|
||||
});
|
||||
if (timelineChanged) {
|
||||
useSessionStore
|
||||
|
||||
@@ -97,6 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
onSelect: onAgentSelect,
|
||||
},
|
||||
],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { callPluginRpc } from "@getpaseo/plugin/host";
|
||||
import type { PluginCommandCapabilities, PluginPanelLocation } from "@getpaseo/plugin";
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
|
||||
import type { CommandCenterContribution } from "@/command-center/contributions";
|
||||
import { getCommandCenterIcon } from "@/command-center/icon";
|
||||
@@ -7,17 +5,7 @@ import { resolvePluginIcon } from "../icons";
|
||||
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
|
||||
import type { PluginSurfaceRuntime } from "../surface-runtime";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
export interface PluginCommandCenterNavigation {
|
||||
openSurface(pluginId: string, surfaceId: string): void;
|
||||
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
|
||||
openAgentPanel(
|
||||
pluginId: string,
|
||||
panelId: string,
|
||||
agentId: string,
|
||||
location: PluginPanelLocation,
|
||||
): void;
|
||||
}
|
||||
import { createPluginCapabilities, type PluginNavigation } from "../actions";
|
||||
|
||||
export interface PluginCommandCenterSource {
|
||||
plugins: readonly InstalledPlugin[];
|
||||
@@ -25,34 +13,17 @@ export interface PluginCommandCenterSource {
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string | null;
|
||||
agentId: string | null;
|
||||
navigation: PluginCommandCenterNavigation;
|
||||
navigation: PluginNavigation;
|
||||
reportError(error: unknown): void;
|
||||
}
|
||||
|
||||
function capabilities(
|
||||
plugin: InstalledPlugin,
|
||||
runtime: PluginSurfaceRuntime,
|
||||
navigation: PluginCommandCenterNavigation,
|
||||
): PluginCommandCapabilities {
|
||||
return {
|
||||
paseo: runtime.paseo,
|
||||
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
|
||||
openSurface(surfaceId) {
|
||||
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
|
||||
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
|
||||
}
|
||||
navigation.openSurface(plugin.id, surfaceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPluginCommandCenterContributions(
|
||||
source: PluginCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
const contributions: CommandCenterContribution[] = [];
|
||||
for (const plugin of source.plugins) {
|
||||
const runtime = source.runtime(plugin.id);
|
||||
const common = capabilities(plugin, runtime, source.navigation);
|
||||
const common = createPluginCapabilities(plugin, runtime, source.navigation);
|
||||
for (const [rank, item] of plugin.commandCenterItems.entries()) {
|
||||
if (item.context === "workspace" && !source.workspaceId) continue;
|
||||
if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue;
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { useCommandCenterActions } from "@/command-center/provider";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useActiveWorkspaceSelection,
|
||||
navigateToWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { PluginPanelLocation } from "@getpaseo/plugin";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
|
||||
import { hostIdFromPathname } from "../routes";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { buildPluginCommandCenterContributions } from "./contributions";
|
||||
import { getFocusedAgentId } from "./context";
|
||||
import { createPluginNavigation } from "../navigation";
|
||||
|
||||
export function PluginCommandCenterActions() {
|
||||
const pathname = usePathname();
|
||||
@@ -64,33 +61,7 @@ export function PluginCommandCenterActions() {
|
||||
state: stateSource,
|
||||
workspaceId: workspaceExists ? workspaceId : null,
|
||||
agentId: agentExists ? focusedAgentId : null,
|
||||
navigation: {
|
||||
openSurface(pluginId, surfaceId) {
|
||||
router.push(
|
||||
buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }),
|
||||
);
|
||||
},
|
||||
openWorkspacePanel(pluginId, panelId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
const placement = resolvePluginPanelPlacement(workspaceKey, location);
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
|
||||
placement,
|
||||
});
|
||||
},
|
||||
openAgentPanel(pluginId, panelId, agentId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
const placement = resolvePluginPanelPlacement(workspaceKey, location);
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
|
||||
placement,
|
||||
});
|
||||
},
|
||||
},
|
||||
navigation: createPluginNavigation({ serverId, workspaceId }),
|
||||
reportError(error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
@@ -105,7 +76,6 @@ export function PluginCommandCenterActions() {
|
||||
toast,
|
||||
workspaceExists,
|
||||
workspaceId,
|
||||
workspaceKey,
|
||||
]);
|
||||
|
||||
useCommandCenterActions({
|
||||
@@ -115,11 +85,3 @@ export function PluginCommandCenterActions() {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePluginPanelPlacement(workspaceKey: string | null, location: PluginPanelLocation) {
|
||||
if (location !== "explorer") return undefined;
|
||||
if (!workspaceKey) throw new Error("No active workspace");
|
||||
const paneId = useWorkspaceLayoutStore.getState().showExplorerSidebar(workspaceKey);
|
||||
if (!paneId) throw new Error("Explorer is unavailable");
|
||||
return { mode: "pane" as const, paneId };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/host";
|
||||
import type { PluginComposerPillProps, PluginTheme } from "@getpaseo/plugin";
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { Platform, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
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 { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import type { InstalledPlugin, PluginComposerPillContribution } from "../types";
|
||||
import { pluginComposerPillStore } from "./store";
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function resolvePlatform(): PluginComposerPillProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
function PluginComposerPill({
|
||||
plugin,
|
||||
contribution,
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
compact,
|
||||
hostLabel,
|
||||
theme,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
contribution: PluginComposerPillContribution;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
compact: boolean;
|
||||
hostLabel: string;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const toast = useToast();
|
||||
const [pending, setPending] = useState(false);
|
||||
const runtime = useMemo(() => createPluginSurfaceRuntime(client, plugin.id), [client, plugin.id]);
|
||||
const state = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
|
||||
const props = useMemo<PluginComposerPillProps>(
|
||||
() => ({
|
||||
theme,
|
||||
host: { id: serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePlatform() },
|
||||
workspaceId,
|
||||
agentId,
|
||||
}),
|
||||
[agentId, compact, hostLabel, serverId, theme, workspaceId],
|
||||
);
|
||||
const press = useCallback(async () => {
|
||||
if (!runtime || pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await contribution.onPress();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [contribution, pending, runtime, toast]);
|
||||
const pillStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
composerPillStyles.body,
|
||||
styles.pill,
|
||||
(hovered || pressed) && composerPillStyles.bodyActive,
|
||||
pending && styles.disabled,
|
||||
],
|
||||
[pending],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ busy: pending, disabled: pending }), [pending]);
|
||||
if (!runtime) return null;
|
||||
const Component = contribution.Component;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Component}>
|
||||
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
|
||||
<PluginClientStateProvider source={state}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={contribution.title}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={pending}
|
||||
onPress={press}
|
||||
style={pillStyle}
|
||||
>
|
||||
<Component {...props} />
|
||||
{pending ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
|
||||
</Pressable>
|
||||
</PluginClientStateProvider>
|
||||
</PluginRuntimeBoundary>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginComposerPill = withUnistyles(PluginComposerPill);
|
||||
|
||||
export function PluginComposerPills({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
compact,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const registrations = useSyncExternalStore(
|
||||
pluginComposerPillStore.subscribe,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
);
|
||||
const hosts = useHosts();
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
registrations.filter(
|
||||
({ installation, contribution }) =>
|
||||
installation.serverId === serverId &&
|
||||
contribution.workspaceId === workspaceId &&
|
||||
contribution.agentId === agentId,
|
||||
),
|
||||
[agentId, registrations, serverId, workspaceId],
|
||||
);
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
return (
|
||||
<>
|
||||
{visible.map(({ installation, contribution }) => (
|
||||
<ThemedPluginComposerPill
|
||||
key={`${installation.id}/${contribution.id}`}
|
||||
plugin={installation}
|
||||
contribution={contribution}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
compact={compact}
|
||||
hostLabel={hostLabel}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHasPluginComposerPills(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): boolean {
|
||||
const registrations = useSyncExternalStore(
|
||||
pluginComposerPillStore.subscribe,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
);
|
||||
return registrations.some(
|
||||
({ installation, contribution }) =>
|
||||
installation.serverId === serverId &&
|
||||
contribution.workspaceId === workspaceId &&
|
||||
contribution.agentId === agentId,
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
pill: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,111 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { startPluginClientSide } from "./lifecycle";
|
||||
import { pluginComposerPillStore } from "./store";
|
||||
|
||||
function Pill(_props: PluginComposerPillProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): InstalledPlugin {
|
||||
return {
|
||||
id: "review",
|
||||
serverId: "host-a",
|
||||
clientBundle: "bundle",
|
||||
queryClient: new QueryClient(),
|
||||
cleanup: () => undefined,
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const daemonClient = {} as DaemonClient;
|
||||
const installations: InstalledPlugin[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const plugin of installations.splice(0)) {
|
||||
pluginComposerPillStore.removeInstallation(plugin);
|
||||
}
|
||||
});
|
||||
|
||||
describe("plugin client-side lifecycle", () => {
|
||||
it("lets client code add and remove a targeted composer pill at runtime", async () => {
|
||||
const captured: { client?: PluginClientContext } = {};
|
||||
let cleanupCount = 0;
|
||||
const plugin = installation((context) => {
|
||||
captured.client = context;
|
||||
return () => {
|
||||
cleanupCount += 1;
|
||||
};
|
||||
});
|
||||
installations.push(plugin);
|
||||
const stop = startPluginClientSide(plugin, daemonClient);
|
||||
const client = captured.client;
|
||||
if (!client) throw new Error("Client lifecycle did not start");
|
||||
|
||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||
const remove = client.addComposerPill({
|
||||
id: "review-ready",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: Pill,
|
||||
onPress() {},
|
||||
});
|
||||
|
||||
expect(
|
||||
pluginComposerPillStore.getSnapshot().map(({ contribution }) => ({
|
||||
id: contribution.id,
|
||||
workspaceId: contribution.workspaceId,
|
||||
agentId: contribution.agentId,
|
||||
})),
|
||||
).toEqual([{ id: "review-ready", workspaceId: "workspace-a", agentId: "agent-a" }]);
|
||||
|
||||
remove();
|
||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||
|
||||
client.addComposerPill({
|
||||
id: "review-ready",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: Pill,
|
||||
onPress() {},
|
||||
});
|
||||
await stop();
|
||||
|
||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||
expect(cleanupCount).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects duplicate pills only within the same plugin target", () => {
|
||||
const plugin = installation(() => () => undefined);
|
||||
installations.push(plugin);
|
||||
const contribution = {
|
||||
id: "review-ready",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: Pill,
|
||||
onPress() {},
|
||||
};
|
||||
|
||||
pluginComposerPillStore.add(plugin, contribution);
|
||||
expect(() => pluginComposerPillStore.add(plugin, contribution)).toThrow(
|
||||
"Duplicate composer pill: review-ready",
|
||||
);
|
||||
expect(() =>
|
||||
pluginComposerPillStore.add(plugin, { ...contribution, agentId: "agent-b" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
PluginCleanup,
|
||||
PluginClientContext,
|
||||
PluginClientOpenPanelOptions,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
createPluginAgentActionContext,
|
||||
createPluginCapabilities,
|
||||
createPluginWorkspaceActionContext,
|
||||
} from "../actions";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { createPluginNavigation } from "../navigation";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { pluginComposerPillStore } from "./store";
|
||||
|
||||
export function startPluginClientSide(
|
||||
installation: InstalledPlugin,
|
||||
daemonClient: DaemonClient,
|
||||
): PluginCleanup {
|
||||
if (!installation.clientSide) return () => undefined;
|
||||
const runtime = createPluginSurfaceRuntime(daemonClient, installation.id);
|
||||
if (!runtime) throw new Error("Plugin host is offline");
|
||||
const state = createPluginClientStateSource(installation.serverId);
|
||||
const capabilities = createPluginCapabilities(
|
||||
installation,
|
||||
runtime,
|
||||
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
|
||||
);
|
||||
const context: PluginClientContext = {
|
||||
...capabilities,
|
||||
addComposerPill(contribution) {
|
||||
return pluginComposerPillStore.add(installation, contribution);
|
||||
},
|
||||
openPanel(panelId, options) {
|
||||
openClientPanel({ installation, runtime, state, panelId, options });
|
||||
},
|
||||
};
|
||||
|
||||
let cleanup: PluginCleanup;
|
||||
try {
|
||||
cleanup = installation.clientSide(context);
|
||||
if (typeof cleanup !== "function") {
|
||||
throw new Error("Plugin client-side entrypoint must return a cleanup function");
|
||||
}
|
||||
} catch (error) {
|
||||
pluginComposerPillStore.removeInstallation(installation);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let stopped = false;
|
||||
return async () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
pluginComposerPillStore.removeInstallation(installation);
|
||||
await cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
function openClientPanel(input: {
|
||||
installation: InstalledPlugin;
|
||||
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
|
||||
state: ReturnType<typeof createPluginClientStateSource>;
|
||||
panelId: string;
|
||||
options: PluginClientOpenPanelOptions;
|
||||
}): void {
|
||||
const { installation, runtime, state, panelId, options } = input;
|
||||
const workspaceId = options.workspaceId.trim();
|
||||
const agentId = options.agentId?.trim();
|
||||
const navigation = createPluginNavigation({ serverId: installation.serverId, workspaceId });
|
||||
const action = agentId
|
||||
? createPluginAgentActionContext({
|
||||
plugin: installation,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
agentId,
|
||||
})
|
||||
: createPluginWorkspaceActionContext({
|
||||
plugin: installation,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
});
|
||||
if (!action) throw new Error("Plugin panel context is unavailable");
|
||||
action.openPanel(panelId, { location: options.location });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { PluginComposerPillContribution } from "@getpaseo/plugin";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export interface RegisteredPluginComposerPill {
|
||||
installation: InstalledPlugin;
|
||||
contribution: PluginComposerPillContribution;
|
||||
}
|
||||
|
||||
class PluginComposerPillStore {
|
||||
private entries: RegisteredPluginComposerPill[] = [];
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): readonly RegisteredPluginComposerPill[] => this.entries;
|
||||
|
||||
add(installation: InstalledPlugin, input: PluginComposerPillContribution): () => void {
|
||||
const contribution = validateContribution(input);
|
||||
const duplicate = this.entries.some(
|
||||
(entry) =>
|
||||
entry.installation === installation &&
|
||||
entry.contribution.workspaceId === contribution.workspaceId &&
|
||||
entry.contribution.agentId === contribution.agentId &&
|
||||
entry.contribution.id === contribution.id,
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new Error(`Duplicate composer pill: ${contribution.id}`);
|
||||
}
|
||||
const entry = { installation, contribution };
|
||||
this.entries = [...this.entries, entry];
|
||||
this.publish();
|
||||
let removed = false;
|
||||
return () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
this.entries = this.entries.filter((candidate) => candidate !== entry);
|
||||
this.publish();
|
||||
};
|
||||
}
|
||||
|
||||
removeInstallation(installation: InstalledPlugin): void {
|
||||
const next = this.entries.filter((entry) => entry.installation !== installation);
|
||||
if (next.length === this.entries.length) return;
|
||||
this.entries = next;
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function validateContribution(
|
||||
contribution: PluginComposerPillContribution,
|
||||
): PluginComposerPillContribution {
|
||||
const id = contribution.id.trim();
|
||||
const title = contribution.title.trim();
|
||||
const workspaceId = contribution.workspaceId.trim();
|
||||
const agentId = contribution.agentId.trim();
|
||||
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid composer pill id: ${contribution.id}`);
|
||||
if (!title) throw new Error(`Composer pill ${id} has no title`);
|
||||
if (!workspaceId) throw new Error(`Composer pill ${id} has no workspace`);
|
||||
if (!agentId) throw new Error(`Composer pill ${id} has no agent`);
|
||||
if (typeof contribution.Component !== "function") {
|
||||
throw new Error(`Composer pill ${id} is not a component`);
|
||||
}
|
||||
if (typeof contribution.onPress !== "function") {
|
||||
throw new Error(`Composer pill ${id} has no callback`);
|
||||
}
|
||||
return { ...contribution, id, title, workspaceId, agentId };
|
||||
}
|
||||
|
||||
export const pluginComposerPillStore = new PluginComposerPillStore();
|
||||
@@ -212,6 +212,28 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("Duplicate Command Center item: review");
|
||||
});
|
||||
|
||||
it("collects one explicit client-side entrypoint", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function contributeClient() { return function() {}; }
|
||||
plugin.addClientSide(contributeClient);
|
||||
`),
|
||||
);
|
||||
expect(plugin.clientSide).toBeTypeOf("function");
|
||||
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function contributeClient() { return function() {}; }
|
||||
plugin.addClientSide(contributeClient);
|
||||
plugin.addClientSide(contributeClient);
|
||||
`),
|
||||
),
|
||||
).toThrow("Plugin has more than one client-side entrypoint");
|
||||
});
|
||||
|
||||
it("rejects duplicate attachment source ids", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
@@ -355,6 +377,26 @@ describe("evaluatePluginClientBundle", () => {
|
||||
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
|
||||
});
|
||||
|
||||
it("provides Paseo UI through @getpaseo/plugin/react-native", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
`(function(require) {
|
||||
const { Icon, Modal, useToast } = require("@getpaseo/plugin/react-native");
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) {
|
||||
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") {
|
||||
throw new Error("React Native plugin UI is incomplete");
|
||||
}
|
||||
plugin.addSurface("main", function Surface() { return null; });
|
||||
return function() {};
|
||||
};
|
||||
return module.exports;
|
||||
})`,
|
||||
);
|
||||
|
||||
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
|
||||
});
|
||||
|
||||
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
defineRpc,
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginCommandCenterItemContribution,
|
||||
type PluginClientContribution,
|
||||
type PluginSidebarContribution,
|
||||
type PluginSurfaceProps,
|
||||
type PluginThemeContribution,
|
||||
@@ -25,6 +26,7 @@ import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo
|
||||
import type { EvaluatedPlugin } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { Icon, resolvePluginIcon } from "./icons";
|
||||
import { pluginReactNativeRuntime } from "./react-native/runtime";
|
||||
import { parsePluginThemeContribution } from "./themes";
|
||||
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||
@@ -71,6 +73,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
@@ -163,6 +166,13 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
|
||||
});
|
||||
},
|
||||
addClientSide(contribution: PluginClientContribution) {
|
||||
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
|
||||
if (typeof contribution !== "function") {
|
||||
throw new Error("Plugin client-side entrypoint is not a function");
|
||||
}
|
||||
collector.clientSide = contribution;
|
||||
},
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
|
||||
const normalizedId = requireId(contribution.id, "attachment source id");
|
||||
if (attachmentSourceIds.has(normalizedId)) {
|
||||
@@ -254,6 +264,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
useRpc,
|
||||
};
|
||||
}
|
||||
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
|
||||
return pluginReactNativeRuntime;
|
||||
}
|
||||
if (name === "@getpaseo/plugin/server") {
|
||||
return { defineAttachmentSource, defineRpc };
|
||||
}
|
||||
@@ -299,6 +312,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
sidebarItems: collector.sidebarItems,
|
||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||
commandCenterItems: collector.commandCenterItems,
|
||||
clientSide: collector.clientSide,
|
||||
attachmentSources: collector.attachmentSources,
|
||||
themes: collector.themes,
|
||||
timelineTransformers: collector.timelineTransformers,
|
||||
|
||||
@@ -4,3 +4,4 @@ export { PluginSidebarItems } from "./sidebar-items";
|
||||
export { PluginSurfaceScreen } from "./surface-screen";
|
||||
export { usePluginAttachmentPicker } from "./attachments/picker";
|
||||
export { PluginResourceAttachmentPill } from "./attachments/pill";
|
||||
export { PluginComposerPills, useHasPluginComposerPills } from "./composer-pills";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { router } from "expo-router";
|
||||
import type { PluginPanelLocation } from "@getpaseo/plugin";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildPluginSurfaceRoute } from "./routes";
|
||||
import type { PluginNavigation } from "./actions";
|
||||
|
||||
export function createPluginNavigation(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | null;
|
||||
}): PluginNavigation {
|
||||
const { serverId, workspaceId } = input;
|
||||
function placement(location: PluginPanelLocation) {
|
||||
if (location !== "explorer") return undefined;
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
const workspaceKey = `${serverId}:${workspaceId}`;
|
||||
const paneId = useWorkspaceLayoutStore.getState().showExplorerSidebar(workspaceKey);
|
||||
if (!paneId) throw new Error("Explorer is unavailable");
|
||||
return { mode: "pane" as const, paneId };
|
||||
}
|
||||
return {
|
||||
openSurface(pluginId, surfaceId) {
|
||||
router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }));
|
||||
},
|
||||
openWorkspacePanel(pluginId, panelId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
|
||||
placement: placement(location),
|
||||
});
|
||||
},
|
||||
openAgentPanel(pluginId, panelId, agentId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
|
||||
placement: placement(location),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/react-native";
|
||||
import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/host";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
|
||||
import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context";
|
||||
|
||||
interface ModalContextValue {
|
||||
open: boolean;
|
||||
dismiss(): void;
|
||||
header: {
|
||||
title: string;
|
||||
leading?: ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
const ModalContext = createContext<ModalContextValue | null>(null);
|
||||
|
||||
function ModalRoot({ title, icon, open, onOpenChange, children }: ModalProps) {
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
const dismiss = useCallback(() => onOpenChangeRef.current(false), []);
|
||||
const header = useMemo(() => ({ title, leading: icon }), [icon, title]);
|
||||
const value = useMemo(() => ({ open, dismiss, header }), [dismiss, header, open]);
|
||||
return <ModalContext.Provider value={value}>{children}</ModalContext.Provider>;
|
||||
}
|
||||
|
||||
function useModalContext(): ModalContextValue {
|
||||
const context = useContext(ModalContext);
|
||||
if (!context) throw new Error("Modal.Content must be rendered inside Modal");
|
||||
return context;
|
||||
}
|
||||
|
||||
function ModalContent({ children }: ModalContentProps) {
|
||||
const modal = useModalContext();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useAppToast();
|
||||
const bridgePluginRuntime = usePluginRuntimeContextBridge();
|
||||
const contextBridge = useCallback(
|
||||
(content: ReactNode) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastApiProvider api={toast}>{bridgePluginRuntime(content)}</ToastApiProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
[bridgePluginRuntime, queryClient, toast],
|
||||
);
|
||||
const { AdaptiveModalSheet } =
|
||||
require("../../components/adaptive-modal-sheet") as typeof import("../../components/adaptive-modal-sheet");
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={modal.open}
|
||||
onClose={modal.dismiss}
|
||||
header={modal.header}
|
||||
contextBridge={contextBridge}
|
||||
>
|
||||
{children}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export const Modal: ModalComponent = Object.assign(ModalRoot, { Content: ModalContent });
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Icon } from "../icons";
|
||||
import { Modal } from "./modal";
|
||||
import { useToast } from "./toast";
|
||||
|
||||
export const pluginReactNativeRuntime = { Icon, Modal, useToast };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/react-native";
|
||||
import { useToast as useAppToast } from "@/contexts/toast-api-context";
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const toast = useAppToast();
|
||||
return useMemo(
|
||||
() => ({
|
||||
show(message: string, options?: ToastOptions) {
|
||||
toast.show(message, options);
|
||||
},
|
||||
error(message: string) {
|
||||
toast.error(message);
|
||||
},
|
||||
}),
|
||||
[toast],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { startPluginClientSide } from "./composer-pills/lifecycle";
|
||||
import { evaluatePluginClientBundle } from "./evaluate";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
@@ -29,7 +31,10 @@ class PluginRegistry {
|
||||
installCatalog(
|
||||
serverId: string,
|
||||
catalog: CatalogPlugin[],
|
||||
options: { replacePluginId?: string } = {},
|
||||
options: {
|
||||
replacePluginId?: string;
|
||||
client?: DaemonClient;
|
||||
} = {},
|
||||
): boolean {
|
||||
const previous = this.byHost.get(serverId) ?? [];
|
||||
const previousTimelineBundles = previous
|
||||
@@ -61,16 +66,30 @@ class PluginRegistry {
|
||||
return [existing];
|
||||
}
|
||||
const queryClient = new QueryClient();
|
||||
const evaluated = [
|
||||
{
|
||||
...evaluatePluginClientBundle(entry.id, entry.clientBundle),
|
||||
serverId,
|
||||
clientBundle: entry.clientBundle,
|
||||
queryClient,
|
||||
},
|
||||
];
|
||||
const evaluated = evaluatePluginClientBundle(entry.id, entry.clientBundle);
|
||||
const installation: InstalledPlugin = {
|
||||
...evaluated,
|
||||
serverId,
|
||||
clientBundle: entry.clientBundle,
|
||||
queryClient,
|
||||
};
|
||||
if (installation.clientSide) {
|
||||
if (!options.client) throw new Error("Plugin client runtime is unavailable");
|
||||
let clientCleanup;
|
||||
try {
|
||||
clientCleanup = startPluginClientSide(installation, options.client);
|
||||
} catch (error) {
|
||||
queryClient.clear();
|
||||
void Promise.resolve(evaluated.cleanup()).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
installation.cleanup = async () => {
|
||||
await clientCleanup();
|
||||
await evaluated.cleanup();
|
||||
};
|
||||
}
|
||||
this.evaluationErrors.delete(key);
|
||||
return evaluated;
|
||||
return [installation];
|
||||
} catch (error) {
|
||||
this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error));
|
||||
console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error);
|
||||
|
||||
@@ -21,6 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
|
||||
],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -26,6 +26,7 @@ function installation(
|
||||
})),
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -54,6 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes,
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -17,6 +17,7 @@ function plugin(input: {
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientContribution,
|
||||
PluginCleanup,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginThemeContribution,
|
||||
@@ -17,11 +20,12 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont
|
||||
|
||||
export interface EvaluatedPlugin {
|
||||
id: string;
|
||||
cleanup: () => void;
|
||||
cleanup: PluginCleanup;
|
||||
surfaces: PluginSurfaceContribution[];
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSide: PluginClientContribution | null;
|
||||
attachmentSources: PluginAttachmentSourceContribution[];
|
||||
themes: PluginThemeContribution[];
|
||||
timelineTransformers: PluginTimelineTransformerContribution[];
|
||||
@@ -37,6 +41,7 @@ export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginThemeContribution,
|
||||
|
||||
@@ -23,6 +23,7 @@ function installed(): InstalledPlugin {
|
||||
},
|
||||
],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { WorkspaceDirectoryReplica } from "./workspace-replica";
|
||||
|
||||
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
|
||||
@@ -208,3 +212,21 @@ it("does not invent a null-key project from a workspace update", () => {
|
||||
expect(session?.projects.has("fresh-project")).toBe(false);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("does not restore a targeted cached workspace while its archive is pending", () => {
|
||||
const serverId = "cached-workspace-during-archive";
|
||||
const workspaceId = "archived-workspace";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
markWorkspaceArchivePending({ serverId, workspaceId });
|
||||
|
||||
try {
|
||||
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
} finally {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ export class WorkspaceDirectoryReplica {
|
||||
workspace: WorkspaceDescriptor,
|
||||
project: ProjectDescriptor | undefined,
|
||||
): void {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return;
|
||||
const snapshot = this.read();
|
||||
snapshot.workspaces.set(workspace.id, workspace);
|
||||
if (project) snapshot.projects.set(project.projectId, project);
|
||||
|
||||
@@ -12,6 +12,14 @@ import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore }
|
||||
|
||||
const SERVER_ID = "cached-host";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve(): void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
class MemoryStorage implements ReplicaRowStore {
|
||||
readonly rows = new Map<string, ReplicaRow>();
|
||||
readonly changes: ReplicaRowChanges[] = [];
|
||||
@@ -23,6 +31,8 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
writes = 0;
|
||||
cleanups = 0;
|
||||
nextWriteFailure: Error | null = null;
|
||||
readGate: Promise<void> | null = null;
|
||||
onRead: (() => void) | null = null;
|
||||
|
||||
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
|
||||
return `${row.serverId}:${row.kind}:${row.id}`;
|
||||
@@ -36,6 +46,8 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
|
||||
this.onRead?.();
|
||||
await this.readGate;
|
||||
const acceptedKinds = new Set(kinds);
|
||||
const acceptedIds = ids ? new Set(ids) : null;
|
||||
return [...this.rows.values()].filter(
|
||||
@@ -217,6 +229,72 @@ describe("ReplicaCache", () => {
|
||||
expect(restoredTimeline).toEqual(timeline());
|
||||
});
|
||||
|
||||
it("never reads directory rows older than an accepted deferred deletion", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitDirectory(SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
|
||||
cache.commitDirectory(SERVER_ID, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
});
|
||||
|
||||
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
expect((await cache.readDirectory(SERVER_ID)).projects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("fails closed when an accepted deletion cannot be persisted before a read", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitDirectory(SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
storage.nextWriteFailure = new Error("disk busy");
|
||||
|
||||
cache.commitDirectory(SERVER_ID, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
});
|
||||
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discards a durable read when the host changes while it is in flight", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitDirectory(SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
storage.onRead = started.resolve;
|
||||
storage.readGate = release.promise;
|
||||
|
||||
const reading = cache.readAgent(SERVER_ID, "agent-1");
|
||||
await started.promise;
|
||||
cache.commitDirectory(SERVER_ID, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
});
|
||||
release.resolve();
|
||||
|
||||
expect(await reading).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never reads a timeline older than an accepted deferred replacement", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old"));
|
||||
await cache.flush();
|
||||
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("New"));
|
||||
|
||||
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem("New")]);
|
||||
});
|
||||
|
||||
it("round-trips plugin timeline items", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -784,6 +784,7 @@ function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | und
|
||||
|
||||
export class ReplicaCache {
|
||||
private readonly activeServerIds = new Set<string>();
|
||||
private readonly hostRevisions = new Map<string, number>();
|
||||
private readonly storedRows = new Map<string, Map<string, ReplicaRow>>();
|
||||
private readonly hostBytes = new Map<string, number>();
|
||||
private readonly hostWriteOrder = new Map<string, true>();
|
||||
@@ -897,9 +898,13 @@ export class ReplicaCache {
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
if (!this.activeServerIds.has(serverId)) return [];
|
||||
const revision = this.hostRevisions.get(serverId) ?? 0;
|
||||
try {
|
||||
await this.prepareStore();
|
||||
return await this.rowStore.read(serverId, kinds, ids);
|
||||
await this.flush();
|
||||
if (!this.canReadHostRevision(serverId, revision)) return [];
|
||||
const rows = await this.rowStore.read(serverId, kinds, ids);
|
||||
return this.canReadHostRevision(serverId, revision) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -985,6 +990,7 @@ export class ReplicaCache {
|
||||
},
|
||||
): void {
|
||||
if (!this.activeServerIds.has(serverId)) return;
|
||||
this.advanceHostRevision(serverId);
|
||||
this.clearPendingDirectoryChanges(serverId);
|
||||
const desiredRows: ReplicaRow[] = [];
|
||||
for (const agent of directory.agents.values()) {
|
||||
@@ -1013,6 +1019,7 @@ export class ReplicaCache {
|
||||
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void {
|
||||
if (!this.activeServerIds.has(serverId)) return;
|
||||
if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload");
|
||||
this.advanceHostRevision(serverId);
|
||||
const stored = serializeTimeline(timeline);
|
||||
if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored);
|
||||
else this.queueEntityDelete(serverId, "timeline", agentId);
|
||||
@@ -1022,6 +1029,8 @@ export class ReplicaCache {
|
||||
setHosts(serverIds: Iterable<string>): void {
|
||||
const next = new Set(serverIds);
|
||||
const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId));
|
||||
const added = [...next].filter((serverId) => !this.activeServerIds.has(serverId));
|
||||
for (const serverId of [...removed, ...added]) this.advanceHostRevision(serverId);
|
||||
this.activeServerIds.clear();
|
||||
for (const serverId of next) this.activeServerIds.add(serverId);
|
||||
for (const serverId of removed) {
|
||||
@@ -1032,6 +1041,8 @@ export class ReplicaCache {
|
||||
}
|
||||
|
||||
reconcileServerId(oldServerId: string, newServerId: string): void {
|
||||
this.advanceHostRevision(oldServerId);
|
||||
this.advanceHostRevision(newServerId);
|
||||
const rows = this.storedRows.get(oldServerId);
|
||||
if (rows) {
|
||||
const newRows = this.storedRows.get(newServerId) ?? new Map<string, ReplicaRow>();
|
||||
@@ -1142,6 +1153,29 @@ export class ReplicaCache {
|
||||
);
|
||||
}
|
||||
|
||||
private canReadHostRevision(serverId: string, revision: number): boolean {
|
||||
return (
|
||||
this.activeServerIds.has(serverId) &&
|
||||
(this.hostRevisions.get(serverId) ?? 0) === revision &&
|
||||
!this.hasPendingHostChanges(serverId)
|
||||
);
|
||||
}
|
||||
|
||||
private hasPendingHostChanges(serverId: string): boolean {
|
||||
if (this.pendingDirectoryReplacements.has(serverId)) return true;
|
||||
for (const row of this.pendingUpserts.values()) {
|
||||
if (row.serverId === serverId) return true;
|
||||
}
|
||||
for (const row of this.pendingDeletes.values()) {
|
||||
if (row.serverId === serverId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private advanceHostRevision(serverId: string): void {
|
||||
this.hostRevisions.set(serverId, (this.hostRevisions.get(serverId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private drainPendingChanges(): PendingReplicaChanges {
|
||||
const changes = {
|
||||
upserts: [...this.pendingUpserts.values()],
|
||||
|
||||
@@ -217,6 +217,7 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
|
||||
workspaceKind: "directory",
|
||||
status: "done",
|
||||
currentBranch: "main",
|
||||
changeRequestNumber: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ function contentHeadingLineHeight(contentSize: number, tier: keyof typeof FONT_S
|
||||
*
|
||||
* Usage:
|
||||
* const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
* <Markdown style={markdownStyles}>{content}</Markdown>
|
||||
* <Markdown style={markdownStyles} markdownit={parser}>{content}</Markdown>
|
||||
*
|
||||
* Always pass `markdownit` from `@/utils/markdown-parser`. Omit it and
|
||||
* react-native-markdown-display builds its own parser with `typographer: true`,
|
||||
* which rewrites a literal `(c)` as ©.
|
||||
*/
|
||||
export function createMarkdownStyles(theme: Theme) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAssistantMarkdownParser } from "./assistant-markdown-parser";
|
||||
|
||||
describe("createAssistantMarkdownParser", () => {
|
||||
it("renders agent text verbatim", () => {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
|
||||
// The reported bug, plus the substitutions that share its cause.
|
||||
expect(parser.renderInline("(c) (C) (r) (tm) (p)")).toBe("(c) (C) (r) (tm) (p)");
|
||||
expect(parser.renderInline("wait for it...")).toBe("wait for it...");
|
||||
expect(parser.renderInline("a -- b")).toBe("a -- b");
|
||||
// Smart quotes are off too: a curled quote is not pasteable into a shell.
|
||||
expect(parser.renderInline(`run --name="my repo"`)).toBe("run --name="my repo"");
|
||||
expect(parser.renderInline("it's fine")).toBe("it's fine");
|
||||
});
|
||||
|
||||
it("allows file:// links, unlike every other parser", () => {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
|
||||
expect(parser.render("[open](file:///tmp/a.ts)")).toContain('href="file:///tmp/a.ts"');
|
||||
});
|
||||
|
||||
it("still rejects javascript: links", () => {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
|
||||
expect(parser.render("[x](javascript:alert(1))")).not.toContain("href");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
|
||||
export function createAssistantMarkdownParser(): MarkdownIt {
|
||||
const parser = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
});
|
||||
const parser = createMarkdownParser({ linkify: true });
|
||||
const defaultValidateLink = parser.validateLink.bind(parser);
|
||||
|
||||
// Assistant messages are the only surface allowed to link into the
|
||||
// filesystem. Every other parser keeps markdown-it's stricter default.
|
||||
parser.validateLink = (url: string) =>
|
||||
url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMarkdownParser } from "./markdown-parser";
|
||||
|
||||
// Every string markdown-it's typographer would rewrite, with the character it
|
||||
// would rewrite it to. Sourced from markdown-it/lib/rules_core/replacements.js
|
||||
// and smartquotes.js. `--flag` is deliberately absent: the en-dash rules need
|
||||
// whitespace or a word character on both sides, so a CLI flag after a space is
|
||||
// never touched and asserting on it would prove nothing.
|
||||
const REWRITTEN_BY_TYPOGRAPHER = [
|
||||
"(c)",
|
||||
"(C)",
|
||||
"(r)",
|
||||
"(R)",
|
||||
"(tm)",
|
||||
"(TM)",
|
||||
"(p)",
|
||||
"(P)",
|
||||
"+-",
|
||||
"two dots .. here",
|
||||
"wait for it...",
|
||||
"what????",
|
||||
"stop!!!!",
|
||||
"hmm,, ok",
|
||||
"a -- b",
|
||||
"word--word",
|
||||
"a --- b",
|
||||
'run --name="my repo"',
|
||||
"it's fine",
|
||||
];
|
||||
|
||||
describe("createMarkdownParser", () => {
|
||||
it("renders every typographer-rewritten sequence verbatim", () => {
|
||||
const parser = createMarkdownParser({ linkify: true });
|
||||
|
||||
for (const source of REWRITTEN_BY_TYPOGRAPHER) {
|
||||
expect(parser.renderInline(source)).toBe(escapeHtml(source));
|
||||
}
|
||||
});
|
||||
|
||||
it("would fail if typographer were switched back on", () => {
|
||||
// Guards the assertion above: proves the fixture actually exercises the
|
||||
// rules, so a future refactor can't quietly make the test vacuous.
|
||||
const typographer = createMarkdownParser({ linkify: true });
|
||||
typographer.set({ typographer: true });
|
||||
|
||||
for (const source of REWRITTEN_BY_TYPOGRAPHER) {
|
||||
expect(typographer.renderInline(source)).not.toBe(escapeHtml(source));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects file:// links", () => {
|
||||
const parser = createMarkdownParser({ linkify: true });
|
||||
|
||||
expect(parser.render("[open](file:///tmp/a.ts)")).not.toContain("href");
|
||||
});
|
||||
|
||||
it("rejects javascript: links", () => {
|
||||
const parser = createMarkdownParser({ linkify: true });
|
||||
|
||||
expect(parser.render("[x](javascript:alert(1))")).not.toContain("href");
|
||||
});
|
||||
|
||||
it("linkifies bare URLs only when asked", () => {
|
||||
expect(createMarkdownParser({ linkify: true }).render("see https://paseo.sh now")).toContain(
|
||||
'href="https://paseo.sh"',
|
||||
);
|
||||
expect(
|
||||
createMarkdownParser({ linkify: false }).render("see https://paseo.sh now"),
|
||||
).not.toContain("href");
|
||||
});
|
||||
});
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
/**
|
||||
* The one place that decides how the app parses markdown.
|
||||
*
|
||||
* `typographer` stays off. It switches on two markdown-it core rules that
|
||||
* rewrite characters the user may copy back into a shell or a file:
|
||||
* `replacements` turns `(c)` into ©, `a -- b` into an en dash and `...` into
|
||||
* an ellipsis, and `smartquotes` curls straight quotes so `--name="x"` stops
|
||||
* being pasteable. Agent output, plans and file previews all have to render
|
||||
* the text they were handed.
|
||||
*
|
||||
* `linkify` is a parameter rather than a shared default because the surfaces
|
||||
* disagree today: chat and the default renderer linkify bare URLs, plan cards
|
||||
* never have. Unifying that is a product decision on its own.
|
||||
*/
|
||||
export function createMarkdownParser({ linkify }: { linkify: boolean }): MarkdownIt {
|
||||
return new MarkdownIt({ html: false, linkify });
|
||||
}
|
||||
@@ -136,3 +136,74 @@ describe("buildProjects", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace change request number", () => {
|
||||
function summarize(overrides: Partial<WorkspaceDescriptor>) {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
serverName: "Host A",
|
||||
isOnline: true,
|
||||
projects: [descriptor("prj_a", "shared", "/a/app")],
|
||||
workspaces: [{ ...workspace("ws-a", "prj_a", "/a/app"), ...overrides }],
|
||||
},
|
||||
],
|
||||
});
|
||||
return result.projects[0]?.hosts[0]?.workspaces[0]?.changeRequestNumber;
|
||||
}
|
||||
|
||||
const pullRequest = {
|
||||
url: "https://github.com/acme/app/pull/42",
|
||||
title: "Refactor payments",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/checkout",
|
||||
isMerged: false,
|
||||
};
|
||||
|
||||
test("prefers the number the daemon sent over parsing the url", () => {
|
||||
expect(
|
||||
summarize({
|
||||
// The url says 999, the authoritative field says 42 — the field wins.
|
||||
githubRuntime: {
|
||||
pullRequest: { ...pullRequest, number: 42, url: "https://example.test/pull/999" },
|
||||
},
|
||||
forge: "github",
|
||||
}),
|
||||
).toBe(42);
|
||||
});
|
||||
|
||||
test("falls back to the url when the daemon omits the number", () => {
|
||||
expect(summarize({ githubRuntime: { pullRequest }, forge: "github" })).toBe(42);
|
||||
});
|
||||
|
||||
test("parses a gitlab merge request url", () => {
|
||||
expect(
|
||||
summarize({
|
||||
githubRuntime: {
|
||||
pullRequest: { ...pullRequest, url: "https://gitlab.com/acme/app/-/merge_requests/7" },
|
||||
},
|
||||
forge: "gitlab",
|
||||
}),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
test("resolves the number when an old daemon omits the forge", () => {
|
||||
expect(summarize({ githubRuntime: { pullRequest: { ...pullRequest, number: 42 } } })).toBe(42);
|
||||
});
|
||||
|
||||
test("is null when the workspace has no pull request", () => {
|
||||
expect(summarize({})).toBeNull();
|
||||
expect(summarize({ githubRuntime: { pullRequest: null } })).toBeNull();
|
||||
});
|
||||
|
||||
test("is null when the number is absent and the url is unparseable", () => {
|
||||
expect(
|
||||
summarize({
|
||||
githubRuntime: { pullRequest: { ...pullRequest, url: "not-a-url" } },
|
||||
forge: "github",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { HostProjectListItem } from "@/projects/host-project-model";
|
||||
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
|
||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
id: string;
|
||||
@@ -10,6 +11,8 @@ export interface WorkspaceSummary {
|
||||
status: WorkspaceDescriptor["status"];
|
||||
currentBranch: string | null;
|
||||
archivingAt?: string;
|
||||
/** Pull/merge request number backing this workspace, so a query can find it. */
|
||||
changeRequestNumber: number | null;
|
||||
}
|
||||
|
||||
export interface ProjectHostEntry {
|
||||
@@ -133,6 +136,20 @@ function resolveHostRepoRoot(input: {
|
||||
return input.workspaces[0]?.projectRootPath || input.projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace's pull/merge request number.
|
||||
*
|
||||
* The wire carries the number directly, so prefer it. `selectPrHintFromStatus`
|
||||
* is only the fallback: it derives the number by regex-parsing the PR/MR URL,
|
||||
* which yields null for a noncanonical or absent URL even when the daemon sent
|
||||
* a perfectly good number.
|
||||
*/
|
||||
function toChangeRequestNumber(workspace: WorkspaceDescriptor): number | null {
|
||||
const pullRequest = workspace.githubRuntime?.pullRequest;
|
||||
if (!pullRequest) return null;
|
||||
return pullRequest.number ?? selectPrHintFromStatus(pullRequest, workspace.forge)?.number ?? null;
|
||||
}
|
||||
|
||||
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
|
||||
const currentBranch = workspace.gitRuntime?.currentBranch?.trim();
|
||||
return {
|
||||
@@ -143,6 +160,7 @@ function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
|
||||
status: workspace.status,
|
||||
currentBranch: currentBranch && currentBranch !== "HEAD" ? currentBranch : null,
|
||||
...(workspace.archivingAt ? { archivingAt: workspace.archivingAt } : {}),
|
||||
changeRequestNumber: toChangeRequestNumber(workspace),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,21 +78,25 @@ export async function inspectConfig(
|
||||
path.join(directory, "main.client.tsx"),
|
||||
`import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||
import {
|
||||
Icon,
|
||||
type PluginAgentPanelProps,
|
||||
type PluginClientContext,
|
||||
type PluginComposerPillProps,
|
||||
useAgent,
|
||||
usePaseo,
|
||||
useWorkspace,
|
||||
} from "@getpaseo/plugin";
|
||||
import { inspect } from "./inspect.shared";
|
||||
|
||||
export function Surface() {
|
||||
const paseo = usePaseo();
|
||||
const toast = useToast();
|
||||
const createWorkspace = () => paseo.workspaces.create({
|
||||
source: { kind: "directory", path: "/repo" },
|
||||
});
|
||||
void createWorkspace;
|
||||
return <><Icon name="Settings" size={18} color="#123456" /><Text>Paseo API</Text></>;
|
||||
return <><Icon name="Settings" size={18} color="#123456" /><Text onPress={() => toast.show("Ready")}>Paseo API</Text><Modal title="Example" icon={<Icon name="Settings" />} open={false} onOpenChange={() => {}}><Modal.Content><Text>Modal</Text></Modal.Content></Modal></>;
|
||||
}
|
||||
|
||||
export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
|
||||
@@ -108,12 +112,30 @@ export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
|
||||
});
|
||||
return <Text>{workspaceName}: {agentTitle}</Text>;
|
||||
}
|
||||
|
||||
export function ComposerPill({ workspaceId, agentId }: PluginComposerPillProps) {
|
||||
return <Text>{workspaceId}: {agentId}</Text>;
|
||||
}
|
||||
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
return client.addComposerPill({
|
||||
id: "open-review",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: ComposerPill,
|
||||
async onPress() {
|
||||
await client.rpc(inspect, {});
|
||||
client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" });
|
||||
},
|
||||
});
|
||||
}
|
||||
`,
|
||||
),
|
||||
writeFile(
|
||||
path.join(directory, "index.ts"),
|
||||
`import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { AgentPanel, Surface } from "./main.client";
|
||||
import { AgentPanel, contributeClient, Surface } from "./main.client";
|
||||
import { inspectConfig } from "./inspect.server";
|
||||
import { inspect } from "./inspect.shared";
|
||||
|
||||
@@ -138,6 +160,7 @@ export default function contribute(plugin: PluginContext) {
|
||||
openPanel("review");
|
||||
},
|
||||
});
|
||||
plugin.addClientSide(contributeClient);
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -56,6 +56,46 @@ const SDK_DECLARATIONS = `declare module "@getpaseo/plugin/server" {
|
||||
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
|
||||
}
|
||||
|
||||
declare module "@getpaseo/plugin/react-native" {
|
||||
import type { ComponentType, FunctionComponent, ReactNode } from "react";
|
||||
|
||||
export interface PluginIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ModalContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ModalComponent extends FunctionComponent<ModalProps> {
|
||||
Content: ComponentType<ModalContentProps>;
|
||||
}
|
||||
|
||||
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
export interface ToastOptions {
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
}
|
||||
export interface ToastApi {
|
||||
show(message: string, options?: ToastOptions): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export const Icon: ComponentType<PluginIconProps>;
|
||||
export const Modal: ModalComponent;
|
||||
export function useToast(): ToastApi;
|
||||
}
|
||||
|
||||
declare module "@getpaseo/plugin" {
|
||||
import type { ComponentType } from "react";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
@@ -155,8 +195,26 @@ declare module "@getpaseo/plugin" {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface PluginComposerPillProps extends PluginHostProps {
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface PluginComposerPillContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
Component: ComponentType<PluginComposerPillProps>;
|
||||
onPress(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export type PluginPanelLocation = "workspace" | "explorer";
|
||||
export interface PluginOpenPanelOptions { location?: PluginPanelLocation; }
|
||||
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export type PluginWorkspacePanelContribution =
|
||||
| { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
|
||||
@@ -241,6 +299,12 @@ declare module "@getpaseo/plugin" {
|
||||
openPanel(id: string, options?: PluginOpenPanelOptions): void;
|
||||
}
|
||||
|
||||
export interface PluginClientContext extends PluginCommandCapabilities {
|
||||
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
|
||||
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
|
||||
}
|
||||
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
|
||||
|
||||
export type PluginCommandCenterItemContribution =
|
||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise<void> }
|
||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> }
|
||||
@@ -258,6 +322,7 @@ declare module "@getpaseo/plugin" {
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||
addClientSide(contribution: PluginClientContribution): void;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||
addTheme(contribution: PluginThemeContribution): void;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(contribution: PluginTimelineTransformerContribution<ItemType>): void;
|
||||
|
||||
@@ -757,6 +757,127 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
||||
archivedAt: "2026-05-16T01:00:00.000Z",
|
||||
});
|
||||
expect(agent.current()?.archivedAt).toBe("2026-05-16T01:00:00.000Z");
|
||||
expect(agent.archivedAt).toBe("2026-05-16T01:00:00.000Z");
|
||||
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("agent handles list the session's own commands through the existing daemon RPC", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const agent = client.agents.ref("agent_sdk");
|
||||
|
||||
const commandsPromise = agent.commands({ requestId: "agent-commands-request" });
|
||||
const request = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(request).toMatchObject({
|
||||
type: "list_commands_request",
|
||||
agentId: "agent_sdk",
|
||||
requestId: "agent-commands-request",
|
||||
});
|
||||
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
requestId: "agent-commands-request",
|
||||
agentId: "agent_sdk",
|
||||
commands: [
|
||||
{
|
||||
name: "brainstorming",
|
||||
description: "Turn an idea into a design",
|
||||
argumentHint: "[topic]",
|
||||
kind: "skill",
|
||||
},
|
||||
{
|
||||
name: "usage",
|
||||
description: "Show usage",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(commandsPromise).resolves.toMatchObject({
|
||||
agentId: "agent_sdk",
|
||||
error: null,
|
||||
commands: [
|
||||
{ name: "brainstorming", kind: "skill" },
|
||||
{ name: "usage", kind: "command" },
|
||||
],
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("agent handles expose the observed snapshot through readonly properties", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const agent = client.agents.ref("agent_sdk");
|
||||
|
||||
expect(agent.capabilities).toBeNull();
|
||||
expect(agent.availableModes).toBeNull();
|
||||
expect(agent.pendingPermissions).toBeNull();
|
||||
expect(agent.activeTurn).toBeNull();
|
||||
expect(agent.lastUsage).toBeNull();
|
||||
expect(agent.lastError).toBeNull();
|
||||
expect(agent.features).toBeNull();
|
||||
expect(agent.runtimeInfo).toBeNull();
|
||||
expect(agent.archivedAt).toBeNull();
|
||||
|
||||
const observedAgent = createAgent({
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsRewindBoth: true,
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: true,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
availableModes: [{ id: "plan", label: "Plan" }],
|
||||
pendingPermissions: [{ id: "permission-sdk", provider: "codex", name: "shell", kind: "tool" }],
|
||||
activeTurn: { turnId: "turn-sdk", startedAt: "2026-05-16T00:05:00.000Z" },
|
||||
lastUsage: { inputTokens: 120, outputTokens: 45, totalCostUsd: 0.02 },
|
||||
lastError: "provider exited",
|
||||
features: [{ type: "toggle", id: "web-search", label: "Web search", value: true }],
|
||||
runtimeInfo: { provider: "codex", sessionId: "session-sdk", model: "gpt-5.4" },
|
||||
archivedAt: "2026-05-16T02:00:00.000Z",
|
||||
});
|
||||
const unsubscribe = agent.subscribe(() => {});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "agent_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
agent: observedAgent,
|
||||
project: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(agent.capabilities).toEqual(observedAgent.capabilities);
|
||||
expect(agent.availableModes).toEqual([{ id: "plan", label: "Plan" }]);
|
||||
expect(agent.pendingPermissions).toEqual([
|
||||
{ id: "permission-sdk", provider: "codex", name: "shell", kind: "tool" },
|
||||
]);
|
||||
expect(agent.activeTurn).toEqual({
|
||||
turnId: "turn-sdk",
|
||||
startedAt: "2026-05-16T00:05:00.000Z",
|
||||
});
|
||||
expect(agent.lastUsage).toEqual({ inputTokens: 120, outputTokens: 45, totalCostUsd: 0.02 });
|
||||
expect(agent.lastError).toBe("provider exited");
|
||||
expect(agent.features).toEqual([
|
||||
{ type: "toggle", id: "web-search", label: "Web search", value: true },
|
||||
]);
|
||||
expect(agent.runtimeInfo).toEqual({
|
||||
provider: "codex",
|
||||
sessionId: "session-sdk",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
expect(agent.archivedAt).toBe("2026-05-16T02:00:00.000Z");
|
||||
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
FetchWorkspacesResponseMessage,
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
ListAvailableProvidersResponse,
|
||||
ListCommandsResponse,
|
||||
ListProviderFeaturesRequestMessage,
|
||||
ListProviderFeaturesResponseMessage,
|
||||
ListProviderModelsResponseMessage,
|
||||
@@ -237,6 +238,12 @@ export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
|
||||
|
||||
export type PaseoAgentRunResult = WaitForFinishResult;
|
||||
|
||||
export interface PaseoAgentCommandsOptions {
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type PaseoAgentCommandsResult = ListCommandsResponse["payload"];
|
||||
|
||||
export type PaseoAgentUpdate = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
|
||||
export type PaseoAgentStream = Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"];
|
||||
@@ -259,9 +266,25 @@ export interface PaseoAgentTimelineHandle {
|
||||
|
||||
export interface PaseoAgentHandle {
|
||||
readonly id: string;
|
||||
/**
|
||||
* `workspaceId` through `archivedAt` mirror the last snapshot this handle
|
||||
* observed. A handle from `ref()` reads `null` for all of them until
|
||||
* `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or
|
||||
* `subscribe()` delivers a snapshot. Optional snapshot values also read as
|
||||
* `null`; use `current()` when you need to distinguish those states.
|
||||
*/
|
||||
readonly workspaceId: string | null;
|
||||
readonly cwd: string | null;
|
||||
readonly status: PaseoAgent["status"] | null;
|
||||
readonly capabilities: PaseoAgent["capabilities"] | null;
|
||||
readonly availableModes: PaseoAgent["availableModes"] | null;
|
||||
readonly pendingPermissions: PaseoAgent["pendingPermissions"] | null;
|
||||
readonly activeTurn: NonNullable<PaseoAgent["activeTurn"]> | null;
|
||||
readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null;
|
||||
readonly lastError: NonNullable<PaseoAgent["lastError"]> | null;
|
||||
readonly features: NonNullable<PaseoAgent["features"]> | null;
|
||||
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
|
||||
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
|
||||
readonly timeline: PaseoAgentTimelineHandle;
|
||||
current(): PaseoAgent | null;
|
||||
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
|
||||
@@ -270,6 +293,14 @@ export interface PaseoAgentHandle {
|
||||
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
|
||||
/** Waits for the current turn, including one started with `prompt`. */
|
||||
waitForFinish(timeoutMs?: number): Promise<PaseoAgentRunResult>;
|
||||
/**
|
||||
* Asks the running session for the slash commands and skills it actually
|
||||
* loaded. Providers answer from the live session, so this sees built-in and
|
||||
* bundled entries that no directory scan can find. The payload carries its own
|
||||
* `error` string; a provider that cannot answer reports it there rather than
|
||||
* rejecting.
|
||||
*/
|
||||
commands(options?: PaseoAgentCommandsOptions): Promise<PaseoAgentCommandsResult>;
|
||||
archive(): Promise<{ archivedAt: string }>;
|
||||
detach(): Promise<void>;
|
||||
subscribe(handler: (update: PaseoAgentUpdate) => void): () => void;
|
||||
@@ -598,6 +629,33 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
|
||||
get status() {
|
||||
return current?.status ?? null;
|
||||
},
|
||||
get capabilities() {
|
||||
return current?.capabilities ?? null;
|
||||
},
|
||||
get availableModes() {
|
||||
return current?.availableModes ?? null;
|
||||
},
|
||||
get pendingPermissions() {
|
||||
return current?.pendingPermissions ?? null;
|
||||
},
|
||||
get activeTurn() {
|
||||
return current?.activeTurn ?? null;
|
||||
},
|
||||
get lastUsage() {
|
||||
return current?.lastUsage ?? null;
|
||||
},
|
||||
get lastError() {
|
||||
return current?.lastError ?? null;
|
||||
},
|
||||
get features() {
|
||||
return current?.features ?? null;
|
||||
},
|
||||
get runtimeInfo() {
|
||||
return current?.runtimeInfo ?? null;
|
||||
},
|
||||
get archivedAt() {
|
||||
return current?.archivedAt ?? null;
|
||||
},
|
||||
current: () => current,
|
||||
refresh: async (requestId) => {
|
||||
const result = await daemonClient.fetchAgent({ agentId: id, requestId });
|
||||
@@ -629,6 +687,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
|
||||
}
|
||||
return result;
|
||||
},
|
||||
commands: (options) => daemonClient.listCommands({ agentId: id, ...options }),
|
||||
archive: async () => {
|
||||
const result = await daemonClient.archiveAgent(id);
|
||||
if (current) {
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
"source": "./src/server.ts",
|
||||
"default": "./dist/server.js"
|
||||
},
|
||||
"./react-native": {
|
||||
"types": "./dist/react-native.d.ts",
|
||||
"source": "./src/react-native.ts",
|
||||
"default": "./dist/react-native.js"
|
||||
},
|
||||
"./host": {
|
||||
"types": "./dist/host.d.ts",
|
||||
"source": "./src/host.ts",
|
||||
|
||||
@@ -11,12 +11,16 @@ export interface PluginClientStateSource {
|
||||
|
||||
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
|
||||
|
||||
export function usePluginClientStateSource(): PluginClientStateSource | null {
|
||||
return useContext(PluginClientStateContext);
|
||||
}
|
||||
|
||||
function usePluginEntity<Entity, Selection>(input: {
|
||||
id: string;
|
||||
read(source: PluginClientStateSource, id: string): Entity | null;
|
||||
selector(entity: Entity): Selection;
|
||||
}): Selection | null {
|
||||
const source = useContext(PluginClientStateContext);
|
||||
const source = usePluginClientStateSource();
|
||||
if (!source) throw new Error("Plugin state hooks must run inside a workspace panel");
|
||||
return useSyncExternalStoreWithSelector(
|
||||
source.subscribe,
|
||||
|
||||
@@ -99,6 +99,32 @@ export interface PluginAgentPanelProps extends PluginHostProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface PluginComposerPillProps extends PluginHostProps {
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface PluginComposerPillContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
Component: ComponentType<PluginComposerPillProps>;
|
||||
onPress(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface PluginClientContext extends PluginCommandCapabilities {
|
||||
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
|
||||
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
|
||||
}
|
||||
|
||||
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
|
||||
|
||||
export type PluginWorkspacePanelContribution =
|
||||
| (PluginWorkspacePanelBase & {
|
||||
context: "workspace";
|
||||
@@ -262,6 +288,7 @@ export interface PluginContext {
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||
addClientSide(contribution: PluginClientContribution): void;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||
addTheme(contribution: PluginThemeContribution): void;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { PluginAttachmentSearchPayloadSchema } from "./attachments.js";
|
||||
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
|
||||
export {
|
||||
usePluginRuntimeContextBridge,
|
||||
type PluginRuntimeContextBridge,
|
||||
} from "./runtime-context-bridge.js";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientContribution,
|
||||
PluginContext,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
@@ -22,6 +27,7 @@ interface PluginCollector {
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||
addClientSide(contribution: PluginClientContribution): void;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||
addTheme(contribution: PluginThemeContribution): void;
|
||||
addTimelineTransformer(contribution: PluginTimelineTransformerContribution): void;
|
||||
@@ -33,6 +39,7 @@ export interface PluginRegistrationCollector {
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
workspacePanels: PluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSide: PluginClientContribution | null;
|
||||
attachmentSources: PluginAttachmentSourceContribution[];
|
||||
themes: PluginThemeContribution[];
|
||||
timelineTransformers: PluginTimelineTransformerContribution[];
|
||||
@@ -47,6 +54,7 @@ export function createPluginContext(
|
||||
| "addSidebarItem"
|
||||
| "addWorkspacePanel"
|
||||
| "addCommandCenterItem"
|
||||
| "addClientSide"
|
||||
| "addAttachmentSource"
|
||||
| "addTheme"
|
||||
| "addTimelineTransformer"
|
||||
@@ -65,6 +73,9 @@ export function createPluginContext(
|
||||
addCommandCenterItem(contribution) {
|
||||
collector.addCommandCenterItem(contribution);
|
||||
},
|
||||
addClientSide(contribution) {
|
||||
collector.addClientSide(contribution);
|
||||
},
|
||||
addAttachmentSource(contribution) {
|
||||
collector.addAttachmentSource(contribution);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,11 @@ export type {
|
||||
PluginCleanup,
|
||||
PluginCommandCapabilities,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientContext,
|
||||
PluginClientContribution,
|
||||
PluginClientOpenPanelOptions,
|
||||
PluginComposerPillContribution,
|
||||
PluginComposerPillProps,
|
||||
PluginContribution,
|
||||
PluginContext,
|
||||
PluginGlobalCommandContext,
|
||||
|
||||
@@ -3,12 +3,16 @@ import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
const PaseoApiContext = createContext<PaseoApi | null>(null);
|
||||
|
||||
export function usePaseoContextValue(): PaseoApi | null {
|
||||
return useContext(PaseoApiContext);
|
||||
}
|
||||
|
||||
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
|
||||
return <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePaseo(): PaseoApi {
|
||||
const paseo = useContext(PaseoApiContext);
|
||||
const paseo = usePaseoContextValue();
|
||||
if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface");
|
||||
return paseo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ComponentType, FunctionComponent, ReactNode } from "react";
|
||||
import type { PluginIconProps } from "./contracts.js";
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ModalContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ModalComponent extends FunctionComponent<ModalProps> {
|
||||
Content: ComponentType<ModalContentProps>;
|
||||
}
|
||||
|
||||
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
|
||||
export interface ToastOptions {
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
show(message: string, options?: ToastOptions): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export declare const Icon: ComponentType<PluginIconProps>;
|
||||
export declare const Modal: ModalComponent;
|
||||
export declare function useToast(): ToastApi;
|
||||
|
||||
export type { PluginIconProps } from "./contracts.js";
|
||||
@@ -2,12 +2,16 @@ import { createContext, useCallback, useContext, useMemo, type ReactNode } from
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import { callPluginRpc, type PluginRpcContract } from "./rpc.js";
|
||||
|
||||
interface PluginRpcContextValue {
|
||||
export interface PluginRpcContextValue {
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
|
||||
|
||||
export function usePluginRpcContextValue(): PluginRpcContextValue | null {
|
||||
return useContext(PluginRpcContext);
|
||||
}
|
||||
|
||||
export function PluginRpcProvider({
|
||||
children,
|
||||
invoke,
|
||||
@@ -22,7 +26,7 @@ export function PluginRpcProvider({
|
||||
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
|
||||
const context = useContext(PluginRpcContext);
|
||||
const context = usePluginRpcContextValue();
|
||||
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
|
||||
|
||||
return useCallback(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
|
||||
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
|
||||
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
|
||||
|
||||
export type PluginRuntimeContextBridge = (children: ReactNode) => ReactNode;
|
||||
|
||||
/** Rebuilds plugin runtime contexts inside React Native portal hosts. */
|
||||
export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
const paseo = usePaseoContextValue();
|
||||
const rpc = usePluginRpcContextValue();
|
||||
const state = usePluginClientStateSource();
|
||||
|
||||
if (!paseo || !rpc) {
|
||||
throw new Error("Plugin UI must run inside a contributed plugin surface");
|
||||
}
|
||||
|
||||
return useCallback(
|
||||
(children: ReactNode) => {
|
||||
const content = state ? (
|
||||
<PluginClientStateProvider source={state}>{children}</PluginClientStateProvider>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
return (
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginRpcProvider invoke={rpc.invoke}>{content}</PluginRpcProvider>
|
||||
</PaseoApiProvider>
|
||||
);
|
||||
},
|
||||
[paseo, rpc, state],
|
||||
);
|
||||
}
|
||||
@@ -1283,7 +1283,11 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
title: "Plan",
|
||||
description: "Review the proposed plan before implementation starts.",
|
||||
input: {
|
||||
plan: "1. Add the README note.\n2. Keep the change scoped.\n3. Verify the diff.",
|
||||
// The (c), the quoted flag and the --- are load-bearing: they trip
|
||||
// three different markdown-it typographer rules, and
|
||||
// plan-card-markdown.spec.ts asserts all three render verbatim. That
|
||||
// is the only guard against the plan card's parser prop going missing.
|
||||
plan: '1. Add the (c) README note.\n2. Run --name="my repo".\n3. Verify ---buzz in the diff.',
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
|
||||
@@ -604,6 +604,98 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("streams Pi content blocks by content index when an earlier thinking delta arrives late", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
await session.startTurn("hello");
|
||||
fakeSession.emit({
|
||||
type: "message_start",
|
||||
message: {
|
||||
role: "assistant",
|
||||
responseId: "response-1",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "" },
|
||||
{ type: "text", text: "" },
|
||||
],
|
||||
},
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "thinking_start", contentIndex: 0 },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "verify" },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_start", contentIndex: 1 },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "Answer" },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "." },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "thinking_end", contentIndex: 0 },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: " complete." },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_end", contentIndex: 1 },
|
||||
});
|
||||
|
||||
expect(events.timelineItems()).toEqual([
|
||||
{ type: "reasoning", text: "verify" },
|
||||
{ type: "reasoning", text: "." },
|
||||
{ type: "assistant_message", text: "Answer", messageId: "response-1" },
|
||||
{ type: "assistant_message", text: " complete.", messageId: "response-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("flushes indexed Pi blocks under the previous message id when its end event is missing", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
await session.startTurn("hello");
|
||||
fakeSession.emit({
|
||||
type: "message_start",
|
||||
message: {
|
||||
role: "assistant",
|
||||
responseId: "response-1",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "" },
|
||||
{ type: "text", text: "" },
|
||||
],
|
||||
},
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "think" },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "answer" },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_start",
|
||||
message: { role: "assistant", responseId: "response-2", content: [] },
|
||||
});
|
||||
|
||||
expect(events.timelineItems()).toEqual([
|
||||
{ type: "reasoning", text: "think" },
|
||||
{ type: "assistant_message", text: "answer", messageId: "response-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("streams Pi task calls as sub-agent cards with lifecycle status", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from "./history-mapper.js";
|
||||
import { materializeProviderImage } from "../provider-image-output.js";
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import { PiMessageContentStream, type PiMessageContentChunk } from "./message-content-stream.js";
|
||||
import { revertPiConversation } from "./rewind.js";
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
|
||||
@@ -1205,6 +1206,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private activeTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
private activeAssistantMessageId: string | null = null;
|
||||
private readonly activeAssistantContent = new PiMessageContentStream();
|
||||
private activeTurnStarted = false;
|
||||
private activeTurnStartedEmitted = false;
|
||||
private pendingSettledMessages: PiAgentMessage[] | null = null;
|
||||
@@ -1300,6 +1302,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.lastInterruptedTurnId = null;
|
||||
this.activeClientMessageId = options?.clientMessageId ?? null;
|
||||
this.activeAssistantMessageId = null;
|
||||
this.activeAssistantContent.reset();
|
||||
this.activeTurnStarted = false;
|
||||
this.activeTurnStartedEmitted = false;
|
||||
this.pendingSettledMessages = null;
|
||||
@@ -2101,7 +2104,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
});
|
||||
return;
|
||||
case "message_start":
|
||||
this.handleMessageStart(event);
|
||||
this.handleMessageStart(event, turnId);
|
||||
return;
|
||||
case "message_end":
|
||||
this.handleMessageEnd(event, turnId);
|
||||
@@ -2246,37 +2249,55 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
if (event.message && event.message.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
// Pi-compatible runtimes may emit updates without a preceding message_start.
|
||||
this.activeAssistantMessageId ??= event.message?.responseId || randomUUID();
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: event.assistantMessageEvent.delta ?? "",
|
||||
messageId: this.activeAssistantMessageId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "thinking_delta") {
|
||||
const message = event.message?.role === "assistant" ? event.message : undefined;
|
||||
this.emitMessageContentChunks(
|
||||
this.activeAssistantContent.update(event.assistantMessageEvent, message),
|
||||
turnId,
|
||||
message?.responseId,
|
||||
);
|
||||
}
|
||||
|
||||
private emitMessageContentChunks(
|
||||
chunks: PiMessageContentChunk[],
|
||||
turnId: string | undefined,
|
||||
responseId?: string,
|
||||
): void {
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.type === "text") {
|
||||
// Pi-compatible runtimes may emit updates without a preceding message_start.
|
||||
this.activeAssistantMessageId ??= responseId || randomUUID();
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: chunk.text,
|
||||
messageId: this.activeAssistantMessageId,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: {
|
||||
type: "reasoning",
|
||||
text: event.assistantMessageEvent.delta ?? "",
|
||||
text: chunk.text,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessageStart(event: Extract<PiAgentSessionEvent, { type: "message_start" }>): void {
|
||||
private handleMessageStart(
|
||||
event: Extract<PiAgentSessionEvent, { type: "message_start" }>,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
if (event.message.role === "assistant") {
|
||||
this.emitMessageContentChunks(this.activeAssistantContent.finish(), turnId);
|
||||
this.activeAssistantMessageId = event.message.responseId || null;
|
||||
this.activeAssistantContent.start(event.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2285,6 +2306,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
if (event.message.role === "assistant") {
|
||||
this.emitMessageContentChunks(this.activeAssistantContent.finish(), turnId);
|
||||
this.activeAssistantMessageId = null;
|
||||
return;
|
||||
}
|
||||
@@ -2355,6 +2377,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.lastInterruptedTurnId = null;
|
||||
return;
|
||||
}
|
||||
this.emitMessageContentChunks(this.activeAssistantContent.finish(), turnId);
|
||||
this.activeTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.activeAssistantMessageId = null;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PiAssistantContent, PiAssistantMessageEvent, PiAgentMessage } from "./rpc-types.js";
|
||||
|
||||
export interface PiMessageContentChunk {
|
||||
type: "text" | "thinking";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface PendingContentBlock {
|
||||
chunks: PiMessageContentChunk[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
// Pi may revisit an earlier contentIndex while a later block is already streaming. Paseo's
|
||||
// timeline is append-only, so later blocks wait here until every earlier block has ended.
|
||||
export class PiMessageContentStream {
|
||||
private readonly blocks = new Map<number, PendingContentBlock>();
|
||||
private readonly completedIndexes = new Set<number>();
|
||||
|
||||
start(content: PiAssistantContent[]): void {
|
||||
this.reset();
|
||||
this.seedBlocks(content);
|
||||
}
|
||||
|
||||
update(
|
||||
event: PiAssistantMessageEvent,
|
||||
message?: Extract<PiAgentMessage, { role: "assistant" }>,
|
||||
): PiMessageContentChunk[] {
|
||||
this.seedBlocks(message?.content ?? []);
|
||||
const contentIndex = readContentIndex(event);
|
||||
|
||||
if (event.type === "text_delta" || event.type === "thinking_delta") {
|
||||
const chunk = {
|
||||
type: event.type === "text_delta" ? ("text" as const) : ("thinking" as const),
|
||||
text: event.delta ?? "",
|
||||
};
|
||||
if (contentIndex === null) {
|
||||
return [chunk];
|
||||
}
|
||||
this.ensureBlock(contentIndex).chunks.push(chunk);
|
||||
return this.flushReadyBlocks();
|
||||
}
|
||||
|
||||
if (contentIndex === null) {
|
||||
return [];
|
||||
}
|
||||
if (isContentStart(event)) {
|
||||
this.ensureBlock(contentIndex);
|
||||
return this.flushReadyBlocks();
|
||||
}
|
||||
if (isContentEnd(event)) {
|
||||
this.ensureBlock(contentIndex).complete = true;
|
||||
return this.flushReadyBlocks();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
finish(): PiMessageContentChunk[] {
|
||||
for (const block of this.blocks.values()) {
|
||||
block.complete = true;
|
||||
}
|
||||
const chunks = this.flushReadyBlocks();
|
||||
this.reset();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.blocks.clear();
|
||||
this.completedIndexes.clear();
|
||||
}
|
||||
|
||||
private seedBlocks(content: PiAssistantContent[]): void {
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
this.ensureBlock(index);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureBlock(contentIndex: number): PendingContentBlock {
|
||||
const existing = this.blocks.get(contentIndex);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const block = { chunks: [], complete: false };
|
||||
if (!this.completedIndexes.has(contentIndex)) {
|
||||
this.blocks.set(contentIndex, block);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
private flushReadyBlocks(): PiMessageContentChunk[] {
|
||||
const chunks: PiMessageContentChunk[] = [];
|
||||
while (this.blocks.size > 0) {
|
||||
const contentIndex = Math.min(...this.blocks.keys());
|
||||
const block = this.blocks.get(contentIndex);
|
||||
if (!block) break;
|
||||
chunks.push(...block.chunks.splice(0));
|
||||
if (!block.complete) break;
|
||||
this.blocks.delete(contentIndex);
|
||||
this.completedIndexes.add(contentIndex);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
function readContentIndex(event: PiAssistantMessageEvent): number | null {
|
||||
if (!("contentIndex" in event)) return null;
|
||||
return Number.isInteger(event.contentIndex) && (event.contentIndex ?? -1) >= 0
|
||||
? (event.contentIndex ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isContentStart(event: PiAssistantMessageEvent): boolean {
|
||||
return (
|
||||
event.type === "text_start" ||
|
||||
event.type === "thinking_start" ||
|
||||
event.type === "toolcall_start"
|
||||
);
|
||||
}
|
||||
|
||||
function isContentEnd(event: PiAssistantMessageEvent): boolean {
|
||||
return (
|
||||
event.type === "text_end" || event.type === "thinking_end" || event.type === "toolcall_end"
|
||||
);
|
||||
}
|
||||
@@ -149,9 +149,11 @@ export interface PiRpcResponse {
|
||||
}
|
||||
|
||||
export type PiAssistantMessageEvent =
|
||||
| { type: "text_delta"; delta?: string }
|
||||
| { type: "thinking_delta"; delta?: string }
|
||||
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
|
||||
| { type: "text_delta"; contentIndex?: number; delta?: string }
|
||||
| { type: "thinking_delta"; contentIndex?: number; delta?: string }
|
||||
| { type: "text_start" | "text_end" | "thinking_start" | "thinking_end"; contentIndex?: number }
|
||||
| { type: "toolcall_start" | "toolcall_delta" | "toolcall_end"; contentIndex?: number }
|
||||
| { type: "start" | "done" };
|
||||
|
||||
export type PiAgentSessionEvent =
|
||||
| { type: "agent_start" }
|
||||
|
||||
@@ -83,7 +83,8 @@ describe("plugin author module externals", () => {
|
||||
const entryPath = path.join(directory, "index.ts");
|
||||
await writeFile(
|
||||
entryPath,
|
||||
`import { Icon, type PluginContext } from "${sdk}";
|
||||
`import type { PluginContext } from "${sdk}";
|
||||
import { Icon } from "${sdk}/react-native";
|
||||
import { defineRpc } from "${sdk}/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -106,8 +107,10 @@ export default function contribute(plugin: PluginContext) {
|
||||
);
|
||||
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
|
||||
expect(clientBundle).toContain(`${sdk}/react-native`);
|
||||
expect(clientBundle).toContain(`${sdk}/server`);
|
||||
expect(serverBundle).toContain(`${sdk}/server`);
|
||||
expect(serverBundle).not.toContain(`${sdk}/react-native`);
|
||||
expect(clientBundle).toContain("Settings");
|
||||
expect(serverBundle).not.toContain("Settings");
|
||||
expect(clientBundle).not.toContain("Invalid plugin RPC method");
|
||||
@@ -117,7 +120,7 @@ export default function contribute(plugin: PluginContext) {
|
||||
});
|
||||
|
||||
describe("plugin contribution targets", () => {
|
||||
it("keeps timeline contributions out of the server bundle", async () => {
|
||||
it("keeps client contributions out of the server bundle", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const entryPath = path.join(directory, "index.ts");
|
||||
@@ -135,6 +138,16 @@ describe("plugin contribution targets", () => {
|
||||
schema: { safeParse(value) { return { success: true, data: value }; } },
|
||||
Component() { return null; },
|
||||
});
|
||||
plugin.addClientSide((client) => {
|
||||
return client.addComposerPill({
|
||||
id: "composer-card",
|
||||
title: "Composer card",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component() { return null; },
|
||||
onPress() {},
|
||||
});
|
||||
});
|
||||
return () => undefined;
|
||||
}
|
||||
`,
|
||||
@@ -142,7 +155,9 @@ describe("plugin contribution targets", () => {
|
||||
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
|
||||
expect(clientBundle).toContain("timeline-card");
|
||||
expect(clientBundle).toContain("composer-card");
|
||||
expect(serverBundle).not.toContain("timeline-card");
|
||||
expect(serverBundle).not.toContain("composer-card");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { parse } from "@babel/parser";
|
||||
import type { Plugin } from "esbuild";
|
||||
import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js";
|
||||
import {
|
||||
PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
|
||||
PLUGIN_SDK_SPECIFIERS,
|
||||
} from "./plugin-sdk-specifiers.js";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
|
||||
@@ -80,6 +83,7 @@ const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<str
|
||||
"addSidebarItem",
|
||||
"addWorkspacePanel",
|
||||
"addCommandCenterItem",
|
||||
"addClientSide",
|
||||
"addAttachmentSource",
|
||||
"addTheme",
|
||||
"addTimelineTransformer",
|
||||
@@ -272,10 +276,23 @@ function makeHermesInteropEager(code: string): string {
|
||||
return code.replaceAll("get: () => from[key]", "value: from[key]");
|
||||
}
|
||||
|
||||
function exactSpecifierFilter(specifiers: readonly string[]): RegExp {
|
||||
const alternatives = specifiers.map((specifier) =>
|
||||
specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
);
|
||||
return new RegExp(`^(${alternatives.join("|")})$`);
|
||||
}
|
||||
|
||||
function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
|
||||
const filter =
|
||||
target === "server"
|
||||
? /^(@tanstack\/react-query|react|react\/jsx-runtime|react-native)$/
|
||||
? exactSpecifierFilter([
|
||||
"@tanstack/react-query",
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-native",
|
||||
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
|
||||
])
|
||||
: /^node:/;
|
||||
return {
|
||||
name: `paseo-plugin-${target}-unused-platform-modules`,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
|
||||
import { isPluginSdkSpecifier } from "./plugin-sdk-specifiers.js";
|
||||
import { isPluginClientOnlySdkSpecifier, isPluginSdkSpecifier } from "./plugin-sdk-specifiers.js";
|
||||
|
||||
type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
|
||||
|
||||
@@ -71,6 +71,9 @@ const pluginAuthorRuntime = {
|
||||
};
|
||||
|
||||
function runtimeRequire(name: string): unknown {
|
||||
if (isPluginClientOnlySdkSpecifier(name)) {
|
||||
throw new Error(`${name} is available only in plugin client code`);
|
||||
}
|
||||
if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime;
|
||||
return nodeRequire(name);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,23 @@
|
||||
// COMPAT(plugin-sdk-scope): @paseo/plugin was the SDK name through 0.5.0-beta.1 and was never
|
||||
// published — that scope is not ours. Plugins scaffolded against that name still import it, so
|
||||
// both spellings resolve. Remove the @paseo/* entries after 2026-11-19.
|
||||
export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [
|
||||
"@getpaseo/plugin/react-native",
|
||||
"@paseo/plugin/react-native",
|
||||
] as const;
|
||||
|
||||
export const PLUGIN_SDK_SPECIFIERS = [
|
||||
"@getpaseo/plugin",
|
||||
"@getpaseo/plugin/server",
|
||||
"@paseo/plugin",
|
||||
"@paseo/plugin/server",
|
||||
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
|
||||
] as const;
|
||||
|
||||
export function isPluginSdkSpecifier(name: string): boolean {
|
||||
return (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
export function isPluginClientOnlySdkSpecifier(name: string): boolean {
|
||||
return (PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,43 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
diffWorkspaceGitRefs,
|
||||
diffWorkspaceGitRemoteRefs,
|
||||
fetchWorkspaceGitRemote,
|
||||
parseWorkspaceGitRefs,
|
||||
} from "./workspace-git-fetch.js";
|
||||
import type { RunGitCommand } from "../utils/run-git-command.js";
|
||||
|
||||
describe("workspace Git remote refs", () => {
|
||||
test("runs the complete fetch through the supplied Git runner", async () => {
|
||||
const commands: string[][] = [];
|
||||
let refRead = 0;
|
||||
const runGitCommand: RunGitCommand = async (args) => {
|
||||
commands.push(args);
|
||||
const stdout =
|
||||
args[0] === "for-each-ref"
|
||||
? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}`
|
||||
: "";
|
||||
return { stdout, stderr: "", truncated: false, exitCode: 0, signal: null };
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand),
|
||||
).resolves.toMatchObject({
|
||||
changes: [
|
||||
{
|
||||
kind: "moved",
|
||||
ref: "origin/main",
|
||||
beforeOid: "aaaaaaaa",
|
||||
afterOid: "bbbbbbbb",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(commands).toEqual([
|
||||
["for-each-ref", "--format=%(refname)%00%(objectname)"],
|
||||
["fetch", "origin", "--prune"],
|
||||
["for-each-ref", "--format=%(refname)%00%(objectname)"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses refs for semantic before/after comparison", () => {
|
||||
expect(
|
||||
parseWorkspaceGitRefs(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js";
|
||||
|
||||
export interface WorkspaceGitFetchResult {
|
||||
changes: WorkspaceGitRemoteRefChange[] | null;
|
||||
@@ -19,18 +19,19 @@ export type WorkspaceGitRemoteRefChange =
|
||||
export async function fetchWorkspaceGitRemote(
|
||||
cwd: string,
|
||||
observer: WorkspaceGitFetchObserver,
|
||||
run: RunGitCommand = runGitCommand,
|
||||
): Promise<WorkspaceGitFetchResult> {
|
||||
let before: Map<string, string> | null = null;
|
||||
let after: Map<string, string> | null = null;
|
||||
let error: unknown | null = null;
|
||||
try {
|
||||
before = await readWorkspaceGitRefs(cwd);
|
||||
before = await readWorkspaceGitRefs(cwd, run);
|
||||
observer.onRefSnapshot("before");
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
try {
|
||||
await runGitCommand(["fetch", "origin", "--prune"], {
|
||||
await run(["fetch", "origin", "--prune"], {
|
||||
cwd,
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
|
||||
timeout: 120_000,
|
||||
@@ -39,7 +40,7 @@ export async function fetchWorkspaceGitRemote(
|
||||
error ??= caught;
|
||||
}
|
||||
try {
|
||||
after = await readWorkspaceGitRefs(cwd);
|
||||
after = await readWorkspaceGitRefs(cwd, run);
|
||||
} catch (caught) {
|
||||
error ??= caught;
|
||||
}
|
||||
@@ -66,8 +67,8 @@ function collectWorkspaceGitRemoteRefs(refs: ReadonlyMap<string, string>): Set<s
|
||||
);
|
||||
}
|
||||
|
||||
async function readWorkspaceGitRefs(cwd: string): Promise<Map<string, string>> {
|
||||
const { stdout } = await runGitCommand(["for-each-ref", "--format=%(refname)%00%(objectname)"], {
|
||||
async function readWorkspaceGitRefs(cwd: string, run: RunGitCommand): Promise<Map<string, string>> {
|
||||
const { stdout } = await run(["for-each-ref", "--format=%(refname)%00%(objectname)"], {
|
||||
cwd,
|
||||
});
|
||||
return parseWorkspaceGitRefs(stdout);
|
||||
|
||||
@@ -1389,7 +1389,11 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(runGitFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1], expect.anything());
|
||||
expect(runGitFetch).toHaveBeenLastCalledWith(
|
||||
worktrees[1],
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
for (const subscription of subscriptions.slice(1)) {
|
||||
subscription.unsubscribe();
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
type CheckoutStatusGit,
|
||||
type PullRequestStatusResult,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js";
|
||||
import {
|
||||
runGitCommand as runGitCommandReal,
|
||||
snapshotGitCommandRuntimeMetrics,
|
||||
} from "../utils/run-git-command.js";
|
||||
import {
|
||||
getWorkspaceGitSelfHealPhaseMs,
|
||||
WorkspaceGitServiceImpl,
|
||||
@@ -448,6 +451,48 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("attributes Git commands submitted by a workspace refresh", async () => {
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-provenance-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(join(repoDir, "tracked.txt"), "tracked\n");
|
||||
execFileSync("git", ["add", "tracked.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=paseo@example.test",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: repoDir, stdio: "pipe" },
|
||||
);
|
||||
snapshotGitCommandRuntimeMetrics();
|
||||
const service = createService({
|
||||
getCheckoutSnapshotFacts: getCheckoutSnapshotFactsUncached as never,
|
||||
getCheckoutStatus: getCheckoutStatusUncached as never,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.getSnapshot(repoDir, { force: true, reason: "provenance-test" });
|
||||
|
||||
const metrics = snapshotGitCommandRuntimeMetrics();
|
||||
expect(metrics.submitted).toBeGreaterThan(0);
|
||||
expect(metrics.provenanceTop).toEqual([
|
||||
["workspace-refresh:provenance-test", metrics.submitted],
|
||||
]);
|
||||
} finally {
|
||||
service.dispose();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("getSnapshot cold-loads when no snapshot exists yet with one shell burst", async () => {
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const getCheckoutShortstat = vi.fn(async () => ({ additions: 1, deletions: 0 }));
|
||||
|
||||
@@ -46,7 +46,11 @@ import {
|
||||
getRealpathAwareRelativePath,
|
||||
isRealpathInsideRoot,
|
||||
} from "../utils/path.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import {
|
||||
createRunGitCommand,
|
||||
runGitCommand,
|
||||
type RunGitCommand,
|
||||
} from "../utils/run-git-command.js";
|
||||
import { branchNameFromRef } from "../utils/worktree-metadata.js";
|
||||
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
@@ -351,6 +355,7 @@ interface WorkspaceGitServiceDependencies {
|
||||
runGitFetch: (
|
||||
cwd: string,
|
||||
observer: WorkspaceGitFetchObserver,
|
||||
runGitCommand: RunGitCommand,
|
||||
) => Promise<WorkspaceGitFetchResult>;
|
||||
runGitCommand: typeof runGitCommand;
|
||||
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
||||
@@ -2776,11 +2781,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
});
|
||||
}
|
||||
try {
|
||||
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
|
||||
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
|
||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return null;
|
||||
}
|
||||
return this.refreshSnapshot(target, request);
|
||||
return this.refreshSnapshot(target, request, runRefreshGitCommand);
|
||||
});
|
||||
if (!admittedSnapshot) {
|
||||
break;
|
||||
@@ -2819,12 +2825,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private async refreshSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
runRefreshGitCommand: RunGitCommand,
|
||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||
let facts = target.latestFacts;
|
||||
if (request.movedRemoteRefs.size > 0 && !request.refreshStructure && !request.refreshWorktree) {
|
||||
if (facts?.isGit && target.latestGit?.isGit) {
|
||||
try {
|
||||
await this.refreshRefDerivedSnapshot(target, facts, request.movedRemoteRefs);
|
||||
await this.refreshRefDerivedSnapshot(
|
||||
target,
|
||||
facts,
|
||||
request.movedRemoteRefs,
|
||||
runRefreshGitCommand,
|
||||
);
|
||||
return this.combineSnapshot(target);
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
@@ -2833,23 +2845,27 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
);
|
||||
}
|
||||
}
|
||||
facts = await this.refreshGitSnapshot(target, {
|
||||
...request,
|
||||
refreshStructure: true,
|
||||
refreshWorktree: true,
|
||||
});
|
||||
facts = await this.refreshGitSnapshot(
|
||||
target,
|
||||
{
|
||||
...request,
|
||||
refreshStructure: true,
|
||||
refreshWorktree: true,
|
||||
},
|
||||
runRefreshGitCommand,
|
||||
);
|
||||
return this.combineSnapshot(target);
|
||||
}
|
||||
if (request.refreshStructure || !facts || !target.latestGit) {
|
||||
facts = await this.refreshGitSnapshot(target, request);
|
||||
facts = await this.refreshGitSnapshot(target, request, runRefreshGitCommand);
|
||||
} else if (request.refreshWorktree) {
|
||||
await this.refreshWorktreeSnapshot(target, facts);
|
||||
await this.refreshWorktreeSnapshot(target, facts, runRefreshGitCommand);
|
||||
}
|
||||
if (!facts) {
|
||||
facts = await this.refreshGitSnapshot(target, request);
|
||||
facts = await this.refreshGitSnapshot(target, request, runRefreshGitCommand);
|
||||
}
|
||||
if (request.includeForge) {
|
||||
await this.refreshForgeSnapshot(target, request, facts);
|
||||
await this.refreshForgeSnapshot(target, request, facts, runRefreshGitCommand);
|
||||
}
|
||||
|
||||
const snapshot = this.combineSnapshot(target);
|
||||
@@ -2861,6 +2877,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target: WorkspaceGitTarget,
|
||||
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
|
||||
movedRemoteRefs: ReadonlySet<string>,
|
||||
runRefreshGitCommand: RunGitCommand,
|
||||
): Promise<void> {
|
||||
const latestGit = target.latestGit;
|
||||
if (!latestGit?.isGit) {
|
||||
@@ -2872,7 +2889,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
facts,
|
||||
{ aheadBehind: latestGit.aheadBehind, diffStat: latestGit.diffStat },
|
||||
movedRemoteRefs,
|
||||
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, logger: this.logger, facts },
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
facts,
|
||||
runGitCommand: runRefreshGitCommand,
|
||||
},
|
||||
);
|
||||
target.latestFacts = { ...facts, upstreamStatus: derived.upstreamStatus };
|
||||
target.latestGit = {
|
||||
@@ -2890,6 +2913,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private async refreshWorktreeSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
facts: CheckoutSnapshotFacts,
|
||||
runRefreshGitCommand: RunGitCommand,
|
||||
): Promise<void> {
|
||||
const latestGit = target.latestGit;
|
||||
if (!latestGit || !facts.isGit) {
|
||||
@@ -2902,6 +2926,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
facts,
|
||||
runGitCommand: runRefreshGitCommand,
|
||||
};
|
||||
const worktree = await this.deps.getCheckoutWorktreeState(target.cwd, context);
|
||||
target.latestGit = {
|
||||
@@ -2916,6 +2941,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private async refreshGitSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
runRefreshGitCommand: RunGitCommand,
|
||||
): Promise<CheckoutSnapshotFacts> {
|
||||
const now = this.deps.now();
|
||||
target.lastShellOutAtMs = now.getTime();
|
||||
@@ -2926,6 +2952,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
runGitCommand: runRefreshGitCommand,
|
||||
};
|
||||
const facts = await this.loadCheckoutFacts(target, baseContext);
|
||||
const context: CheckoutContext = { ...baseContext, facts };
|
||||
@@ -2980,6 +3007,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
facts: CheckoutSnapshotFacts,
|
||||
runRefreshGitCommand: RunGitCommand,
|
||||
): Promise<void> {
|
||||
const remoteUrl = target.latestGit?.remoteUrl ?? null;
|
||||
const resolution = await this.forgeResolver.resolveFromRemoteUrlAsync(remoteUrl);
|
||||
@@ -3006,6 +3034,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
force: forceForge,
|
||||
reason: request.reason,
|
||||
facts,
|
||||
runGitCommand: runRefreshGitCommand,
|
||||
});
|
||||
// Carry the resolved forge (probe-aware) so the wire projection labels
|
||||
// self-managed GitLab hosts correctly instead of falling back to "github".
|
||||
@@ -3112,14 +3141,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
let result: WorkspaceGitFetchResult | null = null;
|
||||
const eventsBeforeFetchSnapshot: FileChange[] = [];
|
||||
try {
|
||||
result = await this.deps.runGitFetch(target.cwd, {
|
||||
onRefSnapshot: (phase) => {
|
||||
const events = target.bufferedFetchMetadataEvents.splice(0);
|
||||
if (phase === "before") {
|
||||
eventsBeforeFetchSnapshot.push(...events);
|
||||
}
|
||||
result = await this.deps.runGitFetch(
|
||||
target.cwd,
|
||||
{
|
||||
onRefSnapshot: (phase) => {
|
||||
const events = target.bufferedFetchMetadataEvents.splice(0);
|
||||
if (phase === "before") {
|
||||
eventsBeforeFetchSnapshot.push(...events);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
createRunGitCommand("background-fetch"),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||
@@ -3353,6 +3386,7 @@ async function loadForgeSnapshot(options: {
|
||||
force?: boolean;
|
||||
reason?: string;
|
||||
facts?: CheckoutSnapshotFacts;
|
||||
runGitCommand: RunGitCommand;
|
||||
}): Promise<WorkspaceGitRuntimeSnapshot["forge"]> {
|
||||
const forgeService = options.forgeService;
|
||||
if (!forgeService) {
|
||||
@@ -3379,7 +3413,7 @@ async function loadForgeSnapshot(options: {
|
||||
force: options.force,
|
||||
reason: options.reason,
|
||||
},
|
||||
{ facts: options.facts },
|
||||
{ facts: options.facts, runGitCommand: options.runGitCommand },
|
||||
);
|
||||
return buildForgeSnapshot(result.authState, result.status, null);
|
||||
} catch (error) {
|
||||
|
||||
@@ -151,6 +151,12 @@ export interface ForgeCommandFailureParams {
|
||||
cwd: string;
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
/**
|
||||
* Whatever the failing command printed to stdout. GraphQL batch callers use
|
||||
* this to salvage partial `data` when one aliased sub-query errors but the
|
||||
* others succeeded (the CLI exits non-zero yet still prints the body).
|
||||
*/
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
export class ForgeCommandError extends Error {
|
||||
@@ -159,6 +165,7 @@ export class ForgeCommandError extends Error {
|
||||
readonly cwd: string;
|
||||
readonly exitCode: number | null;
|
||||
readonly stderr: string;
|
||||
readonly stdout?: string;
|
||||
|
||||
constructor(label: { brand: string; binary: string }, params: ForgeCommandFailureParams) {
|
||||
super(`${label.brand} CLI command failed: ${label.binary} ${params.args.join(" ")}`);
|
||||
@@ -166,6 +173,9 @@ export class ForgeCommandError extends Error {
|
||||
this.cwd = params.cwd;
|
||||
this.exitCode = params.exitCode;
|
||||
this.stderr = params.stderr;
|
||||
if (params.stdout !== undefined) {
|
||||
this.stdout = params.stdout;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +237,7 @@ export function normalizeCliCommandError(options: NormalizeCliCommandErrorOption
|
||||
return options.createMissingError();
|
||||
}
|
||||
const stderr = bufferOrStringToString(failure.stderr);
|
||||
const stdout = bufferOrStringToString(failure.stdout);
|
||||
const message = failure.message ?? "";
|
||||
if (options.isAuthFailureText(stderr) || options.isAuthFailureText(message)) {
|
||||
return options.createAuthError(stderr);
|
||||
@@ -239,6 +250,7 @@ export function normalizeCliCommandError(options: NormalizeCliCommandErrorOption
|
||||
stderr:
|
||||
stderr ||
|
||||
`${options.commandName} was terminated before completing (timed out after ${options.timeoutMs}ms or exceeded the output limit)`,
|
||||
...(stdout ? { stdout } : {}),
|
||||
});
|
||||
}
|
||||
return options.createCommandError({
|
||||
@@ -246,6 +258,7 @@ export function normalizeCliCommandError(options: NormalizeCliCommandErrorOption
|
||||
cwd: options.cwd,
|
||||
exitCode: typeof failure.code === "number" ? failure.code : null,
|
||||
stderr: stderr || message,
|
||||
...(stdout ? { stdout } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,76 @@ function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> =
|
||||
});
|
||||
}
|
||||
|
||||
function batchPollPrNodeJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/widgets/pull/42",
|
||||
title: "Widget PR",
|
||||
state: "MERGED",
|
||||
isDraft: false,
|
||||
baseRefName: "main",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-42",
|
||||
mergedAt: "2026-08-01T00:00:00Z",
|
||||
mergeable: "UNKNOWN",
|
||||
reviewDecision: null,
|
||||
headRepositoryOwner: { login: "acme" },
|
||||
mergeStateStatus: "CLEAN",
|
||||
autoMergeRequest: null,
|
||||
viewerCanEnableAutoMerge: false,
|
||||
viewerCanDisableAutoMerge: false,
|
||||
viewerCanMergeAsAdmin: false,
|
||||
viewerCanUpdateBranch: false,
|
||||
isMergeQueueEnabled: false,
|
||||
isInMergeQueue: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function batchPollRepositoryJson(
|
||||
nodes: Array<Record<string, unknown>>,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
isFork: false,
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
pullRequests: { nodes },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function batchPollChecksAliasJson(
|
||||
contexts: Array<Record<string, unknown>>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
pullRequest: {
|
||||
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: contexts } } } }] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function batchPollStatusJson(aliases: Record<string, unknown>): string {
|
||||
return JSON.stringify({ data: aliases });
|
||||
}
|
||||
|
||||
function batchPollStatusWithRateLimitJson(input: {
|
||||
aliases: Record<string, unknown>;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
resetAt: string;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
data: {
|
||||
...input.aliases,
|
||||
rateLimit: { limit: input.limit, remaining: input.remaining, resetAt: input.resetAt },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createCurrentPullRequestStatus(
|
||||
overrides: Partial<CurrentPullRequestStatus> = {},
|
||||
): CurrentPullRequestStatus {
|
||||
@@ -976,6 +1046,677 @@ describe("ForgeService", () => {
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("batches PR status polls across workspaces into one GraphQL request", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 41,
|
||||
url: "https://github.com/acme/widgets/pull/41",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-a",
|
||||
}),
|
||||
]),
|
||||
t1: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 52,
|
||||
url: "https://github.com/acme/gadgets/pull/52",
|
||||
headRefName: "feat-b",
|
||||
headRefOid: "oid-b",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
t1: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
}),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 41,
|
||||
url: "https://github.com/acme/widgets/pull/41",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-a",
|
||||
}),
|
||||
]),
|
||||
t1: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 52,
|
||||
url: "https://github.com/acme/gadgets/pull/52",
|
||||
headRefName: "feat-b",
|
||||
headRefOid: "oid-b",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" };
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
|
||||
now: () => now,
|
||||
});
|
||||
const statusesA: Array<CurrentPullRequestStatus | null> = [];
|
||||
const statusesB: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
headSha: "oid-a",
|
||||
onStatus: (status) => statusesA.push(status),
|
||||
});
|
||||
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-b",
|
||||
headRef: "feat-b",
|
||||
headSha: "oid-b",
|
||||
onStatus: (status) => statusesB.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]);
|
||||
expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")');
|
||||
expect(runner.calls[0]?.args[3]).toContain('t1: repository(owner: "acme", name: "gadgets")');
|
||||
expect(runner.calls[0]?.args[3]).toContain('headRefName: "feat-a"');
|
||||
expect(runner.calls[0]?.args[3]).toContain('headRefName: "feat-b"');
|
||||
expect(runner.calls[1]?.args[3]).toContain("pullRequest(number: 41)");
|
||||
expect(runner.calls[1]?.args[3]).toContain("pullRequest(number: 52)");
|
||||
expect(statusesA).toEqual([
|
||||
expect.objectContaining({
|
||||
number: 41,
|
||||
state: "merged",
|
||||
isMerged: true,
|
||||
checksStatus: "success",
|
||||
forgeSpecific: expect.objectContaining({ forge: "github", mergeStateStatus: "CLEAN" }),
|
||||
}),
|
||||
]);
|
||||
expect(statusesB).toEqual([
|
||||
expect.objectContaining({ number: 52, state: "merged", checksStatus: "success" }),
|
||||
]);
|
||||
|
||||
// Merged PR checks are frozen: the second cycle reuses the cached rollup
|
||||
// and issues only the discovery request.
|
||||
now = EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(3);
|
||||
expect(statusesA).toHaveLength(2);
|
||||
expect(statusesA[1]).toEqual(expect.objectContaining({ number: 41, checksStatus: "success" }));
|
||||
|
||||
subscriptionA?.unsubscribe();
|
||||
subscriptionB?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("refetches checks for open PRs on every batched poll at fast cadence", async () => {
|
||||
let now = 0;
|
||||
const openNode = batchPollPrNodeJson({
|
||||
number: 7,
|
||||
url: "https://github.com/acme/widgets/pull/7",
|
||||
state: "OPEN",
|
||||
mergedAt: null,
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-open",
|
||||
});
|
||||
const pendingChecks = batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "PENDING" },
|
||||
]);
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([openNode]) }),
|
||||
batchPollStatusJson({ t0: pendingChecks }),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([openNode]) }),
|
||||
batchPollStatusJson({ t0: pendingChecks }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
expect(statuses).toEqual([
|
||||
expect.objectContaining({ number: 7, state: "open", checksStatus: "pending" }),
|
||||
]);
|
||||
|
||||
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(4);
|
||||
expect(statuses).toHaveLength(2);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("backs off all batched targets on a failed batch without per-target fallback calls", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([{ error: new Error("network down") }]);
|
||||
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" };
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
|
||||
now: () => now,
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-b",
|
||||
headRef: "feat-b",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
|
||||
expect(errors).toHaveLength(2);
|
||||
|
||||
subscriptionA?.unsubscribe();
|
||||
subscriptionB?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("falls back to the per-target lookup when a fork repository has no matching PR", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([], { isFork: true }) }),
|
||||
currentPullRequestJson({ headRefName: "feat-a" }),
|
||||
currentPullRequestGithubFactsJson(),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "forkowner/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]);
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("redirects a fork poll to its parent and reuses that batch address", async () => {
|
||||
let now = 0;
|
||||
const parent = { owner: { login: "upstream" }, name: "widgets" };
|
||||
const forkPr = batchPollPrNodeJson({
|
||||
url: "https://github.com/upstream/widgets/pull/42",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-a",
|
||||
headRepositoryOwner: { login: "forkowner" },
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([], { isFork: true, parent }),
|
||||
}),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([forkPr]) }),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
}),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([forkPr]) }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "forkowner/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
headSha: "oid-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
|
||||
expect(runner.calls[0]?.args[3]).toContain(
|
||||
't0: repository(owner: "forkowner", name: "widgets")',
|
||||
);
|
||||
expect(runner.calls[1]?.args[3]).toContain(
|
||||
't0: repository(owner: "upstream", name: "widgets")',
|
||||
);
|
||||
expect(statuses).toEqual([
|
||||
expect.objectContaining({ number: 42, repoOwner: "upstream", checksStatus: "success" }),
|
||||
]);
|
||||
|
||||
now = EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls[3]?.args[3]).toContain(
|
||||
't0: repository(owner: "upstream", name: "widgets")',
|
||||
);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("treats a missing pullRequests connection as a failed alias", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: { isFork: false, autoMergeAllowed: true },
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(statuses).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("treats a malformed checks connection as a failed alias", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([batchPollPrNodeJson({ state: "OPEN", mergedAt: null })]),
|
||||
}),
|
||||
batchPollStatusJson({ t0: { pullRequest: {} } }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(statuses).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("pauses a host until reset when a batch reaches the GraphQL reserve", async () => {
|
||||
let now = 0;
|
||||
const resetAt = new Date(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS).toISOString();
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusWithRateLimitJson({
|
||||
aliases: { t0: batchPollRepositoryJson([batchPollPrNodeJson()]) },
|
||||
limit: 5_000,
|
||||
remaining: 2_000,
|
||||
resetAt,
|
||||
}),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([]) }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(runner.calls[0]?.args[3]).toContain("rateLimit");
|
||||
expect(errors).toHaveLength(1);
|
||||
|
||||
now = EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
|
||||
await flushMicrotasks();
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
|
||||
now += 1_000;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushMicrotasks();
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("loads the reset time once when GitHub rejects a batch for rate limiting", async () => {
|
||||
let now = 0;
|
||||
const rateLimitError = Object.assign(new Error("gh exited 1"), {
|
||||
code: 1,
|
||||
stderr: "GraphQL: API rate limit already exceeded for user ID 123",
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
{ error: rateLimitError },
|
||||
JSON.stringify({ resources: { graphql: { remaining: 0, reset: 300 } } }),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([]) }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([
|
||||
["api", "graphql"],
|
||||
["api", "rate_limit"],
|
||||
]);
|
||||
|
||||
now = EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
|
||||
await flushMicrotasks();
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
|
||||
now += 1_000;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushMicrotasks();
|
||||
expect(runner.calls).toHaveLength(3);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("keeps refetching checks for a closed PR while its checks are still pending", async () => {
|
||||
let now = 0;
|
||||
const closedNode = batchPollPrNodeJson({
|
||||
number: 9,
|
||||
url: "https://github.com/acme/widgets/pull/9",
|
||||
state: "CLOSED",
|
||||
mergedAt: null,
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-closed",
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([closedNode]) }),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "PENDING" },
|
||||
]),
|
||||
}),
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson([closedNode]) }),
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
headSha: "oid-closed",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
expect(statuses).toEqual([expect.objectContaining({ number: 9, checksStatus: "pending" })]);
|
||||
|
||||
// Pending checks are never frozen, so the next cycle re-runs the checks
|
||||
// query and observes the completed run.
|
||||
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(4);
|
||||
expect(statuses[1]).toEqual(expect.objectContaining({ number: 9, checksStatus: "success" }));
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("falls back to the per-target lookup when the batch candidate page is full", async () => {
|
||||
let now = 0;
|
||||
const crowdedNodes = Array.from({ length: 10 }, (_, index) =>
|
||||
batchPollPrNodeJson({
|
||||
number: 100 + index,
|
||||
url: `https://github.com/acme/widgets/pull/${100 + index}`,
|
||||
state: "OPEN",
|
||||
mergedAt: null,
|
||||
headRefName: "feat-a",
|
||||
headRepositoryOwner: { login: "stranger" },
|
||||
}),
|
||||
);
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({ t0: batchPollRepositoryJson(crowdedNodes) }),
|
||||
currentPullRequestJson({ headRefName: "feat-a" }),
|
||||
currentPullRequestGithubFactsJson(),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
// Ten same-named fork PRs filled the page, so absence of a self-owned PR
|
||||
// is inconclusive and the target resolves through the legacy path.
|
||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("ignores another fork owner's same-named branch in batched polls without an owner hint", async () => {
|
||||
let now = 0;
|
||||
const runner = createScriptedRunner([
|
||||
batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 900,
|
||||
url: "https://github.com/acme/widgets/pull/900",
|
||||
state: "OPEN",
|
||||
mergedAt: null,
|
||||
headRefName: "main",
|
||||
headRepositoryOwner: { login: "stranger" },
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
|
||||
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "main",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(statuses).toEqual([null]);
|
||||
|
||||
subscription?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("recovers healthy aliases from a partially failed batch response", async () => {
|
||||
let now = 0;
|
||||
const partialBody = batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({
|
||||
number: 41,
|
||||
url: "https://github.com/acme/widgets/pull/41",
|
||||
headRefName: "feat-a",
|
||||
headRefOid: "oid-a",
|
||||
}),
|
||||
]),
|
||||
t1: null,
|
||||
});
|
||||
const runner = createScriptedRunner([
|
||||
{
|
||||
error: Object.assign(new Error("gh exited 1"), {
|
||||
code: 1,
|
||||
stdout: partialBody,
|
||||
stderr: "GraphQL: Could not resolve to a Repository",
|
||||
}),
|
||||
},
|
||||
batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/missing" };
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => null,
|
||||
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
|
||||
now: () => now,
|
||||
});
|
||||
const statuses: Array<CurrentPullRequestStatus | null> = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
headSha: "oid-a",
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-b",
|
||||
headRef: "feat-b",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
expect(statuses).toEqual([
|
||||
expect.objectContaining({ number: 41, state: "merged", checksStatus: "success" }),
|
||||
]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
|
||||
|
||||
subscriptionA?.unsubscribe();
|
||||
subscriptionB?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
|
||||
const runner = createRunner([pullRequestTimelineJson()]);
|
||||
const service = createGitHubService({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -521,6 +521,14 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("honors commit signing configuration", async () => {
|
||||
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "signed\n");
|
||||
|
||||
await expect(commitAll(repoDir, "signed update")).rejects.toThrow("failed to sign the data");
|
||||
});
|
||||
|
||||
it("includes both paths for a staged rename in structured diffs", async () => {
|
||||
execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir });
|
||||
|
||||
@@ -3530,6 +3538,21 @@ const x = 1;
|
||||
);
|
||||
});
|
||||
|
||||
it("honors commit signing configuration for squash merges", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir });
|
||||
|
||||
await expect(mergeToBase(repoDir, { baseRef: "main", mode: "squash" })).rejects.toThrow(
|
||||
"failed to sign the data",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses stored baseRefName for Paseo worktrees (no heuristics)", async () => {
|
||||
// Create a non-default base branch with a unique commit.
|
||||
execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir });
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
ForgeCommandError,
|
||||
} from "../services/forge-cli-command.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
|
||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||
import {
|
||||
branchNameFromRef,
|
||||
@@ -848,6 +848,7 @@ export interface CheckoutContext {
|
||||
worktreesRoot?: string;
|
||||
logger?: Pick<Logger, "trace" | "warn">;
|
||||
facts?: CheckoutSnapshotFacts | null;
|
||||
runGitCommand?: RunGitCommand;
|
||||
}
|
||||
|
||||
export type CheckoutSnapshotFacts =
|
||||
@@ -876,9 +877,16 @@ function isNotGitRepositoryError(error: unknown): boolean {
|
||||
return error instanceof Error && /not a git repository/i.test(error.message);
|
||||
}
|
||||
|
||||
async function requireGitRepo(cwd: string): Promise<void> {
|
||||
function getRunGitCommand(context?: CheckoutContext): RunGitCommand {
|
||||
return context?.runGitCommand ?? runGitCommand;
|
||||
}
|
||||
|
||||
async function requireGitRepo(cwd: string, context?: CheckoutContext): Promise<void> {
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--git-dir"], { cwd, envOverlay: READ_ONLY_GIT_ENV });
|
||||
await getRunGitCommand(context)(["rev-parse", "--git-dir"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
} catch {
|
||||
throw new NotGitRepoError(cwd);
|
||||
}
|
||||
@@ -900,15 +908,18 @@ async function requireGitWorktreeRoot(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||
export async function getCurrentBranch(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
const { stdout } = await getRunGitCommand(context)(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const branch = stdout.trim();
|
||||
if (branch === "HEAD") {
|
||||
return await getRebaseHeadBranch(cwd);
|
||||
return await getRebaseHeadBranch(cwd, context);
|
||||
}
|
||||
return branch.length > 0 ? branch : null;
|
||||
} catch {
|
||||
@@ -924,7 +935,7 @@ async function getCurrentHeadSha(cwd: string, context?: CheckoutContext): Promis
|
||||
return knownSha;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], {
|
||||
const { stdout } = await getRunGitCommand(context)(["rev-parse", "HEAD"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
@@ -948,12 +959,12 @@ async function addHeadShaToPullRequestLookupTarget(
|
||||
return headSha ? { ...target, headSha } : target;
|
||||
}
|
||||
|
||||
async function getRebaseHeadBranch(cwd: string): Promise<string | null> {
|
||||
async function getRebaseHeadBranch(cwd: string, context?: CheckoutContext): Promise<string | null> {
|
||||
const paths = ["rebase-merge/head-name", "rebase-apply/head-name"];
|
||||
const results = await Promise.all(
|
||||
paths.map(async (path): Promise<string | null> => {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--git-path", path], {
|
||||
const { stdout } = await getRunGitCommand(context)(["rev-parse", "--git-path", path], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -972,7 +983,7 @@ async function getRebaseHeadBranch(cwd: string): Promise<string | null> {
|
||||
|
||||
async function getWorktreeRoot(cwd: string, context?: CheckoutContext): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
const { stdout } = await getRunGitCommand(context)(["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
@@ -989,12 +1000,15 @@ async function getWorktreeRoot(cwd: string, context?: CheckoutContext): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMainRepoRoot(cwd: string): Promise<string> {
|
||||
const { stdout: commonDirOut } = await runGitCommand(["rev-parse", "--git-common-dir"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return getMainRepoRootFromCommonDir(cwd, resolveGitRevParsePath(cwd, commonDirOut));
|
||||
export async function getMainRepoRoot(cwd: string, context?: CheckoutContext): Promise<string> {
|
||||
const { stdout: commonDirOut } = await getRunGitCommand(context)(
|
||||
["rev-parse", "--git-common-dir"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
return getMainRepoRootFromCommonDir(cwd, resolveGitRevParsePath(cwd, commonDirOut), context);
|
||||
}
|
||||
|
||||
async function getMainRepoRootFromCommonDir(
|
||||
@@ -1011,10 +1025,13 @@ async function getMainRepoRootFromCommonDir(
|
||||
return dirname(normalized);
|
||||
}
|
||||
|
||||
const { stdout: worktreeOut } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout: worktreeOut } = await getRunGitCommand(context)(
|
||||
["worktree", "list", "--porcelain"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const worktrees = parseWorktreeList(worktreeOut);
|
||||
const nonBareNonPaseo = worktrees.filter(
|
||||
(wt) =>
|
||||
@@ -1217,7 +1234,7 @@ async function resolveBaseRefForCwd(
|
||||
const storedBaseRef = await getStoredBaseRefForCwd(cwd, context);
|
||||
return {
|
||||
storedBaseRef,
|
||||
resolvedBaseRef: storedBaseRef ?? (await resolveBaseRef(cwd)),
|
||||
resolvedBaseRef: storedBaseRef ?? (await resolveBaseRef(cwd, context)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1253,7 +1270,7 @@ function resolveOperationBaseRef(input: {
|
||||
}
|
||||
|
||||
async function isWorkingTreeDirty(cwd: string, context?: CheckoutContext): Promise<boolean> {
|
||||
const { stdout } = await runGitCommand(["status", "--porcelain"], {
|
||||
const { stdout } = await getRunGitCommand(context)(["status", "--porcelain"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
@@ -1261,9 +1278,12 @@ async function isWorkingTreeDirty(cwd: string, context?: CheckoutContext): Promi
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
export async function getOriginRemoteUrl(cwd: string): Promise<string | null> {
|
||||
export async function getOriginRemoteUrl(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||
const { stdout } = await getRunGitCommand(context)(["config", "--get", "remote.origin.url"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -1285,7 +1305,7 @@ async function getGitConfigValue(
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["config", "--get", key], {
|
||||
const { stdout } = await getRunGitCommand(context)(["config", "--get", key], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
@@ -1303,11 +1323,14 @@ async function getGitRemotePushUrl(
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["remote", "get-url", "--push", remoteName], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
});
|
||||
const { stdout } = await getRunGitCommand(context)(
|
||||
["remote", "get-url", "--push", remoteName],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
},
|
||||
);
|
||||
const value = stdout.trim();
|
||||
return value.length > 0 ? value : null;
|
||||
} catch {
|
||||
@@ -1383,9 +1406,12 @@ async function resolvePullRequestStatusLookupTarget(
|
||||
return pushTarget ?? branchTarget;
|
||||
}
|
||||
|
||||
export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
|
||||
export async function resolveAbsoluteGitDir(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], {
|
||||
const { stdout } = await getRunGitCommand(context)(["rev-parse", "--absolute-git-dir"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -1396,9 +1422,9 @@ export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null>
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGitCommonDir(cwd: string): Promise<string | null> {
|
||||
async function resolveGitCommonDir(cwd: string, context?: CheckoutContext): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--git-common-dir"], {
|
||||
const { stdout } = await getRunGitCommand(context)(["rev-parse", "--git-common-dir"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -1435,9 +1461,12 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||
export async function resolveRepositoryDefaultBranch(
|
||||
repoRoot: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(
|
||||
const { stdout } = await getRunGitCommand(context)(
|
||||
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
@@ -1453,10 +1482,13 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
|
||||
? remoteShort.slice("origin/".length)
|
||||
: remoteShort;
|
||||
try {
|
||||
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${localName}`], {
|
||||
cwd: repoRoot,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
await getRunGitCommand(context)(
|
||||
["show-ref", "--verify", "--quiet", `refs/heads/${localName}`],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
return localName;
|
||||
} catch {
|
||||
return remoteShort;
|
||||
@@ -1466,7 +1498,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
|
||||
// ignore
|
||||
}
|
||||
|
||||
const { stdout } = await runGitCommand(["branch", "--format=%(refname:short)"], {
|
||||
const { stdout } = await getRunGitCommand(context)(["branch", "--format=%(refname:short)"], {
|
||||
cwd: repoRoot,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -1487,8 +1519,8 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
return resolveRepositoryDefaultBranch(repoRoot);
|
||||
async function resolveBaseRef(repoRoot: string, context?: CheckoutContext): Promise<string | null> {
|
||||
return resolveRepositoryDefaultBranch(repoRoot, context);
|
||||
}
|
||||
|
||||
interface ComparisonBaseRefName {
|
||||
@@ -1515,7 +1547,7 @@ async function doesGitRefExist(
|
||||
fullRef: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<boolean> {
|
||||
const result = await runGitCommand(["show-ref", "--verify", "--quiet", fullRef], {
|
||||
const result = await getRunGitCommand(context)(["show-ref", "--verify", "--quiet", fullRef], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
acceptExitCodes: [0, 1],
|
||||
@@ -1632,7 +1664,7 @@ async function getAheadBehindForComparisonRef(
|
||||
currentBranch: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<AheadBehind | null> {
|
||||
const { stdout } = await runGitCommand(
|
||||
const { stdout } = await getRunGitCommand(context)(
|
||||
["rev-list", "--left-right", "--count", `${comparisonRef}...${currentBranch}`],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||
);
|
||||
@@ -1651,7 +1683,7 @@ async function getUpstreamStatus(
|
||||
context?: CheckoutContext,
|
||||
): Promise<UpstreamStatus | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(
|
||||
const { stdout } = await getRunGitCommand(context)(
|
||||
[
|
||||
"for-each-ref",
|
||||
"--format=%(upstream)%00%(upstream:track,nobracket)",
|
||||
@@ -1690,10 +1722,10 @@ async function inspectCheckoutContext(
|
||||
}
|
||||
|
||||
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir] = await Promise.all([
|
||||
getCurrentBranch(cwd),
|
||||
getOriginRemoteUrl(cwd),
|
||||
resolveAbsoluteGitDir(cwd),
|
||||
resolveGitCommonDir(cwd),
|
||||
getCurrentBranch(cwd, context),
|
||||
getOriginRemoteUrl(cwd, context),
|
||||
resolveAbsoluteGitDir(cwd, context),
|
||||
resolveGitCommonDir(cwd, context),
|
||||
]);
|
||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, {
|
||||
context,
|
||||
@@ -1940,7 +1972,7 @@ export async function getCheckoutSnapshotFacts(
|
||||
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||
: null;
|
||||
const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata);
|
||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
|
||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context));
|
||||
const mainRepoRoot = await getMainRepoRootFromCommonDir(
|
||||
cwd,
|
||||
inspected.gitCommonDir,
|
||||
@@ -2593,12 +2625,19 @@ function parseCheckoutShortstat(text: string): CheckoutShortstat | null {
|
||||
|
||||
const UNTRACKED_SHORTSTAT_MAX_FILES = 500;
|
||||
|
||||
async function countUntrackedAdditions(cwd: string, throwOnGitError = false): Promise<number> {
|
||||
async function countUntrackedAdditions(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
throwOnGitError = false,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["ls-files", "--others", "--exclude-standard"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout } = await getRunGitCommand(context)(
|
||||
["ls-files", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const files = stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
@@ -2646,7 +2685,7 @@ async function getCheckoutShortstatUncached(
|
||||
}
|
||||
if (!context?.facts?.isGit) {
|
||||
try {
|
||||
await requireGitRepo(cwd);
|
||||
await requireGitRepo(cwd, context);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -2656,33 +2695,37 @@ async function getCheckoutShortstatUncached(
|
||||
const localBaseRef = facts?.isGit
|
||||
? facts.resolvedBaseRef
|
||||
: await getResolvedBaseRefForCwd(cwd, context);
|
||||
const currentBranch = facts?.isGit ? facts.currentBranch : await getCurrentBranch(cwd);
|
||||
const currentBranch = facts?.isGit ? facts.currentBranch : await getCurrentBranch(cwd, context);
|
||||
const comparisonRef = await resolveShortstatComparisonRef({
|
||||
cwd,
|
||||
currentBranch,
|
||||
localBaseRef,
|
||||
facts,
|
||||
context,
|
||||
});
|
||||
if (!comparisonRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: mergeBaseOut } = await runGitCommand(["merge-base", "HEAD", comparisonRef], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout: mergeBaseOut } = await getRunGitCommand(context)(
|
||||
["merge-base", "HEAD", comparisonRef],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const mergeBase = mergeBaseOut.trim();
|
||||
if (!mergeBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [{ stdout }, untrackedAdditions] = await Promise.all([
|
||||
runGitCommand(["diff", "--shortstat", mergeBase], {
|
||||
getRunGitCommand(context)(["diff", "--shortstat", mergeBase], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
}),
|
||||
countUntrackedAdditions(cwd, options?.throwOnGitError),
|
||||
countUntrackedAdditions(cwd, context, options?.throwOnGitError),
|
||||
]);
|
||||
|
||||
const tracked = parseCheckoutShortstat(stdout);
|
||||
@@ -2704,8 +2747,9 @@ async function resolveShortstatComparisonRef(input: {
|
||||
currentBranch: string | null;
|
||||
localBaseRef: string | null;
|
||||
facts?: CheckoutSnapshotFacts | null;
|
||||
context?: CheckoutContext;
|
||||
}): Promise<string | null> {
|
||||
const { cwd, currentBranch, localBaseRef, facts } = input;
|
||||
const { cwd, currentBranch, localBaseRef, facts, context } = input;
|
||||
if (!currentBranch) {
|
||||
return null;
|
||||
}
|
||||
@@ -2714,13 +2758,13 @@ async function resolveShortstatComparisonRef(input: {
|
||||
try {
|
||||
return facts?.isGit && facts.resolvedBaseRef === localBaseRef && facts.comparisonBaseRef
|
||||
? facts.comparisonBaseRef
|
||||
: await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
||||
: await resolveBestComparisonBaseRef(cwd, localBaseRef, context);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
|
||||
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`, context);
|
||||
return hasOrigin ? `origin/${currentBranch}` : null;
|
||||
}
|
||||
|
||||
@@ -3343,7 +3387,7 @@ export async function commitChanges(
|
||||
if (options.addAll ?? true) {
|
||||
await runGitCommand(["add", "-A"], { cwd, timeout: 120_000 });
|
||||
}
|
||||
await runGitCommand(["-c", "commit.gpgsign=false", "commit", "-m", options.message], {
|
||||
await runGitCommand(["commit", "-m", options.message], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -3521,7 +3565,7 @@ export async function mergeToBase(
|
||||
});
|
||||
const message =
|
||||
options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`;
|
||||
await runGitCommand(["-c", "commit.gpgsign=false", "commit", "-m", message], {
|
||||
await runGitCommand(["commit", "-m", message], {
|
||||
cwd: operationCwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -3976,9 +4020,11 @@ async function getPullRequestStatusUncached(
|
||||
const unavailable = getUnavailablePullRequestStatus(context?.facts);
|
||||
if (unavailable) return unavailable;
|
||||
if (!context?.facts?.isGit) {
|
||||
await requireGitRepo(cwd);
|
||||
await requireGitRepo(cwd, context);
|
||||
}
|
||||
const head = context?.facts?.isGit ? context.facts.currentBranch : await getCurrentBranch(cwd);
|
||||
const head = context?.facts?.isGit
|
||||
? context.facts.currentBranch
|
||||
: await getCurrentBranch(cwd, context);
|
||||
if (!head) {
|
||||
return buildPullRequestStatusResult(null, "no_remote");
|
||||
}
|
||||
|
||||
@@ -37,9 +37,9 @@ describe("GitCommandRuntimeMetricsWindow", () => {
|
||||
|
||||
test("reports live queue pressure across window resets", () => {
|
||||
const { metrics, advance } = createMetricsWindow(1);
|
||||
const active = metrics.submit("fetch");
|
||||
const active = metrics.submit("fetch", "background-fetch");
|
||||
metrics.start(active);
|
||||
const pending = metrics.submit("rev-parse");
|
||||
const pending = metrics.submit("rev-parse", "workspace-refresh:watch");
|
||||
metrics.observeLimiter(1, 1);
|
||||
advance(25);
|
||||
|
||||
@@ -53,6 +53,12 @@ describe("GitCommandRuntimeMetricsWindow", () => {
|
||||
oldestPendingMs: 25,
|
||||
submitted: 2,
|
||||
started: 1,
|
||||
provenanceTop: [
|
||||
["background-fetch", 1],
|
||||
["workspace-refresh:watch", 1],
|
||||
],
|
||||
pendingProvenanceTop: [["workspace-refresh:watch", 1]],
|
||||
activeProvenanceTop: [["background-fetch", 1]],
|
||||
});
|
||||
|
||||
advance(10);
|
||||
@@ -73,6 +79,9 @@ describe("GitCommandRuntimeMetricsWindow", () => {
|
||||
failed: 1,
|
||||
timedOut: 1,
|
||||
queueWaitMs: { count: 1, p50Ms: 35, p95Ms: 35, maxMs: 35 },
|
||||
provenanceTop: [],
|
||||
pendingProvenanceTop: [],
|
||||
activeProvenanceTop: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,17 +21,22 @@ export interface GitCommandRuntimeMetricsSnapshot {
|
||||
queueWaitMs: GitCommandDurationStats;
|
||||
executionMs: GitCommandDurationStats;
|
||||
operationsTop: Array<[string, number]>;
|
||||
provenanceTop: Array<[string, number]>;
|
||||
pendingProvenanceTop: Array<[string, number]>;
|
||||
activeProvenanceTop: Array<[string, number]>;
|
||||
}
|
||||
|
||||
interface GitCommandRuntimeMetric {
|
||||
queuedAtMs: number;
|
||||
startedAtMs: number | null;
|
||||
provenance: string;
|
||||
}
|
||||
|
||||
type Clock = () => number;
|
||||
|
||||
export class GitCommandRuntimeMetricsWindow {
|
||||
private readonly pendingCommands = new Set<GitCommandRuntimeMetric>();
|
||||
private readonly activeCommands = new Set<GitCommandRuntimeMetric>();
|
||||
private active = 0;
|
||||
private peakActive = 0;
|
||||
private peakPending = 0;
|
||||
@@ -43,6 +48,7 @@ export class GitCommandRuntimeMetricsWindow {
|
||||
private readonly queueWaitSamples: number[] = [];
|
||||
private readonly executionSamples: number[] = [];
|
||||
private readonly operationCounts = new Map<string, number>();
|
||||
private readonly provenanceCounts = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly limits: {
|
||||
@@ -52,11 +58,12 @@ export class GitCommandRuntimeMetricsWindow {
|
||||
private readonly clock: Clock = Date.now,
|
||||
) {}
|
||||
|
||||
submit(operation: string): GitCommandRuntimeMetric {
|
||||
const metric = { queuedAtMs: this.clock(), startedAtMs: null };
|
||||
submit(operation: string, provenance = "unattributed"): GitCommandRuntimeMetric {
|
||||
const metric = { queuedAtMs: this.clock(), startedAtMs: null, provenance };
|
||||
this.pendingCommands.add(metric);
|
||||
this.submittedCount += 1;
|
||||
this.operationCounts.set(operation, (this.operationCounts.get(operation) ?? 0) + 1);
|
||||
this.provenanceCounts.set(provenance, (this.provenanceCounts.get(provenance) ?? 0) + 1);
|
||||
return metric;
|
||||
}
|
||||
|
||||
@@ -71,6 +78,7 @@ export class GitCommandRuntimeMetricsWindow {
|
||||
}
|
||||
const now = this.clock();
|
||||
metric.startedAtMs = now;
|
||||
this.activeCommands.add(metric);
|
||||
this.active += 1;
|
||||
this.startedCount += 1;
|
||||
this.peakActive = Math.max(this.peakActive, this.active);
|
||||
@@ -83,6 +91,7 @@ export class GitCommandRuntimeMetricsWindow {
|
||||
}
|
||||
const startedAtMs = metric.startedAtMs;
|
||||
metric.startedAtMs = null;
|
||||
this.activeCommands.delete(metric);
|
||||
this.active = Math.max(0, this.active - 1);
|
||||
this.completedCount += 1;
|
||||
if (!outcome.success) {
|
||||
@@ -122,6 +131,9 @@ export class GitCommandRuntimeMetricsWindow {
|
||||
operationsTop: [...this.operationCounts.entries()]
|
||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||
.slice(0, 12),
|
||||
provenanceTop: topCounts(this.provenanceCounts),
|
||||
pendingProvenanceTop: topProvenance(this.pendingCommands),
|
||||
activeProvenanceTop: topProvenance(this.activeCommands),
|
||||
};
|
||||
|
||||
this.peakActive = limiter.active;
|
||||
@@ -134,10 +146,25 @@ export class GitCommandRuntimeMetricsWindow {
|
||||
this.queueWaitSamples.length = 0;
|
||||
this.executionSamples.length = 0;
|
||||
this.operationCounts.clear();
|
||||
this.provenanceCounts.clear();
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
function topProvenance(commands: ReadonlySet<GitCommandRuntimeMetric>): Array<[string, number]> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const command of commands) {
|
||||
counts.set(command.provenance, (counts.get(command.provenance) ?? 0) + 1);
|
||||
}
|
||||
return topCounts(counts);
|
||||
}
|
||||
|
||||
function topCounts(counts: ReadonlyMap<string, number>): Array<[string, number]> {
|
||||
return [...counts.entries()]
|
||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function summarizeDurations(samples: number[]): GitCommandDurationStats {
|
||||
if (samples.length === 0) {
|
||||
return { count: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setImmediate as scheduleImmediate } from "node:timers";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { GitProcessScheduler, resolveGitProcessPolicy } from "./git-process-scheduler.js";
|
||||
@@ -55,18 +56,76 @@ describe("GitProcessScheduler", () => {
|
||||
const started: number[] = [];
|
||||
|
||||
const activeObservation = scheduler.run(createConcurrencyTask(0, started, releases));
|
||||
await vi.waitFor(() => expect(started).toEqual([0]));
|
||||
const queuedObservation = scheduler.run(createConcurrencyTask(2, started, releases));
|
||||
const userOperation = scheduler.run(createConcurrencyTask(3, started, releases), {
|
||||
priority: "high",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(started).toEqual([0]));
|
||||
releases.shift()?.();
|
||||
await Promise.all([activeObservation, queuedObservation, userOperation]);
|
||||
|
||||
expect(started).toEqual([0, 3, 2]);
|
||||
});
|
||||
|
||||
test("yields to daemon liveness work between process starts", async () => {
|
||||
const scheduler = new GitProcessScheduler({
|
||||
maxProcessesPerSecond: 100,
|
||||
maxProcessConcurrency: 8,
|
||||
});
|
||||
const started: number[] = [];
|
||||
const start = () => {
|
||||
started.push(started.length);
|
||||
const completed = Promise.resolve();
|
||||
return { result: completed, exited: completed };
|
||||
};
|
||||
|
||||
const livenessTurn = new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const tasks = Array.from({ length: 8 }, () => scheduler.run(start));
|
||||
await livenessTurn;
|
||||
|
||||
expect(started).toHaveLength(1);
|
||||
await Promise.all(tasks);
|
||||
});
|
||||
|
||||
test("starts an isolated process without an extra event-loop turn", async () => {
|
||||
const scheduler = new GitProcessScheduler({
|
||||
maxProcessesPerSecond: 100,
|
||||
maxProcessConcurrency: 8,
|
||||
});
|
||||
let started = false;
|
||||
|
||||
const result = scheduler.run(() => {
|
||||
started = true;
|
||||
const completed = Promise.resolve();
|
||||
return { result: completed, exited: completed };
|
||||
});
|
||||
|
||||
expect(started).toBe(true);
|
||||
await result;
|
||||
});
|
||||
|
||||
test("queued processes keep moving while application timers are faked", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scheduler = new GitProcessScheduler({
|
||||
maxProcessesPerSecond: 100,
|
||||
maxProcessConcurrency: 1,
|
||||
});
|
||||
const releases: Array<() => void> = [];
|
||||
const started: number[] = [];
|
||||
|
||||
const first = scheduler.run(createConcurrencyTask(0, started, releases));
|
||||
const second = scheduler.run(createConcurrencyTask(2, started, releases));
|
||||
expect(started).toEqual([0]);
|
||||
|
||||
releases.shift()?.();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => scheduleImmediate(resolve));
|
||||
|
||||
expect(started).toEqual([0, 2]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
test("limits process starts in each strict one-second interval", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scheduler = new GitProcessScheduler({
|
||||
@@ -81,13 +140,14 @@ describe("GitProcessScheduler", () => {
|
||||
};
|
||||
const tasks = Array.from({ length: 4 }, () => scheduler.run(recordStart));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(startedAt).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(startedAt).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(startedAt).toHaveLength(3);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(startedAt.length).toBeLessThanOrEqual(2);
|
||||
while (startedAt.length < 3) {
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
}
|
||||
while (startedAt.length < 4) {
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
|
||||
expect(startedAt).toHaveLength(4);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setImmediate as scheduleImmediate } from "node:timers";
|
||||
import pThrottle from "p-throttle";
|
||||
|
||||
export interface GitProcessPolicy {
|
||||
@@ -50,6 +51,7 @@ export class GitProcessScheduler {
|
||||
private admitted = 0;
|
||||
private running = 0;
|
||||
private waiting = 0;
|
||||
private drainScheduled = false;
|
||||
|
||||
constructor(readonly policy: GitProcessPolicy) {
|
||||
const throttle = pThrottle({
|
||||
@@ -79,25 +81,45 @@ export class GitProcessScheduler {
|
||||
this.waiting = Math.max(0, this.waiting - 1);
|
||||
this.admitted = Math.max(0, this.admitted - 1);
|
||||
reject(error);
|
||||
this.drain();
|
||||
this.scheduleDrain();
|
||||
});
|
||||
};
|
||||
const queue =
|
||||
options?.priority === "high" ? this.highPriorityQueue : this.normalPriorityQueue;
|
||||
queue.push(admit);
|
||||
this.drain();
|
||||
if (this.admitted === 0 && !this.drainScheduled) {
|
||||
this.drainOne();
|
||||
} else {
|
||||
this.scheduleDrain();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
while (this.admitted < this.policy.maxProcessConcurrency) {
|
||||
const admit = this.highPriorityQueue.shift() ?? this.normalPriorityQueue.shift();
|
||||
if (!admit) {
|
||||
return;
|
||||
}
|
||||
this.admitted += 1;
|
||||
admit();
|
||||
private scheduleDrain(): void {
|
||||
if (this.drainScheduled || this.admitted >= this.policy.maxProcessConcurrency) {
|
||||
return;
|
||||
}
|
||||
if (this.highPriorityQueue.length === 0 && this.normalPriorityQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.drainScheduled = true;
|
||||
scheduleImmediate(() => this.drainOne());
|
||||
}
|
||||
|
||||
private drainOne(): void {
|
||||
this.drainScheduled = false;
|
||||
if (this.admitted >= this.policy.maxProcessConcurrency) {
|
||||
return;
|
||||
}
|
||||
const admit = this.highPriorityQueue.shift() ?? this.normalPriorityQueue.shift();
|
||||
if (!admit) {
|
||||
return;
|
||||
}
|
||||
this.admitted += 1;
|
||||
admit();
|
||||
// Spawning can be synchronous and expensive on a throttled host. Let timers,
|
||||
// sockets, and child exits run before admitting another Git process.
|
||||
this.scheduleDrain();
|
||||
}
|
||||
|
||||
private async execute<T>(
|
||||
@@ -116,7 +138,7 @@ export class GitProcessScheduler {
|
||||
} finally {
|
||||
this.running = Math.max(0, this.running - 1);
|
||||
this.admitted = Math.max(0, this.admitted - 1);
|
||||
this.drain();
|
||||
this.scheduleDrain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +409,20 @@ describe("runGitCommand", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds queue provenance to a Git command runner", async () => {
|
||||
const { createRunGitCommand, snapshotGitCommandRuntimeMetrics } = await loadRunGitCommand(1);
|
||||
enqueueSpawnBehaviors({ stdoutData: "ok" });
|
||||
|
||||
const runWorkspaceRefreshGitCommand = createRunGitCommand("workspace-refresh:watch");
|
||||
await runWorkspaceRefreshGitCommand(["status"], { cwd: process.cwd() });
|
||||
|
||||
expect(snapshotGitCommandRuntimeMetrics()).toMatchObject({
|
||||
provenanceTop: [["workspace-refresh:watch", 1]],
|
||||
pendingProvenanceTop: [],
|
||||
activeProvenanceTop: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves truncated stdout, caps output, and kills the child process", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(1);
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ export interface GitCommandResult {
|
||||
signal: NodeJS.Signals | null;
|
||||
}
|
||||
|
||||
export type RunGitCommand = (
|
||||
args: string[],
|
||||
options: GitCommandOptions,
|
||||
) => Promise<GitCommandResult>;
|
||||
|
||||
export function createRunGitCommand(provenance: string): RunGitCommand {
|
||||
return (args, options) => runGitCommandWithProvenance(args, options, provenance);
|
||||
}
|
||||
|
||||
export interface GitCommandMetric {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
@@ -249,16 +258,17 @@ function getEnvOverlayKeys(envOverlay: ProcessEnvRecord | undefined): string[] {
|
||||
return Object.keys(envOverlay ?? {}).sort();
|
||||
}
|
||||
|
||||
export function runGitCommand(
|
||||
function runGitCommandWithProvenance(
|
||||
args: string[],
|
||||
options: GitCommandOptions,
|
||||
provenance?: string,
|
||||
): Promise<GitCommandResult> {
|
||||
const metricsState = submitGitCommandMetric(args, options.cwd);
|
||||
const commandTrace = submitGitCommandTrace(args, options.cwd, {
|
||||
active: gitProcessScheduler.activeCount,
|
||||
pending: gitProcessScheduler.pendingCount,
|
||||
});
|
||||
const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args));
|
||||
const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args), provenance);
|
||||
const startCommand = () => {
|
||||
let releaseProcessSlot!: () => void;
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
@@ -526,6 +536,8 @@ export function runGitCommand(
|
||||
return promise;
|
||||
}
|
||||
|
||||
export const runGitCommand: RunGitCommand = runGitCommandWithProvenance;
|
||||
|
||||
function formatGitCommand(args: string[]): string {
|
||||
return ["git", ...args].join(" ");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { ExamplePanel } from "./main.client";
|
||||
import { contributeClient, ExamplePanel } from "./main.client";
|
||||
import { increment } from "./increment.server";
|
||||
import { incrementRpc } from "./increment.shared";
|
||||
|
||||
@@ -22,5 +22,6 @@ export default function contribute(plugin: PluginContext) {
|
||||
openPanel("counter");
|
||||
},
|
||||
});
|
||||
plugin.addClientSide(contributeClient);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,73 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/plugin";
|
||||
import {
|
||||
Icon,
|
||||
type PluginClientContext,
|
||||
type PluginComposerPillProps,
|
||||
type PluginWorkspacePanelProps,
|
||||
useAgent,
|
||||
useRpc,
|
||||
useWorkspace,
|
||||
} from "@getpaseo/plugin";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { incrementRpc } from "./increment.shared";
|
||||
|
||||
export function OpenCounterPill({ theme, workspaceId, agentId }: PluginComposerPillProps) {
|
||||
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
|
||||
const agent = useAgent(agentId, ({ title }) => ({ title }));
|
||||
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
|
||||
return (
|
||||
<>
|
||||
<Icon name="Blocks" size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{agent?.title ?? workspace?.name ?? "Counter"}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
let stopped = false;
|
||||
const register = (agent: { id: string; workspaceId?: string | null }) => {
|
||||
if (stopped || !agent.workspaceId) return;
|
||||
pills.get(agent.id)?.();
|
||||
const workspaceId = agent.workspaceId;
|
||||
const remove = client.addComposerPill({
|
||||
id: "open-counter",
|
||||
title: "Open plugin counter",
|
||||
workspaceId,
|
||||
agentId: agent.id,
|
||||
Component: OpenCounterPill,
|
||||
onPress() {
|
||||
client.openPanel("counter", { workspaceId });
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, remove);
|
||||
};
|
||||
const remove = (agentId: string) => {
|
||||
pills.get(agentId)?.();
|
||||
pills.delete(agentId);
|
||||
};
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind === "remove") remove(update.agentId);
|
||||
else register(update.agent);
|
||||
});
|
||||
void client.paseo.agents
|
||||
.list()
|
||||
.then(({ entries }) => {
|
||||
for (const { agent } of entries) register(agent);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
for (const dispose of pills.values()) dispose();
|
||||
pills.clear();
|
||||
};
|
||||
}
|
||||
|
||||
export function ExamplePanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
|
||||
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
|
||||
const callIncrement = useRpc(incrementRpc);
|
||||
|
||||
@@ -14,7 +14,7 @@ category: Plugins
|
||||
See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) for planned contribution
|
||||
surfaces and their current status.
|
||||
|
||||
Paseo plugins add native workspace panels, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
|
||||
Paseo plugins add native workspace panels, composer pills, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
|
||||
|
||||
Plugins are trusted local code. Install only code you trust: backend code runs unsandboxed with access to the daemon machine, and client contributions run inside the Paseo app.
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ The required root manifest is `paseo-plugin.json`. It contains the default plugi
|
||||
|
||||
The entry point is `index.ts` at the plugin root. Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
|
||||
|
||||
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes.
|
||||
The generated declaration file supplies `@getpaseo/plugin`, `@getpaseo/plugin/react-native`, and
|
||||
`@getpaseo/plugin/server` types for local typechecking. Paseo supplies the runtime modules.
|
||||
Regenerate a fresh project with the matching CLI when the plugin contract changes.
|
||||
|
||||
Add runtime-specific files as the plugin grows:
|
||||
|
||||
@@ -67,15 +69,16 @@ Paseo builds separate client and server bundles from `index.ts`. It rejects impo
|
||||
|
||||
Paseo provides these modules to client code:
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------- | --------------------------------------- |
|
||||
| `@getpaseo/plugin` | Host UI components, hooks, and UI types |
|
||||
| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
|
||||
| `@tanstack/react-query` | Request state and caching |
|
||||
| `react` | Components and hooks |
|
||||
| `react/jsx-runtime` | Compiled JSX |
|
||||
| `react-native` | Cross-platform UI |
|
||||
| `zod` | Shared schemas |
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | ------------------------------------- |
|
||||
| `@getpaseo/plugin` | Contribution contracts and data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
|
||||
| `@tanstack/react-query` | Request state and caching |
|
||||
| `react` | Components and hooks |
|
||||
| `react/jsx-runtime` | Compiled JSX |
|
||||
| `react-native` | Cross-platform UI |
|
||||
| `zod` | Shared schemas |
|
||||
|
||||
These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "<name>" is not available in plugin client code`.
|
||||
|
||||
@@ -166,15 +169,105 @@ export default function contribute(plugin: PluginContext) {
|
||||
|
||||
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body.
|
||||
|
||||
Use the host-provided `Icon` component for Lucide icons inside client surfaces. It renders the icon from Paseo's installed Lucide version, so plugin bundles do not import `lucide-react-native` or `react-native-svg`. An unknown name renders nothing instead of failing the plugin surface.
|
||||
## Host UI
|
||||
|
||||
Import Paseo-owned UI from `@getpaseo/plugin/react-native` in `*.client.tsx` files. This example
|
||||
opens a controlled modal, renders a host icon, and confirms the action with a toast:
|
||||
|
||||
```tsx
|
||||
import { Icon } from "@getpaseo/plugin";
|
||||
import type { PluginSurfaceProps } from "@getpaseo/plugin";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
<Icon name="Settings" size={18} color={theme.colors.foreground} />;
|
||||
export function IssueActions({ theme }: PluginSurfaceProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
function saveIssue() {
|
||||
toast.show("Issue saved", { variant: "success" });
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Icon name="Pencil" size={18} color={theme.colors.foreground} />
|
||||
<Text style={{ color: theme.colors.foreground }}>Edit issue</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Modal
|
||||
title="Edit issue"
|
||||
icon={<Icon name="Pencil" size={18} color={theme.colors.foreground} />}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Pressable accessibilityRole="button" onPress={saveIssue}>
|
||||
<Text style={{ color: theme.colors.foreground }}>Save</Text>
|
||||
</Pressable>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Keep `Icon` in a `*.client.tsx` module.
|
||||
### Modal
|
||||
|
||||
`Modal` uses a bottom sheet on compact layouts and a centered dialog otherwise. The plugin owns
|
||||
the `open` state.
|
||||
|
||||
| Prop | Type | Required | Behavior |
|
||||
| -------------- | ------------------------- | -------- | -------------------------------------------- |
|
||||
| `title` | `string` | Yes | Labels the modal and its visible header. |
|
||||
| `icon` | `ReactNode` | No | Renders before the title in the header. |
|
||||
| `open` | `boolean` | Yes | Shows the modal content when `true`. |
|
||||
| `onOpenChange` | `(open: boolean) => void` | Yes | Receives `false` when the user dismisses it. |
|
||||
| `children` | `ReactNode` | Yes | Contains `Modal.Content`. |
|
||||
|
||||
`Modal.Content` owns the body below the host-rendered header:
|
||||
|
||||
| Prop | Type | Required | Behavior |
|
||||
| ---------- | ----------- | -------- | --------------------------------------------- |
|
||||
| `children` | `ReactNode` | Yes | Renders the plugin's React Native UI content. |
|
||||
|
||||
The close button, backdrop, platform back action, web Escape key, and compact sheet gesture dismiss
|
||||
the modal. Dismissal calls `onOpenChange(false)`; the plugin must update `open` to close it.
|
||||
|
||||
Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorkspace`, and
|
||||
`useAgent` work inside them.
|
||||
|
||||
### Toasts
|
||||
|
||||
`useToast()` returns two methods:
|
||||
|
||||
| Method | Behavior |
|
||||
| ------------------------- | ----------------------------------------------------------- |
|
||||
| `show(message, options?)` | Shows a toast for 2,200 ms unless `durationMs` is supplied. |
|
||||
| `error(message)` | Shows an error toast for 3,200 ms. |
|
||||
|
||||
`show` accepts these options:
|
||||
|
||||
| Option | Type | Default |
|
||||
| ------------ | ---------------------------------------------------------- | ----------- |
|
||||
| `variant` | `"default" \| "info" \| "success" \| "warning" \| "error"` | `"default"` |
|
||||
| `durationMs` | `number` | `2200` |
|
||||
|
||||
Showing another toast replaces the currently visible toast. An empty message is ignored.
|
||||
|
||||
### Icons
|
||||
|
||||
`Icon` renders a [Lucide icon](https://lucide.dev/icons/) from Paseo's installed icon set. Plugin bundles do not import
|
||||
`lucide-react-native` or `react-native-svg`.
|
||||
|
||||
| Prop | Type | Required | Behavior |
|
||||
| ------- | -------- | -------- | ----------------------------------------------- |
|
||||
| `name` | `string` | Yes | Lucide icon name. Unknown names render nothing. |
|
||||
| `size` | `number` | No | Icon width and height. |
|
||||
| `color` | `string` | No | Icon color. Use a plugin theme token. |
|
||||
|
||||
## Timeline items
|
||||
|
||||
@@ -478,6 +571,92 @@ Every callback receives:
|
||||
|
||||
An agent callback may open either an agent panel or a workspace panel. A workspace callback may open only a workspace panel. Unknown surface and panel IDs fail visibly. Use `paseo` for normal workspace, agent, provider, and daemon-config operations. Use `rpc` for plugin-specific filesystem, credential, vendor, or daemon-local work.
|
||||
|
||||
## Composer pills
|
||||
|
||||
Register a headless client entrypoint from `index.ts`:
|
||||
|
||||
```ts
|
||||
import { contributeClient } from "./review.client";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addClientSide(contributeClient);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
The client entrypoint owns pill creation and removal:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Icon,
|
||||
type PluginClientContext,
|
||||
type PluginComposerPillProps,
|
||||
useAgent,
|
||||
} from "@getpaseo/plugin";
|
||||
import { Text } from "react-native";
|
||||
|
||||
function ReviewPill({ theme, agentId }: PluginComposerPillProps) {
|
||||
const agent = useAgent(agentId, ({ title }) => ({ title }));
|
||||
return (
|
||||
<>
|
||||
<Icon name="Scan" size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>
|
||||
{agent?.title ?? "Review"}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`addComposerPill` fields:
|
||||
|
||||
| Field | Required | Meaning |
|
||||
| ------------- | -------- | ---------------------------------------------------------- |
|
||||
| `id` | Yes | Plugin-local ID within the target agent. |
|
||||
| `title` | Yes | Accessible button label. |
|
||||
| `workspaceId` | Yes | Workspace whose composer track owns the pill. |
|
||||
| `agentId` | Yes | Agent whose composer track owns the pill. |
|
||||
| `Component` | Yes | React Native component rendering the pill's icon and text. |
|
||||
| `onPress` | Yes | Client-side callback. |
|
||||
|
||||
`addClientSide` runs once per plugin installation in each connected app. Its context exposes
|
||||
`paseo`, typed `rpc`, `openSurface`, explicit-context `openPanel`, and `addComposerPill`.
|
||||
`addComposerPill` returns an idempotent removal function. Paseo also removes every outstanding pill
|
||||
when the client entrypoint, plugin installation, or host connection is torn down.
|
||||
|
||||
Paseo owns the pressable, shared pill chrome, pending state, error reporting, and track-bar
|
||||
placement. The component receives `theme`, `host`, `layout`, `workspaceId`, and `agentId`. Read
|
||||
current values with `useWorkspace` and `useAgent`. The plugin owns when the pill exists, its icon
|
||||
and text, and the callback. `openPanel(id, { workspaceId, agentId? })` opens or focuses a panel
|
||||
registered by the same plugin.
|
||||
|
||||
## Use the Paseo SDK
|
||||
|
||||
Use `usePaseo()` for ordinary Paseo operations from a surface. It borrows the selected host's existing connection; do not create another client.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user