From 73cf9ee90838d95aa98b5d90b2202fe657b4bf40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 03:48:32 +0100 Subject: [PATCH] feat(ui): Home page and identity-row IA for the five-zone sidebar (#110295) * feat(ui): Home page, identity row as agent menu, Pages header, Threads-hosted actions * fix(ui): rename Home badge local shadowing the lit state import --- ui/src/components/app-sidebar-session-list.ts | 48 ++++-- ui/src/components/app-sidebar.test.ts | 69 ++++++--- ui/src/components/app-sidebar.ts | 139 +++++++++++------- ui/src/components/icons.ts | 6 + ui/src/components/sidebar-agent-card.ts | 90 ++++-------- ui/src/i18n/locales/en.ts | 2 + ui/src/lib/sessions/grouping.ts | 15 +- ui/src/styles/layout.css | 88 ++++++----- 8 files changed, 258 insertions(+), 199 deletions(-) diff --git a/ui/src/components/app-sidebar-session-list.ts b/ui/src/components/app-sidebar-session-list.ts index f36df4447225..0ab951110d1e 100644 --- a/ui/src/components/app-sidebar-session-list.ts +++ b/ui/src/components/app-sidebar-session-list.ts @@ -377,6 +377,39 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen >` : nothing} + ${section.id === "ungrouped" + ? html` + + + ` + : nothing} ${group ? html` - ${this.renderSessionListBody(visibleSessions, { showDraft: Boolean(this.draftSessionAgentId) && diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index ea5d5b29faee..91e0f10df99a 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -336,7 +336,7 @@ describe("AppSidebar update card wiring", () => { }); describe("AppSidebar brand actions", () => { - it("starts a session for the active agent and leaves the sessions header sort-only", async () => { + it("starts a session for the active agent from the Threads header", async () => { const gateway = createGateway({} as GatewayBrowserClient); const agentsList = { defaultId: "main", @@ -355,12 +355,14 @@ describe("AppSidebar brand actions", () => { sidebar.onOpenNewSession = onOpenNewSession; await sidebar.updateComplete; - const button = sidebar.querySelector(".sidebar-brand .sidebar-new-session"); + // The new-session affordance moved off the brand row onto Threads. + expect(sidebar.querySelector(".sidebar-brand .sidebar-new-session")).toBeNull(); + expect(sidebar.querySelector(".sidebar-recent-sessions__head--root")).toBeNull(); + const button = sidebar.querySelector( + '[data-session-section="ungrouped"] .sidebar-new-session', + ); expect(button?.getAttribute("aria-label")).toBe("New session"); expect(button?.disabled).toBe(false); - expect( - sidebar.querySelector(".sidebar-recent-sessions__head--root .sidebar-session-new"), - ).toBeNull(); button?.click(); expect(onOpenNewSession).toHaveBeenCalledExactlyOnceWith("research"); @@ -391,7 +393,7 @@ describe("AppSidebar agent chip", () => { sidebar.onNavigate = onNavigate; await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; const rows = [ ...sidebar.querySelectorAll( @@ -426,7 +428,7 @@ describe("AppSidebar agent chip", () => { sidebar.onNavigate = onNavigate; await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; const menu = sidebar.querySelector(".sidebar-agent-menu"); const settingsAgent = [ @@ -517,7 +519,7 @@ describe("AppSidebar agent chip", () => { // the main session lives behind the identity card instead of the list. expect(sidebar.querySelector(".sidebar-agent-section")).toBeNull(); expect(sidebar.querySelectorAll(".sidebar-recent-session")).toHaveLength(0); - expect(sidebar.querySelector(".sidebar-agent-card__switcher-unread")).not.toBeNull(); + expect(sidebar.querySelector(".sidebar-agent-card__menu-unread")).not.toBeNull(); // Mid-switch (selected agent != loaded result agent) the list renders the // target agent's cached rows instead of flashing empty until refresh. @@ -530,6 +532,27 @@ describe("AppSidebar agent chip", () => { expect(rows[0]?.textContent).toContain("Research task"); }); + it("routes Home to the main session and marks it active there", async () => { + const gateway = createGateway({} as GatewayBrowserClient); + const setSessionKey = vi.fn(); + (gateway as { setSessionKey: (key: string) => void }).setSessionKey = setSessionKey; + const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"])); + const navigate = vi.fn(); + sidebar.onNavigate = navigate; + sidebar.connected = true; + (sidebar as unknown as { activeRouteId: string }).activeRouteId = "chat"; + sidebar.sessionKey = "agent:main:main"; + await sidebar.updateComplete; + + const home = sidebar.querySelector(".nav-item--home"); + expect(home?.textContent).toContain("Home"); + expect(home?.getAttribute("aria-current")).toBe("page"); + + home?.click(); + expect(setSessionKey).toHaveBeenCalledWith("agent:main:main"); + expect(navigate).toHaveBeenCalledWith("chat", { search: "?session=agent%3Amain%3Amain" }); + }); + it("treats the global key as the main session under global scope", async () => { const gateway = createGateway({} as GatewayBrowserClient); const harness = createSessionsHarness("main", ["global"]); @@ -554,12 +577,12 @@ describe("AppSidebar agent chip", () => { }); await sidebar.updateComplete; - // The advertised global main hides behind the identity card instead of - // leaking into Threads; ordinary sessions still list, and the card - // surfaces the global row's unread state. + // The advertised global main hides behind the Home row instead of + // leaking into Threads; ordinary sessions still list, and Home surfaces + // the global row's unread state. expect(sidebar.querySelector('[data-session-key="global"]')).toBeNull(); expect(sidebar.querySelector('[data-session-key="agent:main:side-quest"]')).not.toBeNull(); - expect(sidebar.querySelector(".sidebar-agent-card__main .session-unread-dot")).not.toBeNull(); + expect(sidebar.querySelector(".nav-item--home .session-unread-dot")).not.toBeNull(); }); it("promotes main-session children to top-level threads, including alias parent keys", async () => { @@ -1207,7 +1230,7 @@ describe("AppSidebar agent chip", () => { await sidebar.updateComplete; expect(sidebar.querySelector(".sidebar-agent-card__name")?.textContent?.trim()).toBe("Molty"); - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; const menu = sidebar.querySelector(".sidebar-agent-menu"); @@ -1244,7 +1267,7 @@ describe("AppSidebar agent chip", () => { openExternal.mockRestore(); await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; const reopenedMenu = sidebar.querySelector(".sidebar-agent-menu"); @@ -1284,7 +1307,7 @@ describe("AppSidebar agent chip", () => { sidebar.onNavigate = onNavigate; await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; const settingsRow = [ ...sidebar.querySelectorAll(".sidebar-agent-menu wa-dropdown-item"), @@ -1315,7 +1338,7 @@ describe("AppSidebar agent chip", () => { sidebar.connected = true; await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; expect(sidebar.querySelector(".sidebar-agent-menu__filter")).toBeNull(); expect( @@ -1339,7 +1362,7 @@ describe("AppSidebar agent chip", () => { context.agentSelection.state.selectedId = "agent-1"; await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; const input = sidebar.querySelector(".sidebar-agent-menu__filter input"); expect(input).not.toBeNull(); @@ -1390,7 +1413,7 @@ describe("AppSidebar agent chip", () => { sidebar.connected = true; await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; expect(sidebar.querySelector(".sidebar-agent-menu__filter")).not.toBeNull(); expect( @@ -1412,7 +1435,7 @@ describe("AppSidebar agent chip", () => { sidebar.pinnedAgentIds = ["deleted-agent"]; await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; expect( sidebar.querySelectorAll( @@ -1435,7 +1458,7 @@ describe("AppSidebar agent chip", () => { sidebar.connected = true; await sidebar.updateComplete; - sidebar.querySelector(".sidebar-agent-card__switcher")?.click(); + sidebar.querySelector(".sidebar-agent-card__main")?.click(); await sidebar.updateComplete; const rows = [ ...sidebar.querySelectorAll( @@ -3953,7 +3976,7 @@ describe("AppSidebar transient menus", () => { it("ignores a stale agent-menu hide after opening its replacement", async () => { const gateway = createGateway({} as GatewayBrowserClient); const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"])); - const trigger = sidebar.querySelector(".sidebar-agent-card__switcher"); + const trigger = sidebar.querySelector(".sidebar-agent-card__main"); if (!trigger) { throw new Error("expected agent menu trigger"); } @@ -3987,9 +4010,9 @@ describe("AppSidebar transient menus", () => { it("ignores a stale More-menu hide after opening its replacement", async () => { const gateway = createGateway({} as GatewayBrowserClient); const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"])); - const trigger = sidebar.querySelector("button.nav-item--action"); + const trigger = sidebar.querySelector(".sidebar-nav__head-action"); if (!trigger) { - throw new Error("expected More menu trigger"); + throw new Error("expected Pages menu trigger"); } trigger.click(); diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 77a8ec01534a..891017bf0522 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -1,11 +1,11 @@ import { html, nothing } from "lit"; import { state } from "lit/decorators.js"; import type { NavigationRouteId } from "../app-navigation.ts"; +import { pathForRoute } from "../app-route-paths.ts"; import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts"; import { controlUiPublicAssetPath } from "../app/public-assets.ts"; import { t } from "../i18n/index.ts"; import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts"; -import { resolveAgentAvatarUrl } from "../lib/avatar.ts"; import "./menu-surface.ts"; import "./session-menu.ts"; import "./sidebar-agent-card.ts"; @@ -14,7 +14,10 @@ import "./sidebar-build-chip.ts"; import "./sidebar-update-card.ts"; import "./theme-mode-toggle.ts"; import "./tooltip.ts"; +import { resolveAgentAvatarUrl } from "../lib/avatar.ts"; +import { searchForSession } from "../lib/sessions/index.ts"; import { areUiSessionKeysEquivalent, normalizeAgentId } from "../lib/sessions/session-key.ts"; +import { shouldHandleNavigationClick } from "./app-sidebar-nav-menus.ts"; import { AppSidebarSessionListElement } from "./app-sidebar-session-list.ts"; import { icons } from "./icons.ts"; import { @@ -76,25 +79,34 @@ class AppSidebar extends AppSidebarSessionListElement { private renderBrand() { const collapseLabel = t("nav.collapse"); - const chipAgentId = this.activeChipAgent().activeId; - const newSessionTitle = this.connected - ? t("chat.runControls.newSession") - : t("chat.runControls.newSessionDisconnected"); + const gatewayStatus = t("chat.gatewayStatus", { + status: this.connected ? t("common.online") : t("common.offline"), + }); + const { activeId: cardAgentId, agent: cardAgent, agents: cardAgents } = this.activeChipAgent(); + const menuUnread = cardAgents.some((entry) => { + const agentId = normalizeAgentId(entry.id); + return agentId !== cardAgentId && this.agentUnreadCount(agentId) > 0; + }); + const cardName = cardAgent ? normalizeAgentLabel(cardAgent) : cardAgentId; + const cardAvatarText = + (cardAgent ? resolveAgentTextAvatar(cardAgent) : null) ?? + (cardName || cardAgentId).slice(0, 1).toUpperCase(); return html`