fix(ui): show new task prompts immediately without duplicates (#111953)

* fix(ui): stabilize new task prompt rendering

* test(ui): cover prompt handoff integration

* test(ui): initialize prompt handoff fixtures
This commit is contained in:
Jason (Json)
2026-07-20 14:47:32 -06:00
committed by GitHub
parent a52eb2134b
commit 21bfc75648
17 changed files with 538 additions and 20 deletions
+4
View File
@@ -35,6 +35,7 @@ import type {
} from "./context.ts";
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
import { createApplicationGateway } from "./gateway-store.ts";
import { createInitialUserMessageHandoff } from "./initial-user-message-handoff.ts";
import { createNativeChatDrafts } from "./native-bridge.ts";
import { startNativeLinkRouting } from "./native-link-routing.ts";
import { createNativeNotificationsCapability } from "./native-notifications.ts";
@@ -324,6 +325,7 @@ export function bootstrapApplication(): ApplicationRuntime {
const nativeNotifications = createNativeNotificationsCapability();
const webPush = createWebPushCapability(gateway);
const skillWorkshopRevision = createSkillWorkshopRevisionHandoff();
const initialUserMessage = createInitialUserMessageHandoff();
applyStartupPresentation(settings);
const router = createApplicationRouter();
let pendingGatewayConnection =
@@ -396,6 +398,7 @@ export function bootstrapApplication(): ApplicationRuntime {
nativeNotifications,
webPush,
skillWorkshopRevision,
initialUserMessage,
navigate: (routeId, options) => {
void router
.navigate(routeId, context, { history: "push" }, routeLocation(routeId, options))
@@ -457,6 +460,7 @@ export function bootstrapApplication(): ApplicationRuntime {
nativeNotifications?.dispose();
webPush.dispose();
skillWorkshopRevision.clear();
initialUserMessage.clear();
},
};
}
+19
View File
@@ -58,6 +58,24 @@ export type ApplicationSkillWorkshopRevisionHandoff = {
clear: () => void;
};
export type ApplicationInitialUserMessage = {
role: "user";
content: unknown[];
timestamp: number;
};
type InitialUserMessageHandoff = {
message: ApplicationInitialUserMessage;
owner: object;
sessionKey: string;
};
export type ApplicationInitialUserMessageHandoff = {
prepare: (handoff: InitialUserMessageHandoff) => void;
read: (sessionKey: string, owner: object | null) => ApplicationInitialUserMessage | null;
clear: (sessionKey?: string) => void;
};
export type ApplicationContext<TRouteId extends string = string> = {
readonly basePath: string;
readonly gateway: ApplicationGateway;
@@ -76,6 +94,7 @@ export type ApplicationContext<TRouteId extends string = string> = {
readonly nativeNotifications: NativeNotificationsCapability | null;
readonly webPush: WebPushCapability;
readonly skillWorkshopRevision: ApplicationSkillWorkshopRevisionHandoff;
readonly initialUserMessage: ApplicationInitialUserMessageHandoff;
readonly navigate: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
readonly revalidate: (routeId?: TRouteId) => Promise<void>;
@@ -0,0 +1,50 @@
import { areUiSessionKeysEquivalent } from "../lib/sessions/session-key.ts";
import type { ApplicationInitialUserMessageHandoff } from "./context.ts";
// Terminal history removes normal entries; this cap bounds abandoned active-session handoffs.
const MAX_PENDING_INITIAL_USER_MESSAGES = 32;
export function createInitialUserMessageHandoff(): ApplicationInitialUserMessageHandoff {
const pending = new Map<
string,
Pick<Parameters<ApplicationInitialUserMessageHandoff["prepare"]>[0], "message" | "owner">
>();
const findKey = (sessionKey: string) => {
for (const candidate of pending.keys()) {
if (areUiSessionKeysEquivalent(candidate, sessionKey)) {
return candidate;
}
}
return undefined;
};
return {
prepare: (handoff) => {
const existingKey = findKey(handoff.sessionKey);
if (existingKey) {
pending.delete(existingKey);
}
pending.set(handoff.sessionKey, { message: handoff.message, owner: handoff.owner });
while (pending.size > MAX_PENDING_INITIAL_USER_MESSAGES) {
const oldestKey = pending.keys().next().value;
if (oldestKey === undefined) {
break;
}
pending.delete(oldestKey);
}
},
read: (sessionKey, owner) => {
const handoff = pending.get(findKey(sessionKey) ?? "");
return handoff && handoff.owner === owner ? handoff.message : null;
},
clear: (sessionKey) => {
if (sessionKey === undefined) {
pending.clear();
return;
}
const key = findKey(sessionKey);
if (key) {
pending.delete(key);
}
},
};
}
+69 -4
View File
@@ -228,6 +228,72 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
}
});
it("shows the initial prompt while the newly created session is still running", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessionKey = "agent:main:visible-initial-prompt";
const message = "keep this prompt visible while the agent works";
const activeOutputTimestamp = Date.now() + 60_000;
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": { key: sessionKey, runStarted: true },
"chat.history": {
messages: [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "active-tool-call",
name: "read",
arguments: { path: "SKILL.md" },
},
],
timestamp: activeOutputTimestamp,
__openclaw: { id: "active-assistant", seq: 2 },
},
{
role: "toolResult",
toolCallId: "active-tool-call",
toolName: "read",
content: [{ type: "text", text: "working" }],
timestamp: activeOutputTimestamp + 1,
__openclaw: { id: "active-tool-result", seq: 3 },
},
],
sessionId: "visible-initial-prompt",
sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" },
},
},
});
try {
await page.goto(`${server.baseUrl}new`);
await page.locator(".new-session-page__message").fill(message);
await page.getByRole("button", { name: "Start thread" }).click();
await page.waitForURL((url) => url.searchParams.get("session") === sessionKey, {
timeout: 30_000,
});
await gateway.waitForRequest("chat.history");
await page.getByText("SKILL.md", { exact: true }).waitFor();
await expect.poll(() => page.locator(".chat-group.user").textContent()).toContain(message);
const userRow = await page.locator(".chat-group.user").boundingBox();
const toolRow = await page.getByText("SKILL.md", { exact: true }).boundingBox();
expect(userRow).not.toBeNull();
expect(toolRow).not.toBeNull();
if (!userRow || !toolRow) {
throw new Error("expected visible prompt and tool rows");
}
expect(userRow.y).toBeLessThan(toolRow.y);
} finally {
await context.close();
}
});
it("waits for pasted image reads before enabling session creation", async () => {
const context = await browser.newContext({
locale: "en-US",
@@ -1695,7 +1761,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
}
});
it("creates a session while a canonical session refresh is pending", async () => {
it("navigates to a created session while canonical session refresh is pending", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
@@ -1761,12 +1827,11 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
agentId: "main",
message: "create during refresh",
});
expect(new URL(page.url()).pathname).toBe("/new");
await gateway.resolveDeferred("sessions.list", listResponse);
await expect
.poll(() => new URL(page.url()).search)
.toContain(`session=${encodeURIComponent(sessionKey)}`);
await gateway.resolveDeferred("sessions.list", listResponse);
} finally {
await context.close();
}
@@ -0,0 +1,51 @@
import { expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SessionsListResult } from "../../api/types.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import { createSessionCapability } from "./index.ts";
it("returns a created session before background list reconciliation finishes", async () => {
let resolveList: (result: SessionsListResult) => void = () => undefined;
const pendingList = new Promise<SessionsListResult>((resolve) => {
resolveList = resolve;
});
const key = "agent:main:created-in-background";
const request = vi.fn(async (method: string) => {
if (method === "sessions.create") {
return { key };
}
if (method === "sessions.list") {
return await pendingList;
}
throw new Error(`Unexpected request: ${method}`);
});
const client = { request } as unknown as GatewayBrowserClient;
const sessions = createSessionCapability({
snapshot: {
client,
connected: true,
hello: null,
assistantAgentId: "main",
sessionKey: "agent:main:main",
},
subscribe: () => () => undefined,
subscribeEvents: () => () => undefined,
});
const created = vi.fn();
sessions.subscribeCreated(created);
await expect(
sessions.createResult({ agentId: "main" }, { reconciliation: "background" }),
).resolves.toMatchObject({ key });
expect(created).not.toHaveBeenCalled();
resolveList({
ts: 2,
path: "(multiple)",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [{ key, kind: "direct", updatedAt: 2 }],
});
await waitForFast(() => expect(created).toHaveBeenCalledWith(key));
sessions.dispose();
});
+32 -10
View File
@@ -175,6 +175,8 @@ type SessionConnectionScope = {
epoch: number;
};
type SessionCreateReconciliation = "blocking" | "background";
type SessionMessageSubscription = {
key: string;
agentId?: string | null;
@@ -194,7 +196,10 @@ export type SessionCapability = {
reconcileRunTerminal: (terminal: SessionRunTerminal) => boolean;
refresh: (options?: SessionRefreshOptions) => Promise<void>;
refreshReplacement: (agentId?: string | null) => Promise<void>;
createResult: (params?: SessionCreateParams) => Promise<SessionCreateOutcome | null>;
createResult: (
params?: SessionCreateParams,
options?: { reconciliation?: SessionCreateReconciliation },
) => Promise<SessionCreateOutcome | null>;
create: (params?: SessionCreateParams) => Promise<string | null>;
patch: SessionPatchRoute;
setModelOverride: (key: string, value: string | null | undefined) => void;
@@ -960,7 +965,10 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
return refresh({ ...options, force: true });
};
const createResult = async (params: SessionCreateParams = {}) => {
const createResult = async (
params: SessionCreateParams = {},
options: { reconciliation?: SessionCreateReconciliation } = {},
) => {
const scope = captureConnection();
if (!scope) {
return null;
@@ -974,14 +982,28 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
if (!isCurrentConnection(scope)) {
return null;
}
await refreshReplacement(params.agentId);
if (!isCurrentConnection(scope)) {
return null;
}
// Creation may overlap read-only list loading. Notify presentation owners
// after its queued refresh so they never guess from stale list churn.
for (const listener of createdListeners) {
listener(result.key);
const reconcileCreatedSession = async () => {
await refreshReplacement(params.agentId);
if (!isCurrentConnection(scope)) {
return;
}
// Creation may overlap read-only list loading. Notify presentation owners
// after its queued refresh so they never guess from stale list churn.
for (const listener of createdListeners) {
listener(result.key);
}
};
if (options.reconciliation === "background") {
void reconcileCreatedSession().catch((error: unknown) => {
if (isCurrentConnection(scope)) {
publish({ ...state, error: String(error) });
}
});
} else {
await reconcileCreatedSession();
if (!isCurrentConnection(scope)) {
return null;
}
}
return result;
} catch (error) {
+12
View File
@@ -9,6 +9,7 @@ import type {
SessionBranch,
SessionsListResult,
} from "../../api/types.ts";
import type { ApplicationInitialUserMessageHandoff } from "../../app/context.ts";
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import {
isAssistantHeartbeatAckForDisplay,
@@ -58,6 +59,7 @@ import {
preserveOptimisticTailMessages,
readTranscriptSequence,
} from "./history-merge.ts";
import { reconcileInitialUserMessageHandoff } from "./initial-turn-handoff.ts";
import {
controlUiNowMs,
recordControlUiPerformanceEvent,
@@ -318,6 +320,7 @@ function collectLateOptimisticTailMessages(
export type ChatState = {
client: GatewayBrowserClient | null;
connected: boolean;
initialUserMessage?: ApplicationInitialUserMessageHandoff;
/** Monotonic owner epoch; reconnects can reuse the same client object. */
connectionEpoch: number;
sessionKey: string;
@@ -1272,6 +1275,15 @@ async function loadChatHistoryUncached(
if (lateOptimisticTail.length > 0) {
state.chatMessages = [...state.chatMessages, ...lateOptimisticTail];
}
if (state.initialUserMessage) {
reconcileInitialUserMessageHandoff(
state.initialUserMessage,
state,
sessionKey,
authoritativeMessages,
isSessionRunActive(res.sessionInfo ?? {}),
);
}
retireHistoryProvenSteeredChips(state);
state.chatHistoryPagination = reconciledHistory?.pagination ?? nextPagination;
state.currentSessionId = nextSessionId;
@@ -10,6 +10,7 @@ import type { ControlUiSessionPullRequest } from "../../../../src/gateway/contro
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts";
import type { CatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import "./chat-pane.ts";
@@ -105,6 +106,7 @@ export function createSessionContext(
terminalEnabled: false,
},
},
initialUserMessage: createInitialUserMessageHandoff(),
sessions,
} as unknown as ApplicationContext;
}
+2
View File
@@ -14,6 +14,7 @@ import type {
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts";
import { buildCatalogSessionKey, type CatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import {
@@ -85,6 +86,7 @@ function createInitializationContext(): ApplicationContext {
},
agentSelection: { state: { selectedId: "main" } },
agents: { state: { agentsList: null } },
initialUserMessage: createInitialUserMessageHandoff(),
sessions: {},
} as unknown as ApplicationContext;
}
+2
View File
@@ -220,6 +220,7 @@ import {
storedChatOutboxScopeKey,
} from "./composer-persistence.ts";
import { exportChatMarkdown } from "./export.ts";
import { admitInitialUserMessageHandoff } from "./initial-turn-handoff.ts";
import {
hasAbortableSessionRun,
reconcileStaleChatRunAfterSessionStatePublication,
@@ -2212,6 +2213,7 @@ class ChatPane extends OpenClawLightDomElement {
pageState.chatHistoryPagination = snapshot.pagination;
pageState.currentSessionId = snapshot.sessionId;
}
admitInitialUserMessageHandoff(pageState.initialUserMessage, pageState, initialSessionKey);
}
}
chatState.attach(pageState);
+2
View File
@@ -1,6 +1,7 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as assistantIdentity from "../../app/assistant-identity.ts";
import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts";
import {
buildFallbackSlashCommands,
replaceSlashCommands,
@@ -517,6 +518,7 @@ describe("route composer fallback", () => {
assistantAgentId: "main",
agentsList: { defaultId: "main", mainKey: "main" },
hello: null,
initialUserMessage: createInitialUserMessageHandoff(),
sessionKey: "agent:main:first",
chatMessage,
chatComposerFallbackByScope: {},
+4 -1
View File
@@ -111,7 +111,7 @@ import {
storedChatOutboxScopeKey,
type StoredChatOutboxScope,
} from "./composer-persistence.ts";
import { admitInitialTurnHandoff } from "./initial-turn-handoff.ts";
import { admitInitialTurnHandoff, admitInitialUserMessageHandoff } from "./initial-turn-handoff.ts";
import {
handleChatDraftChange,
handleChatInputHistoryKey,
@@ -183,6 +183,7 @@ export type ChatPageHost = ChatHost &
SessionWorkspaceHost &
BackgroundTasksHost & {
sessions: SessionCapability;
initialUserMessage: ApplicationContext["initialUserMessage"];
settings: UiSettings;
password: string;
onboarding: boolean;
@@ -552,6 +553,7 @@ export function resetChatStateForRouteSession(
// switchPaneSession requests an update only after adopting the new baseline.
syncVisibleChatQueueProjection(state, { requestUpdate: false });
const initialTurn = admitInitialTurnHandoff(state, sessionKey);
admitInitialUserMessageHandoff(state.initialUserMessage, state, sessionKey);
const { fallback } = resolveChatComposerMemoryFallback(state, sessionKey);
if (fallback) {
state.chatMessage = fallback.message;
@@ -1224,6 +1226,7 @@ export function createPageState(
const appConfig = context.config.current;
const state = {
sessions: context.sessions,
initialUserMessage: context.initialUserMessage,
settings,
password: "",
onboarding: false,
+30
View File
@@ -2341,6 +2341,36 @@ describe("buildCachedChatItems", () => {
expect(messageAt(groupAt(groups, 0), 1).duplicateCount).toBeUndefined();
});
it("hides a pending send after history accepts its idempotency key", () => {
const groups = messageGroups({
messages: [
{
role: "user",
content: "accepted prompt",
timestamp: 1,
__openclaw: { idempotencyKey: "accepted-run:user", seq: 1 },
},
],
queue: [
{
id: "pending-send-1",
text: "accepted prompt",
createdAt: 2,
sendRunId: "accepted-run",
sendSubmittedAtMs: 10,
sendState: "sending",
},
],
});
expect(groups).toHaveLength(1);
expect(groupAt(groups, 0).messages).toHaveLength(1);
expect(messageRecord(groupAt(groups, 0))["__openclaw"]).toMatchObject({
idempotencyKey: "accepted-run:user",
seq: 1,
});
});
it("keeps failed queued sends out of the thread", () => {
const groups = messageGroups({
queue: [
+12 -2
View File
@@ -46,6 +46,7 @@ import {
shouldRenderQueuedSendInThread,
} from "./chat-progress.ts";
import { getOrCreateSessionCacheValue } from "./session-cache.ts";
import { chatMessagesContainQueuedSend } from "./steer-lifecycle.ts";
import type { PlanStatus } from "./tool-stream.ts";
import { buildUserChatMessageContentBlocks } from "./user-message-content.ts";
@@ -1291,8 +1292,17 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
});
}
const queuedSends = Array.isArray(props.queue) ? props.queue : [];
const activeRunQueuedSends = queuedSends.filter((queued) => queued.sendState === "waiting-model");
const futureQueuedSends = queuedSends.filter((queued) => !activeRunQueuedSends.includes(queued));
// Once authoritative history carries the send id, that message owns the bubble.
// Keep the queue row for run progress and delivery retirement, but do not render both copies.
const threadQueuedSends = queuedSends.filter(
(queued) => !chatMessagesContainQueuedSend(history, queued, true),
);
const activeRunQueuedSends = threadQueuedSends.filter(
(queued) => queued.sendState === "waiting-model",
);
const futureQueuedSends = threadQueuedSends.filter(
(queued) => !activeRunQueuedSends.includes(queued),
);
const futureQueuedTimestamp = futureQueuedSends.reduce<number | null>(
(earliest, queued) =>
earliest == null ? queued.createdAt : Math.min(earliest, queued.createdAt),
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts";
import {
admitInitialUserMessageHandoff,
prepareInitialUserMessageHandoff,
reconcileInitialUserMessageHandoff,
} from "./initial-turn-handoff.ts";
describe("initial user message handoff", () => {
it("reprojects an accepted first prompt across state replacement until history owns it", () => {
const sessionKey = "agent:main:new-session";
const hello = {};
const handoff = createInitialUserMessageHandoff();
prepareInitialUserMessageHandoff(
handoff,
sessionKey,
{
text: "show this while the run is active",
createdAt: 123,
},
hello,
);
const otherSession = { chatMessages: [] as unknown[], hello };
expect(admitInitialUserMessageHandoff(handoff, otherSession, "agent:main:other")).toBe(false);
expect(otherSession.chatMessages).toEqual([]);
const createdSession = { chatMessages: [] as unknown[], hello };
expect(admitInitialUserMessageHandoff(handoff, createdSession, sessionKey)).toBe(true);
expect(createdSession.chatMessages).toEqual([
{
role: "user",
content: [{ type: "text", text: "show this while the run is active" }],
timestamp: 123,
},
]);
expect(admitInitialUserMessageHandoff(handoff, createdSession, sessionKey)).toBe(false);
const secondSessionKey = "agent:main:second-new-session";
prepareInitialUserMessageHandoff(
handoff,
secondSessionKey,
{
text: "keep the other active prompt too",
createdAt: 124,
},
hello,
);
const secondActiveSession = { chatMessages: [] as unknown[], hello };
expect(admitInitialUserMessageHandoff(handoff, secondActiveSession, secondSessionKey)).toBe(
true,
);
const assistantOutput = {
role: "assistant",
content: [{ type: "text", text: "already working" }],
};
const remountedSession = { chatMessages: [assistantOutput] as unknown[], hello };
expect(admitInitialUserMessageHandoff(handoff, remountedSession, sessionKey)).toBe(true);
expect(remountedSession.chatMessages).toEqual([
...createdSession.chatMessages,
assistantOutput,
]);
const persisted = {
role: "user",
content: [{ type: "text", text: "show this while the run is active" }],
__openclaw: { seq: 1 },
};
remountedSession.chatMessages = [persisted];
expect(
reconcileInitialUserMessageHandoff(handoff, remountedSession, sessionKey, [persisted], true),
).toBe(false);
const activeRunReset = { chatMessages: [] as unknown[], hello };
expect(admitInitialUserMessageHandoff(handoff, activeRunReset, sessionKey)).toBe(true);
activeRunReset.chatMessages = [persisted];
expect(
reconcileInitialUserMessageHandoff(handoff, activeRunReset, sessionKey, [persisted], false),
).toBe(false);
expect(admitInitialUserMessageHandoff(handoff, { chatMessages: [], hello }, sessionKey)).toBe(
false,
);
});
it("does not duplicate a first prompt that history already loaded", () => {
const sessionKey = "agent:main:main";
const routeSessionKey = "main";
const hello = {};
const handoff = createInitialUserMessageHandoff();
prepareInitialUserMessageHandoff(
handoff,
sessionKey,
{
text: "history won the race",
createdAt: 123,
},
hello,
);
const persisted = {
role: "user",
content: [{ type: "text", text: "history won the race" }],
__openclaw: { seq: 1 },
};
const createdSession = { chatMessages: [persisted] as unknown[], hello };
expect(
reconcileInitialUserMessageHandoff(
handoff,
createdSession,
routeSessionKey,
[persisted],
false,
),
).toBe(false);
expect(createdSession.chatMessages).toEqual([persisted]);
expect(
admitInitialUserMessageHandoff(handoff, { chatMessages: [], hello }, routeSessionKey),
).toBe(false);
});
it("does not expose a pending prompt after reconnecting", () => {
const sessionKey = "agent:main:new-session";
const originalConnection = {};
const handoff = createInitialUserMessageHandoff();
prepareInitialUserMessageHandoff(
handoff,
sessionKey,
{ text: "private prompt", createdAt: 123 },
originalConnection,
);
const replacementGatewaySession = { chatMessages: [] as unknown[], hello: {} };
expect(admitInitialUserMessageHandoff(handoff, replacementGatewaySession, sessionKey)).toBe(
false,
);
expect(replacementGatewaySession.chatMessages).toEqual([]);
});
});
+74 -1
View File
@@ -1,6 +1,13 @@
import type {
ApplicationInitialUserMessage,
ApplicationInitialUserMessageHandoff,
} from "../../app/context.ts";
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
import { releaseChatAttachmentPayloads } from "./attachment-payload-store.ts";
import {
getChatAttachmentDataUrl,
releaseChatAttachmentPayloads,
} from "./attachment-payload-store.ts";
import {
markLocalRecoveryItem,
markVolatileQueuedMessage,
@@ -8,6 +15,8 @@ import {
type ChatQueueScopedSessionHost,
writeChatQueueForScope,
} from "./chat-queue.ts";
import { messageDisplaySignature } from "./history-merge.ts";
import { buildUserChatMessageContentBlocks } from "./user-message-content.ts";
const INITIAL_TURN_HANDOFF_TTL_MS = 60_000;
@@ -37,6 +46,27 @@ export function prepareInitialTurnHandoff(sessionKey: string, item: ChatQueueIte
pending = { item, sessionKey, timer };
}
/** Hands the accepted first prompt to chat before transcript persistence catches up. */
export function prepareInitialUserMessageHandoff(
handoff: ApplicationInitialUserMessageHandoff,
sessionKey: string,
item: Pick<ChatQueueItem, "attachments" | "createdAt" | "text">,
owner: object,
): void {
const durableAttachments = item.attachments?.map((attachment) => {
const dataUrl = getChatAttachmentDataUrl(attachment);
return dataUrl ? { ...attachment, dataUrl, previewUrl: dataUrl } : attachment;
});
const message: ApplicationInitialUserMessage = {
role: "user",
content: buildUserChatMessageContentBlocks(item.text, durableAttachments),
timestamp: item.createdAt,
};
// Keep the projection until terminal history owns it so active first turns
// survive later pane/history resets.
handoff.prepare({ message, owner, sessionKey });
}
function consumeInitialTurnHandoff(sessionKey: string): ChatQueueItem | null {
if (!pending || !areUiSessionKeysEquivalent(pending.sessionKey, sessionKey)) {
return null;
@@ -62,3 +92,46 @@ export function admitInitialTurnHandoff(
markVolatileQueuedMessage(host, item.id);
return true;
}
export function admitInitialUserMessageHandoff(
handoff: ApplicationInitialUserMessageHandoff,
host: { chatMessages: unknown[]; hello?: object | null },
sessionKey: string,
): boolean {
const message = handoff.read(sessionKey, host.hello ?? null);
if (!message) {
return false;
}
const signature = messageDisplaySignature(message);
const matchingMessage = host.chatMessages.find(
(candidate) => signature && messageDisplaySignature(candidate) === signature,
);
if (matchingMessage) {
return false;
}
host.chatMessages = [message, ...host.chatMessages];
return true;
}
/** Keeps the accepted prompt projected until authoritative history owns it. */
export function reconcileInitialUserMessageHandoff(
handoff: ApplicationInitialUserMessageHandoff,
host: { chatMessages: unknown[]; hello?: object | null },
sessionKey: string,
authoritativeMessages: unknown[],
runActive: boolean,
): boolean {
const message = handoff.read(sessionKey, host.hello ?? null);
if (!message) {
return false;
}
const signature = messageDisplaySignature(message);
const historyOwnsMessage = authoritativeMessages.some(
(candidate) => signature && messageDisplaySignature(candidate) === signature,
);
if (historyOwnsMessage && !runActive) {
handoff.clear(sessionKey);
return false;
}
return admitInitialUserMessageHandoff(handoff, host, sessionKey);
}
+35 -2
View File
@@ -19,6 +19,7 @@ import "../../styles/chat.css";
import "../../styles/new-session.css";
import { buildChatApiAttachments, restoreChatApiAttachments } from "../chat/attachment-api.ts";
import { renderWelcomeState } from "../chat/components/chat-welcome.ts";
import { prepareInitialUserMessageHandoff } from "../chat/initial-turn-handoff.ts";
import { NewSessionAttachmentDraft } from "./attachment-draft.ts";
import * as catalog from "./catalog-target.ts";
import { CloudProfileDiscovery, selectProfiles } from "./cloud-profile-discovery.ts";
@@ -687,13 +688,15 @@ class NewSessionPage extends OpenClawLightDomElement {
? this.pendingCloud.gatewayUrl
: context.gateway.connection.gatewayUrl;
const submissionClient = context.gateway.snapshot.client;
if (!submissionClient) {
const submissionConnection = context.gateway.snapshot.hello;
if (!submissionClient || !submissionConnection) {
return;
}
const submissionRecoveryScope = pendingCloud
? this.pendingCloud.recoveryScope
: submissionClient.recoveryScope;
const requestId = ++this.submitRequestToken;
const submittedAt = Date.now();
this.submitting = true;
this.error = null;
// Retire hidden pickers before their late requests can mutate this submitted draft.
@@ -761,7 +764,9 @@ class NewSessionPage extends OpenClawLightDomElement {
const result =
pendingCloud && this.pendingCloud.phase !== "creating"
? { key: this.pendingCloud.sessionKey, initialRun: { status: "idle" as const } }
: await context.sessions.createResult(cloudCreateParams ?? createParams);
: await context.sessions.createResult(cloudCreateParams ?? createParams, {
reconciliation: "background",
});
if (requestId !== this.submitRequestToken && !cloudProfileId) {
return;
}
@@ -859,8 +864,24 @@ class NewSessionPage extends OpenClawLightDomElement {
this.error = cloudStart.error || t("newSession.createFailed");
return;
}
if (requestId !== this.submitRequestToken) {
return;
}
prepareInitialUserMessageHandoff(
context.initialUserMessage,
result.key,
{
text: submissionCloudRecovery.message,
attachments,
createdAt: submittedAt,
},
submissionConnection,
);
this.attachmentDraft.clearAfterSubmit(true);
} else {
if (requestId !== this.submitRequestToken) {
return;
}
const handedOffAttachments =
result.initialRun.status === "rejected" &&
retainRejectedInitialTurn({
@@ -871,6 +892,18 @@ class NewSessionPage extends OpenClawLightDomElement {
message,
sessionKey: result.key,
});
if (result.initialRun.status === "started") {
prepareInitialUserMessageHandoff(
context.initialUserMessage,
result.key,
{
text: message,
attachments,
createdAt: submittedAt,
},
submissionConnection,
);
}
this.attachmentDraft.clearAfterSubmit(!handedOffAttachments);
}
if (requestId !== this.submitRequestToken) {