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
This commit is contained in:
Peter Steinberger
2026-08-14 02:00:03 -07:00
committed by GitHub
parent d4b1e9644e
commit 1011b4102f
12 changed files with 204 additions and 199 deletions
@@ -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 };
@@ -24,6 +24,7 @@ type RunEntryDelegate = (params: RunEntryParams) => Promise<RunEntryResult>;
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<typeof vi.fn>;
freezeAbortMock: ReturnType<typeof vi.fn>;
retainFailureUntilCompleteMock: ReturnType<typeof vi.fn>;
updateSessionIdMock: ReturnType<typeof vi.fn>;
} {
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<string, unknown> {
if (typeof value !== "object" || value === null) {
throw new Error(`${label} was not an object`);
@@ -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<typeof import("./agent-runner-execution.js").executeAgentTurn>
>;
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<Parameters<typeof runReplyAgent>[0]> = {},
): Parameters<typeof runReplyAgent>[0] {
@@ -163,7 +151,7 @@ function makeRunReplyAgentParams(
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
replyOperation: createReplyOperation(),
replyOperation: createMockReplyOperation().replyOperation,
...overrides,
};
}
@@ -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<Parameters<typeof runReplyAgent>[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();
+73 -1
View File
@@ -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<TypingController> = {},
@@ -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",
@@ -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({
@@ -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(), {
-26
View File
@@ -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`
<div
class="nav-section__items"
@dragover=${(event: DragEvent) => host.sessionOrganizer.handleSidebarZoneDragOver(event)}
@dragleave=${(event: DragEvent) => host.sessionOrganizer.handleSidebarZoneDragLeave(event)}
@drop=${(event: DragEvent) => host.sessionOrganizer.handleSidebarZoneDrop(event)}
>
${content}
</div>
`;
}
/** 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`
<div class="sidebar-nav__head sidebar-nav__head--pinned">
<span class="sidebar-recent-sessions__label-text">${t("nav.pinned")}</span>
</div>
`;
}
export function renderAppSidebarPagesHead(host: AppSidebarRenderHost) {
return html`
<div class="sidebar-nav__head">
+20 -36
View File
@@ -26,10 +26,8 @@ import {
renderAppSidebarFooterBar,
renderAppSidebarHomeRow,
renderAppSidebarPagesHead,
renderAppSidebarPinnedHead,
renderAppSidebarPluginTabEntry,
renderAppSidebarZoneEntry,
renderAppSidebarZoneGroup,
} from "./app-sidebar-render.ts";
import type { SessionCatalogGroupsRenderer } from "./app-sidebar-session-catalog-render.ts";
import type { CatalogSessionMenuRequest } from "./app-sidebar-session-catalogs.ts";
@@ -457,10 +455,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
override render() {
const sidebarZone = this.reconciledSidebarZone();
// Pinned sessions keep their slot in the canonical entry order but render as
// their own group, so navigation entries stay a contiguous Pages list.
const pinnedEntries = sidebarZone.entries.filter((entry) => entry.type === "session");
const navEntries = sidebarZone.entries.filter((entry) => entry.type !== "session");
return html`
<aside
class="sidebar"
@@ -481,37 +475,27 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
>
<nav class="sidebar-nav" @contextmenu=${this.sidebarMenus.openCustomizeMenuFromContext}>
${renderAppSidebarPagesHead(this)}
${renderAppSidebarZoneGroup(
this,
html`
${renderAppSidebarHomeRow(this)}
${navEntries.map((entry) =>
renderAppSidebarZoneEntry(
this,
entry,
sidebarZone.sessionRows,
sidebarZone.workboardRows,
),
)}
${sidebarPluginTabs(this.context?.gateway.snapshot.hello?.controlUiTabs).map(
(tab) => renderAppSidebarPluginTabEntry(this, tab),
)}
`,
)}
${pinnedEntries.length > 0
? html`${renderAppSidebarPinnedHead()}
${renderAppSidebarZoneGroup(
<div
class="nav-section__items"
@dragover=${(event: DragEvent) =>
this.sessionOrganizer.handleSidebarZoneDragOver(event)}
@dragleave=${(event: DragEvent) =>
this.sessionOrganizer.handleSidebarZoneDragLeave(event)}
@drop=${(event: DragEvent) => this.sessionOrganizer.handleSidebarZoneDrop(event)}
>
${renderAppSidebarHomeRow(this)}
${sidebarZone.entries.map((entry) =>
renderAppSidebarZoneEntry(
this,
pinnedEntries.map((entry) =>
renderAppSidebarZoneEntry(
this,
entry,
sidebarZone.sessionRows,
sidebarZone.workboardRows,
),
),
)}`
: nothing}
entry,
sidebarZone.sessionRows,
sidebarZone.workboardRows,
),
)}
${sidebarPluginTabs(this.context?.gateway.snapshot.hello?.controlUiTabs).map(
(tab) => renderAppSidebarPluginTabEntry(this, tab),
)}
</div>
</nav>
${this.renderSessions()}
</div>
@@ -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) {
+1 -7
View File
@@ -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;
@@ -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<HTMLElement>(".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");