From 1011b4102fd166d07b0404186d3473278eecfc01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 02:00:03 -0700 Subject: [PATCH] fix(ui): restore custom page and session ordering (#123530) * fix(ui): restore interleaved sidebar ordering Render pinned sessions at their persisted slots among page entries again instead of projecting them into a separate group. This restores custom page/session interleaving regressed by #121712 while preserving drag-to-pin behavior. * test: consolidate steering authority fixtures * test: isolate CLI commentary fixture * fix: dispatch tool-bound turns normally --- ...nt-runner-execution-cli-commentary.test.ts | 1 + .../agent-runner-execution.test-support.ts | 56 +------------- ...t-runner.final-media-runreplyagent.test.ts | 24 ++---- .../reply/agent-runner.media-paths.test.ts | 72 ++++++++---------- src/auto-reply/reply/test-helpers.ts | 74 ++++++++++++++++++- .../chat-send-message-injection.ts | 2 +- .../chat.directive-tags.test.ts | 68 +++++++++++++++++ ui/src/components/app-sidebar-render.ts | 26 ------- ui/src/components/app-sidebar.ts | 56 +++++--------- .../session-management.sidebar.e2e.test.ts | 3 +- ui/src/styles/layout.css | 8 +- .../app-sidebar-cases/sidebar-zone.ts | 13 +--- 12 files changed, 204 insertions(+), 199 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution-cli-commentary.test.ts b/src/auto-reply/reply/agent-runner-execution-cli-commentary.test.ts index 101952a36d72..247744bb4839 100644 --- a/src/auto-reply/reply/agent-runner-execution-cli-commentary.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-cli-commentary.test.ts @@ -92,6 +92,7 @@ function useScriptedClaudeCliBackend() { function createClaudeCliFollowupRun() { const followupRun = createFollowupRun(); + followupRun.run.agentId = "agent"; followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; followupRun.run.skillsSnapshot = { prompt: "", skills: [], version: 0 }; diff --git a/src/auto-reply/reply/agent-runner-execution.test-support.ts b/src/auto-reply/reply/agent-runner-execution.test-support.ts index d8ebf9562aa8..c816c13f67aa 100644 --- a/src/auto-reply/reply/agent-runner-execution.test-support.ts +++ b/src/auto-reply/reply/agent-runner-execution.test-support.ts @@ -24,6 +24,7 @@ type RunEntryDelegate = (params: RunEntryParams) => Promise; type RunCliAgent = typeof import("../../agents/cli-runner.js").runCliAgent; export const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`; +export { createMockReplyOperation } from "./test-helpers.js"; export const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE = "⚠️ The model provider returned HTTP 429 before replying. This can mean rate limiting, exhausted quota, or an account balance/billing issue. Check the selected provider/model, API key, and provider billing/quota dashboard, then try again."; export const PROVIDER_INTERNAL_ERROR_USER_MESSAGE = @@ -489,61 +490,6 @@ export function createTestUserTurnRecorder(message: PersistedUserTurnMessage) { }); } -export function createMockReplyOperation(options?: { abortSignal?: AbortSignal }): { - replyOperation: ReplyOperation; - failMock: ReturnType; - freezeAbortMock: ReturnType; - retainFailureUntilCompleteMock: ReturnType; - updateSessionIdMock: ReturnType; -} { - const failMock = vi.fn(); - const freezeAbortMock = vi.fn(); - const retainFailureUntilCompleteMock = vi.fn(); - const updateSessionIdMock = vi.fn(); - return { - failMock, - freezeAbortMock, - retainFailureUntilCompleteMock, - updateSessionIdMock, - replyOperation: { - key: "main", - sessionId: "session", - abortSignal: options?.abortSignal ?? new AbortController().signal, - staleExpiryReason: undefined, - resetTriggered: false, - terminalRecovery: false, - acceptedSteeredInboundAudio: false, - phase: "running", - result: null, - startedAtMs: Date.now(), - lastActivityAtMs: Date.now(), - hasOwnedSessionId: vi.fn((sessionId: string) => sessionId === "session"), - recordActivity: vi.fn(), - setPhase: vi.fn(), - markWaitingForDeferredMaintenance: vi.fn(), - markDeferredMaintenanceWaitEnded: vi.fn(), - markWaitingForGlobalLane: vi.fn(), - markGlobalLaneWaitEnded: vi.fn(), - updateSessionId: updateSessionIdMock, - updateSessionKey: vi.fn(), - bindToolAuthorityFingerprint: vi.fn(), - bindToolAuthorityRoute: vi.fn(), - attachBackend: vi.fn(), - detachBackend: vi.fn(), - freezeAbort: freezeAbortMock, - retainFailureUntilComplete: retainFailureUntilCompleteMock, - complete: vi.fn(), - completeThen: vi.fn((afterClear: () => void) => afterClear()), - completeWithAfterClearBarrier: vi.fn(), - fail: failMock, - abortByUser: vi.fn(() => true), - abortForRestart: vi.fn(() => true), - markTerminalRecovery: vi.fn(), - markAcceptedSteeredInboundAudio: vi.fn(), - }, - }; -} - export function requireRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null) { throw new Error(`${label} was not an object`); diff --git a/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts index 256a47b4f583..2b1cf45d48fc 100644 --- a/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts @@ -3,8 +3,11 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TemplateContext } from "../templating.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; -import type { ReplyOperation } from "./reply-run-registry.js"; -import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js"; +import { + createMockFollowupRun, + createMockReplyOperation, + createMockTypingController, +} from "./test-helpers.js"; const executeAgentTurnMock = vi.fn(); const resolveOutboundAttachmentFromUrlMock = vi.fn(); @@ -109,21 +112,6 @@ type AgentTurnExecutionResult = Awaited< ReturnType >; -function createReplyOperation(): ReplyOperation { - return { - result: undefined, - startedAtMs: Date.now(), - lastActivityAtMs: Date.now(), - recordActivity: vi.fn(), - setPhase: vi.fn(), - bindToolAuthorityFingerprint: vi.fn(), - freezeAbort: vi.fn(), - fail: vi.fn(), - complete: vi.fn(), - completeThen: vi.fn(), - } as unknown as ReplyOperation; -} - function makeRunReplyAgentParams( overrides: Partial[0]> = {}, ): Parameters[0] { @@ -163,7 +151,7 @@ function makeRunReplyAgentParams( resolvedBlockStreamingBreak: "message_end", shouldInjectGroupIntro: false, typingMode: "instant", - replyOperation: createReplyOperation(), + replyOperation: createMockReplyOperation().replyOperation, ...overrides, }; } diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index 66d3a979f1b7..81136f06d63a 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -7,11 +7,13 @@ import type { EmbeddedAgentQueueMessageOutcome } from "../../agents/embedded-age import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { TemplateContext } from "../templating.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; +import { createReplyOperation as createRegisteredReplyOperation } from "./reply-run-registry.js"; +import { resolveFollowupRunToolAuthorityFingerprint } from "./reply-tool-authority.js"; import { - createReplyOperation as createRegisteredReplyOperation, - type ReplyOperation, -} from "./reply-run-registry.js"; -import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js"; + createMockFollowupRun, + createMockReplyOperation, + createMockTypingController, +} from "./test-helpers.js"; const runEmbeddedAgentMock = vi.fn(); const runWithModelFallbackMock = vi.fn(); @@ -51,12 +53,6 @@ const resolveOutboundAttachmentFromUrlMock = vi.fn(); const createReplyMediaContextRuntimeMock = vi.fn(); const EXPECTED_STEER_QUEUE_IDENTITY = "channel-user:v1:6f3f31084a7a2a6ff17176c0c16682e64d9f21301f64ff7e5bf1173b54fadc33"; -const TEST_TOOL_AUTHORITY_FINGERPRINT = "test-tool-authority"; - -vi.mock("./reply-tool-authority.js", () => ({ - resolveFollowupRunToolAuthorityFingerprint: () => "test-tool-authority", -})); - vi.mock("../../agents/model-fallback-runner.js", () => ({ runWithModelFallback: (params: { provider: string; @@ -294,24 +290,6 @@ vi.mock("./reply-media-paths.runtime.js", async (importOriginal) => { const { runReplyAgent } = await import("./agent-runner.js"); -function createReplyOperation(): ReplyOperation { - return { - result: undefined, - toolAuthorityFingerprint: TEST_TOOL_AUTHORITY_FINGERPRINT, - abortSignal: new AbortController().signal, - startedAtMs: Date.now(), - lastActivityAtMs: Date.now(), - recordActivity: vi.fn(), - setPhase: vi.fn(), - bindToolAuthorityFingerprint: vi.fn(), - bindToolAuthorityRoute: vi.fn(), - freezeAbort: vi.fn(), - fail: vi.fn(), - complete: vi.fn(), - completeThen: vi.fn(), - } as unknown as ReplyOperation; -} - function makeRunReplyAgentParams( overrides: Partial[0]> & { provider?: string; @@ -322,10 +300,9 @@ function makeRunReplyAgentParams( const provider = overrides.provider ?? "whatsapp"; const prompt = overrides.prompt ?? "generate chart"; const workspaceDir = overrides.workspaceDir ?? "/tmp/workspace"; - - return { - commandBody: prompt, - followupRun: createMockFollowupRun({ + const followupRun = + overrides.followupRun ?? + createMockFollowupRun({ prompt, run: { agentId: "main", @@ -333,7 +310,17 @@ function makeRunReplyAgentParams( messageProvider: provider, workspaceDir, }, - }) as unknown as FollowupRun, + }); + const replyOperation = overrides.replyOperation ?? createMockReplyOperation().replyOperation; + if (overrides.isActive === true && !replyOperation.toolAuthorityFingerprint) { + replyOperation.bindToolAuthorityFingerprint( + resolveFollowupRunToolAuthorityFingerprint(followupRun), + ); + } + + return { + commandBody: prompt, + followupRun, queueKey: "main", resolvedQueue: { mode: "interrupt" } as QueueSettings, shouldSteer: false, @@ -355,7 +342,7 @@ function makeRunReplyAgentParams( resolvedBlockStreamingBreak: "message_end", shouldInjectGroupIntro: false, typingMode: "instant", - replyOperation: createReplyOperation(), + replyOperation, ...overrides, }; } @@ -495,7 +482,7 @@ describe("runReplyAgent media path normalization", () => { queueIdentity: EXPECTED_STEER_QUEUE_IDENTITY, onQueueAccepted: parkedSteerAcceptedMock, taskSuggestionDeliveryMode: "gateway", - toolAuthorityFingerprint: TEST_TOOL_AUTHORITY_FINGERPRINT, + toolAuthorityFingerprint: resolveFollowupRunToolAuthorityFingerprint(followupRun), }, ); expect(enqueueFollowupRunMock).not.toHaveBeenCalled(); @@ -544,7 +531,7 @@ describe("runReplyAgent media path normalization", () => { images, media: followupRun.media, taskSuggestionDeliveryMode: undefined, - toolAuthorityFingerprint: TEST_TOOL_AUTHORITY_FINGERPRINT, + toolAuthorityFingerprint: resolveFollowupRunToolAuthorityFingerprint(followupRun), }, ); expect(enqueueFollowupRunMock).not.toHaveBeenCalled(); @@ -585,13 +572,17 @@ describe("runReplyAgent media path normalization", () => { }); it("latches audio only after the active reply operation accepts the steer", async () => { + const followupRun = { + ...createMockFollowupRun({ prompt: "summarize the audio" }), + currentInboundAudio: true, + } as unknown as FollowupRun; const operation = createRegisteredReplyOperation({ sessionKey: "agent:main:whatsapp:direct:chat-1", sessionId: "session", resetTriggered: false, }); operation.setPhase("running"); - operation.bindToolAuthorityFingerprint(TEST_TOOL_AUTHORITY_FINGERPRINT); + operation.bindToolAuthorityFingerprint(resolveFollowupRunToolAuthorityFingerprint(followupRun)); expect(operation.acceptedSteeredInboundAudio).toBe(false); queueEmbeddedAgentMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ queued: true, @@ -602,16 +593,13 @@ describe("runReplyAgent media path normalization", () => { await runReplyAgent( makeRunReplyAgentParams({ + followupRun, replyOperation: operation, sessionKey: "agent:main:whatsapp:direct:chat-1", resolvedQueue: { mode: "steer" } as QueueSettings, shouldSteer: true, shouldFollowup: true, isActive: true, - followupRun: { - ...createMockFollowupRun({ prompt: "summarize the audio" }), - currentInboundAudio: true, - } as unknown as FollowupRun, }), ); @@ -627,7 +615,7 @@ describe("runReplyAgent media path normalization", () => { queueIdentity: EXPECTED_STEER_QUEUE_IDENTITY, onQueueAccepted: parkedSteerAcceptedMock, taskSuggestionDeliveryMode: undefined, - toolAuthorityFingerprint: TEST_TOOL_AUTHORITY_FINGERPRINT, + toolAuthorityFingerprint: operation.toolAuthorityFingerprint, }, ); expect(enqueueFollowupRunMock).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/test-helpers.ts b/src/auto-reply/reply/test-helpers.ts index 211604afc6c6..af9f17a07a52 100644 --- a/src/auto-reply/reply/test-helpers.ts +++ b/src/auto-reply/reply/test-helpers.ts @@ -1,8 +1,80 @@ /** Shared test fixtures for reply queue and typing-controller tests. */ import { vi } from "vitest"; import type { FollowupRun } from "./queue.js"; +import type { ReplyOperation } from "./reply-run-registry.js"; import type { TypingController } from "./typing.js"; +/** Creates a stateful reply-operation double without registering global run state. */ +export function createMockReplyOperation( + overrides: { + abortSignal?: AbortSignal; + key?: string; + sessionId?: string; + toolAuthorityFingerprint?: string; + } = {}, +) { + const failMock = vi.fn(); + const freezeAbortMock = vi.fn(); + const retainFailureUntilCompleteMock = vi.fn(); + const updateSessionIdMock = vi.fn(); + const sessionId = overrides.sessionId ?? "session"; + let toolAuthorityFingerprint = overrides.toolAuthorityFingerprint; + let toolAuthorityRoute: ReplyOperation["toolAuthorityRoute"]; + const replyOperation: ReplyOperation = { + key: overrides.key ?? "main", + sessionId, + abortSignal: overrides.abortSignal ?? new AbortController().signal, + resetTriggered: false, + terminalRecovery: false, + acceptedSteeredInboundAudio: false, + get toolAuthorityFingerprint() { + return toolAuthorityFingerprint; + }, + get toolAuthorityRoute() { + return toolAuthorityRoute; + }, + phase: "running", + result: null, + staleExpiryReason: undefined, + startedAtMs: Date.now(), + lastActivityAtMs: Date.now(), + hasOwnedSessionId: vi.fn((candidate: string) => candidate === sessionId), + recordActivity: vi.fn(), + setPhase: vi.fn(), + markWaitingForDeferredMaintenance: vi.fn(), + markDeferredMaintenanceWaitEnded: vi.fn(), + markWaitingForGlobalLane: vi.fn(), + markGlobalLaneWaitEnded: vi.fn(), + markTerminalRecovery: vi.fn(), + markAcceptedSteeredInboundAudio: vi.fn(), + bindToolAuthorityFingerprint: vi.fn((fingerprint) => { + toolAuthorityFingerprint = fingerprint; + }), + bindToolAuthorityRoute: vi.fn((route) => { + toolAuthorityRoute = route; + }), + updateSessionId: updateSessionIdMock, + updateSessionKey: vi.fn(), + attachBackend: vi.fn(), + detachBackend: vi.fn(), + freezeAbort: freezeAbortMock, + retainFailureUntilComplete: retainFailureUntilCompleteMock, + complete: vi.fn(), + completeThen: vi.fn((afterClear) => afterClear()), + completeWithAfterClearBarrier: vi.fn(), + fail: failMock, + abortByUser: vi.fn(() => true), + abortForRestart: vi.fn(() => true), + }; + return { + replyOperation, + failMock, + freezeAbortMock, + retainFailureUntilCompleteMock, + updateSessionIdMock, + }; +} + /** Creates a typed mock typing controller with optional method overrides. */ export function createMockTypingController( overrides: Partial = {}, @@ -31,7 +103,7 @@ export function createMockFollowupRun( enqueuedAt: Date.now(), originatingTo: "channel:C1", run: { - agentId: "agent", + agentId: "main", agentDir: "/tmp/agent", sessionId: "session", sessionKey: "main", diff --git a/src/gateway/server-methods/chat-send-message-injection.ts b/src/gateway/server-methods/chat-send-message-injection.ts index 546d1315dbcd..764e43b65bf3 100644 --- a/src/gateway/server-methods/chat-send-message-injection.ts +++ b/src/gateway/server-methods/chat-send-message-injection.ts @@ -42,7 +42,7 @@ export function createChatSendMessageInjectionStarter(params: { const { cfg, entry } = params.session; const { ctx, isInternalTextSlashCommandTurn, replyOptionImages, replyOptionMedia } = params.turn; return (): ReplyMessageInjectionAttempt | undefined => { - if (!params.target || isInternalTextSlashCommandTurn) { + if (!params.target || isInternalTextSlashCommandTurn || toolBindings !== undefined) { return undefined; } const { debounceMs } = resolveQueueSettings({ diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 25cb6ac48a9b..4eb386857cbb 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -29,6 +29,7 @@ import { replyRunRegistry as baseReplyRunRegistry, type ReplyBackendQueueMessageOptions, } from "../../auto-reply/reply/reply-run-registry.js"; +import { testing as replyRunRegistryTesting } from "../../auto-reply/reply/reply-run-registry.test-support.js"; import type { MsgContext } from "../../auto-reply/templating.js"; import { appendTranscriptMessage, @@ -1306,6 +1307,7 @@ afterAll(async () => { describe("chat directive tag stripping for non-streaming final payloads", () => { afterEach(() => { + replyRunRegistryTesting.resetReplyRunRegistry(); mockState.config = {}; mockState.finalText = "[[reply_to_current]]"; mockState.finalPayload = null; @@ -1548,6 +1550,72 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(context.addChatRun).toHaveBeenCalledOnce(); }); + it("dispatches tool-bound input instead of injecting it into the active run", async () => { + await createGatewayUserTurnSqliteFixture("openclaw-chat-send-tool-bound-dispatch-"); + await appendTranscriptMessage(transcriptScope(), { + eventId: "current-leaf", + message: { role: "assistant", content: "working" }, + now: 1, + parentId: null, + }); + const { context, respond, send } = createChatRequestFixture(); + const queueMessage = vi.fn(async () => {}); + const operation = replyRunRegistry.begin({ + sessionKey: "agent:main:main", + sessionId: mockState.sessionId, + resetTriggered: false, + originatingLeafEntryId: "current-leaf", + }); + operation.setPhase("running"); + operation.attachBackend({ + kind: "embedded", + cancel: () => {}, + messageInjection: { isAvailable: () => true, queueMessage }, + }); + const toolBindings = { browser: { kind: "tab", tabId: 1, targetId: "target-1" } }; + + try { + await send({ + idempotencyKey: "idem-tool-bound-dispatch", + requestParams: { + expectedLeafEntryId: "current-leaf", + queueMode: "steer", + toolBindings, + }, + client: { + connId: "copilot", + pairedClientId: "openclaw-browser-copilot", + connect: { + role: "operator", + scopes: ["operator.read", "operator.write"], + caps: ["run-tool-bindings"], + client: { + id: "openclaw-browser-copilot", + version: "test", + platform: "chrome", + mode: "ui", + }, + }, + }, + waitFor: "none", + }); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ status: "started" }), + undefined, + expect.any(Object), + ); + expect(queueMessage).not.toHaveBeenCalled(); + expect(context.addChatRun).toHaveBeenCalledOnce(); + operation.complete(); + await waitForAssertion(() => + expect(mockState.lastDispatchCtx?.GatewayRunToolBindings).toEqual(toolBindings), + ); + } finally { + operation.complete(); + } + }); + it("rejects targetless steer when the owner immutable leaf differs", async () => { await createGatewayUserTurnSqliteFixture("openclaw-chat-send-targetless-leaf-mismatch-"); await appendTranscriptMessage(transcriptScope(), { diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index 6c20f1159fbd..84f02e2652f3 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -230,32 +230,6 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) { `; } -/** Both zone groups accept drops, so dragging a session onto either one pins it - and records its slot in the canonical entry order. */ -export function renderAppSidebarZoneGroup(host: AppSidebarRenderHost, content: unknown) { - return html` - - `; -} - -/** Pinned sessions are elevated content, not navigation, so they carry their own - section label instead of trailing the Pages list. No customize affordance: - the Pages head owns the pin editor, and pinning is a per-session action. */ -export function renderAppSidebarPinnedHead() { - return html` - - `; -} - export function renderAppSidebarPagesHead(host: AppSidebarRenderHost) { return html` diff --git a/ui/src/e2e/session-management.sidebar.e2e.test.ts b/ui/src/e2e/session-management.sidebar.e2e.test.ts index 769f55a44e93..6cfa51bea530 100644 --- a/ui/src/e2e/session-management.sidebar.e2e.test.ts +++ b/ui/src/e2e/session-management.sidebar.e2e.test.ts @@ -676,7 +676,7 @@ suite.define(() => { } }); - it("pins a session dropped below an existing row in the Pinned group", async () => { + it("pins a session dropped below an existing row in the interleaved sidebar zone", async () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block", @@ -723,7 +723,6 @@ suite.define(() => { await expect .poll(() => trimmedTextContents(pinnedEntry.locator(".sidebar-recent-session__name"))) .toEqual(["Already pinned"]); - await expect.poll(() => page.locator(".sidebar-nav__head--pinned").count()).toBe(1); await captureUiProof(page, "sidebar-session-before-pinned-drop.png"); const pinnedBox = await pinnedEntry.boundingBox(); if (!pinnedBox) { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 6c74e0847801..0e9f26f789c7 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -912,7 +912,7 @@ openclaw-settings-save-indicator { /* Sticky headers inside the sidebar reuse --sidebar-bg so scrolled rows never shine through with a mismatched tone. */ --sidebar-bg: color-mix(in srgb, var(--bg) 96%, var(--bg-elevated) 4%); - /* One rhythm for every group boundary in the sidebar (Pages, Pinned, and each + /* One rhythm for every group boundary in the sidebar (Pages and each session section) so the column reads as separate shelves, not one list. */ --sidebar-group-gap: var(--space-4); --scrollbar-size: 6px; @@ -3118,12 +3118,6 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) { flex: 1 1 auto; } -/* Pinned sessions are their own group, so they take the sidebar's inter-group - rhythm instead of butting against the Pages list. */ -.sidebar-nav__head--pinned { - margin-top: var(--sidebar-group-gap); -} - .sidebar-nav__head-action { display: inline-flex; align-items: center; diff --git a/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts b/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts index 05b45292f0cb..f9a069348204 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts @@ -206,14 +206,12 @@ describe("AppSidebar interleaved zone", () => { expect(sidebar.querySelector(".sidebar-session-pagination")).toBeNull(); }); - it("renders pinned sessions as their own labelled group below the Pages routes", async () => { + it("renders routes and pinned sessions in the canonical entry order", async () => { const { sidebar, sessions } = await mountZone(); const result = sessions.sessions.state.result; if (!result) { throw new Error("expected session list"); } - // Nothing pinned yet: the group must not reserve a label or its spacing. - expect(sidebar.querySelector(".sidebar-nav__head--pinned")).toBeNull(); sessions.publish({ result: { ...result, @@ -230,14 +228,7 @@ describe("AppSidebar interleaved zone", () => { const labels = [...sidebar.querySelectorAll(".sidebar-zone-entry")].map((entry) => entry.textContent?.trim(), ); - // Routes keep their configured order; the pinned session leaves the Pages - // list and heads its own group, so it renders after every route. - expect(labels).toEqual(["Usage", "Plugins", "Alpha"]); - const pinnedHead = sidebar.querySelector(".sidebar-nav__head--pinned"); - expect(pinnedHead?.textContent?.trim()).toBe("Pinned"); - expect( - pinnedHead?.nextElementSibling?.contains(zoneEntry(sidebar, "session:agent:main:alpha")), - ).toBe(true); + expect(labels).toEqual(["Usage", "Alpha", "Plugins"]); expect(sidebar.querySelector('[data-session-section="pinned"]')).toBeNull(); const pinnedRow = sidebar.querySelector('[data-session-key="agent:main:alpha"]'); const pinnedTree = pinnedRow?.closest(".sidebar-session-tree");