feat: keep workspace tab icons visible

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 13:50:29 +02:00
parent e3533ebf1f
commit bd8d1f1f08
6 changed files with 117 additions and 62 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep workspace tool tab icons visible in the desktop workspace panel and collapse tab names only in compact panel widths.
+35 -7
View File
@@ -10,6 +10,8 @@ export interface WorkspacePanelEmptyState {
body?: string;
}
type WorkspacePanelBadge = string | number | TemplateResult | undefined;
@customElement("workspace-panel")
export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
@@ -66,9 +68,16 @@ export class WorkspacePanel extends LitElement {
<div class="workspace-header-strip" @scroll=${this.onWorkspaceHeaderScroll}>
${this.hideToolTabs ? null : html`
<div class="tabs">
${visiblePanels.map((panel) => html`
<button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${this.renderPanelTitle(panel, context)}</button>
`)}
${visiblePanels.map((panel) => {
const selected = selectedPanel?.id === panel.id;
const badge = panel.badge?.(context);
const ariaLabel = this.panelTabAriaLabel(panel, badge);
return html`
<button class=${this.panelTabClass(panel, selected)} title=${ariaLabel} aria-label=${ariaLabel} aria-pressed=${String(selected)} @click=${() => { this.onSelectTool(panel.id); }}>
${this.renderPanelTabContent(panel, badge)}
</button>
`;
})}
</div>
`}
<small>${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}</small>
@@ -86,10 +95,29 @@ export class WorkspacePanel extends LitElement {
`;
}
private renderPanelTitle(panel: QualifiedWorkspacePanelContribution, context: WorkspacePanelContext): TemplateResult {
const badge = panel.badge?.(context);
if (badge === undefined || badge === "") return html`${panel.title}`;
return html`${panel.title} <span class="tab-badge">${badge}</span>`;
private panelTabClass(panel: QualifiedWorkspacePanelContribution, selected: boolean): string {
return [
...(panel.icon === undefined ? [] : ["icon-tab"]),
...(selected ? ["selected"] : []),
].join(" ");
}
private panelTabAriaLabel(panel: QualifiedWorkspacePanelContribution, badge: WorkspacePanelBadge): string {
if (typeof badge !== "string" && typeof badge !== "number") return panel.title;
const trimmedBadge = String(badge).trim();
return trimmedBadge === "" ? panel.title : `${panel.title}, ${trimmedBadge}`;
}
private renderPanelTabContent(panel: QualifiedWorkspacePanelContribution, badge: WorkspacePanelBadge): TemplateResult {
return html`
${panel.icon === undefined ? null : html`<span class="tab-custom-icon" aria-hidden="true">${panel.icon}</span>`}
<span class="tab-label">${panel.title}</span>
${this.isEmptyBadge(badge) ? null : html`<span class="tab-badge">${badge}</span>`}
`;
}
private isEmptyBadge(badge: WorkspacePanelBadge): boolean {
return badge === undefined || badge === "";
}
private renderEmptyState(state: WorkspacePanelEmptyState): TemplateResult {
@@ -1,8 +1,9 @@
import { LitElement, css, html, svg, type TemplateResult } from "lit";
import { LitElement, css, html, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { AppState } from "../../appState";
import { renderAppTabIcon, type AppTabBuiltinIcon } from "../tabIcons";
export type AppMobileMainTabBuiltinIcon = "navigation" | "chat" | "files" | "git" | "terminal";
export type AppMobileMainTabBuiltinIcon = AppTabBuiltinIcon;
export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult;
export interface AppMobileMainTab {
@@ -85,7 +86,7 @@ export class AppMobileMainTabs extends LitElement {
private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map<AppState["mainView"], string>) {
return tab.icon === undefined
? html`<span class="tab-fallback" aria-hidden="true">${fallbackLabels.get(tab.id) ?? this.initialsLabel(tab.label)}</span>`
: this.renderIcon(tab.icon);
: renderAppTabIcon(tab.icon);
}
private fallbackLabels(): Map<AppState["mainView"], string> {
@@ -115,56 +116,6 @@ export class AppMobileMainTabs extends LitElement {
return trimmed === "" ? "?" : trimmed;
}
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`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="6" cy="7" r="1.5"></circle>
<path d="M10 7h8"></path>
<circle cx="6" cy="12" r="1.5"></circle>
<path d="M10 12h8"></path>
<circle cx="6" cy="17" r="1.5"></circle>
<path d="M10 17h8"></path>
</svg>
`;
case "chat":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M7 5h10a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-6l-5 4v-4H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3Z"></path>
<path d="M8 9h8"></path>
<path d="M8 13h5"></path>
</svg>
`;
case "files":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
</svg>
`;
case "git":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="6" cy="6" r="2"></circle>
<circle cx="18" cy="6" r="2"></circle>
<circle cx="12" cy="18" r="2"></circle>
<path d="M8 6h6"></path>
<path d="M6 8v2a6 6 0 0 0 6 6"></path>
<path d="M18 8v2a6 6 0 0 1-6 6"></path>
</svg>
`;
case "terminal":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
<path d="m7 10 3 3-3 3"></path>
<path d="M12 16h5"></path>
</svg>
`;
}
}
private observeMobileTabs(): void {
const mobileTabs = this.mobileTabsElement();
if (this.observedMobileTabs === mobileTabs) return;
+11 -2
View File
@@ -159,7 +159,7 @@ export const appStyles = css`
`;
export const workspacePanelStyles = css`
:host { display: flex; flex-direction: column; min-height: 0; color: var(--pi-text); background: var(--pi-bg); font: 13px system-ui, sans-serif; }
:host { display: flex; flex-direction: column; min-height: 0; color: var(--pi-text); background: var(--pi-bg); font: 13px system-ui, sans-serif; container-type: inline-size; }
header { flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); }
.workspace-header-scroll-frame { position: relative; min-width: 0; background: var(--pi-bg); }
.workspace-header-scroll-frame::before, .workspace-header-scroll-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 18px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
@@ -169,9 +169,18 @@ export const workspacePanelStyles = css`
.workspace-header-strip { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
.tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; }
.tabs button { flex: 0 0 auto; white-space: nowrap; }
.tabs button.icon-tab { min-width: 34px; }
button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.tab-badge { display: inline-block; min-width: 14px; 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-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.tab-custom-icon { flex: 0 0 auto; width: 16px; height: 16px; display: inline-grid; place-items: center; color: currentColor; pointer-events: none; }
.tab-custom-icon svg { width: 16px; height: 16px; pointer-events: none; }
.tab-label { min-width: 0; }
.tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; 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; }
@container (max-width: 430px) {
.tabs button.icon-tab { justify-content: center; padding-inline: 7px; }
.tabs button.icon-tab .tab-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
}
.panel-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: auto; }
.empty-state { box-sizing: border-box; width: min(100%, 380px); margin: auto; padding: 24px; display: grid; gap: 8px; color: var(--pi-muted); text-align: center; }
.empty-state h2 { margin: 0; color: var(--pi-text); font-size: 15px; line-height: 1.3; }
+58
View File
@@ -0,0 +1,58 @@
import { html, svg, type TemplateResult } from "lit";
export type AppTabBuiltinIcon = "navigation" | "chat" | "files" | "git" | "terminal";
export type AppTabIcon = AppTabBuiltinIcon | TemplateResult;
export function renderAppTabIcon(icon: AppTabIcon): TemplateResult {
if (typeof icon !== "string") return html`<span class="tab-custom-icon" aria-hidden="true">${icon}</span>`;
return renderBuiltinTabIcon(icon);
}
export function renderBuiltinTabIcon(icon: AppTabBuiltinIcon): TemplateResult {
switch (icon) {
case "navigation":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="6" cy="7" r="1.5"></circle>
<path d="M10 7h8"></path>
<circle cx="6" cy="12" r="1.5"></circle>
<path d="M10 12h8"></path>
<circle cx="6" cy="17" r="1.5"></circle>
<path d="M10 17h8"></path>
</svg>
`;
case "chat":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M7 5h10a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-6l-5 4v-4H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3Z"></path>
<path d="M8 9h8"></path>
<path d="M8 13h5"></path>
</svg>
`;
case "files":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
</svg>
`;
case "git":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="6" cy="6" r="2"></circle>
<circle cx="18" cy="6" r="2"></circle>
<circle cx="12" cy="18" r="2"></circle>
<path d="M8 6h6"></path>
<path d="M6 8v2a6 6 0 0 0 6 6"></path>
<path d="M18 8v2a6 6 0 0 1-6 6"></path>
</svg>
`;
case "terminal":
return svg`
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
<path d="m7 10 3 3-3 3"></path>
<path d="M12 16h5"></path>
</svg>
`;
}
}
+4
View File
@@ -2,6 +2,7 @@ import { html, type TemplateResult } from "lit";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api";
import { workspaceImagePreviewUrl } from "../../api/urls";
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../../../shared/workspaceFiles";
import { renderBuiltinTabIcon } from "../../components/tabIcons";
import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types";
export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
@@ -9,12 +10,14 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
{
id: "workspace.files",
title: "Files",
icon: renderBuiltinTabIcon("files"),
order: 10,
render: renderFiles,
},
{
id: "workspace.git",
title: "Git",
icon: renderBuiltinTabIcon("git"),
order: 20,
visible: ({ workspace }) => workspace.isGitRepo,
render: renderGit,
@@ -22,6 +25,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
{
id: "workspace.terminal",
title: "Terminal",
icon: renderBuiltinTabIcon("terminal"),
order: 30,
badge: (context) => context.activeTerminalCount > 0 ? context.activeTerminalCount : undefined,
render: renderTerminal,