feat: add responsive location context

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 20:17:50 +02:00
parent 32182a5539
commit 2631a63ec7
6 changed files with 133 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add persistent project, workspace, and session context in the web UI so mobile users keep their location visible while navigating between panels and chat.
+97
View File
@@ -45,6 +45,7 @@ export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState();
@query("chat-view") private chatView?: ChatView;
@query("prompt-editor") private promptEditor?: PromptEditor;
@query(".context-items") private contextItems?: HTMLElement;
@query(".mobile-tabs") private mobileTabs?: HTMLElement;
private readonly sessions = new SessionController(
@@ -86,7 +87,9 @@ export class PiWebApp extends LitElement {
private readonly realtime = new RealtimeSocket();
private readonly activeTerminalIds = new Set<string>();
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
private observedContextItems: HTMLElement | undefined;
private observedMobileTabs: HTMLElement | undefined;
private contextItemsResizeObserver: ResizeObserver | undefined;
private mobileTabsResizeObserver: ResizeObserver | undefined;
private terminalAutoStartWorkspaceId: string | undefined;
private piWebStatusTimer: number | undefined;
@@ -95,6 +98,8 @@ export class PiWebApp extends LitElement {
@state() private activeThemeId: QualifiedContributionId = DEFAULT_THEME_ID;
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
@state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined;
@state() private contextCanScrollLeft = false;
@state() private contextCanScrollRight = false;
@state() private mobileTabsCanScrollLeft = false;
@state() private mobileTabsCanScrollRight = false;
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
@@ -112,8 +117,12 @@ export class PiWebApp extends LitElement {
};
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
this.isMobileNavigationLayout = event.matches;
this.updateContextScrollState();
this.updateMobileTabsScrollState();
};
private readonly onContextScroll = () => {
this.updateContextScrollState();
};
private readonly onMobileTabsScroll = () => {
this.updateMobileTabsScrollState();
};
@@ -153,6 +162,9 @@ export class PiWebApp extends LitElement {
this.git.dispose();
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
this.piWebStatusTimer = undefined;
this.contextItemsResizeObserver?.disconnect();
this.contextItemsResizeObserver = undefined;
this.observedContextItems = undefined;
this.mobileTabsResizeObserver?.disconnect();
this.mobileTabsResizeObserver = undefined;
this.observedMobileTabs = undefined;
@@ -160,12 +172,16 @@ export class PiWebApp extends LitElement {
}
override firstUpdated(): void {
this.observeContextItems();
this.observeMobileTabs();
this.updateContextScrollState();
this.updateMobileTabsScrollState();
}
override updated(): void {
this.observeContextItems();
this.observeMobileTabs();
this.updateContextScrollState();
this.updateMobileTabsScrollState();
}
@@ -571,10 +587,64 @@ export class PiWebApp extends LitElement {
void this.sessions.send(text, streamingBehavior);
}
private renderContextBar() {
const project = this.state.selectedProject;
const workspace = this.state.selectedWorkspace;
const session = this.state.selectedSession;
const projectLabel = projectContextLabel(project);
const workspaceLabel = workspaceContextLabel(workspace);
const sessionLabel = sessionContextLabel(session);
return html`
<nav class=${this.contextBarClass()} aria-label="Current location">
<span class="context-bar-label">Location</span>
<ol class="context-items" @scroll=${this.onContextScroll}>
<li class=${project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(project)} aria-label=${`Project: ${projectLabel}`}>
<span class="context-kind">Project</span>
<span class="context-value">${projectLabel}</span>
</li>
<li class=${workspace === undefined ? "context-chip empty" : "context-chip"} title=${workspaceContextTitle(workspace)} aria-label=${`Workspace: ${workspaceLabel}`}>
<span class="context-kind">Workspace</span>
<span class="context-value">${workspaceLabel}</span>
</li>
<li class=${session === undefined ? "context-chip empty" : "context-chip"} title=${sessionContextTitle(session)} aria-label=${`Session: ${sessionLabel}`}>
<span class="context-kind">Session</span>
<span class="context-value">${sessionLabel}</span>
</li>
</ol>
</nav>
`;
}
private contextBarClass(): string {
return `context-bar${this.contextCanScrollLeft ? " can-scroll-left" : ""}${this.contextCanScrollRight ? " can-scroll-right" : ""}`;
}
private mobileTabsFrameClass(): string {
return `mobile-tabs-frame${this.mobileTabsCanScrollLeft ? " can-scroll-left" : ""}${this.mobileTabsCanScrollRight ? " can-scroll-right" : ""}`;
}
private observeContextItems(): void {
const contextItems = this.contextItems;
if (this.observedContextItems === contextItems) return;
this.contextItemsResizeObserver?.disconnect();
this.observedContextItems = contextItems;
this.contextItemsResizeObserver = undefined;
if (contextItems === undefined || typeof ResizeObserver === "undefined") return;
this.contextItemsResizeObserver = new ResizeObserver(() => {
this.updateContextScrollState();
});
this.contextItemsResizeObserver.observe(contextItems);
}
private updateContextScrollState(): void {
const contextItems = this.contextItems;
const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth);
const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1;
const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1;
if (this.contextCanScrollLeft !== canScrollLeft) this.contextCanScrollLeft = canScrollLeft;
if (this.contextCanScrollRight !== canScrollRight) this.contextCanScrollRight = canScrollRight;
}
private observeMobileTabs(): void {
const mobileTabs = this.mobileTabs;
if (this.observedMobileTabs === mobileTabs) return;
@@ -603,6 +673,7 @@ export class PiWebApp extends LitElement {
<div class=${`shell ${state.mainView === "navigation" ? "navigation-view" : state.mainView === "chat" ? "chat-view" : "workspace-view"}`}>
<aside>${this.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
<main class=${state.mainView === "chat" ? "chat-view" : state.mainView === "navigation" ? "navigation-view" : "workspace-view"}>
${this.renderContextBar()}
<div class=${this.mobileTabsFrameClass()}>
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
<button class=${state.mainView === "navigation" ? "mobile-navigation-tab selected" : "mobile-navigation-tab"} @click=${() => { this.selectMainView("navigation"); }}>Sessions</button>
@@ -642,6 +713,32 @@ function createPluginRegistry(): PluginRegistry {
return registry;
}
function projectContextLabel(project: Project | undefined): string {
return project?.name ?? "No project";
}
function projectContextTitle(project: Project | undefined): string {
return project === undefined ? "No project selected" : `${project.name}${project.path}`;
}
function workspaceContextLabel(workspace: Workspace | undefined): string {
return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`;
}
function workspaceContextTitle(workspace: Workspace | undefined): string {
return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""}${workspace.path}`;
}
function sessionContextLabel(session: SessionInfo | undefined): string {
const name = session?.name?.trim();
const firstMessage = session?.firstMessage.trim();
return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session";
}
function sessionContextTitle(session: SessionInfo | undefined): string {
return session === undefined ? "No session selected" : session.path;
}
function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
}
+3 -1
View File
@@ -70,7 +70,9 @@ export class ProjectList extends LitElement {
private renderHeading() {
if (!this.collapsible) return "Projects";
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Projects</span><small>${this.projects.length}</small></button>`;
const selectedSummary = this.selected?.name ?? "No project selected";
const selectedTitle = this.selected?.path ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.projects.length}</small></button>`;
}
private renderActivity(project: Project) {
+3 -1
View File
@@ -82,9 +82,11 @@ export class SessionList extends LitElement {
private renderHeading(sessionCount: number) {
if (!this.collapsible) return html`<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>`;
const selectedSummary = this.selected === undefined ? "No session selected" : sessionLabel(this.selected);
const selectedTitle = this.selected?.path ?? selectedSummary;
return html`
<h2>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Sessions</span><small>${sessionCount}</small></button>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${sessionCount}</small></button>
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
</h2>
`;
+3 -1
View File
@@ -53,7 +53,9 @@ export class WorkspaceList extends LitElement {
private renderHeading() {
if (!this.collapsible) return "Workspaces";
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "" : ""} Workspaces</span><small>${this.workspaces.length}</small></button>`;
const selectedSummary = this.selected === undefined ? "No workspace selected" : `${this.selected.label}${this.selected.isMain ? " · main" : ""} · ${this.selected.path}`;
const selectedTitle = this.selected?.path ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.workspaces.length}</small></button>`;
}
private renderActivity(workspace: Workspace) {
+22 -1
View File
@@ -57,6 +57,14 @@ export const appStyles = css`
project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid var(--pi-border-muted); }
session-list { flex: 1 1 auto; min-height: 0; overflow: auto; }
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.context-bar-label { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 6px; margin: 0; padding: 0; list-style: none; overflow: hidden; }
.context-chip { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; }
.context-chip.empty { border-style: dashed; color: var(--pi-muted); }
.context-kind { flex: 0 0 auto; color: var(--pi-muted); font-size: 10px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; }
.context-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.context-chip.empty .context-kind { color: var(--pi-dim); }
.mobile-tabs-frame { position: relative; display: none; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); }
.mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
@@ -84,6 +92,16 @@ export const appStyles = css`
.shell { grid-template-columns: minmax(0, 1fr); }
aside { display: none; }
main, .shell.workspace-view > workspace-panel { grid-column: 1; }
.context-bar { padding: 6px 0; }
.context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.context-bar::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; }
.context-bar-label { display: none; }
.context-items { gap: 5px; padding: 0 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; -webkit-overflow-scrolling: touch; }
.context-chip { flex: 0 0 auto; padding: 4px 8px; }
.context-kind { display: none; }
.context-value { overflow: visible; text-overflow: clip; }
.mobile-navigation-tab { display: block; }
main.navigation-view chat-view, main.navigation-view prompt-editor, main.navigation-view status-bar,
main.navigation-view .empty { display: none; }
@@ -157,8 +175,11 @@ export const listStyles = css`
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
section > button { display: block; width: 100%; text-align: left; margin: 6px 0; }
.subheading { margin-top: 14px; }
.section-toggle { display: flex; flex: 1 1 auto; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; text-transform: inherit; }
.section-toggle { display: flex; flex: 1 1 auto; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; text-align: left; text-transform: inherit; }
.section-toggle span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.section-title { display: grid; gap: 2px; min-width: 0; }
.section-toggle .section-selected { display: block; color: var(--pi-text); font-size: 12px; font-weight: 600; line-height: 1.25; text-transform: none; }
.section-toggle .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
.section-toggle small { display: inline; color: inherit; font-size: inherit; }
.action-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; margin: 6px 0; cursor: pointer; }
.action-row:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; border-radius: 8px; }