diff --git a/.changeset/navigation-overflow-lens.md b/.changeset/navigation-overflow-lens.md new file mode 100644 index 0000000..ce429ce --- /dev/null +++ b/.changeset/navigation-overflow-lens.md @@ -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. diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index 3d86118..a20c02e 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -52,7 +52,7 @@ export class ProjectList extends LitElement { @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }} >
- ${project.name}${this.renderActivity(project)}${project.path} + ${project.name}${this.renderActivity(project)}${project.path}
diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index a7dc10a..5b923cb 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -105,7 +105,7 @@ export class SessionList extends LitElement { @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(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.renderStatus(session)}${String(session.messageCount)} messages
diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 4d6d9c6..6a86e67 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -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 = {}; @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): 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 {

${this.renderHeading()}

${this.collapsed ? null : this.workspaces.map((workspace) => { const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`; + const items = this.workspaceLabelItems(workspace); return html`
{ 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); }} >
- - ${label} - ${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))} - - ${this.renderActivity(workspace)}${workspace.path} + ${this.renderWorkspaceMain(label, items, workspace)}
+ ${this.overflowLensWorkspaceId === workspace.id ? html` +
+ ${this.renderWorkspaceMain(label, items, workspace)} +
+ ` : null}
`; })} @@ -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` + + ${label} + ${renderWorkspaceLabelItems(items)} + + ${this.renderActivity(workspace)}${workspace.path} + `; + } + + 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(".action-row.selected")?.scrollIntoView({ block: "nearest" }); } diff --git a/src/client/src/components/rowOverflowLens.ts b/src/client/src/components/rowOverflowLens.ts new file mode 100644 index 0000000..f8109c5 --- /dev/null +++ b/src/client/src/components/rowOverflowLens.ts @@ -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(".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`; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 56ec383..b42037c 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } } `;