feat: add workspace overflow lens

This commit is contained in:
Federico Jaramillo Martinez
2026-05-20 09:38:33 +02:00
parent 1f06b25dee
commit 3da2fcf251
6 changed files with 117 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add in-place overflow lenses for workspace rows so truncated workspace labels and plugin links can be read or clicked, and cap long project and session names to two lines.
+1 -1
View File
@@ -52,7 +52,7 @@ export class ProjectList extends LitElement {
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
>
<div class="action-main">
<span>${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
<span class="action-name">${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
</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>
+1 -1
View File
@@ -105,7 +105,7 @@ export class SessionList extends LitElement {
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(session)); }}
>
<div class="action-main">
<span>${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.renderStatus(session)}${String(session.messageCount)} messages</small>
</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>
+52 -7
View File
@@ -1,9 +1,10 @@
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import type { Workspace, WorkspaceActivity } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types";
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
import { renderActivityIndicator } from "./activityBadge";
import { focusMovedWithinCurrentTarget, rowOverflowLensStyle, shouldOpenOverflowLensFromFocus, shouldOpenOverflowLensFromPointer } from "./rowOverflowLens";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
import { renderWorkspaceLabelItems } from "./workspaceLabel";
@@ -18,8 +19,12 @@ export class WorkspaceList extends LitElement {
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void;
@state() private overflowLensWorkspaceId: string | undefined;
@state() private overflowLensStyle = "";
protected override updated(changed: PropertyValues<this>): void {
if (changed.has("workspaces") && this.overflowLensWorkspaceId !== undefined && !this.workspaces.some((workspace) => workspace.id === this.overflowLensWorkspaceId)) this.overflowLensWorkspaceId = undefined;
if (changed.has("collapsed") && this.collapsed) this.overflowLensWorkspaceId = undefined;
if ((changed.has("selected") || changed.has("workspaces") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView();
}
@@ -29,21 +34,27 @@ export class WorkspaceList extends LitElement {
<h2>${this.renderHeading()}</h2>
${this.collapsed ? null : this.workspaces.map((workspace) => {
const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`;
const items = this.workspaceLabelItems(workspace);
return html`
<div
class=${`action-row workspace-row ${this.selected?.id === workspace.id ? "selected" : ""}`}
tabindex="0"
title=${workspace.path}
@pointerenter=${(event: PointerEvent) => { if (shouldOpenOverflowLensFromPointer(event)) this.openOverflowLens(workspace.id, event.currentTarget); }}
@pointerleave=${() => { this.closeOverflowLens(workspace.id); }}
@focusin=${(event: FocusEvent) => { if (shouldOpenOverflowLensFromFocus(event)) this.openOverflowLens(workspace.id, event.currentTarget); }}
@focusout=${(event: FocusEvent) => { this.closeOverflowLensOnFocusOut(workspace.id, event); }}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(workspace)); }}
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(workspace)); }}
@keydown=${(event: KeyboardEvent) => { this.handleWorkspaceKeydown(event, workspace); }}
>
<div class="action-main">
<span class="workspace-label">
<span class="workspace-label-base">${label}</span>
${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))}
</span>
<small>${this.renderActivity(workspace)}${workspace.path}</small>
${this.renderWorkspaceMain(label, items, workspace)}
</div>
${this.overflowLensWorkspaceId === workspace.id ? html`
<div class="row-overflow-lens" style=${this.overflowLensStyle}>
${this.renderWorkspaceMain(label, items, workspace)}
</div>
` : null}
</div>
`;
})}
@@ -63,6 +74,40 @@ export class WorkspaceList extends LitElement {
return renderActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active") ?? "";
}
private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace) {
return html`
<span class="workspace-label">
<span class="workspace-label-base">${label}</span>
${renderWorkspaceLabelItems(items)}
</span>
<small>${this.renderActivity(workspace)}${workspace.path}</small>
`;
}
private openOverflowLens(workspaceId: string, target: EventTarget | null): void {
this.overflowLensWorkspaceId = workspaceId;
this.overflowLensStyle = rowOverflowLensStyle(target);
}
private closeOverflowLens(workspaceId: string): void {
if (this.overflowLensWorkspaceId === workspaceId) this.overflowLensWorkspaceId = undefined;
}
private closeOverflowLensOnFocusOut(workspaceId: string, event: FocusEvent): void {
if (focusMovedWithinCurrentTarget(event)) return;
this.closeOverflowLens(workspaceId);
}
private handleWorkspaceKeydown(event: KeyboardEvent, workspace: Workspace): void {
if (event.key === "Escape" && this.overflowLensWorkspaceId === workspace.id) {
event.preventDefault();
event.stopPropagation();
this.overflowLensWorkspaceId = undefined;
return;
}
activateSelectableRowFromKeyboard(event, () => this.onSelect?.(workspace));
}
private scrollSelectedIntoView(): void {
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
}
@@ -0,0 +1,52 @@
const LENS_MARGIN_PX = 8;
const MAX_LENS_WIDTH_PX = 720;
interface RowOverflowLensRect {
top: number;
left: number;
width: number;
height: number;
}
interface RowOverflowLensViewport {
width: number;
}
export function rowOverflowLensStyle(target: EventTarget | null): string {
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return "";
const anchor = target.querySelector<HTMLElement>(".action-main") ?? target;
return rowOverflowLensStyleForRect(anchor.getBoundingClientRect(), { width: window.innerWidth });
}
export function rowOverflowLensStyleForRect(rect: RowOverflowLensRect, viewport: RowOverflowLensViewport): string {
const left = Math.max(LENS_MARGIN_PX, rect.left);
const availableWidth = Math.max(0, viewport.width - left - LENS_MARGIN_PX);
const maxWidth = Math.min(MAX_LENS_WIDTH_PX, availableWidth);
const minWidth = Math.min(rect.width, maxWidth);
return [
`top: ${px(rect.top)};`,
`left: ${px(left)};`,
`height: ${px(rect.height)};`,
`min-width: ${px(minWidth)};`,
`max-width: ${px(maxWidth)};`,
].join(" ");
}
export function shouldOpenOverflowLensFromPointer(event: PointerEvent): boolean {
return event.pointerType === "mouse" || event.pointerType === "pen";
}
export function shouldOpenOverflowLensFromFocus(event: FocusEvent): boolean {
if (typeof HTMLElement === "undefined" || !(event.currentTarget instanceof HTMLElement)) return false;
return event.currentTarget.matches(":focus-visible");
}
export function focusMovedWithinCurrentTarget(event: FocusEvent): boolean {
if (typeof HTMLElement === "undefined" || typeof Node === "undefined") return false;
return event.currentTarget instanceof HTMLElement && event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget);
}
function px(value: number): string {
return `${String(Math.round(value))}px`;
}
+6
View File
@@ -180,6 +180,7 @@ export const listStyles = css`
.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-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; }
.tree-marker { color: var(--pi-dim); margin-right: 5px; }
@@ -201,6 +202,11 @@ export const listStyles = css`
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); }
.workspace-label-link { color: var(--pi-accent); text-decoration: none; }
.workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; }
.row-overflow-lens { position: fixed; z-index: 40; box-sizing: border-box; width: max-content; overflow: hidden; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface-hover); color: var(--pi-text); padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); cursor: pointer; }
.action-row.selected > .row-overflow-lens { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.row-overflow-lens small { overflow: visible; text-overflow: clip; white-space: nowrap; }
.row-overflow-lens .workspace-label { display: inline-flex; max-width: none; overflow: visible; white-space: nowrap; flex-wrap: nowrap; }
.row-overflow-lens .workspace-label-base, .row-overflow-lens .workspace-label-item, .row-overflow-lens .workspace-label-render { overflow: visible; text-overflow: clip; white-space: nowrap; }
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
`;