Archived
feat: add responsive location context
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user