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
@@ -1021,7 +1021,7 @@ export class PiWebApp extends LitElement {
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" },
{ id: "chat", label: "Chat", icon: "chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
const icon = this.mobilePanelIcon(panel);
const icon = panel.icon ?? this.mobilePanelIcon(panel);
return {
id: panel.id,
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 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 {
id: AppState["mainView"];
@@ -115,6 +116,7 @@ export class AppMobileMainTabs extends LitElement {
}
private renderIcon(icon: AppMobileMainTabIcon) {
if (typeof icon !== "string") return html`<span class="tab-custom-icon" aria-hidden="true">${icon}</span>`;
switch (icon) {
case "navigation":
return svg`
@@ -206,6 +208,8 @@ export class AppMobileMainTabs extends LitElement {
.mobile-tabs .navigation-tab { display: none; }
.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-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-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; }
+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"]);
});
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", () => {
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 { Workspace } from "../api";
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;
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;
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));
+5
View File
@@ -7,6 +7,7 @@ import type { LocalContributionId, PluginId, QualifiedContributionId } from "./i
export type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export interface PiWebPluginRegistration {
id: PluginId;
@@ -23,6 +24,7 @@ export interface PluginActivationContext {
apiVersion: 1;
pluginId: PluginId;
html: HtmlTemplateTag;
svg: SvgTemplateTag;
}
export interface PluginActivationResult {
@@ -118,9 +120,12 @@ export interface WorkspacePanelContext {
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void;
}
export type WorkspacePanelIcon = TemplateResult;
export interface WorkspacePanelContribution {
id: LocalContributionId;
title: string;
icon?: WorkspacePanelIcon;
order?: number;
visible?: (context: WorkspacePanelVisibilityContext) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;