From 6b565c047f9800a23e32ffc43b3fbc5de063eea8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 04:53:15 -0700 Subject: [PATCH] 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. --- ui/src/app/app-shell-gateway.ts | 1 + ui/src/app/app-shell-view.ts | 13 ++++ ui/src/components/app-sidebar-base.ts | 1 + ui/src/components/app-sidebar-render.ts | 5 +- ...p-sidebar-session-navigation-logic.test.ts | 2 + .../app-sidebar-session-navigation-logic.ts | 2 + .../app-sidebar-session-navigation.ts | 1 + .../app-sidebar-session-row-render.ts | 1 + .../components/app-sidebar-session-types.ts | 1 + ui/src/components/session-row-badges.ts | 9 +++ .../session-management.sidebar.e2e.test.ts | 49 +++++++++++++++ ui/src/i18n/locales/en.ts | 1 + ui/src/lib/chat/outbox-store.test.ts | 29 +++++++++ ui/src/lib/chat/outbox-store.ts | 60 ++++++++++++++----- .../pages/chat/composer-persistence.test.ts | 16 +++-- ui/src/pages/chat/composer-persistence.ts | 6 ++ .../app-sidebar-cases/outbox-badges.ts | 31 ++++++++++ ui/src/test-helpers/app-sidebar.ts | 1 + 18 files changed, 208 insertions(+), 21 deletions(-) diff --git a/ui/src/app/app-shell-gateway.ts b/ui/src/app/app-shell-gateway.ts index 91202102fee7..290da40aaf3f 100644 --- a/ui/src/app/app-shell-gateway.ts +++ b/ui/src/app/app-shell-gateway.ts @@ -29,6 +29,7 @@ export type StoredOutboxScopeHost = { }; export type OutboxStoreRuntime = { + listStoredDraftScopes: (state: StoredOutboxScopeHost) => ReadonlySet; summarizeStoredChatOutboxes: (state: StoredOutboxScopeHost) => { countsByScope: ReadonlyMap; total: number; diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 726aede8dcde..fd3de7f61234 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -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), diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index 0b64802ba4b2..11252c149d59 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -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; diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index 62e4220ec990..33ac3ef67871 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -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) { > ` : nothing} - ${approvalNeeded || outboxCount > 0 + ${approvalNeeded || outboxCount > 0 || hasComposerDraft ? html` ${approvalNeeded ? html` @@ -223,7 +224,7 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) { > ` : nothing} - ${renderSessionRowBadges({ hasAutomation: false, outboxCount })} + ${renderSessionRowBadges({ hasAutomation: false, outboxCount, hasComposerDraft })} ` : nothing} diff --git a/ui/src/components/app-sidebar-session-navigation-logic.test.ts b/ui/src/components/app-sidebar-session-navigation-logic.test.ts index 405dfca9721b..315a842f6a9c 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.test.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.test.ts @@ -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, }); diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index 9350b0c0c3d8..bf028efed7e2 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -118,6 +118,7 @@ export function buildSidebarSessionNavigationState(input: { runtimeSampledAtByRow: WeakMap; loadingChildSessionKeys: ReadonlySet; 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, diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 87d16cae1021..8228c0a8845d 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -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, }); diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index e1ff3babf4dd..03a74fde09a4 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -321,6 +321,7 @@ export function renderRecentSession(params: { > ${renderSessionRowBadges({ ...session, + hasComposerDraft: session.hasComposerDraft === true && !session.visuallyActive, pullRequest: session.pullRequest ?? display?.pullRequest, hasApproval: sessionHasPendingApproval( host.sessionData.approvalBadgeSnapshot(), diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts index 14a163d604b1..43a50ea1cd26 100644 --- a/ui/src/components/app-sidebar-session-types.ts +++ b/ui/src/components/app-sidebar-session-types.ts @@ -87,6 +87,7 @@ export type SidebarRecentSession = { hasAutomation: boolean; pullRequest?: SessionCatalogPullRequestSummary; outboxCount?: number; + hasComposerDraft?: boolean; unread: boolean; lastMessagePreview?: string; lastReadAt?: number; diff --git a/ui/src/components/session-row-badges.ts b/ui/src/components/session-row-badges.ts index a5c06fb4f347..4dee3b6d4ef4 100644 --- a/ui/src/components/session-row-badges.ts +++ b/ui/src/components/session-row-badges.ts @@ -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, diff --git a/ui/src/e2e/session-management.sidebar.e2e.test.ts b/ui/src/e2e/session-management.sidebar.e2e.test.ts index 8e175452c785..40998754478c 100644 --- a/ui/src/e2e/session-management.sidebar.e2e.test.ts +++ b/ui/src/e2e/session-management.sidebar.e2e.test.ts @@ -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"; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index f7317bb05807..8e031618375d 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/lib/chat/outbox-store.test.ts b/ui/src/lib/chat/outbox-store.test.ts index 3e254675f295..a089c53cd8fa 100644 --- a/ui/src/lib/chat/outbox-store.test.ts +++ b/ui/src/lib/chat/outbox-store.test.ts @@ -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"); diff --git a/ui/src/lib/chat/outbox-store.ts b/ui/src/lib/chat/outbox-store.ts index 1217125572a6..9f18a032da94 100644 --- a/ui/src/lib/chat/outbox-store.ts +++ b/ui/src/lib/chat/outbox-store.ts @@ -65,6 +65,11 @@ export type StoredChatOutbox = StoredChatOutboxScope & { queue: ChatQueueItem[]; }; +type StoredComposerRow = { + scope: ComposerStorageScope; + session: StoredComposerSession; +}; + type StoredChatOutboxSummary = { countsByScope: ReadonlyMap; 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 { + const scopeKeys = new Set(); + 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>(); for (const outbox of listStoredChatOutboxes(state)) { diff --git a/ui/src/pages/chat/composer-persistence.test.ts b/ui/src/pages/chat/composer-persistence.test.ts index f9e004ce3ada..0c00de926348 100644 --- a/ui/src/pages/chat/composer-persistence.test.ts +++ b/ui/src/pages/chat/composer-persistence.test.ts @@ -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", () => { diff --git a/ui/src/pages/chat/composer-persistence.ts b/ui/src/pages/chat/composer-persistence.ts index 003bb7f481ba..43e84588c761 100644 --- a/ui/src/pages/chat/composer-persistence.ts +++ b/ui/src/pages/chat/composer-persistence.ts @@ -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. diff --git a/ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts b/ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts index a44af8540de9..5f046a08cec2 100644 --- a/ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts +++ b/ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts @@ -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( + `[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(); }); }); diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 9da1a4c5f7e9..fc494f9b0ed6 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -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;