From 23e82e1292f71598d19ab4a74dba83505de8d3f8 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 22 May 2026 14:32:39 +0200 Subject: [PATCH] fix: improve workspace empty states --- .changeset/friendly-workspace-empty-states.md | 5 ++ src/client/src/appState.ts | 4 ++ src/client/src/components/PiWebApp.ts | 51 ++++++++++++++++++- src/client/src/components/WorkspacePanel.ts | 30 +++++++++-- src/client/src/components/shared.ts | 3 ++ .../src/controllers/projectController.ts | 4 +- .../src/controllers/workspaceController.ts | 10 ++-- 7 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 .changeset/friendly-workspace-empty-states.md diff --git a/.changeset/friendly-workspace-empty-states.md b/.changeset/friendly-workspace-empty-states.md new file mode 100644 index 0000000..f980d40 --- /dev/null +++ b/.changeset/friendly-workspace-empty-states.md @@ -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. diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index d891d11..9a25dd5 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 3bdd0ee..d6a7938 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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` { this.openWorkspaceTool(tool); }}>`; + const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined; + return html` { this.openWorkspaceTool(tool); }}>`; } 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` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} ${state.thinkingDialog !== undefined ? html` { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}>` : null} ${state.authDialog !== undefined ? html` { 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(); }}>` : null} - ` : html`
Select or start a session.
`} + ` : html`
${this.sessionEmptyMessage()}
`} ${this.renderWorkspacePanel()} ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 57eddeb..826b293 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -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`
Select a workspace.
`; + 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`
Workspace panel unavailable.
`; + 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 { - ${selectedPanel === undefined ? html`
No workspace panels registered.
` : html` + ${selectedPanel === undefined ? this.renderEmptyState({ + title: "No workspace tools available", + body: "No tools are available for this workspace.", + }) : html`
${selectedPanel.render(context)}
@@ -77,6 +92,15 @@ export class WorkspacePanel extends LitElement { return html`${panel.title} ${badge}`; } + private renderEmptyState(state: WorkspacePanelEmptyState): TemplateResult { + return html` +
+

${state.title}

+ ${state.body === undefined ? null : html`

${state.body}

`} +
+ `; + } + private workspaceHeaderFrameClass(): string { return `workspace-header-scroll-frame${this.workspaceHeaderCanScrollLeft ? " can-scroll-left" : ""}${this.workspaceHeaderCanScrollRight ? " can-scroll-right" : ""}`; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 3f79fb4..59578fb 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index 9ed98d5..21528a6 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -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 }); } } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index df33124..405c824 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -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 });