mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
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:
committed by
GitHub
parent
d4b1e9644e
commit
1011b4102f
@@ -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();
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user