diff --git a/.changeset/permanent-tab-icons.md b/.changeset/permanent-tab-icons.md new file mode 100644 index 0000000..7c7f5b3 --- /dev/null +++ b/.changeset/permanent-tab-icons.md @@ -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. diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 826b293..ced47fb 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -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 {
${this.hideToolTabs ? null : html`
- ${visiblePanels.map((panel) => html` - - `)} + ${visiblePanels.map((panel) => { + const selected = selectedPanel?.id === panel.id; + const badge = panel.badge?.(context); + const ariaLabel = this.panelTabAriaLabel(panel, badge); + return html` + + `; + })}
`} ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)} @@ -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} ${badge}`; + 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``} + ${panel.title} + ${this.isEmptyBadge(badge) ? null : html`${badge}`} + `; + } + + private isEmptyBadge(badge: WorkspacePanelBadge): boolean { + return badge === undefined || badge === ""; } private renderEmptyState(state: WorkspacePanelEmptyState): TemplateResult { diff --git a/src/client/src/components/appShell/AppMobileMainTabs.ts b/src/client/src/components/appShell/AppMobileMainTabs.ts index 8c85b3a..ac08d28 100644 --- a/src/client/src/components/appShell/AppMobileMainTabs.ts +++ b/src/client/src/components/appShell/AppMobileMainTabs.ts @@ -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) { return tab.icon === undefined ? html`` - : this.renderIcon(tab.icon); + : renderAppTabIcon(tab.icon); } private fallbackLabels(): Map { @@ -115,56 +116,6 @@ export class AppMobileMainTabs extends LitElement { return trimmed === "" ? "?" : trimmed; } - private renderIcon(icon: AppMobileMainTabIcon) { - if (typeof icon !== "string") return html``; - switch (icon) { - case "navigation": - return svg` - - `; - case "chat": - return svg` - - `; - case "files": - return svg` - - `; - case "git": - return svg` - - `; - case "terminal": - return svg` - - `; - } - } - private observeMobileTabs(): void { const mobileTabs = this.mobileTabsElement(); if (this.observedMobileTabs === mobileTabs) return; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 462c469..e4a4ee1 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/components/tabIcons.ts b/src/client/src/components/tabIcons.ts new file mode 100644 index 0000000..1c86bf1 --- /dev/null +++ b/src/client/src/components/tabIcons.ts @@ -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``; + return renderBuiltinTabIcon(icon); +} + +export function renderBuiltinTabIcon(icon: AppTabBuiltinIcon): TemplateResult { + switch (icon) { + case "navigation": + return svg` + + `; + case "chat": + return svg` + + `; + case "files": + return svg` + + `; + case "git": + return svg` + + `; + case "terminal": + return svg` + + `; + } +} diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index c04c270..8ba9ed8 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -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,