From 2631a63ec73a63ac74fb45439d74e47ed89136d2 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 19 May 2026 20:17:50 +0200 Subject: [PATCH] feat: add responsive location context --- .changeset/mobile-location-context.md | 5 ++ src/client/src/components/PiWebApp.ts | 97 ++++++++++++++++++++++ src/client/src/components/ProjectList.ts | 4 +- src/client/src/components/SessionList.ts | 4 +- src/client/src/components/WorkspaceList.ts | 4 +- src/client/src/components/shared.ts | 23 ++++- 6 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 .changeset/mobile-location-context.md diff --git a/.changeset/mobile-location-context.md b/.changeset/mobile-location-context.md new file mode 100644 index 0000000..d9d9860 --- /dev/null +++ b/.changeset/mobile-location-context.md @@ -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. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index e692dbe..0a253f7 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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(); 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` + + `; + } + + 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 {
+ ${this.renderContextBar()}
@@ -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): boolean { return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value); } diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index e65b813..3d86118 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -70,7 +70,9 @@ export class ProjectList extends LitElement { private renderHeading() { if (!this.collapsible) return "Projects"; - return html``; + const selectedSummary = this.selected?.name ?? "No project selected"; + const selectedTitle = this.selected?.path ?? selectedSummary; + return html``; } private renderActivity(project: Project) { diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index fe1e9bc..a7dc10a 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -82,9 +82,11 @@ export class SessionList extends LitElement { private renderHeading(sessionCount: number) { if (!this.collapsible) return html`

Sessions

`; + const selectedSummary = this.selected === undefined ? "No session selected" : sessionLabel(this.selected); + const selectedTitle = this.selected?.path ?? selectedSummary; return html`

- +

`; diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 288d386..4d6d9c6 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -53,7 +53,9 @@ export class WorkspaceList extends LitElement { private renderHeading() { if (!this.collapsible) return "Workspaces"; - return html``; + 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``; } private renderActivity(workspace: Workspace) { diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index dfde745..21fa02f 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; }