mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): show unsent-draft pencil on sidebar session rows (#121476)
* feat(ui): show unsent-draft pencil on sidebar session rows Typed-but-unsent composer text now surfaces as a pencil badge on the owning session's sidebar row (and Home row) once you switch away. Draft persistence now notifies stored-outbox subscribers so the indicator appears and clears live. The active session suppresses the badge since its composer is already visible. * chore: refresh merge ref for CI against current main * chore: refresh merge ref against healed main * fix(ui): notify draft indicator only on presence transitions Unconditional notify on every draft persist let outbox-projection subscribers re-persist a stale pane over a newer draft (chat-state route-fallback invariant). The sidebar pencil only consumes presence, so notify on empty/non-empty transitions only.
This commit is contained in:
committed by
GitHub
parent
60e1f40562
commit
6b565c047f
@@ -29,6 +29,7 @@ export type StoredOutboxScopeHost = {
|
||||
};
|
||||
|
||||
export type OutboxStoreRuntime = {
|
||||
listStoredDraftScopes: (state: StoredOutboxScopeHost) => ReadonlySet<string>;
|
||||
summarizeStoredChatOutboxes: (state: StoredOutboxScopeHost) => {
|
||||
countsByScope: ReadonlyMap<string, number>;
|
||||
total: number;
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "./settings.ts";
|
||||
|
||||
const EMPTY_OUTBOX_COUNT_FOR_SESSION = () => 0;
|
||||
const EMPTY_SESSION_HAS_DRAFT = () => false;
|
||||
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
|
||||
? "⌘K"
|
||||
: "Ctrl K";
|
||||
@@ -100,6 +101,9 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
const storedOutboxes = outboxStoreRuntime
|
||||
? outboxStoreRuntime.summarizeStoredChatOutboxes(outboxScopeHost)
|
||||
: null;
|
||||
const storedDraftScopeKeys = outboxStoreRuntime
|
||||
? outboxStoreRuntime.listStoredDraftScopes(outboxScopeHost)
|
||||
: null;
|
||||
const outboxCountForSession = outboxStoreRuntime
|
||||
? (sessionKey: string) => {
|
||||
const scope = outboxStoreRuntime.resolveStoredChatOutboxScope(outboxScopeHost, sessionKey);
|
||||
@@ -108,6 +112,14 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
);
|
||||
}
|
||||
: EMPTY_OUTBOX_COUNT_FOR_SESSION;
|
||||
const hasSessionDraft = outboxStoreRuntime
|
||||
? (sessionKey: string) => {
|
||||
const scope = outboxStoreRuntime.resolveStoredChatOutboxScope(outboxScopeHost, sessionKey);
|
||||
return (
|
||||
storedDraftScopeKeys?.has(outboxStoreRuntime.storedChatOutboxScopeKey(scope)) === true
|
||||
);
|
||||
}
|
||||
: EMPTY_SESSION_HAS_DRAFT;
|
||||
const navigationSnapshot = context.navigation.snapshot;
|
||||
const overlaySnapshot = context.overlays.snapshot;
|
||||
const terminalAvailable = isTerminalAvailable(
|
||||
@@ -200,6 +212,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
connected: gatewayConnected,
|
||||
offline: gatewaySnapshot.offlineStable,
|
||||
outboxCountForSession,
|
||||
hasSessionDraft,
|
||||
terminalAvailable,
|
||||
catalogOpenTarget: normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget),
|
||||
canPairDevice: gatewayConnected && (operatorAccess.canAdmin || operatorAccess.canPair),
|
||||
|
||||
@@ -29,6 +29,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) connected = false;
|
||||
@property({ attribute: false }) offline = false;
|
||||
@property({ attribute: false }) outboxCountForSession: (sessionKey: string) => number = () => 0;
|
||||
@property({ attribute: false }) hasSessionDraft: (sessionKey: string) => boolean = () => false;
|
||||
@property({ attribute: false }) terminalAvailable = false;
|
||||
@property({ attribute: false }) catalogOpenTarget: CatalogOpenTarget = "viewer";
|
||||
@property({ attribute: false }) canPairDevice = false;
|
||||
|
||||
@@ -165,6 +165,7 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
|
||||
const active =
|
||||
isSessionRouteId(host.activeRouteId) &&
|
||||
areUiSessionKeysEquivalent(host.getRouteSessionKey(), mainKey);
|
||||
const hasComposerDraft = !active && host.hasSessionDraft(mainKey);
|
||||
const running = mainRow?.hasActiveRun === true;
|
||||
const unread = mainRow?.unread === true && !active;
|
||||
// Home shares the sidebar's leading-slot contract: run state rings its icon
|
||||
@@ -211,7 +212,7 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
|
||||
>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${approvalNeeded || outboxCount > 0
|
||||
${approvalNeeded || outboxCount > 0 || hasComposerDraft
|
||||
? html`<span class="nav-item__state sidebar-home-session-states">
|
||||
${approvalNeeded
|
||||
? html`<openclaw-tooltip .content=${t("sessionsView.approvalNeeded")}>
|
||||
@@ -223,7 +224,7 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
|
||||
>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${renderSessionRowBadges({ hasAutomation: false, outboxCount })}
|
||||
${renderSessionRowBadges({ hasAutomation: false, outboxCount, hasComposerDraft })}
|
||||
</span>`
|
||||
: nothing}
|
||||
</a>
|
||||
|
||||
@@ -37,6 +37,7 @@ function projectSidebarSession(
|
||||
runtimeSampledAtByRow: new WeakMap(),
|
||||
loadingChildSessionKeys: new Set(),
|
||||
outboxCountForSessionKey: () => 0,
|
||||
hasSessionDraft: () => false,
|
||||
resolveAttention: () => ({ kind: "none" }),
|
||||
resolveAgentStatusNote: () => undefined,
|
||||
});
|
||||
@@ -263,6 +264,7 @@ it("keeps a prepared worktree session in Coding before canonical metadata arrive
|
||||
runtimeSampledAtByRow: new WeakMap(),
|
||||
loadingChildSessionKeys: new Set(),
|
||||
outboxCountForSessionKey: () => 0,
|
||||
hasSessionDraft: () => false,
|
||||
resolveAttention: () => ({ kind: "none" }),
|
||||
resolveAgentStatusNote: () => undefined,
|
||||
});
|
||||
|
||||
@@ -118,6 +118,7 @@ export function buildSidebarSessionNavigationState(input: {
|
||||
runtimeSampledAtByRow: WeakMap<GatewaySessionRow, number>;
|
||||
loadingChildSessionKeys: ReadonlySet<string>;
|
||||
outboxCountForSessionKey: (sessionKey: string) => number;
|
||||
hasSessionDraft: (sessionKey: string) => boolean;
|
||||
resolveAttention: (row: GatewaySessionRow) => SidebarRecentSession["attention"];
|
||||
resolveAgentStatusNote: (row: GatewaySessionRow) => string | undefined;
|
||||
}): SidebarSessionNavigationState {
|
||||
@@ -202,6 +203,7 @@ export function buildSidebarSessionNavigationState(input: {
|
||||
hasAutomation: row.hasAutomation === true,
|
||||
pullRequest: context?.sessions.pullRequestSummary(row.key),
|
||||
outboxCount: input.outboxCountForSessionKey(row.key),
|
||||
hasComposerDraft: input.hasSessionDraft(row.key),
|
||||
unread: row.archived !== true && row.unread === true,
|
||||
lastMessagePreview: normalizeOptionalString(row.lastMessagePreview),
|
||||
lastReadAt: row.lastReadAt,
|
||||
|
||||
@@ -294,6 +294,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
runtimeSampledAtByRow: this.runtimeSampledAtByRow,
|
||||
loadingChildSessionKeys: this.sessionData.loadingChildSessionKeys,
|
||||
outboxCountForSessionKey: (sessionKey) => this.outboxCountForSessionKey(sessionKey),
|
||||
hasSessionDraft: (sessionKey) => this.hasSessionDraft(sessionKey),
|
||||
resolveAttention: (row) => this.attention.resolveSessionAttention(row),
|
||||
resolveAgentStatusNote: (row) => this.attention.resolveSessionAgentStatus(row)?.note,
|
||||
});
|
||||
|
||||
@@ -321,6 +321,7 @@ export function renderRecentSession(params: {
|
||||
></openclaw-viewer-facepile>
|
||||
${renderSessionRowBadges({
|
||||
...session,
|
||||
hasComposerDraft: session.hasComposerDraft === true && !session.visuallyActive,
|
||||
pullRequest: session.pullRequest ?? display?.pullRequest,
|
||||
hasApproval: sessionHasPendingApproval(
|
||||
host.sessionData.approvalBadgeSnapshot(),
|
||||
|
||||
@@ -87,6 +87,7 @@ export type SidebarRecentSession = {
|
||||
hasAutomation: boolean;
|
||||
pullRequest?: SessionCatalogPullRequestSummary;
|
||||
outboxCount?: number;
|
||||
hasComposerDraft?: boolean;
|
||||
unread: boolean;
|
||||
lastMessagePreview?: string;
|
||||
lastReadAt?: number;
|
||||
|
||||
@@ -60,6 +60,7 @@ export function renderSessionRowBadges(params: {
|
||||
pullRequest?: SessionCatalogPullRequestSummary;
|
||||
hasApproval?: boolean;
|
||||
outboxCount?: number;
|
||||
hasComposerDraft?: boolean;
|
||||
placementState?: SessionPlacementState;
|
||||
workspaceConflictCount?: number;
|
||||
}) {
|
||||
@@ -90,6 +91,7 @@ export function renderSessionRowBadges(params: {
|
||||
!pullRequestLabel &&
|
||||
!params.hasApproval &&
|
||||
outboxCount === 0 &&
|
||||
!params.hasComposerDraft &&
|
||||
!displayedPlacementState &&
|
||||
!hasWorkspaceConflict
|
||||
) {
|
||||
@@ -145,6 +147,13 @@ export function renderSessionRowBadges(params: {
|
||||
${outboxCount > 0
|
||||
? renderSessionRowBadge(outboxLabel, icons.clock, "session-row-badge--queued", outboxCount)
|
||||
: nothing}
|
||||
${params.hasComposerDraft
|
||||
? renderSessionRowBadge(
|
||||
t("sessionsView.unsentDraft"),
|
||||
icons.pencil,
|
||||
"session-row-badge--draft",
|
||||
)
|
||||
: nothing}
|
||||
${displayedPlacementState || hasWorkspaceConflict
|
||||
? renderSessionRowBadge(
|
||||
cloudLabel,
|
||||
|
||||
@@ -25,6 +25,55 @@ import {
|
||||
const suite = createSessionManagementE2eSuite();
|
||||
|
||||
suite.define(() => {
|
||||
it("shows an unsent-draft pencil after switching sessions and removes it after clearing", async () => {
|
||||
const firstKey = "agent:main:draft-first";
|
||||
const secondKey = "agent:main:draft-second";
|
||||
const context = await suite.browser.newContext({
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow(firstKey, "Draft first", 2),
|
||||
sessionRow(secondKey, "Draft second", 1),
|
||||
]),
|
||||
},
|
||||
sessionKey: firstKey,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, firstKey));
|
||||
const firstRow = page.locator(`[data-session-key="${firstKey}"]`);
|
||||
const secondRow = page.locator(`[data-session-key="${secondKey}"]`);
|
||||
const composer = page.locator(".agent-chat__composer-combobox > textarea");
|
||||
await firstRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await secondRow.waitFor({ state: "visible" });
|
||||
await composer.waitFor({ state: "visible" });
|
||||
await captureUiProof(page, "draft-indicator-before.png");
|
||||
|
||||
await composer.fill("Keep this unsent");
|
||||
await secondRow.getByRole("link").click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(secondKey));
|
||||
await firstRow.getByRole("img", { name: "Unsent draft" }).waitFor();
|
||||
await captureUiProof(page, "draft-indicator-after.png");
|
||||
|
||||
await firstRow.getByRole("link").click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(firstKey));
|
||||
expect(await firstRow.getByRole("img", { name: "Unsent draft" }).count()).toBe(0);
|
||||
|
||||
await composer.fill("");
|
||||
await secondRow.getByRole("link").click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(secondKey));
|
||||
await expect.poll(() => firstRow.getByRole("img", { name: "Unsent draft" }).count()).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("expands child sessions inline and opens a child chat", async () => {
|
||||
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
|
||||
const parentKey = "agent:main:release-plan";
|
||||
|
||||
@@ -817,6 +817,7 @@ export const en: TranslationMap = {
|
||||
approvalNeeded: "Approval needed",
|
||||
queuedMessage: "{count} message queued to send",
|
||||
queuedMessages: "{count} messages queued to send",
|
||||
unsentDraft: "Unsent draft",
|
||||
noSessions: "No sessions found.",
|
||||
noActiveSessions: "No active sessions.",
|
||||
noArchivedSessions: "No archived sessions.",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import {
|
||||
listStoredDraftScopes,
|
||||
listStoredChatOutboxes,
|
||||
resolveStoredChatOutboxScope,
|
||||
storedChatOutboxScopeKey,
|
||||
@@ -19,6 +20,34 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("stored outbox summaries", () => {
|
||||
it("lists only non-empty drafts under the same scope used by sidebar sessions", () => {
|
||||
const gatewayUrl = "ws://gateway.test/control";
|
||||
sessionStorage.setItem(
|
||||
`openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
gatewayOwner: gatewayUrl,
|
||||
sessions: {
|
||||
"thread-draft\u0000agent:main": {
|
||||
draft: "finish this message",
|
||||
draftRevision: 3,
|
||||
updatedAt: 3,
|
||||
},
|
||||
"thread-empty\u0000agent:main": { draftRevision: 2, updatedAt: 2 },
|
||||
"thread-queue\u0000agent:main": {
|
||||
queue: [{ id: "queued", text: "queued", createdAt: 1 }],
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const state = { settings: { gatewayUrl } };
|
||||
|
||||
expect([...listStoredDraftScopes(state)]).toEqual([
|
||||
storedChatOutboxScopeKey(resolveStoredChatOutboxScope(state, "thread-draft")),
|
||||
]);
|
||||
});
|
||||
|
||||
it("bridges matching storage events until the last subscriber leaves", () => {
|
||||
const addEventListener = vi.spyOn(window, "addEventListener");
|
||||
const removeEventListener = vi.spyOn(window, "removeEventListener");
|
||||
|
||||
@@ -65,6 +65,11 @@ export type StoredChatOutbox = StoredChatOutboxScope & {
|
||||
queue: ChatQueueItem[];
|
||||
};
|
||||
|
||||
type StoredComposerRow = {
|
||||
scope: ComposerStorageScope;
|
||||
session: StoredComposerSession;
|
||||
};
|
||||
|
||||
type StoredChatOutboxSummary = {
|
||||
countsByScope: ReadonlyMap<string, number>;
|
||||
total: number;
|
||||
@@ -586,7 +591,7 @@ export function applyStoredChatOutboxScope(
|
||||
};
|
||||
}
|
||||
|
||||
export function listStoredChatOutboxes(state: ChatComposerScope): StoredChatOutbox[] {
|
||||
function listStoredComposerRows(state: ChatComposerScope): StoredComposerRow[] {
|
||||
const storage = getSafeSessionStorage();
|
||||
if (!storage) {
|
||||
return [];
|
||||
@@ -627,10 +632,10 @@ export function listStoredChatOutboxes(state: ChatComposerScope): StoredChatOutb
|
||||
// A full storage bucket must not hide already-readable outboxes.
|
||||
}
|
||||
}
|
||||
const outboxes: StoredChatOutbox[] = [];
|
||||
const rows: StoredComposerRow[] = [];
|
||||
for (const [storeSessionKey, session] of Object.entries(store.sessions)) {
|
||||
const separatorIndex = storeSessionKey.lastIndexOf(separator);
|
||||
if (separatorIndex < 0 || !session.queue?.length) {
|
||||
if (separatorIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const agentScope = storeSessionKey.slice(separatorIndex + separator.length);
|
||||
@@ -640,23 +645,50 @@ export function listStoredChatOutboxes(state: ChatComposerScope): StoredChatOutb
|
||||
agentScope === UNRESOLVED_GLOBAL_AGENT_SCOPE ? undefined : agentScope,
|
||||
store.mainAlias,
|
||||
);
|
||||
outboxes.push({
|
||||
sessionKey: scope.conversationKey,
|
||||
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
|
||||
queue: session.queue.map((item) => applyStoredChatOutboxScope(item, scope)),
|
||||
});
|
||||
rows.push({ scope, session });
|
||||
}
|
||||
return outboxes.toSorted(
|
||||
(left, right) =>
|
||||
(left.queue[0]?.createdAt ?? Number.MAX_SAFE_INTEGER) -
|
||||
(right.queue[0]?.createdAt ?? Number.MAX_SAFE_INTEGER) ||
|
||||
left.sessionKey.localeCompare(right.sessionKey),
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listStoredDraftScopes(state: ChatComposerScope): ReadonlySet<string> {
|
||||
const scopeKeys = new Set<string>();
|
||||
for (const { scope, session } of listStoredComposerRows(state)) {
|
||||
// Empty drafts are revision tombstones, not user-visible composer text.
|
||||
if (session.draft) {
|
||||
scopeKeys.add(
|
||||
storedChatOutboxScopeKey({
|
||||
sessionKey: scope.conversationKey,
|
||||
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return scopeKeys;
|
||||
}
|
||||
|
||||
export function listStoredChatOutboxes(state: ChatComposerScope): StoredChatOutbox[] {
|
||||
const outboxes: StoredChatOutbox[] = [];
|
||||
for (const { scope, session } of listStoredComposerRows(state)) {
|
||||
if (!session.queue?.length) {
|
||||
continue;
|
||||
}
|
||||
outboxes.push({
|
||||
sessionKey: scope.conversationKey,
|
||||
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
|
||||
queue: session.queue.map((item) => applyStoredChatOutboxScope(item, scope)),
|
||||
});
|
||||
}
|
||||
return outboxes.toSorted(
|
||||
(left, right) =>
|
||||
(left.queue[0]?.createdAt ?? Number.MAX_SAFE_INTEGER) -
|
||||
(right.queue[0]?.createdAt ?? Number.MAX_SAFE_INTEGER) ||
|
||||
left.sessionKey.localeCompare(right.sessionKey),
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeStoredChatOutboxes(state: ChatComposerScope): StoredChatOutboxSummary {
|
||||
const idsByScope = new Map<string, Set<string>>();
|
||||
for (const outbox of listStoredChatOutboxes(state)) {
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("chat composer persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies durable outbox subscribers on writes until they unsubscribe", () => {
|
||||
it("notifies stored outbox subscribers on draft presence transitions and queue writes", () => {
|
||||
const state = createState();
|
||||
const original = reconnectItem("notify", 1);
|
||||
const updated = { ...original, text: "updated message" };
|
||||
@@ -91,9 +91,15 @@ describe("chat composer persistence", () => {
|
||||
|
||||
try {
|
||||
expect(persistChatComposerState({ ...state, chatMessage: "draft only" })).toBe(true);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(admitStoredChatComposerQueueItem(state, state.sessionKey, original)).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
// Content-only re-persists stay silent so projection subscribers cannot
|
||||
// react by re-persisting a stale pane over the newer draft.
|
||||
expect(persistChatComposerState({ ...state, chatMessage: "draft only, edited" })).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(persistChatComposerState({ ...state, chatMessage: "" })).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(admitStoredChatComposerQueueItem(state, state.sessionKey, original)).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
updateStoredChatComposerQueueItem(
|
||||
state,
|
||||
@@ -103,7 +109,7 @@ describe("chat composer persistence", () => {
|
||||
original.agentId,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -117,7 +123,7 @@ describe("chat composer persistence", () => {
|
||||
updated.agentId,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("flushes a debounced draft before its owner releases state", () => {
|
||||
|
||||
@@ -399,6 +399,12 @@ function persistChatComposerStateResult(
|
||||
options.agentId,
|
||||
).session;
|
||||
if (persisted?.draftRevision === draftRevision && (persisted.draft ?? "") === draft) {
|
||||
// Notify only on presence transitions: sidebar draft indicators consume
|
||||
// presence, and content-only notifies would let projection subscribers
|
||||
// re-persist a stale pane over a newer draft (route-fallback invariant).
|
||||
if (Boolean(storedDraft) !== Boolean(draft)) {
|
||||
notifyStoredChatOutboxChanges();
|
||||
}
|
||||
return "persisted";
|
||||
}
|
||||
// Retention limits can make a successful storage write omit this draft.
|
||||
|
||||
@@ -4,6 +4,33 @@ import "../../components/app-sidebar.ts";
|
||||
import { createGateway, createSessions, mountSidebar } from "../app-sidebar.ts";
|
||||
|
||||
describe("AppSidebar outbox badges", () => {
|
||||
it("shows draft pencils only for inactive sessions with stored composer text", async () => {
|
||||
const draftKey = "agent:main:draft-thread";
|
||||
const activeDraftKey = "agent:main:active-draft-thread";
|
||||
const plainKey = "agent:main:plain-thread";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(
|
||||
gateway,
|
||||
createSessions("main", [draftKey, activeDraftKey, plainKey]),
|
||||
);
|
||||
sidebar.activeRouteId = "chat";
|
||||
sidebar.sessionKey = activeDraftKey;
|
||||
sidebar.hasSessionDraft = (sessionKey) =>
|
||||
sessionKey === draftKey || sessionKey === activeDraftKey;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const draftBadge = sidebar.querySelector<HTMLElement>(
|
||||
`[data-session-key="${draftKey}"] .session-row-badge--draft`,
|
||||
);
|
||||
expect(draftBadge?.getAttribute("aria-label")).toBe("Unsent draft");
|
||||
expect(
|
||||
sidebar.querySelector(`[data-session-key="${activeDraftKey}"] .session-row-badge--draft`),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sidebar.querySelector(`[data-session-key="${plainKey}"] .session-row-badge--draft`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows connected session outbox counts and removes the badge when empty", async () => {
|
||||
const sessionKey = "agent:main:queued-thread";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
@@ -39,10 +66,14 @@ describe("AppSidebar outbox badges", () => {
|
||||
},
|
||||
);
|
||||
sidebar.outboxCountForSession = () => 3;
|
||||
sidebar.hasSessionDraft = () => true;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const badges = sidebar.querySelectorAll(".nav-item--home .session-row-badge--queued");
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.textContent).toContain("3");
|
||||
expect(
|
||||
sidebar.querySelector('.nav-item--home .session-row-badge--draft[aria-label="Unsent draft"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export type SidebarLifecycleState = HTMLElement & {
|
||||
connected: boolean;
|
||||
offline: boolean;
|
||||
outboxCountForSession: (sessionKey: string) => number;
|
||||
hasSessionDraft: (sessionKey: string) => boolean;
|
||||
terminalAvailable: boolean;
|
||||
catalogOpenTarget: "viewer" | "terminal";
|
||||
canPairDevice: boolean;
|
||||
|
||||
Reference in New Issue
Block a user