feat(plugins): unify panel props and persist tab state (#7685)

* feat(plugins): unify panel props and persist tab state

* refactor(plugins): make agent panel registration canonical

* docs(plugins): update Chinese panel API reference and migration
This commit is contained in:
Mohamed Boudra
2026-10-23 13:26:19 +07:00
committed by GitHub
parent 21e7ccb036
commit 4fe006df86
30 changed files with 618 additions and 242 deletions
+50
View File
@@ -70,6 +70,26 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien
return violations;
}
function typeErrors(code: string): string[] {
const configPath = path.resolve(sourceDirectory, "../tsconfig.examples.json");
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath));
const file = path.resolve(
sourceDirectory,
"../../../plugin-examples/skill-example/client/example.tsx",
);
const host = ts.createCompilerHost(options);
const getSourceFile = host.getSourceFile;
host.getSourceFile = (name, language, ...rest) =>
path.resolve(name) === file
? ts.createSourceFile(name, code, language)
: getSourceFile.call(host, name, language, ...rest);
host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name);
const program = ts.createProgram([file], options, host);
return ts
.getPreEmitDiagnostics(program)
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
}
describe("plugin SDK import boundaries", () => {
it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]);
@@ -142,3 +162,33 @@ describe("plugin example import boundaries", () => {
expect(violations).toEqual([]);
});
});
describe("plugin panel SDK boundary", () => {
it("accepts uniform params and persisted tab state alongside 0.12 panels", () => {
expect(
typeErrors(`
import { type PluginClientContext, type PluginFilePanelProps, type PluginWorkspacePanelProps, type PluginAgentPanelProps, type PluginTab, useTabState } from "@getpaseo/plugin/client";
import { z } from "zod";
function FilePanel({ params, revision, tab, file }: PluginFilePanelProps) {
const [state, setState] = useTabState(tab, z.object({ view: z.enum(["preview", "source"]) }), { view: "preview" });
setState({ view: "source" });
tab.setState({ nested: [true, null, 3] });
// @ts-expect-error only JSON state crosses the host boundary
tab.setState(() => {});
// @ts-expect-error params belong to the opener
params.path = "other";
return null;
}
function OldWorkspace({ context, workspaceId }: PluginWorkspacePanelProps) { return null; }
function OldAgent({ context, workspaceId, agentId }: PluginAgentPanelProps) { return null; }
function Agent({ params }: PluginAgentPanelProps) { params.agentId.toUpperCase(); return null; }
function contribute(client: PluginClientContext) {
client.addWorkspacePanel({ id: "old-workspace", title: "Old", icon: "File", context: "workspace", Component: OldWorkspace });
client.addWorkspacePanel({ id: "old-agent", title: "Old", icon: "File", context: "agent", Component: OldAgent });
client.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component: OldWorkspace });
client.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component: Agent });
}
`),
).toEqual([]);
});
});
+46 -14
View File
@@ -1,9 +1,9 @@
import type { PluginText } from "../text.js";
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile, PluginFileLocation, PluginFileTab } from "./file.js";
import type { PluginFile } from "./file.js";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginButtonRegistration,
@@ -111,23 +111,44 @@ interface PluginWorkspacePanelBase<Text extends PluginText = string> {
locations?: readonly PluginPanelLocation[];
}
export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
context: "workspace";
workspaceId: string;
export interface PluginTab {
/** Shows the tab's unsaved dot and asks before closing. */
setModified(modified: boolean): void;
/** Opaque persisted JSON. Undefined until written; the panel validates it. */
readonly state: unknown;
setState(state: JsonValue): void;
}
export interface PluginAgentPanelProps extends PluginNavigableHostProps {
context: "agent";
export interface PluginPanelProps<Params> extends PluginNavigableHostProps {
workspaceId: string;
readonly params: Readonly<Params>;
revision: number;
tab: PluginTab;
}
export interface PluginWorkspacePanelParams {}
export interface PluginAgentPanelParams {
readonly agentId: string;
}
export interface PluginFilePanelParams {
readonly path: string;
readonly lineStart?: number;
readonly lineEnd?: number;
}
export interface PluginWorkspacePanelProps extends PluginPanelProps<PluginWorkspacePanelParams> {
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
/** @deprecated Use the panel registration to select the context. */
context: "workspace";
}
export interface PluginAgentPanelProps extends PluginPanelProps<PluginAgentPanelParams> {
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
/** @deprecated Use the panel registration to select the context. */
context: "agent";
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
/** @deprecated Read `params.agentId`. */
agentId: string;
}
export interface PluginFilePanelProps extends PluginNavigableHostProps {
workspaceId: string;
/** The file itself. The panel decides how to read it, show it, and save it. */
export interface PluginFilePanelProps extends PluginPanelProps<PluginFilePanelParams> {
file: PluginFile;
tab: PluginFileTab;
location: PluginFileLocation;
}
export interface PluginFilePanelContribution<Text extends PluginText = string> {
@@ -160,6 +181,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
addScreen(contribution: PluginScreenContribution<PluginText>): PluginCleanup;
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
addCommandCenterItem(
contribution: PluginCommandCenterItemContribution<PluginText>,
@@ -187,14 +209,24 @@ export type PluginClientContribution = (client: PluginClientContext) => PluginCl
export type PluginWorkspacePanelContribution<Text extends PluginText = string> =
| (PluginWorkspacePanelBase<Text> & {
context: "workspace";
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
/** @deprecated Omit context. */
context?: "workspace";
Component: ComponentType<PluginWorkspacePanelProps>;
})
| (PluginWorkspacePanelBase<Text> & {
// COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23
/** @deprecated Register with `addAgentPanel`. */
context: "agent";
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginAgentPanelContribution<
Text extends PluginText = string,
> extends PluginWorkspacePanelBase<Text> {
Component: ComponentType<PluginAgentPanelProps>;
}
export interface PluginSettingsScreenContribution<Text extends PluginText = string> {
id: string;
title: Text;
-13
View File
@@ -44,19 +44,6 @@ export interface PluginFileWriteOptions {
/** What happened to the file on disk. Switch on `type`; later versions may add fields. */
export type PluginFileEvent = { type: "changed" } | { type: "deleted" };
/** What the panel reports back to its tab. */
export interface PluginFileTab {
/** Shows the tab's unsaved dot and asks before the tab closes. */
setModified(modified: boolean): void;
}
/** The line a link or search result opened the file at. `revision` bumps on every new target. */
export interface PluginFileLocation {
lineStart?: number;
lineEnd?: number;
revision: number;
}
/** The file changed on disk since the handle last read or wrote it. */
export class PluginFileConflictError extends Error {
constructor(message = "The file changed on disk.") {
+8 -2
View File
@@ -1,5 +1,11 @@
export type {
PluginHostProps,
PluginTab,
PluginPanelProps,
PluginWorkspacePanelParams,
PluginAgentPanelParams,
PluginFilePanelParams,
PluginAgentPanelContribution,
PluginFilePanelContribution,
PluginFilePanelProps,
PluginSurfaceProps,
@@ -52,8 +58,6 @@ export type {
PluginFile,
PluginFileBytesState,
PluginFileEvent,
PluginFileLocation,
PluginFileTab,
PluginFileTextState,
PluginFileUrlState,
PluginFileWriteOptions,
@@ -91,3 +95,5 @@ export declare function openExternalUrl(url: string): Promise<void>;
export { defineTranslations, useTranslation } from "./translations.js";
export type { PluginI18n } from "./translations.js";
export type { PluginText, PluginMessage, PluginTranslations } from "../text.js";
export { useTabState } from "./tab.js";
+16
View File
@@ -0,0 +1,16 @@
import { useCallback, useMemo } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType } from "zod";
import type { PluginTab } from "./contracts.js";
/** Validates the panel's persisted state, using the default for missing or invalid values. */
export function useTabState<T extends JsonValue>(
tab: PluginTab,
schema: ZodType<T>,
defaultState: T,
): [T, (next: T) => void] {
const parsed = useMemo(() => schema.safeParse(tab.state), [schema, tab.state]);
const writeState = tab.setState;
const setState = useCallback((next: T) => writeState(next), [writeState]);
return [parsed.success ? parsed.data : defaultState, setState];
}