fix: stabilize navigation activity indicators

This commit is contained in:
Federico Jaramillo Martinez
2026-06-08 13:33:39 +02:00
parent 25d8188970
commit f501f9d757
7 changed files with 54 additions and 18 deletions
@@ -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.
+4 -4
View File
@@ -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); }}
>
<div class="action-main">
<span class="action-name machine-primary">${this.renderActivity(machine)}<span class="machine-primary-label">${machine.name}</span></span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
<span class="action-name machine-primary"><span class="machine-primary-label">${machine.name}</span></span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
${this.renderActivity(machine)}
</div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
</div>
@@ -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); }
+4 -3
View File
@@ -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)); }}
>
<div class="action-main">
<span class="action-name">${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
<span class="action-name">${project.name}</span><small>${project.path}</small>
${this.renderActivity(project)}
</div>
<div class="action-menu">
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
@@ -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) {
+29 -6
View File
@@ -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<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
@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<boolean> {
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); }}
>
<div class="action-main">
<span class="action-name">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
<span class="action-name">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages</small>
${this.renderActivity(session)}
</div>
<div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
@@ -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<HTMLElement>(".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;
+3 -3
View File
@@ -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`
<span class="workspace-primary">
${this.renderActivity(workspace)}
<span class="workspace-primary-label">${label}</span>
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
</span>
@@ -99,6 +98,7 @@ export class WorkspaceList extends LitElement {
<span class="workspace-label">${renderWorkspaceLabelInlineItems(items)}</span>
</small>
`}
${this.renderActivity(workspace)}
`;
}
@@ -6,3 +6,9 @@ export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined,
if (kind === undefined) return undefined;
return html`<span class=${`activity-indicator ${kind}`} role="img" aria-label=${label} title=${label}></span>`;
}
export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
const indicator = renderActivityIndicator(kind, label);
if (indicator === undefined) return undefined;
return html`<span class="action-activity">${indicator}</span>`;
}
+3 -2
View File
@@ -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); }