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:
Peter Steinberger
2026-08-10 04:53:15 -07:00
committed by GitHub
parent 60e1f40562
commit 6b565c047f
18 changed files with 208 additions and 21 deletions
+1
View File
@@ -29,6 +29,7 @@ export type StoredOutboxScopeHost = {
};
export type OutboxStoreRuntime = {
listStoredDraftScopes: (state: StoredOutboxScopeHost) => ReadonlySet<string>;
summarizeStoredChatOutboxes: (state: StoredOutboxScopeHost) => {
countsByScope: ReadonlyMap<string, number>;
total: number;
+13
View File
@@ -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),
+1
View File
@@ -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;
+3 -2
View File
@@ -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;
+9
View File
@@ -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";
+1
View File
@@ -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.",
+29
View File
@@ -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");
+46 -14
View File
@@ -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)) {
+11 -5
View File
@@ -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();
});
});
+1
View File
@@ -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;