fix: improve workspace empty states

This commit is contained in:
Federico Jaramillo Martinez
2026-05-22 14:32:39 +02:00
parent 6a8f2f2979
commit 23e82e1292
7 changed files with 96 additions and 11 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Improve empty states for workspace tools and session selection when no project, workspace, or session is selected.
+4
View File
@@ -11,6 +11,8 @@ export interface AppState {
messagePageTotal: number;
isLoadingEarlierMessages: boolean;
isReceivingPartialStream: boolean;
isLoadingProjects: boolean;
isLoadingWorkspaces: boolean;
selectedProject: Project | undefined;
selectedWorkspace: Workspace | undefined;
selectedSession: SessionInfo | undefined;
@@ -96,6 +98,8 @@ export function initialAppState(): AppState {
messagePageTotal: 0,
isLoadingEarlierMessages: false,
isReceivingPartialStream: false,
isLoadingProjects: false,
isLoadingWorkspaces: false,
selectedProject: undefined,
selectedWorkspace: undefined,
selectedSession: undefined,
+49 -2
View File
@@ -35,6 +35,7 @@ import "./ActionPalette";
import "./AuthDialog";
import "./ProjectDialog";
import "./WorkspacePanel";
import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import { appStyles } from "./shared";
type NavigationSection = "projects" | "workspaces" | "sessions";
@@ -418,7 +419,8 @@ export class PiWebApp extends LitElement {
const workspace = this.state.selectedWorkspace;
const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace);
const workspaceLabelItems = workspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, workspace);
return html`<workspace-panel .workspace=${workspace} .panelContext=${panelContext} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}></workspace-panel>`;
const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined;
return html`<workspace-panel .workspace=${workspace} .panelContext=${panelContext} .emptyState=${emptyState} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}></workspace-panel>`;
}
private renderNavigationPanel(autoSwitchToChat: boolean) {
@@ -509,6 +511,51 @@ export class PiWebApp extends LitElement {
return this.plugins.getWorkspacePanels().filter((panel) => panel.visible?.({ workspace, state: this.state }) ?? true);
}
private workspacePanelEmptyState(): WorkspacePanelEmptyState {
const project = this.state.selectedProject;
if (this.state.isLoadingProjects) {
return {
title: "Loading projects…",
body: "Looking for projects you have added to Pi Web.",
};
}
if (project === undefined) {
return this.state.projects.length === 0
? {
title: "No projects yet",
body: "Use Actions → Add Project to add a folder. Workspace tools will appear here after you choose a workspace.",
}
: {
title: "Select a project",
body: "Choose a project from the sidebar, then select a workspace to inspect files, Git, or terminals.",
};
}
if (this.state.isLoadingWorkspaces) {
return {
title: "Loading workspaces…",
body: `Preparing workspace tools for ${project.name}.`,
};
}
if (this.state.workspaces.length === 0) {
return {
title: "No workspaces found",
body: `${project.name} does not have any available workspaces. Try selecting the project again or re-adding it.`,
};
}
return {
title: "Select a workspace",
body: `Choose a workspace in ${project.name} to inspect files, Git, or terminals.`,
};
}
private sessionEmptyMessage(): string {
if (this.state.isLoadingProjects) return "Loading projects…";
if (this.state.selectedWorkspace !== undefined) return "Select or start a session.";
if (this.state.selectedProject !== undefined) return "Select a workspace to start a session.";
if (this.state.projects.length === 0) return "Add a project to start a session.";
return "Select a project and workspace to start a session.";
}
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
const workspace = this.state.selectedWorkspace;
if (workspace === undefined) return panel.title;
@@ -847,7 +894,7 @@ export class PiWebApp extends LitElement {
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
${state.authDialog !== undefined ? html`<auth-dialog .state=${state.authDialog} .onChooseMethod=${(authType: "oauth" | "api_key") => { void this.auth.chooseLoginMethod(authType); }} .onSelectProvider=${(providerId: string, authType: "oauth" | "api_key") => { void this.auth.selectLoginProvider(providerId, authType); }} .onApiKeyInput=${(value: string) => { this.auth.updateApiKey(value); }} .onSaveApiKey=${() => { void this.auth.saveApiKey(); }} .onLogoutProvider=${(providerId: string) => { void this.auth.logoutProvider(providerId); }} .onOAuthInput=${(value: string) => { this.auth.updateOAuthInput(value); }} .onOAuthRespond=${(value?: string) => { void this.auth.respondOAuth(value); }} .onOAuthCancel=${() => { void this.auth.cancelOAuth(); }} .onCancel=${() => { this.auth.closeDialog(); }}></auth-dialog>` : null}
` : html`<div class="empty">Select or start a session.</div>`}
` : html`<div class="empty">${this.sessionEmptyMessage()}</div>`}
</main>
${this.renderWorkspacePanel()}
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
+27 -3
View File
@@ -5,10 +5,16 @@ import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, Work
import { workspacePanelStyles } from "./shared";
import { renderWorkspaceLabel } from "./workspaceLabel";
export interface WorkspacePanelEmptyState {
title: string;
body?: string;
}
@customElement("workspace-panel")
export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
@property({ attribute: false }) panelContext: WorkspacePanelContext | undefined;
@property({ attribute: false }) emptyState: WorkspacePanelEmptyState | undefined;
@property() tool: QualifiedContributionId = "core:workspace.files";
@property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = [];
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
@@ -43,9 +49,15 @@ export class WorkspacePanel extends LitElement {
override render() {
const workspace = this.workspace;
if (workspace === undefined) return html`<section class="empty">Select a workspace.</section>`;
if (workspace === undefined) return this.renderEmptyState(this.emptyState ?? {
title: "Select a workspace",
body: "Choose a workspace to inspect files, Git, or terminals.",
});
const context = this.panelContext;
if (context === undefined) return html`<section class="empty">Workspace panel unavailable.</section>`;
if (context === undefined) return this.renderEmptyState({
title: "Workspace tools unavailable",
body: "Try selecting the workspace again.",
});
const visiblePanels = this.panels;
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
return html`
@@ -63,7 +75,10 @@ export class WorkspacePanel extends LitElement {
</div>
</div>
</header>
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : html`
${selectedPanel === undefined ? this.renderEmptyState({
title: "No workspace tools available",
body: "No tools are available for this workspace.",
}) : html`
<div class="panel-content">
${selectedPanel.render(context)}
</div>
@@ -77,6 +92,15 @@ export class WorkspacePanel extends LitElement {
return html`${panel.title} <span class="tab-badge">${badge}</span>`;
}
private renderEmptyState(state: WorkspacePanelEmptyState): TemplateResult {
return html`
<section class="empty-state" role="status">
<h2>${state.title}</h2>
${state.body === undefined ? null : html`<p>${state.body}</p>`}
</section>
`;
}
private workspaceHeaderFrameClass(): string {
return `workspace-header-scroll-frame${this.workspaceHeaderCanScrollLeft ? " can-scroll-left" : ""}${this.workspaceHeaderCanScrollRight ? " can-scroll-right" : ""}`;
}
+3
View File
@@ -130,6 +130,9 @@ export const workspacePanelStyles = css`
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; }
.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; }
.empty-state p { margin: 0; line-height: 1.45; }
small, .muted { color: var(--pi-muted); }
header small { flex: 0 0 auto; min-width: max-content; overflow: visible; text-overflow: clip; white-space: nowrap; }
header .workspace-label { width: max-content; max-width: none; overflow: visible; }
@@ -6,7 +6,7 @@ export class ProjectController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly workspaces: WorkspaceController) {}
async loadProjects() {
this.setState({ error: "" });
this.setState({ error: "", isLoadingProjects: true });
try {
const projects = await api.projects();
const projectIds = new Set(projects.map((project) => project.id));
@@ -14,6 +14,8 @@ export class ProjectController {
this.setState({ projects, workspacesByProjectId });
} catch (error) {
this.setState({ error: String(error) });
} finally {
this.setState({ isLoadingProjects: false });
}
}
@@ -16,7 +16,7 @@ export class WorkspaceController {
clearSelection(options?: { updateUrl?: boolean | undefined }) {
this.sessions.clearActiveSession();
this.setState({ selectedProject: undefined, selectedWorkspace: undefined, workspaces: [], ...resetWorkspaceScopedState() });
this.setState({ selectedProject: undefined, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
if (options?.updateUrl !== false) this.updateUrl();
}
@@ -28,22 +28,22 @@ export class WorkspaceController {
async selectProject(project: Project, target?: RouteTarget) {
this.sessions.clearActiveSession();
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], ...resetWorkspaceScopedState() });
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
try {
const workspaces = await api.workspaces(project.id);
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces } });
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false });
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) });
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
else if (target?.updateUrl !== false) this.updateUrl();
} catch (error) {
this.setState({ error: String(error) });
this.setState({ error: String(error), isLoadingWorkspaces: false });
}
}
async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) {
this.workspaceSelection.rememberWorkspace(workspace);
this.sessions.clearActiveSession();
this.setState({ selectedWorkspace: workspace, ...resetWorkspaceScopedState() });
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
try {
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path));
this.setState({ sessions });