diff --git a/.changeset/friendly-missing-actions-config.md b/.changeset/friendly-missing-actions-config.md
new file mode 100644
index 0000000..ee86a1a
--- /dev/null
+++ b/.changeset/friendly-missing-actions-config.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web-actions": patch
+---
+
+Treat missing workspace actions configuration as an empty optional state instead of an error, with clearer guidance for invalid configs.
diff --git a/plugins/actions/README.md b/plugins/actions/README.md
index c74a30a..8f12131 100644
--- a/plugins/actions/README.md
+++ b/plugins/actions/README.md
@@ -6,7 +6,7 @@ The plugin adds an **Actions** workspace tab. Actions create a new Pi Web termin
## Configuration
-Create `.pi-web/actions.json` in the workspace root:
+Create `.pi-web/actions.json` in the workspace root where you want actions. The file is optional per workspace; workspaces without it simply show no actions.
```json
{
diff --git a/plugins/actions/src/actionsPanelElement.ts b/plugins/actions/src/actionsPanelElement.ts
index 3419951..8a0db10 100644
--- a/plugins/actions/src/actionsPanelElement.ts
+++ b/plugins/actions/src/actionsPanelElement.ts
@@ -2,7 +2,7 @@ import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js";
import { createWorkspaceTerminal, sendTerminalCommand } from "./terminalDispatcher.js";
import { requestPiWebRender } from "./piWebPrivateUi.js";
-import { loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js";
+import { actionsConfigRefreshHint, actionsConfigUnavailableMessage, loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js";
export const actionsPanelTagName = "pi-web-actions-panel";
@@ -100,8 +100,9 @@ class PiWebActionsPanel extends HTMLElement {
private renderConfigState(state: ConfigState): string {
if (state.kind === "loading") return `
Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…
${this.renderStatus()}`;
+ if (state.kind === "missing") return `${renderMissingState(state)}${this.renderStatus()}`;
if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`;
- if (state.config.actions.length === 0) return `No actions configured in ${escapeHtml(ACTIONS_CONFIG_PATH)}.
${this.renderStatus()}`;
+ if (state.config.actions.length === 0) return `No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.
${this.renderStatus()}`;
return `
Actions create a new workspace terminal, send the command, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.
${renderActionGroups(state.config.actions, this.runningActionId)}
@@ -185,8 +186,8 @@ async function refreshWorkspaceConfig(workspace: Workspace): Promise ({
kind: "unavailable",
- message: `No valid ${ACTIONS_CONFIG_PATH} found.`,
- hint: `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`,
+ message: actionsConfigUnavailableMessage,
+ hint: actionsConfigRefreshHint,
detail: error instanceof Error ? error.message : String(error),
}));
configCache.set(key, state);
@@ -199,6 +200,10 @@ function cacheKeyForWorkspace(workspace: Workspace): string {
return `${workspace.projectId}:${workspace.id}`;
}
+function renderMissingState(state: Extract): string {
+ return `${escapeHtml(state.message)}${escapeHtml(state.hint)}
`;
+}
+
function renderUnavailableState(state: Extract): string {
const detail = state.detail === undefined ? "" : `${escapeHtml(state.detail)}`;
return `${escapeHtml(state.message)}${escapeHtml(state.hint)}
${detail}
`;
@@ -268,6 +273,8 @@ function actionStyles(): string {
button { border: 1px solid var(--pi-accent-border); border-radius: 7px; background: var(--pi-accent); color: var(--pi-bg); cursor: pointer; padding: 6px 10px; font: inherit; }
button.secondary { border-color: var(--pi-border); background: var(--pi-surface); color: var(--pi-text); }
button:disabled { cursor: wait; opacity: 0.65; }
+ .empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; }
+ .empty-state p { margin: 6px 0 0; }
.status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; }
.status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
.status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); }
diff --git a/plugins/actions/src/workspaceActionsClient.test.ts b/plugins/actions/src/workspaceActionsClient.test.ts
index dbe1bac..f6057c9 100644
--- a/plugins/actions/src/workspaceActionsClient.test.ts
+++ b/plugins/actions/src/workspaceActionsClient.test.ts
@@ -34,14 +34,24 @@ describe("workspace actions client", () => {
});
});
+ it("treats a missing optional actions config as unconfigured", async () => {
+ const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 }));
+
+ await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toEqual({
+ kind: "missing",
+ message: "No workspace actions configured here.",
+ hint: `${ACTIONS_CONFIG_PATH} is optional. Create it in this workspace if you want custom actions.`,
+ });
+ });
+
it("returns a visible unavailable state instead of throwing on request failures", async () => {
const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 }));
await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
kind: "unavailable",
- message: `No valid ${ACTIONS_CONFIG_PATH} found.`,
- hint: `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`,
- detail: `Unable to read ${ACTIONS_CONFIG_PATH}: HTTP 400`,
+ message: "Could not load workspace actions.",
+ hint: `Fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`,
+ detail: `Unable to read ${ACTIONS_CONFIG_PATH}: HTTP 400: nope`,
});
});
diff --git a/plugins/actions/src/workspaceActionsClient.ts b/plugins/actions/src/workspaceActionsClient.ts
index 008552f..3075433 100644
--- a/plugins/actions/src/workspaceActionsClient.ts
+++ b/plugins/actions/src/workspaceActionsClient.ts
@@ -1,13 +1,18 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { ACTIONS_CONFIG_PATH, parseActionsConfigText, type WorkspaceActionsConfig } from "./config.js";
-export const actionsConfigUnavailableMessage = `No valid ${ACTIONS_CONFIG_PATH} found.`;
-export const actionsConfigRefreshHint = `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`;
+export const actionsConfigMissingMessage = "No workspace actions configured here.";
+export const actionsConfigMissingHint = `${ACTIONS_CONFIG_PATH} is optional. Create it in this workspace if you want custom actions.`;
+export const actionsConfigUnavailableMessage = "Could not load workspace actions.";
+export const actionsConfigRefreshHint = `Fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`;
+
+const missingWorkspaceFileError = "Path does not exist";
export type FetchLike = (input: string, init?: RequestInit) => Promise;
export type WorkspaceActionsConfigLoadResult =
| { kind: "loaded"; config: WorkspaceActionsConfig }
+ | { kind: "missing"; message: string; hint: string }
| { kind: "unavailable"; message: string; hint: string; detail?: string };
interface WorkspaceFileResponse {
@@ -27,7 +32,12 @@ export async function loadWorkspaceActionsConfig(
return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`);
}
- if (!response.ok) return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: HTTP ${String(response.status)}`);
+ if (!response.ok) {
+ const errorMessage = await readResponseErrorMessage(response);
+ if (errorMessage === missingWorkspaceFileError) return missing();
+ const responseSummary = errorMessage === undefined ? `HTTP ${String(response.status)}` : `HTTP ${String(response.status)}: ${errorMessage}`;
+ return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${responseSummary}`);
+ }
let body: unknown;
try {
@@ -59,6 +69,14 @@ export function parseWorkspaceFileResponse(value: unknown): WorkspaceFileRespons
return { content, truncated, binary };
}
+function missing(): WorkspaceActionsConfigLoadResult {
+ return {
+ kind: "missing",
+ message: actionsConfigMissingMessage,
+ hint: actionsConfigMissingHint,
+ };
+}
+
function unavailable(detail: string): WorkspaceActionsConfigLoadResult {
return {
kind: "unavailable",
@@ -68,6 +86,17 @@ function unavailable(detail: string): WorkspaceActionsConfigLoadResult {
};
}
+async function readResponseErrorMessage(response: Response): Promise {
+ try {
+ const body: unknown = await response.json();
+ if (!isRecord(body)) return undefined;
+ const error = body["error"];
+ return typeof error === "string" ? error : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
function formatUnknownError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}