diff --git a/.changeset/relays-tab-strip-scroll.md b/.changeset/relays-tab-strip-scroll.md new file mode 100644 index 0000000..c5e59df --- /dev/null +++ b/.changeset/relays-tab-strip-scroll.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep the relays panel document tab strip's horizontal scroll position when switching documents, instead of jumping back to the left edge on every tab click. diff --git a/pi-web-plugins/relays/relaysPanelElement.test.ts b/pi-web-plugins/relays/relaysPanelElement.test.ts index 992b171..365c860 100644 --- a/pi-web-plugins/relays/relaysPanelElement.test.ts +++ b/pi-web-plugins/relays/relaysPanelElement.test.ts @@ -109,7 +109,7 @@ describe("multiple relays", () => { const select = picker(panel); if (select === null) throw new Error("relay picker missing"); select.value = `${RELAYS_ROOT}/older`; - select.dispatchEvent(new Event("change")); + select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container. await flushAsync(); expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`); @@ -167,6 +167,77 @@ describe("document tabs", () => { expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]); }); + it("keeps the tab strip mounted with its scroll and focus when switching documents", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [ + relayDocument("relay", "notes.md"), + relayDocument("relay", "log.md"), + relayDocument("relay", "status.md"), + ]); + fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body"); + fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body"); + + const panel = await mountPanel(panelContext(fake)); + const strip = tabStrip(panel); + strip.scrollLeft = 120; + const logTab = tabNamed(panel, "log.md"); + + logTab.click(); + await flushAsync(); + + // Switching documents only re-renders the viewer: the strip element and + // its buttons stay mounted, so scroll position and button identity survive. + expect(documentText(panel)).toBe("log body"); + expect(tabStrip(panel)).toBe(strip); + expect(tabStrip(panel).scrollLeft).toBe(120); + expect(tabNamed(panel, "log.md")).toBe(logTab); + expect(activeTab(panel)?.textContent).toBe("log.md"); + }); + + it("scrolls the viewer back to the top when switching documents", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); + fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md"), relayDocument("relay", "status.md")]); + fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body"); + fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body"); + + const panel = await mountPanel(panelContext(fake)); + const viewer = shadow(panel).querySelector("section.viewer"); + if (!(viewer instanceof HTMLElement)) throw new Error("viewer missing"); + viewer.scrollTop = 80; + + tabNamed(panel, "log.md").click(); + await flushAsync(); + + expect(documentText(panel)).toBe("log body"); + expect(viewer.scrollTop).toBe(0); + }); + + it("starts a different relay's tab strip at the left edge", async () => { + const fake = workspaceFilesFake(); + fake.addDirectory(RELAYS_ROOT, [ + relayDirectory("alpha", "2026-01-01T00:00:00.000Z"), + relayDirectory("beta", "2026-02-01T00:00:00.000Z"), + ]); + fake.addDirectory(`${RELAYS_ROOT}/alpha`, [relayDocument("alpha", "status.md")]); + fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md"), relayDocument("beta", "log.md")]); + fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status"); + fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status"); + + const panel = await mountPanel(panelContext(fake)); + tabStrip(panel).scrollLeft = 120; + + const select = picker(panel); + if (select === null) throw new Error("relay picker missing"); + select.value = `${RELAYS_ROOT}/alpha`; + select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container. + await flushAsync(); + + expect(documentText(panel)).toBe("alpha status"); + expect(tabStrip(panel).scrollLeft).toBe(0); + }); + it("shows a truncation notice when the open document is truncated", async () => { const fake = workspaceFilesFake(); fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]); @@ -308,7 +379,7 @@ describe("refresh and context changes", () => { const select = picker(panel); if (select === null) throw new Error("relay picker missing"); select.value = `${RELAYS_ROOT}/alpha`; - select.dispatchEvent(new Event("change")); + select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container. await flushAsync(); tabNamed(panel, "log.md").click(); await flushAsync(); @@ -463,6 +534,12 @@ function refreshButton(panel: RelaysPanelTestElement): HTMLElement { return button; } +function tabStrip(panel: RelaysPanelTestElement): HTMLElement { + const strip = shadow(panel).querySelector("nav.document-tabs"); + if (!(strip instanceof HTMLElement)) throw new Error("tab strip missing"); + return strip; +} + function tabNames(panel: RelaysPanelTestElement): string[] { return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent); } diff --git a/pi-web-plugins/relays/relaysPanelElement.ts b/pi-web-plugins/relays/relaysPanelElement.ts index c061e06..a235653 100644 --- a/pi-web-plugins/relays/relaysPanelElement.ts +++ b/pi-web-plugins/relays/relaysPanelElement.ts @@ -29,6 +29,12 @@ interface RelaySelection { * sanitized HTML and everything else as preformatted text. All async loads * flow through scanToken so stale responses for a previous workspace or * selection never overwrite newer state. + * + * Rendering is region-scoped: the toolbar, tab strip, and viewer are + * persistent elements built once, and each async stage re-renders only its + * own region. Clicking a tab never rebuilds the strip — it toggles the + * active marker in place and re-renders the viewer — so the strip's + * horizontal scroll position and keyboard focus survive document switches. */ class PiWebRelaysPanel extends HTMLElement { private contextValue: WorkspacePanelContext | undefined; @@ -39,36 +45,64 @@ class PiWebRelaysPanel extends HTMLElement { private documentContent: RelayDocumentContent | undefined; private scanToken = 0; private readonly root: ShadowRoot; + private readonly toolbar: HTMLElement; + private readonly tabStrip: HTMLElement; + private readonly viewer: HTMLElement; constructor() { super(); this.root = this.attachShadow({ mode: "open" }); + this.root.innerHTML = ` + ${relaysStyles()} + + +
Select a workspace.
+ `; + this.toolbar = requiredRegion(this.root, ".toolbar"); + this.tabStrip = requiredRegion(this.root, "nav.document-tabs"); + this.viewer = requiredRegion(this.root, ".viewer"); + + // Listeners bind once against the persistent regions and delegate to + // whichever controls the latest region render produced. + this.toolbar.addEventListener("click", (event) => { + const button = event.target instanceof Element ? event.target.closest("button[data-refresh]") : null; + if (button !== null) this.refresh(); + }); + this.toolbar.addEventListener("change", (event) => { + const picker = event.target; + if (!(picker instanceof HTMLSelectElement) || !picker.matches("select[data-relay-picker]")) return; + const context = this.contextValue; + if (context !== undefined) void this.openRelay(context, picker.value); + }); + this.tabStrip.addEventListener("click", (event) => { + const tab = event.target instanceof Element ? event.target.closest("button[data-document-path]") : null; + if (tab === null) return; + const documentPath = tab.getAttribute("data-document-path"); + const context = this.contextValue; + if (context !== undefined && documentPath !== null) void this.openDocument(context, documentPath); + }); } set context(value: WorkspacePanelContext | undefined) { const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue); const nextKey = value === undefined ? undefined : contextKey(value); this.contextValue = value; - // Parent app updates should not rescan or rebuild this shadow DOM for the + // Parent app updates should not rescan or re-render this panel for the // same workspace (mirrors the workspace-tasks panel). if (previousKey === nextKey) return; if (value === undefined) { this.resetScanState(); - this.render(); + this.renderAll(); return; } void this.scan(value, {}); } - connectedCallback(): void { - this.render(); - } - /** Rescan relays, then reload the selected relay's documents and the open document. */ private async scan(context: WorkspacePanelContext, selection: RelaySelection): Promise { const token = ++this.scanToken; this.resetScanState(); - this.render(); + this.renderAll(); const listing = await listWorkspaceRelays(context.files); if (!this.isCurrentScan(context, token)) return; @@ -80,8 +114,9 @@ class PiWebRelaysPanel extends HTMLElement { ? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0] : undefined; this.selectedRelayPath = relay?.path; + this.renderToolbar(); if (relay === undefined) { - this.render(); + this.renderViewer(); return; } await this.loadDocuments(context, token, relay.path, selection.documentPath); @@ -96,6 +131,11 @@ class PiWebRelaysPanel extends HTMLElement { private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise { const token = ++this.scanToken; this.selectedDocumentPath = documentPath; + // The tab set is unchanged: toggle the active marker on the mounted + // buttons instead of rebuilding the strip, so its scroll position and + // focus stay put. A different document starts reading from the top. + this.updateActiveTab(); + this.viewer.scrollTop = 0; await this.loadDocumentContent(context, token, documentPath); } @@ -103,7 +143,8 @@ class PiWebRelaysPanel extends HTMLElement { this.documents = undefined; this.selectedDocumentPath = undefined; this.documentContent = undefined; - this.render(); + this.renderTabs(); + this.renderViewer(); const documents = await listRelayDocuments(context.files, relayPath); if (!this.isCurrentScan(context, token)) return; @@ -113,8 +154,9 @@ class PiWebRelaysPanel extends HTMLElement { ? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents) : undefined; this.selectedDocumentPath = document?.path; + this.renderTabs(); if (document === undefined) { - this.render(); + this.renderViewer(); return; } await this.loadDocumentContent(context, token, document.path); @@ -122,12 +164,12 @@ class PiWebRelaysPanel extends HTMLElement { private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise { this.documentContent = undefined; - this.render(); + this.renderViewer(); const content = await readRelayDocument(context.files, documentPath); if (!this.isCurrentScan(context, token)) return; this.documentContent = content; - this.render(); + this.renderViewer(); } private refresh(): void { @@ -148,38 +190,26 @@ class PiWebRelaysPanel extends HTMLElement { return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context); } - private render(): void { - const context = this.contextValue; - if (context === undefined) { - this.root.innerHTML = `${relaysStyles()}
Select a workspace.
`; + private renderAll(): void { + this.renderToolbar(); + this.renderTabs(); + this.renderViewer(); + } + + private renderToolbar(): void { + if (this.contextValue === undefined) { + this.toolbar.hidden = true; + this.toolbar.replaceChildren(); return; } - this.root.innerHTML = ` - ${relaysStyles()} -
- Relays - - ${this.renderRelayPicker()} - - -
- ${this.renderDocumentTabs()} -
${this.renderViewer()}
+ this.toolbar.hidden = false; + this.toolbar.innerHTML = ` + Relays + + ${this.renderRelayPicker()} + + `; - - this.root.querySelector("button[data-refresh]")?.addEventListener("click", () => { - this.refresh(); - }); - this.root.querySelector("select[data-relay-picker]")?.addEventListener("change", (event) => { - const picker = event.target; - if (picker instanceof HTMLSelectElement) void this.openRelay(context, picker.value); - }); - for (const tab of this.root.querySelectorAll("button[data-document-path]")) { - tab.addEventListener("click", () => { - const documentPath = tab.getAttribute("data-document-path"); - if (documentPath !== null) void this.openDocument(context, documentPath); - }); - } } private renderRelayPicker(): string { @@ -197,17 +227,43 @@ class PiWebRelaysPanel extends HTMLElement { return ``; } - private renderDocumentTabs(): string { + private renderTabs(): void { const documents = this.documents; - if (documents?.kind !== "loaded" || documents.documents.length === 0) return ""; - const tabs = documents.documents.map((document) => { + if (documents?.kind !== "loaded" || documents.documents.length === 0) { + this.tabStrip.hidden = true; + // A new tab set starts at the left edge, not at the previous set's offset. + this.tabStrip.replaceChildren(); + this.tabStrip.scrollLeft = 0; + return; + } + this.tabStrip.hidden = false; + // The strip element itself persists across re-renders, so replacing its + // buttons keeps the container's horizontal scroll position. + this.tabStrip.innerHTML = documents.documents.map((document) => { const active = document.path === this.selectedDocumentPath; return ``; }).join(""); - return ``; } - private renderViewer(): string { + /** Move the active marker between the mounted tab buttons without rebuilding them. */ + private updateActiveTab(): void { + for (const tab of this.tabStrip.querySelectorAll("button[data-document-path]")) { + const active = tab.getAttribute("data-document-path") === this.selectedDocumentPath; + tab.classList.toggle("active", active); + if (active) tab.setAttribute("aria-current", "true"); + else tab.removeAttribute("aria-current"); + } + } + + private renderViewer(): void { + if (this.contextValue === undefined) { + this.viewer.innerHTML = `
Select a workspace.
`; + return; + } + this.viewer.innerHTML = this.renderViewerContent(); + } + + private renderViewerContent(): string { const listing = this.listing; if (listing === undefined) return `

Scanning ${escapeHtml(RELAYS_ROOT)}…

`; if (listing.kind === "unavailable") return renderErrorState("Could not scan workspace relays.", listing.detail); @@ -255,6 +311,13 @@ class PiWebRelaysPanel extends HTMLElement { } } +/** Shell regions come from a literal template; absence means the template broke. */ +function requiredRegion(root: ShadowRoot, selector: string): HTMLElement { + const element = root.querySelector(selector); + if (!(element instanceof HTMLElement)) throw new Error(`relays panel shell is missing ${selector}`); + return element; +} + /** Reload glyph matching the app's own refresh control (AppRefreshControl). */ function refreshIconSvg(): string { return ` @@ -297,6 +360,7 @@ function relaysStyles(): string { the viewer's huge content basis starves them down to a sliver once a tall document renders). The viewer absorbs all shrinking instead. */ .toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); } + .toolbar[hidden], .document-tabs[hidden] { display: none; } .toolbar-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; justify-content: flex-end; gap: 8px; min-width: 0; } .relay-name { min-width: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; } /* Bottom padding (not viewer margin) so the gap below the tabs persists