diff --git a/.changeset/mobile-tab-icons.md b/.changeset/mobile-tab-icons.md
new file mode 100644
index 0000000..27e4b79
--- /dev/null
+++ b/.changeset/mobile-tab-icons.md
@@ -0,0 +1,5 @@
+---
+"@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.
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index 342ed08..ce1b4c6 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -46,7 +46,7 @@ import "./WorkspacePanel";
import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import "./appShell/AppContextBar";
import "./appShell/AppMobileMainTabs";
-import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
+import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs";
import "./appShell/AppNavigationPanel";
import "./appShell/AppPanelEdgeControl";
import "./appShell/AppRefreshControl";
@@ -650,12 +650,19 @@ export class PiWebApp extends LitElement {
return "Select a project and workspace to start a session.";
}
- private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
+ private mobilePanelBadge(panel: QualifiedWorkspacePanelContribution): unknown {
const workspace = this.state.selectedWorkspace;
- if (workspace === undefined) return panel.title;
- const badge = panel.badge?.(this.createWorkspacePanelContext(workspace));
- if (badge === undefined || badge === "") return panel.title;
- return html`${panel.title} ${badge}`;
+ if (workspace === undefined) return undefined;
+ return panel.badge?.(this.createWorkspacePanelContext(workspace));
+ }
+
+ private mobilePanelIcon(panel: QualifiedWorkspacePanelContribution): AppMobileMainTabIcon | undefined {
+ switch (panel.id) {
+ case "core:workspace.files": return "files";
+ case "core:workspace.git": return "git";
+ case "core:workspace.terminal": return "terminal";
+ default: return undefined;
+ }
}
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
@@ -1011,9 +1018,17 @@ export class PiWebApp extends LitElement {
private mobileMainTabs(): AppMobileMainTab[] {
return [
- { id: "navigation", label: "Sessions", className: "navigation-tab" },
- { id: "chat", label: "Chat" },
- ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })),
+ { 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);
+ return {
+ id: panel.id,
+ label: panel.title,
+ ...(icon === undefined ? {} : { icon }),
+ badge: this.mobilePanelBadge(panel),
+ };
+ }),
];
}
diff --git a/src/client/src/components/appShell/AppMobileMainTabs.ts b/src/client/src/components/appShell/AppMobileMainTabs.ts
index 4eaf5fa..44302e3 100644
--- a/src/client/src/components/appShell/AppMobileMainTabs.ts
+++ b/src/client/src/components/appShell/AppMobileMainTabs.ts
@@ -1,10 +1,14 @@
-import { LitElement, css, html } from "lit";
+import { LitElement, css, html, svg } 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 interface AppMobileMainTab {
id: AppState["mainView"];
- label: unknown;
+ label: string;
+ icon?: AppMobileMainTabIcon;
+ badge?: unknown;
className?: string | undefined;
}
@@ -37,12 +41,20 @@ export class AppMobileMainTabs extends LitElement {
}
override render() {
+ const fallbackLabels = this.fallbackLabels();
return html`
- ${this.tabs.map((tab) => html`
-
- `)}
+ ${this.tabs.map((tab) => {
+ const selected = this.selectedView === tab.id;
+ return html`
+
+ `;
+ })}
`;
@@ -59,6 +71,98 @@ export class AppMobileMainTabs extends LitElement {
].join(" ");
}
+ private tabAriaLabel(tab: AppMobileMainTab): string {
+ if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label;
+ const badge = String(tab.badge).trim();
+ return badge === "" ? tab.label : `${tab.label}, ${badge}`;
+ }
+
+ private isEmptyBadge(badge: unknown): boolean {
+ return badge === undefined || badge === "";
+ }
+
+ private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map) {
+ return tab.icon === undefined
+ ? html`${fallbackLabels.get(tab.id) ?? this.initialsLabel(tab.label)}`
+ : this.renderIcon(tab.icon);
+ }
+
+ private fallbackLabels(): Map {
+ const fallbackTabs = this.tabs.filter((tab) => tab.icon === undefined);
+ const counts = new Map();
+ for (const tab of fallbackTabs) {
+ const initials = this.initialsLabel(tab.label);
+ counts.set(initials, (counts.get(initials) ?? 0) + 1);
+ }
+
+ const labels = new Map();
+ for (const tab of fallbackTabs) {
+ const initials = this.initialsLabel(tab.label);
+ labels.set(tab.id, (counts.get(initials) ?? 0) > 1 ? this.fullFallbackLabel(tab.label) : initials);
+ }
+ return labels;
+ }
+
+ private initialsLabel(label: string): string {
+ const words = label.match(/[\p{L}\p{N}]+/gu) ?? [];
+ const initials = words.map((word) => Array.from(word)[0] ?? "").join("").toLocaleUpperCase();
+ return initials === "" ? "?" : initials;
+ }
+
+ private fullFallbackLabel(label: string): string {
+ const trimmed = label.trim();
+ return trimmed === "" ? "?" : trimmed;
+ }
+
+ private renderIcon(icon: AppMobileMainTabIcon) {
+ 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;
@@ -98,13 +202,21 @@ export class AppMobileMainTabs extends LitElement {
.mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; }
.mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
- .mobile-tabs button { flex: 0 0 auto; white-space: nowrap; }
- .navigation-tab { display: none; }
+ .mobile-tabs button { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
+ .mobile-tabs .navigation-tab { display: none; }
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
- .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; 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: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; 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; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
@media (max-width: 760px) {
- .navigation-tab { display: block; }
+ .mobile-tabs { gap: 4px; padding: 6px 8px; }
+ .mobile-tabs button { position: relative; min-width: 40px; height: 36px; justify-content: center; gap: 0; padding: 0 10px; }
+ .mobile-tabs .navigation-tab { display: inline-flex; }
+ .tab-fallback { display: inline-block; }
+ .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; }
+ .tab-badge { position: absolute; top: 1px; right: 1px; min-width: 13px; padding: 0 3px; font-size: 10px; line-height: 13px; }
}
`;
}