From f501f9d757236ea19219175251a6766de3af1abf Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 8 Jun 2026 11:19:52 +0200 Subject: [PATCH] fix: stabilize navigation activity indicators --- .changeset/unified-activity-indicators.md | 5 ++++ src/client/src/components/MachineList.ts | 8 ++--- src/client/src/components/ProjectList.ts | 7 +++-- src/client/src/components/SessionList.ts | 35 ++++++++++++++++++---- src/client/src/components/WorkspaceList.ts | 6 ++-- src/client/src/components/activityBadge.ts | 6 ++++ src/client/src/components/shared.ts | 5 ++-- 7 files changed, 54 insertions(+), 18 deletions(-) create mode 100644 .changeset/unified-activity-indicators.md diff --git a/.changeset/unified-activity-indicators.md b/.changeset/unified-activity-indicators.md new file mode 100644 index 0000000..6cc62ce --- /dev/null +++ b/.changeset/unified-activity-indicators.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Pin navigation activity indicators to the top-right of list chips so active projects, workspaces, and sessions no longer shift their labels. diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index 2363813..8ac457b 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import type { Machine, MachineHealth, WorkspaceActivity } from "../api"; import { machineActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; -import { renderActivityIndicator } from "./activityBadge"; +import { renderActionActivityIndicator } from "./activityBadge"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -67,7 +67,8 @@ export class MachineList extends LitElement { @keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }} >
- ${this.renderActivity(machine)}${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} + ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} + ${this.renderActivity(machine)}
${hasRemoveAction ? this.renderMachineMenu(machine) : null} @@ -78,7 +79,7 @@ export class MachineList extends LitElement { const status = this.statuses[machine.id]?.status ?? machine.status; if (status === "offline" || status === "error") return undefined; const kind = machineActivityIndicator(this.activities[machine.id]); - return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active"); + return renderActionActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active"); } private renderMachineMenu(machine: Machine) { @@ -139,7 +140,6 @@ export class MachineList extends LitElement { css` .machine-row.no-actions .action-main { border-radius: 8px; } .machine-primary { display: flex; align-items: baseline; gap: 6px; } - .machine-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; } .machine-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .machine-menu-panel button.danger { color: var(--pi-danger); } .machine-menu-panel button.danger:hover, .machine-menu-panel button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); } diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index b494d2a..6c72c9a 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import type { Project, Workspace, WorkspaceActivity } from "../api"; import { projectActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; -import { renderActivityIndicator } from "./activityBadge"; +import { renderActionActivityIndicator } from "./activityBadge"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -55,7 +55,8 @@ export class ProjectList extends LitElement { @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }} >
- ${project.name}${this.renderActivity(project)}${project.path} + ${project.name}${project.path} + ${this.renderActivity(project)}
@@ -82,7 +83,7 @@ export class ProjectList extends LitElement { private renderActivity(project: Project) { const kind = projectActivityIndicator(project, this.workspacesByProjectId[project.id] ?? [], this.activities); - return renderActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active") ?? ""; + return renderActionActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active"); } private toggleMenu(projectId: string, target: EventTarget | null) { diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 33adeb7..f85a88e 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -4,8 +4,8 @@ import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; import { isCachedNewSessionInfo } from "../cachedNewSessions"; import { isSessionActive } from "../../../shared/activity"; import { actionMenuPanelStyle } from "./actionMenu"; -import { renderActivityIndicator } from "./activityBadge"; -import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; +import { renderActionActivityIndicator } from "./activityBadge"; +import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; function sessionLabel(session: SessionInfo): string { @@ -32,6 +32,9 @@ export class SessionList extends LitElement { @property({ attribute: false }) onStart?: () => void; @property({ attribute: false }) onToggleCollapsed?: () => void; @property({ attribute: false }) onArchivedCollapsed?: () => void; + @property({ attribute: false }) onFocusPreviousSection?: () => void | Promise; + @property({ attribute: false }) onFocusNextSection?: () => void | Promise; + @property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise; @state() private openMenuSessionId: string | undefined; @state() private menuStyle = ""; @state() private archivedExpanded = false; @@ -68,6 +71,11 @@ export class SessionList extends LitElement { if ((changed.has("selected") || changed.has("sessions") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView(); } + async focusSelectedOrFirst(): Promise { + await this.updateComplete; + return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle, h2 button:not([disabled])" }); + } + override render() { const activeRows = sessionRowsForActiveTree(this.sessions); const activeIds = new Set(activeRows.map((row) => row.session.id)); @@ -111,10 +119,11 @@ export class SessionList extends LitElement { tabindex="0" title=${session.path} @click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(session)); }} - @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(session)); }} + @keydown=${(event: KeyboardEvent) => { this.handleSessionKeydown(event, session); }} >
- ${row.depth > 0 ? html`` : null}${sessionLabel(session)}${row.depth > 2 ? html` depth ${row.depth}` : null}${row.hasMissingParent ? html` parent unavailable` : null}${this.renderStatus(session)}${String(session.messageCount)} messages + ${row.depth > 0 ? html`` : null}${sessionLabel(session)}${row.depth > 2 ? html` depth ${row.depth}` : null}${row.hasMissingParent ? html` parent unavailable` : null}${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages + ${this.renderActivity(session)}
@@ -136,6 +145,15 @@ export class SessionList extends LitElement { `; } + private handleSessionKeydown(event: KeyboardEvent, session: SessionInfo): void { + handleSelectableRowKeyboard(event, { + activate: () => this.onSelect?.(session), + previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); }, + nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); }, + cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); }, + }); + } + private confirmArchiveWithDescendants(session: SessionInfo, descendantCount: number): void { const noun = descendantCount === 1 ? "descendant session" : "descendant sessions"; if (confirm(`Archive “${sessionLabel(session)}” and ${String(descendantCount)} ${noun}?`)) this.onArchiveWithDescendants?.(session); @@ -162,10 +180,15 @@ export class SessionList extends LitElement { this.renderRoot.querySelector(".action-row.selected")?.scrollIntoView({ block: "nearest" }); } - private renderStatus(session: SessionInfo) { + private renderSessionMetaPrefix(session: SessionInfo) { if (isCachedNewSessionInfo(session)) return "new · "; if (session.archived === true) return "read-only · "; - return renderActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active") ?? ""; + return ""; + } + + private renderActivity(session: SessionInfo) { + if (isCachedNewSessionInfo(session) || session.archived === true) return undefined; + return renderActionActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active"); } static override styles = listStyles; diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 838d18f..9bc2d4e 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -4,7 +4,7 @@ import type { Workspace, WorkspaceActivity } from "../api"; import type { WorkspaceLabelItem } from "../plugins/types"; import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; -import { renderActivityIndicator } from "./activityBadge"; +import { renderActionActivityIndicator } from "./activityBadge"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; import { renderWorkspaceLabelInlineItems } from "./workspaceLabel"; @@ -84,13 +84,12 @@ export class WorkspaceList extends LitElement { private renderActivity(workspace: Workspace): TemplateResult | undefined { const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities)); - return renderActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active"); + return renderActionActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active"); } private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { return html` - ${this.renderActivity(workspace)} ${label} ${this.isDeleting(workspace) ? html`Deleting…` : null} @@ -99,6 +98,7 @@ export class WorkspaceList extends LitElement { ${renderWorkspaceLabelInlineItems(items)} `} + ${this.renderActivity(workspace)} `; } diff --git a/src/client/src/components/activityBadge.ts b/src/client/src/components/activityBadge.ts index 8750895..a674f49 100644 --- a/src/client/src/components/activityBadge.ts +++ b/src/client/src/components/activityBadge.ts @@ -6,3 +6,9 @@ export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, if (kind === undefined) return undefined; return html``; } + +export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined { + const indicator = renderActivityIndicator(kind, label); + if (indicator === undefined) return undefined; + return html`${indicator}`; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 495a32b..da0005b 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -225,12 +225,11 @@ export const listStyles = css` .action-row:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; border-radius: 8px; } .action-row.selected .action-main, .action-row.selected .action-menu-toggle { border-color: var(--pi-accent); background: var(--pi-selection-bg); } .action-row.archived .action-main { color: var(--pi-muted); } - .action-main { box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid var(--pi-border); border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; } + .action-main { position: relative; box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid var(--pi-border); border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 22px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; } .action-name { display: -webkit-box; max-height: 2.5em; overflow: hidden; overflow-wrap: anywhere; line-height: 1.25; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } .action-row:not(.selected):hover .action-main { background: var(--pi-surface-hover); } .workspace-row .action-main { border-radius: 8px 0 0 8px; } .workspace-primary { min-width: 0; display: flex; align-items: baseline; gap: 6px; } - .workspace-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; } .workspace-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workspace-status { flex: 0 0 auto; color: var(--pi-warning); font-size: 12px; } .workspace-secondary { margin-top: 3px; } @@ -244,6 +243,8 @@ export const listStyles = css` .workspace-detail-row dd { min-width: 0; margin: 0; overflow-wrap: anywhere; white-space: normal; } .tree-marker { color: var(--pi-dim); margin-right: 5px; } .badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; } + .action-activity { position: absolute; top: 5px; right: 6px; z-index: 1; display: grid; place-items: center; width: 10px; height: 10px; } + .action-activity .activity-indicator { margin: 0; vertical-align: 0; } .activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; } .activity-indicator.session { border-radius: 50%; background: var(--pi-success); } .activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }