feat: support plugin mobile tab icons

This commit is contained in:
Federico Jaramillo Martinez
2026-06-03 21:13:35 +02:00
parent 4548e5c6a0
commit 401dab69ad
8 changed files with 58 additions and 8 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@jmfederico/pi-web": patch
--- ---
Use compact icons and initials for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling. Use compact icons and initials for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling, and let workspace panel plugins provide custom SVG tab icons.
+13 -2
View File
@@ -100,11 +100,11 @@ Module shape excerpt:
export default { export default {
apiVersion: 1, apiVersion: 1,
name: "Info Plugin", name: "Info Plugin",
activate: ({ html }) => ({ activate: ({ html, svg }) => ({
contributions: { contributions: {
actions: [/* action definitions */], actions: [/* action definitions */],
workspaceLabels: [/* compact label definitions */], workspaceLabels: [/* compact label definitions */],
workspacePanels: [/* panel definitions using html */], workspacePanels: [/* panel definitions using html, optional icons using svg */],
}, },
}), }),
}; };
@@ -239,6 +239,7 @@ interface PluginActivationContext {
apiVersion: 1; apiVersion: 1;
pluginId: string; pluginId: string;
html: typeof import("lit").html; html: typeof import("lit").html;
svg: typeof import("lit").svg;
} }
interface PluginActivationResult { interface PluginActivationResult {
@@ -369,6 +370,13 @@ workspacePanels: [
{ {
id: "workspace.info", id: "workspace.info",
title: "Info", title: "Info",
icon: svg`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 10v6"></path>
<path d="M12 7h.01"></path>
</svg>
`,
order: 100, order: 100,
visible: ({ workspace }) => workspace.isGitRepo, visible: ({ workspace }) => workspace.isGitRepo,
render: ({ workspace }) => html` render: ({ workspace }) => html`
@@ -388,6 +396,7 @@ Panel type:
interface WorkspacePanelContribution { interface WorkspacePanelContribution {
id: string; id: string;
title: string; title: string;
icon?: TemplateResult;
order?: number; order?: number;
visible?: (context: { workspace: Workspace }) => boolean; visible?: (context: { workspace: Workspace }) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
@@ -400,6 +409,8 @@ interface WorkspacePanelContext {
} }
``` ```
`icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide.
`workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. If a panel needs file, git, terminal, or session data beyond the helpers documented here, prefer explicit `fetch()` calls and keep them isolated. `workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. If a panel needs file, git, terminal, or session data beyond the helpers documented here, prefer explicit `fetch()` calls and keep them isolated.
Useful workspace shape: Useful workspace shape:
+5
View File
@@ -4,6 +4,7 @@ export type PluginId = string;
export type LocalContributionId = string; export type LocalContributionId = string;
export type QualifiedContributionId = string; export type QualifiedContributionId = string;
export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export interface PiWebPlugin { export interface PiWebPlugin {
apiVersion: 1; apiVersion: 1;
@@ -15,6 +16,7 @@ export interface PluginActivationContext {
apiVersion: 1; apiVersion: 1;
pluginId: PluginId; pluginId: PluginId;
html: HtmlTemplateTag; html: HtmlTemplateTag;
svg: SvgTemplateTag;
} }
export interface PluginActivationResult { export interface PluginActivationResult {
@@ -84,9 +86,12 @@ export interface WorkspacePanelContext {
openTerminal: (options?: { terminalId?: string | undefined }) => void; openTerminal: (options?: { terminalId?: string | undefined }) => void;
} }
export type WorkspacePanelIcon = TemplateResult;
export interface WorkspacePanelContribution { export interface WorkspacePanelContribution {
id: LocalContributionId; id: LocalContributionId;
title: string; title: string;
icon?: WorkspacePanelIcon;
order?: number; order?: number;
visible?: (context: WorkspacePanelContext) => boolean; visible?: (context: WorkspacePanelContext) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
+1 -1
View File
@@ -1021,7 +1021,7 @@ export class PiWebApp extends LitElement {
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" }, { id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" },
{ id: "chat", label: "Chat", icon: "chat" }, { id: "chat", label: "Chat", icon: "chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => { ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
const icon = this.mobilePanelIcon(panel); const icon = panel.icon ?? this.mobilePanelIcon(panel);
return { return {
id: panel.id, id: panel.id,
label: panel.title, label: panel.title,
@@ -1,8 +1,9 @@
import { LitElement, css, html, svg } from "lit"; import { LitElement, css, html, svg, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import type { AppState } from "../../appState"; import type { AppState } from "../../appState";
export type AppMobileMainTabIcon = "navigation" | "chat" | "files" | "git" | "terminal"; export type AppMobileMainTabBuiltinIcon = "navigation" | "chat" | "files" | "git" | "terminal";
export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult;
export interface AppMobileMainTab { export interface AppMobileMainTab {
id: AppState["mainView"]; id: AppState["mainView"];
@@ -115,6 +116,7 @@ export class AppMobileMainTabs extends LitElement {
} }
private renderIcon(icon: AppMobileMainTabIcon) { private renderIcon(icon: AppMobileMainTabIcon) {
if (typeof icon !== "string") return html`<span class="tab-custom-icon" aria-hidden="true">${icon}</span>`;
switch (icon) { switch (icon) {
case "navigation": case "navigation":
return svg` return svg`
@@ -206,6 +208,8 @@ export class AppMobileMainTabs extends LitElement {
.mobile-tabs .navigation-tab { display: none; } .mobile-tabs .navigation-tab { display: none; }
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } .mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.tab-icon { flex: 0 0 auto; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } .tab-icon { flex: 0 0 auto; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.tab-custom-icon { flex: 0 0 auto; width: 18px; height: 18px; display: inline-grid; place-items: center; color: currentColor; pointer-events: none; }
.tab-custom-icon svg { width: 18px; height: 18px; pointer-events: none; }
.tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; } .tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; }
.tab-label { min-width: 0; } .tab-label { min-width: 0; }
.tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } .tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
+25
View File
@@ -51,6 +51,31 @@ describe("PluginRegistry", () => {
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]); expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]);
}); });
it("provides html and svg helpers to plugin activation", () => {
const registry = new PluginRegistry();
registry.register({
id: "example",
plugin: {
apiVersion: 1,
name: "Example",
activate: ({ html, svg }) => ({
contributions: {
workspacePanels: [
{
id: "workspace.logs",
title: "Logs",
icon: svg`<svg viewBox="0 0 24 24"><path d="M4 6h16"></path></svg>`,
render: () => html`<p>Logs</p>`,
},
],
},
}),
},
});
expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined();
});
it("rejects duplicate ids within the same namespace", () => { it("rejects duplicate ids within the same namespace", () => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
+2 -2
View File
@@ -1,4 +1,4 @@
import { html } from "lit"; import { html, svg } from "lit";
import type { AppState } from "../appState"; import type { AppState } from "../appState";
import type { Workspace } from "../api"; import type { Workspace } from "../api";
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types"; import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types";
@@ -31,7 +31,7 @@ export class PluginRegistry {
const apiVersion: unknown = plugin.apiVersion; const apiVersion: unknown = plugin.apiVersion;
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`); if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`);
const result = plugin.activate({ apiVersion: 1, pluginId: id, html }); const result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg });
const contributions = result.contributions; const contributions = result.contributions;
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action)); for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel)); for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel));
+5
View File
@@ -7,6 +7,7 @@ import type { LocalContributionId, PluginId, QualifiedContributionId } from "./i
export type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids"; export type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export interface PiWebPluginRegistration { export interface PiWebPluginRegistration {
id: PluginId; id: PluginId;
@@ -23,6 +24,7 @@ export interface PluginActivationContext {
apiVersion: 1; apiVersion: 1;
pluginId: PluginId; pluginId: PluginId;
html: HtmlTemplateTag; html: HtmlTemplateTag;
svg: SvgTemplateTag;
} }
export interface PluginActivationResult { export interface PluginActivationResult {
@@ -118,9 +120,12 @@ export interface WorkspacePanelContext {
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void; onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void;
} }
export type WorkspacePanelIcon = TemplateResult;
export interface WorkspacePanelContribution { export interface WorkspacePanelContribution {
id: LocalContributionId; id: LocalContributionId;
title: string; title: string;
icon?: WorkspacePanelIcon;
order?: number; order?: number;
visible?: (context: WorkspacePanelVisibilityContext) => boolean; visible?: (context: WorkspacePanelVisibilityContext) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;