diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index a70c68b5e9a2..2dd20b3c1fd9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"f17fda486bebaafacc259619d94b63f6b19420ef47a9740c7b35b954c9ee018e","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"41a07cb777101f78b3a8164d73f47748cd80d5f2321133c708ed5d95003a9ab7","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index e247070936bd..e71a41f4af7c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"c017cdb95283e766f3455b2e5a1c828a7227db87ec82e5f346d94682292cbbf7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"568034c69a1fa474f81e2265c8e662c3b9ceec534507efb4b7d9c1eb9b81effd","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 7c9d05a53834..e4a5e0d46119 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"5da887b8d2f6cb04bb968b1d78ebc6c18f3d9a6ccfd17cf7dd3f6ea6f9cfe3ca","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"3c680d482e06b4a63b28ab58d49ce51fb8b8cc63a1ed6c8217f0b103dd36bcfc","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index a01fa28319fb..356a6de9e32a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"f04ae840aa03ecffd6a5c617a206aeb468ab7e2663f146dee15e82a859ab94ea","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"805e8f91e1b3aa98bc9e36da3a4b4712163905a35128dd6e2063b55f794f52bd","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index e355823cae32..f8918cd5a287 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"da5462c33495c854121e954fe007c8bca17e3fd625f8fed130954705c62e1818","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"229d39c9439d973a1d15dde889bf44a76e5e6c3be94d57c6b0549ca576a56715","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index eb4e80659630..301f7b04cdf7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"71e2548711edb5372870a7afcef25547aae3d2484578c88610749e90ebe5f244","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"bdd1ca3b681b92bb24c2400ceffbb0512fad0c32494590791506336add02e9fa","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 5b726f8b5457..3f2cec096dd7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"20a70ea7ba78d5c618d82338e5901b73389313867458f67dbd95b3f6f91f4cbc","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"0f45949ab482e25e8ff5e5a8f2441ec3cc8c957167ebea95c9cbf97cfcab6c67","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 861dd18cf12a..3c40d1d4e6b9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"736070b02d2196ce75af349e9e71edcf464c8d43a01bdfd70c1b61abfac3e85f","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"e3865965bc52ad0f90ed014c2b042c8ec168b7f4a741e43d00ecc5b1c572c38a","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index ec923a568c3d..32f2f1774948 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"1770f99bd46d2ce7525fcd10cf143163efd1a01ef8a0a5cf22b097200477c762","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"f44cd357642ac0f5cdebf7f3fc04bcd7f27a1489b196fc8853481ec5b64d066f","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 8e1a7383539c..58f092458e03 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"511e572af05c931e92680e1a154c5705a3ef54b822db8990605403d9d5697e3f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"3a008094a2bbdad1073b0ee115ca4851f7b20f0e37357f72374381507af588d2","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index ae368704b7a2..9d94c5b038f2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"9ea9ae4e9fd1c35de0578f6c68722dd9c037430c8b5bb526d0f6266fdf7edb2c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"680ffed0e4c1746bc96698459aba18b351ba79debd9bf0b71fc27e3507a9726a","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 4c2820e013a4..4ff09789d623 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"bf1a5368e13eb0bdef48b175dfa26222272499e31e0bd13bc87dcef79708a869","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"58c0ebbc873452c02046ea1e508b8531bb3400742279d9760d74c38fe1ec7b1d","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 1e6d5cd3b9d6..d4d5f647b39c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"f62643baa25a9c3102e2173269ea301024a844e32dbfb7c39d122eca47a562dc","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"88a3ed709573b4becf0c3e7ba5b42e4ff3172fa1e97f72c937325c1113201390","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index e08221ab744b..62f7d99041a3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"ecf8ac0427d503d3c2f5c174cee58d8aa47192e66bdda123f34546d02e5146b2","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"c2471cffde0078f4a338127c3703dc3a58b396073e2ecd110a080d78a9df16be","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index e81db3bbe769..f5c5ebdf66e5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"9ee8b93b462bd6869a15ad42fa97aab32bd0a5e012b61f2e75355b898f85dc5b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"7415cc3250cbbddb89b9d003425affa38c769e132295abc2357a9f0f8801ae84","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index cab15ae2c440..ab3f8d4b81ff 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"9bcfbbe5a2ddf62272fd953b0687d4e6d34cb80aa5de23e485c468182b79d8e4","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"8ccf14dc4a41678e96508eb532401fbf9aff80061b0bcdd35073dcce24523536","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 36ec32188fb4..08d66a33bfd1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"5bb57a7c8f5f158c973540af418c5ea7df019622ad890e9f8741737e550c4109","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"6bef66f74b76d6e51ce26ac7c331d49199bc13af073ff3f10b9707a6d7b7565e","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 9b3cdb5b309e..fbb9199f1e5d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"8f23b9a7a62f9587e1a7da7adfcc9c2b17f59c3f86e8c8da342797172d16f504","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"f0f2a1217f4b48d50c9871628b6d1884231194cd52cffb076262b5492d1e49d4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/src/gateway/chat-display-projection.test.ts b/src/gateway/chat-display-projection.test.ts index 8ce83c718e93..7c7169b8e9d9 100644 --- a/src/gateway/chat-display-projection.test.ts +++ b/src/gateway/chat-display-projection.test.ts @@ -335,14 +335,15 @@ describe("oversized multimodal chat history", () => { }); }); -describe("private transcript metadata projection", () => { - it("keeps visible text while omitting oversized upstream prompt metadata", () => { +describe("transcript metadata projection", () => { + it("keeps display metadata while omitting oversized upstream prompt metadata", () => { const message = { role: "user", content: "Keep this visible user message.", __openclaw: { id: "message-1", mirrorIdentity: "turn-1:prompt", + replyToId: "message-0", upstreamUserText: "private decorated prompt ".repeat(12_000), }, }; @@ -354,6 +355,7 @@ describe("private transcript metadata projection", () => { __openclaw: { id: "message-1", mirrorIdentity: "turn-1:prompt", + replyToId: "message-0", }, }, ]); diff --git a/src/gateway/server-methods/chat-restart-recovery.ts b/src/gateway/server-methods/chat-restart-recovery.ts index a9a50a26c231..7e202def1c55 100644 --- a/src/gateway/server-methods/chat-restart-recovery.ts +++ b/src/gateway/server-methods/chat-restart-recovery.ts @@ -38,7 +38,7 @@ type RestartSafeChatRequest = { fingerprint: string; }; -export type RestartSafeChatAdmission = { +type RestartSafeChatAdmission = { priorTerminalSourceRunId?: string; requestFingerprint: string; retryExpectedState?: SessionTranscriptTurnExpectedState; diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index 23db654704a4..0b829d886096 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -20,7 +20,6 @@ import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js"; import { updateChatRunProvider } from "../chat-abort.js"; import type { ChatRunTiming } from "../server-chat-state.js"; import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js"; -import { hasGatewayAdminScope } from "./chat-origin-routing.js"; import { terminalizeRestartSafeChatAdmission } from "./chat-restart-recovery.js"; import { prepareChatSendAttachments } from "./chat-send-attachments.js"; import { @@ -38,10 +37,7 @@ import { settleChatSendPreAckMessageInjection, } from "./chat-send-message-injection.js"; import { finalizeChatSendNonAgentReplies } from "./chat-send-nonagent-finalization.js"; -import { - applyChatSendReplyContextFields, - resolveChatSendReplyContext, -} from "./chat-send-reply-context.js"; +import { applyChatSendReplyContextFields } from "./chat-send-reply-context.js"; import { createChatSendReplyDispatch } from "./chat-send-reply-dispatch.js"; import { prepareAndAdmitChatSend } from "./chat-send-setup.js"; import { finalizeChatSendSourceReplies } from "./chat-send-source-finalization.js"; @@ -55,7 +51,6 @@ import { type ChatSendServerTimingPhase, } from "./chat-server-timing.js"; import { createGatewayChatUserTurnController } from "./chat-user-turn-recorder.js"; -import { gatewayClientSenderFields } from "./gateway-client-identity.js"; import { emitSessionsChanged } from "./session-change-event.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; @@ -78,7 +73,6 @@ export async function handleChatSend( supportsTaskSuggestions, p, systemInputProvenance, - rawMessage, reconnectResumeRequested, } = normalizedRequest.value; const { @@ -98,7 +92,6 @@ export async function handleChatSend( expectedLeafEntryId, expectedRunId, resolvedSessionModel, - now, } = preparedSession.value; const { activeRunAbort, @@ -162,39 +155,29 @@ export async function handleChatSend( storePath, ...terminalState, }); - try { const userTurn = createGatewayChatUserTurnController({ - agentId, - cfg, - clientRunId, - initialSessionId: admittedSessionId, - now, - ...(systemInputProvenance ? { provenance: systemInputProvenance } : {}), - rawMessage, - ...(restartSafeAdmission ? { restartAdmission: restartSafeAdmission } : {}), - ...gatewayClientSenderFields(client), - senderIsOwner: hasGatewayAdminScope(client), - sessionKey, - ...(sessionLoadOptions ? { sessionLoadOptions } : {}), + admission: admitted.value, + client, + request: normalizedRequest.value, + session: preparedSession.value, startedAt: admissionStartedAt, - traceAttributes: chatSendTraceAttributes, warn: (message) => context.logGateway.warn(message), }); const { persist: persistGatewayUserTurnTranscript, persistBestEffort: persistGatewayUserTurnTranscriptBestEffort, recorder: userTurnRecorder, + replyContextFieldsPromise, } = userTurn; if (restartSafeAdmission) { const persistedUserTurn = await persistGatewayUserTurnTranscript(); - const admittedEntry = persistedUserTurn?.sessionEntry; // A matching idempotency row and lifecycle claim commit atomically, so // retries adopt the durable turn without submitting it twice. if ( !persistedUserTurn || - admittedEntry?.status !== "running" || - admittedEntry.restartRecoveryDeliveryRunId !== clientRunId + persistedUserTurn.sessionEntry?.status !== "running" || + persistedUserTurn.sessionEntry.restartRecoveryDeliveryRunId !== clientRunId ) { throw new Error("chat turn was not durably admitted"); } @@ -251,18 +234,6 @@ export async function handleChatSend( imageOrder, userTurnTranscriptRecorder: userTurnRecorder, }); - const replyContextFieldsPromise = p.replyToId - ? resolveChatSendReplyContext({ - replyToId: p.replyToId, - cfg, - agentId, - sessionKey, - sessionEntry: entry, - storePath, - userSenderLabel: clientInfo?.displayName, - warn: (message) => context.logGateway.warn(message), - }) - : undefined; const preAckReplyContextPromise = messageInjectionTarget && !isInternalTextSlashCommandTurn ? replyContextFieldsPromise diff --git a/src/gateway/server-methods/chat-send-reply-context.ts b/src/gateway/server-methods/chat-send-reply-context.ts index dbc4dc18744e..fba639492030 100644 --- a/src/gateway/server-methods/chat-send-reply-context.ts +++ b/src/gateway/server-methods/chat-send-reply-context.ts @@ -19,10 +19,21 @@ import { // reply to a huge transcript entry cannot flood the prompt metadata. const REPLY_CONTEXT_BODY_MAX_CHARS = 2000; -type ChatSendReplyContextFields = Partial< +export type ChatSendReplyContextFields = Partial< Pick >; +type ChatSendReplyContextParams = { + replyToId: string | undefined; + cfg: OpenClawConfig; + agentId?: string; + sessionKey: string; + sessionEntry?: SessionTranscriptReadScope["sessionEntry"]; + storePath: string | undefined; + userSenderLabel?: string; + warn?: (message: string) => void; +}; + /** Adds hydrated reply metadata to the direct-injection user prompt. */ export function buildChatSendReplyInjectionText(params: { body: string; @@ -96,16 +107,9 @@ export function applyChatSendReplyContextFields( * reply_to_id linkage; body/sender hydrate only when the transcript message * still resolves, mirroring Discord's missing-referenced-message tolerance. */ -export async function resolveChatSendReplyContext(params: { - replyToId: string | undefined; - cfg: OpenClawConfig; - agentId?: string; - sessionKey: string; - sessionEntry?: SessionTranscriptReadScope["sessionEntry"]; - storePath: string | undefined; - userSenderLabel?: string; - warn?: (message: string) => void; -}): Promise { +export async function resolveChatSendReplyContext( + params: ChatSendReplyContextParams, +): Promise { const replyToId = params.replyToId?.trim(); if (!replyToId) { return {}; diff --git a/src/gateway/server-methods/chat-user-turn-recorder.ts b/src/gateway/server-methods/chat-user-turn-recorder.ts index 2c4ae567437a..699134f40f30 100644 --- a/src/gateway/server-methods/chat-user-turn-recorder.ts +++ b/src/gateway/server-methods/chat-user-turn-recorder.ts @@ -1,7 +1,5 @@ import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js"; -import type { InputProvenance } from "../../sessions/input-provenance.js"; import { buildRunUserTurnIdempotencyKey, createUserTurnTranscriptRecorder, @@ -10,56 +8,82 @@ import { } from "../../sessions/user-turn-transcript.js"; import { loadSessionEntry } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; +import { hasGatewayAdminScope } from "./chat-origin-routing.js"; +import { buildRestartSafeChatTranscriptState } from "./chat-restart-recovery.js"; +import type { AdmittedChatSend } from "./chat-send-admission.js"; import { - buildRestartSafeChatTranscriptState, - type RestartSafeChatAdmission, -} from "./chat-restart-recovery.js"; - -type DiagnosticsAttributes = Record; + resolveChatSendReplyContext, + type ChatSendReplyContextFields, +} from "./chat-send-reply-context.js"; +import type { NormalizedChatSendRequest } from "./chat-send-request.js"; +import type { PreparedChatSendSession } from "./chat-send-session.js"; +import { gatewayClientSenderFields } from "./gateway-client-identity.js"; +import type { GatewayClient } from "./shared-types.js"; type GatewayChatUserTurnController = { baseInput: UserTurnInput; persist: ReturnType["persistFallback"]; persistBestEffort: () => Promise; recorder: UserTurnTranscriptRecorder; + replyContextFieldsPromise?: Promise; setAcceptedSessionId: (sessionId: string) => void; setInputPromise: (input: Promise) => void; }; export function createGatewayChatUserTurnController(params: { - agentId: string; - cfg: OpenClawConfig; - clientRunId: string; - initialSessionId: string; - now: number; - provenance?: InputProvenance; - rawMessage: string; - restartAdmission?: RestartSafeChatAdmission; - sender?: UserTurnInput["sender"]; - senderIsOwner: boolean; - sessionKey: string; - sessionLoadOptions?: { agentId?: string; clone?: boolean }; + admission: AdmittedChatSend; + client: GatewayClient | null; + request: NormalizedChatSendRequest; + session: PreparedChatSendSession; startedAt: number; - traceAttributes: DiagnosticsAttributes; warn: (message: string) => void; }): GatewayChatUserTurnController { + const { admission, request, session } = params; + const sender = gatewayClientSenderFields(params.client).sender; const baseInput: UserTurnInput = { - text: params.rawMessage, - timestamp: params.now, - idempotencyKey: buildRunUserTurnIdempotencyKey(params.clientRunId), - ...(params.sender ? { sender: params.sender } : {}), - ...(params.senderIsOwner ? { senderIsOwner: true } : {}), - ...(params.provenance ? { provenance: params.provenance } : {}), + text: request.rawMessage, + timestamp: session.now, + idempotencyKey: buildRunUserTurnIdempotencyKey(session.clientRunId), + ...(request.p.replyToId ? { replyToId: request.p.replyToId } : {}), + ...(sender ? { sender } : {}), + ...(hasGatewayAdminScope(params.client) ? { senderIsOwner: true } : {}), + ...(request.systemInputProvenance ? { provenance: request.systemInputProvenance } : {}), }; - let inputPromise = Promise.resolve(baseInput); - let acceptedSessionId = params.initialSessionId; + const replyContextFieldsPromise = request.p.replyToId + ? resolveChatSendReplyContext({ + replyToId: request.p.replyToId, + cfg: session.cfg, + agentId: session.agentId, + sessionKey: session.sessionKey, + sessionEntry: session.entry, + storePath: session.storePath, + userSenderLabel: request.clientInfo?.displayName, + warn: params.warn, + }) + : undefined; + let inputPromise = replyContextFieldsPromise + ? replyContextFieldsPromise.then( + (fields): UserTurnInput => ({ + ...baseInput, + ...(fields.ReplyToBody + ? { + replyToPreview: { + text: fields.ReplyToBody, + ...(fields.ReplyToSender ? { senderLabel: fields.ReplyToSender } : {}), + }, + } + : {}), + }), + ) + : Promise.resolve(baseInput); + let acceptedSessionId = admission.admittedSessionId; const recorder = createUserTurnTranscriptRecorder({ input: baseInput, resolveInput: () => inputPromise, target: () => { const { storePath, store, entry } = loadSessionEntry( - params.sessionKey, - params.sessionLoadOptions, + session.sessionKey, + session.sessionLoadOptions, ); if (!entry?.sessionId || entry.sessionId !== acceptedSessionId) { return undefined; @@ -67,18 +91,18 @@ export function createGatewayChatUserTurnController(params: { return { sessionId: entry.sessionId, expectedSessionId: entry.sessionId, - sessionKey: params.sessionKey, + sessionKey: session.sessionKey, sessionEntry: entry, sessionStore: store, storePath, - agentId: params.agentId, - config: params.cfg, + agentId: session.agentId, + config: session.cfg, }; }, - ...(params.restartAdmission + ...(admission.restartSafeAdmission ? buildRestartSafeChatTranscriptState({ - admission: params.restartAdmission, - clientRunId: params.clientRunId, + admission: admission.restartSafeAdmission, + clientRunId: session.clientRunId, startedAt: params.startedAt, }) : {}), @@ -93,8 +117,8 @@ export function createGatewayChatUserTurnController(params: { () => recorder.persistFallback(), { phase: "agent-turn", - config: params.cfg, - attributes: params.traceAttributes, + config: session.cfg, + attributes: admission.chatSendTraceAttributes, }, ); return { @@ -104,11 +128,16 @@ export function createGatewayChatUserTurnController(params: { await persist().catch(() => undefined); }, recorder, + replyContextFieldsPromise, setAcceptedSessionId: (sessionId) => { acceptedSessionId = sessionId; }, setInputPromise: (input) => { - inputPromise = input; + const previousInputPromise = inputPromise; + inputPromise = Promise.all([previousInputPromise, input]).then(([previous, next]) => ({ + ...previous, + ...next, + })); }, }; } diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 654319ddee70..3bb6388a5d0c 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -1913,6 +1913,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(mockState.messageReceivedCalls).toHaveLength(1); expect(readPersistedUserMessages()).toHaveLength(1); + expect(readPersistedUserMessages()[0]?.["__openclaw"]).toMatchObject({ + replyToId: "prior-message", + replyToPreview: { + text: "quoted deployment status", + senderLabel: "Alice", + }, + }); expect(auditEvents.filter((event) => event.reasonCode === "active_run_injected")).toHaveLength( 1, ); diff --git a/src/sessions/user-turn-transcript.metadata.ts b/src/sessions/user-turn-transcript.metadata.ts new file mode 100644 index 000000000000..da6263a27655 --- /dev/null +++ b/src/sessions/user-turn-transcript.metadata.ts @@ -0,0 +1,71 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import type { UserTurnInput } from "./user-turn-transcript.types.js"; + +const REPLY_PREVIEW_TEXT_MAX_CHARS = 2000; +const REPLY_PREVIEW_SENDER_MAX_CHARS = 200; + +function buildUserTurnSenderMeta( + sender: UserTurnInput["sender"], +): Record | undefined { + const senderId = normalizeOptionalString(sender?.id); + const senderName = normalizeOptionalString(sender?.name); + const senderUsername = normalizeOptionalString(sender?.username); + if (!senderId && !senderName && !senderUsername) { + return undefined; + } + return { + ...(senderId ? { senderId } : {}), + ...(senderName ? { senderName } : {}), + ...(senderUsername ? { senderUsername } : {}), + }; +} + +export function buildPersistedUserTurnMetadata( + input: UserTurnInput, + normalizedMedia: readonly unknown[], +): Record { + const replyToId = normalizeOptionalString(input.replyToId); + const replyPreviewText = normalizeOptionalString(input.replyToPreview?.text); + const replyPreviewSender = normalizeOptionalString(input.replyToPreview?.senderLabel); + return { + // Privileged synthetic handoffs may execute owner tools but never author trusted memory. + ...(input.senderIsOwner === undefined + ? {} + : { + senderIsOwner: + input.senderIsOwner && (!input.provenance || input.provenance.kind === "external_user"), + }), + ...buildUserTurnSenderMeta(input.sender), + ...(replyToId ? { replyToId } : {}), + ...(replyPreviewText + ? { + replyToPreview: { + text: truncateUtf16Safe(replyPreviewText, REPLY_PREVIEW_TEXT_MAX_CHARS), + ...(replyPreviewSender + ? { + senderLabel: truncateUtf16Safe( + replyPreviewSender, + REPLY_PREVIEW_SENDER_MAX_CHARS, + ), + } + : {}), + }, + } + : {}), + ...(input.transport ? { transport: input.transport } : {}), + ...(normalizedMedia.length > 0 ? { media: normalizedMedia } : {}), + ...(input.mediaImageLayout + ? { + mediaImageLayout: { + slots: input.mediaImageLayout.slots.map((slot) => ({ ...slot })), + ...(input.mediaImageLayout.suppressedFactIndexes?.length + ? { + suppressedFactIndexes: [...input.mediaImageLayout.suppressedFactIndexes], + } + : {}), + }, + } + : {}), + }; +} diff --git a/src/sessions/user-turn-transcript.persistence.test.ts b/src/sessions/user-turn-transcript.persistence.test.ts index b17b0d9229e3..ce81ace871f1 100644 --- a/src/sessions/user-turn-transcript.persistence.test.ts +++ b/src/sessions/user-turn-transcript.persistence.test.ts @@ -310,6 +310,8 @@ describe("persistUserTurnTranscript", () => { input: { text: "secret prompt", idempotencyKey: "chat-run-1:user", + replyToId: "transcript-reply-1", + replyToPreview: { text: "Original reply", senderLabel: "Molty" }, senderIsOwner: true, provenance, sender: { id: "user-42", name: "Ada" }, @@ -327,6 +329,8 @@ describe("persistUserTurnTranscript", () => { input: { text: "secret prompt", idempotencyKey: "chat-run-1:user", + replyToId: "transcript-reply-1", + replyToPreview: { text: "Original reply", senderLabel: "Molty" }, senderIsOwner: true, provenance, sender: { id: "user-42", name: "Ada" }, @@ -348,6 +352,8 @@ describe("persistUserTurnTranscript", () => { provenance, __openclaw: { hookOwned: true, + replyToId: "transcript-reply-1", + replyToPreview: { text: "Original reply", senderLabel: "Molty" }, senderIsOwner: false, transport: { channel: "reef", diff --git a/src/sessions/user-turn-transcript.ts b/src/sessions/user-turn-transcript.ts index 2880f77c1ab6..858caa91d9f4 100644 --- a/src/sessions/user-turn-transcript.ts +++ b/src/sessions/user-turn-transcript.ts @@ -18,6 +18,7 @@ import { normalizeStructuredMediaEntryForTranscript, resolveTranscriptMediaPath, } from "./user-turn-transcript.media-normalize.js"; +import { buildPersistedUserTurnMetadata } from "./user-turn-transcript.metadata.js"; import type { CreateUserTurnTranscriptRecorderParams, PersistUserTurnTranscriptParams, @@ -59,11 +60,7 @@ function resolveTranscriptMediaType(params: { export function buildPersistedUserTurnMediaInputsFromFields( fields: PersistedUserTurnMessage | null | undefined, ): PersistedUserTurnMediaInput[] { - if (!fields) { - return []; - } - - const facts = readPersistedMediaFacts(fields) ?? []; + const facts = fields ? (readPersistedMediaFacts(fields) ?? []) : []; const normalizedMedia = facts.map((fact) => { const rawPath = normalizeOptionalString(fact.path); const mediaPath = rawPath @@ -123,26 +120,9 @@ export function buildLateMediaAttachedProjection(message: AgentMessage): { return { ...(text ? { text } : {}), media }; } -function buildUserTurnSenderMeta( - sender: UserTurnInput["sender"], -): Record | undefined { - const senderId = normalizeOptionalString(sender?.id); - const senderName = normalizeOptionalString(sender?.name); - const senderUsername = normalizeOptionalString(sender?.username); - if (!senderId && !senderName && !senderUsername) { - return undefined; - } - return { - ...(senderId ? { senderId } : {}), - ...(senderName ? { senderName } : {}), - ...(senderUsername ? { senderUsername } : {}), - }; -} - function readOpenClawMessageMeta(message: AgentMessage): Record | undefined { return asOptionalRecord((message as unknown as Record)["__openclaw"]); } - export function buildPersistedUserTurnMessage(params: UserTurnInput): PersistedUserTurnMessage { const normalizedMedia = (params.media ?? []).map(normalizeStructuredMediaEntryForTranscript); const text = params.text ?? ""; @@ -152,32 +132,7 @@ export function buildPersistedUserTurnMessage(params: UserTurnInput): PersistedU // every historical turn serialize identically on the wire. Persisting a stamp // here would NOT match the bare-current arrival (the gateway no longer stamps // the live turn) — see https://github.com/openclaw/openclaw/issues/3658. - const senderMeta = buildUserTurnSenderMeta(params.sender); - const openClawMeta = { - // Privileged synthetic handoffs may execute owner tools but never author trusted memory. - ...(params.senderIsOwner === undefined - ? {} - : { - senderIsOwner: - params.senderIsOwner && - (!params.provenance || params.provenance.kind === "external_user"), - }), - ...senderMeta, - ...(params.transport ? { transport: params.transport } : {}), - ...(normalizedMedia.length > 0 ? { media: normalizedMedia } : {}), - ...(params.mediaImageLayout - ? { - mediaImageLayout: { - slots: params.mediaImageLayout.slots.map((slot) => ({ ...slot })), - ...(params.mediaImageLayout.suppressedFactIndexes?.length - ? { - suppressedFactIndexes: [...params.mediaImageLayout.suppressedFactIndexes], - } - : {}), - }, - } - : {}), - }; + const openClawMeta = buildPersistedUserTurnMetadata(params, normalizedMedia); const message = { role: "user", content: text, @@ -314,12 +269,23 @@ export function preparePersistedUserTurnMessageForTranscriptWrite( const provenance = normalizeInputProvenance( (message as unknown as { provenance?: unknown }).provenance, ); - const senderIsOwner = readOpenClawMessageMeta(message)?.senderIsOwner; - const originalTransport = readOpenClawMessageMeta(message)?.transport; - const lateMedia = readOpenClawMessageMeta(message)?.lateMedia === true; - const originalMedia = readOpenClawMessageMeta(message)?.media; + const originalMeta = readOpenClawMessageMeta(message); + const senderIsOwner = originalMeta?.senderIsOwner; + const replyToId = normalizeOptionalString(originalMeta?.replyToId); + const originalReplyPreview = asOptionalRecord(originalMeta?.replyToPreview); + const replyPreviewText = normalizeOptionalString(originalReplyPreview?.text); + const replyPreviewSender = normalizeOptionalString(originalReplyPreview?.senderLabel); + const replyToPreview = replyPreviewText + ? { + text: replyPreviewText, + ...(replyPreviewSender ? { senderLabel: replyPreviewSender } : {}), + } + : undefined; + const originalTransport = originalMeta?.transport; + const lateMedia = originalMeta?.lateMedia === true; + const originalMedia = originalMeta?.media; const media = Array.isArray(originalMedia) ? structuredClone(originalMedia) : undefined; - const originalMediaImageLayout = readOpenClawMessageMeta(message)?.mediaImageLayout; + const originalMediaImageLayout = originalMeta?.mediaImageLayout; const mediaImageLayout = originalMediaImageLayout === undefined ? undefined : structuredClone(originalMediaImageLayout); // Hooks receive the original message object and may mutate nested metadata in @@ -340,6 +306,8 @@ export function preparePersistedUserTurnMessageForTranscriptWrite( if ( !idempotencyKey && typeof senderIsOwner !== "boolean" && + !replyToId && + !replyToPreview && !transport && !lateMedia && media === undefined && @@ -350,6 +318,8 @@ export function preparePersistedUserTurnMessageForTranscriptWrite( const protectedMeta = { ...readOpenClawMessageMeta(nextUserMessage), ...(typeof senderIsOwner === "boolean" ? { senderIsOwner } : {}), + ...(replyToId ? { replyToId } : {}), + ...(replyToPreview ? { replyToPreview } : {}), ...(transport ? { transport } : {}), ...(lateMedia ? { lateMedia: true } : {}), ...(media === undefined ? {} : { media }), diff --git a/src/sessions/user-turn-transcript.types.ts b/src/sessions/user-turn-transcript.types.ts index 507087875361..4f1f6bf2bb8b 100644 --- a/src/sessions/user-turn-transcript.types.ts +++ b/src/sessions/user-turn-transcript.types.ts @@ -45,6 +45,10 @@ export type UserTurnInput = { } | null; timestamp?: number; idempotencyKey?: string; + /** Durable transcript message reference used to render and hydrate replies. */ + replyToId?: string; + /** Bounded display fallback for replies whose target is outside loaded history. */ + replyToPreview?: { text: string; senderLabel?: string | null } | null; senderIsOwner?: boolean; provenance?: InputProvenance; /** Durable participant attribution. Callers must opt in at the product boundary. */ diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 9910d4c7739e..00680811dce6 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4970,6 +4970,7 @@ export const en: TranslationMap = { reply: "Reply", replyToMessage: "Reply to message", replyingTo: "Replying to {name}", + originalUnavailable: "The original message is unavailable.", message: "message", currentMessage: "current message", actions: "Message actions", diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 3c5c6895f5e8..8ba8074fffad 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -185,6 +185,7 @@ export type NormalizedMessage = { senderLabel?: string | null; sender?: SenderIdentity; audioAsVoice?: boolean; + replyPreview?: { text: string; senderLabel?: string | null }; replyTarget?: | { kind: "current"; diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index d7fe2dd59303..01bca21f38a9 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -623,6 +623,22 @@ export function normalizeMessage(message: unknown): NormalizedMessage { rawOpenClawMeta && typeof rawOpenClawMeta === "object" && !Array.isArray(rawOpenClawMeta) ? (rawOpenClawMeta as Record) : undefined; + const structuredReplyToId = + typeof openClawMeta?.replyToId === "string" ? openClawMeta.replyToId.trim() : ""; + if (structuredReplyToId) { + replyTarget = { kind: "id", id: structuredReplyToId }; + } + const rawReplyPreview = openClawMeta?.replyToPreview; + const replyPreviewRecord = + rawReplyPreview && typeof rawReplyPreview === "object" && !Array.isArray(rawReplyPreview) + ? (rawReplyPreview as Record) + : undefined; + const replyPreviewText = + typeof replyPreviewRecord?.text === "string" ? replyPreviewRecord.text.trim() : ""; + const replyPreviewSender = + typeof replyPreviewRecord?.senderLabel === "string" + ? replyPreviewRecord.senderLabel.trim() + : ""; const metaSender = normalizeSenderIdentity({ id: openClawMeta?.senderId, name: openClawMeta?.senderName, @@ -658,6 +674,14 @@ export function normalizeMessage(message: unknown): NormalizedMessage { senderLabel, ...(sender ? { sender } : {}), ...(audioAsVoice ? { audioAsVoice: true } : {}), + ...(replyPreviewText + ? { + replyPreview: { + text: replyPreviewText, + ...(replyPreviewSender ? { senderLabel: replyPreviewSender } : {}), + }, + } + : {}), ...(replyTarget ? { replyTarget } : {}), }; } diff --git a/ui/src/pages/chat/chat-pane-history.test.ts b/ui/src/pages/chat/chat-pane-history.test.ts index 254dec4425e2..abdec520e7c0 100644 --- a/ui/src/pages/chat/chat-pane-history.test.ts +++ b/ui/src/pages/chat/chat-pane-history.test.ts @@ -34,15 +34,21 @@ type TestChatPane = HTMLElement & { syncHistoryObserver: () => void; prependUniqueNativeMessages: (messages: unknown[], current: unknown[]) => unknown[]; prependUniqueCatalogMessages: (messages: unknown[]) => unknown[]; - loadOlderMessages: () => Promise; + loadOlderMessages: () => Promise; + requestReplyMessage: (messageId: string) => void; + readReplyMessage: (messageId: string) => unknown; + openReplyMessage: (messageId: string) => void; + currentReplyNavigationId: (sessionKey: string) => string | null; hasOlderMessages: () => boolean; loadingOlder: boolean; olderOffsetsSeen: Set; resetOlderMessagesViewport: () => void; + readonly updateComplete: Promise; transcriptScrollTop: number | null; transcript: { activeSessionKey: string | null; pendingScrollOffsetFor: (sessionKey: string) => number | null; + revealMessage: (messageId: string) => boolean; }; }; @@ -160,6 +166,114 @@ function nativeHistorySeq(message: unknown): number | undefined { } describe("chat pane native history pagination", () => { + it("resolves an unloaded reply preview through chat.message.get", async () => { + const message = { + role: "assistant", + content: "Original answer", + __openclaw: { id: "source-message" }, + }; + const request = vi.fn().mockResolvedValue({ ok: true, message }); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.assistantAgentId = "main"; + + pane.requestReplyMessage("source-message"); + + await vi.waitFor(() => expect(pane.readReplyMessage("source-message")).toBe(message)); + expect(request).toHaveBeenCalledWith("chat.message.get", { + sessionKey: state.sessionKey, + messageId: "source-message", + maxChars: 500, + }); + }); + + it("pages backward until a clicked reply target is loaded, then reveals it", async () => { + const target = { + ...nativeHistoryMessage(1, "Original answer"), + __openclaw: { id: "source-message", seq: 1 }, + }; + const request = vi + .fn() + .mockResolvedValueOnce({ + messages: [nativeHistoryMessage(3), nativeHistoryMessage(4)], + hasMore: true, + nextOffset: 4, + totalMessages: 6, + }) + .mockResolvedValueOnce({ + messages: [target, nativeHistoryMessage(2)], + hasMore: false, + totalMessages: 6, + }); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatMessages = [nativeHistoryMessage(5), nativeHistoryMessage(6)]; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 6 }; + vi.spyOn(pane, "updateComplete", "get").mockReturnValue(Promise.resolve(true)); + const revealMessage = vi.spyOn(pane.transcript, "revealMessage").mockReturnValue(true); + + pane.openReplyMessage("source-message"); + + expect(pane.currentReplyNavigationId(state.sessionKey)).toBe("source-message"); + await vi.waitFor(() => expect(revealMessage).toHaveBeenCalledWith("source-message")); + expect(request).toHaveBeenNthCalledWith(1, "chat.history", { + sessionKey: state.sessionKey, + limit: 100, + offset: 2, + }); + expect(request).toHaveBeenNthCalledWith(2, "chat.history", { + sessionKey: state.sessionKey, + limit: 100, + offset: 4, + }); + expect(pane.currentReplyNavigationId(state.sessionKey)).toBeNull(); + }); + + it("abandons reply navigation when the pane switches sessions", async () => { + const deferred = createDeferred<{ + messages: unknown[]; + hasMore: boolean; + totalMessages: number; + }>(); + const request = vi.fn(() => deferred.promise); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + const revealMessage = vi.spyOn(pane.transcript, "revealMessage"); + + pane.openReplyMessage("source-message"); + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + state.sessionKey = "agent:main:other"; + pane.resetOlderMessagesViewport(); + deferred.resolve({ messages: [], hasMore: false, totalMessages: 4 }); + await deferred.promise; + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(pane.currentReplyNavigationId(state.sessionKey)).toBeNull(); + expect(revealMessage).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledOnce(); + }); + + it("reports an unavailable reply after history is exhausted", async () => { + const request = vi.fn().mockResolvedValue({ + messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], + hasMore: false, + totalMessages: 4, + }); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + + pane.openReplyMessage("missing-message"); + + await vi.waitFor(() => expect(state.lastError).toBe("The original message is unavailable.")); + expect(pane.currentReplyNavigationId(state.sessionKey)).toBeNull(); + }); + it("does not request older rows from a complete imported snapshot", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); diff --git a/ui/src/pages/chat/chat-pane-history.ts b/ui/src/pages/chat/chat-pane-history.ts index 64333d58c0b2..24527f1d826f 100644 --- a/ui/src/pages/chat/chat-pane-history.ts +++ b/ui/src/pages/chat/chat-pane-history.ts @@ -1,8 +1,12 @@ -import type { SessionsCatalogContinueResult } from "../../../../packages/gateway-protocol/src/index.js"; +import type { + ChatMessageGetResult, + SessionsCatalogContinueResult, +} from "../../../../packages/gateway-protocol/src/index.js"; import { COMMAND_PALETTE_TARGET_EVENT, type CommandPaletteTargetDetail, } from "../../components/command-palette-contract.ts"; +import { t } from "../../i18n/index.ts"; import { announceCatalogSessionContinued, parseCatalogSessionKey, @@ -32,6 +36,8 @@ import { clearPaneSessionHandoff, preparePaneSessionHandoff, } from "./chat-pane-shared.ts"; +import type { ChatPageHost } from "./chat-state-host.ts"; +import { persistedMessageEntryId } from "./chat-thread.ts"; import { persistChatComposerState } from "./composer-persistence.ts"; import { captureChatSessionScrollPosition, @@ -41,6 +47,173 @@ import { export abstract class ChatPaneHistory extends ChatPaneSession { private activeCatalogContinuation: symbol | null = null; + private activeOlderLoad: Promise | null = null; + private activeReplyNavigation: symbol | null = null; + private replyNavigationSessionKey: string | null = null; + protected replyNavigationId: string | null = null; + protected replyMessageRevision = 0; + private readonly replyMessages = new Map< + string, + { client: object; settled?: boolean; message?: unknown } + >(); + + protected readonly readReplyMessage = (messageId: string): unknown => { + const state = this.state; + if (!state) { + return undefined; + } + return this.replyMessages.get(this.replyMessageCacheKey(state.sessionKey, messageId))?.message; + }; + + protected readonly requestReplyMessage = (messageId: string): void => { + void this.loadReplyMessage(messageId); + }; + + protected readonly openReplyMessage = (messageId: string): void => { + void this.navigateToReplyMessage(messageId); + }; + + private replyMessageCacheKey(sessionKey: string, messageId: string): string { + const state = this.state; + const agentId = state ? scopedAgentParamsForSession(state, sessionKey).agentId : undefined; + return `${sessionKey}\u0000${agentId ?? ""}\u0000${messageId}`; + } + + private async loadReplyMessage(messageId: string): Promise { + const scope = this.captureConnectionScope(); + if (!scope || parseCatalogSessionKey(scope.state.sessionKey)) { + return; + } + const sessionKey = scope.state.sessionKey; + const agentId = scopedAgentParamsForSession(scope.state, sessionKey).agentId; + const cacheKey = this.replyMessageCacheKey(sessionKey, messageId); + const cached = this.replyMessages.get(cacheKey); + if (cached && (cached.client === scope.client || cached.settled)) { + return; + } + while (this.replyMessages.size >= 256) { + this.replyMessages.delete(this.replyMessages.keys().next().value!); + } + this.replyMessages.set(cacheKey, { client: scope.client }); + try { + const result = await scope.client.request("chat.message.get", { + sessionKey, + ...(agentId ? { agentId } : {}), + messageId, + maxChars: 500, + }); + const pending = this.replyMessages.get(cacheKey); + if (pending?.client !== scope.client || pending.settled) { + return; + } + this.replyMessages.set( + cacheKey, + result.ok && result.message + ? { client: scope.client, settled: true, message: result.message } + : { client: scope.client, settled: true }, + ); + } catch { + const pending = this.replyMessages.get(cacheKey); + if (pending?.client !== scope.client || pending.settled) { + return; + } + this.replyMessages.delete(cacheKey); + } + this.replyMessageRevision += 1; + if ( + this.isConnectionScopeCurrent(scope) && + areUiSessionKeysEquivalent(scope.state.sessionKey, sessionKey) + ) { + this.requestUpdate(); + } + } + + private replyNavigationIsCurrent( + navigation: symbol, + state: ChatPageHost, + sessionKey: string, + sessionId: string, + ): boolean { + return ( + this.activeReplyNavigation === navigation && + this.state === state && + areUiSessionKeysEquivalent(state.sessionKey, sessionKey) && + (!sessionId || state.currentSessionId === sessionId) + ); + } + + protected currentReplyNavigationId(sessionKey: string): string | null { + return this.replyNavigationSessionKey && + areUiSessionKeysEquivalent(this.replyNavigationSessionKey, sessionKey) + ? this.replyNavigationId + : null; + } + + protected currentReplyMessageAccess(sessionKey: string) { + return { + revision: this.replyMessageRevision, + navigationId: this.currentReplyNavigationId(sessionKey), + read: this.readReplyMessage, + request: this.requestReplyMessage, + open: this.openReplyMessage, + }; + } + + private async navigateToReplyMessage(messageId: string): Promise { + const state = this.state; + if (!state || parseCatalogSessionKey(state.sessionKey)) { + return; + } + const sessionKey = state.sessionKey; + const sessionId = state.currentSessionId?.trim() ?? ""; + const navigation = Symbol("reply-navigation"); + this.activeReplyNavigation = navigation; + this.replyNavigationSessionKey = sessionKey; + this.replyNavigationId = messageId; + this.requestUpdate(); + try { + while ( + !state.chatMessages.some((message) => persistedMessageEntryId(message) === messageId) + ) { + if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + return; + } + if (!state.chatHistoryPagination?.hasMore) { + if (this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + state.lastError = t("chat.messages.originalUnavailable"); + state.requestUpdate?.(); + } + return; + } + const loaded = await this.loadOlderMessages(); + if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + return; + } + if (!loaded) { + if (!state.chatHistoryPagination?.hasMore && !state.lastError) { + state.lastError = t("chat.messages.originalUnavailable"); + state.requestUpdate?.(); + } + return; + } + } + if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + return; + } + this.requestUpdate(); + await this.updateComplete; + if (this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + this.transcript.revealMessage(messageId); + } + } finally { + if (this.activeReplyNavigation === navigation) { + this.activeReplyNavigation = null; + this.replyNavigationSessionKey = null; + this.replyNavigationId = null; + this.requestUpdate(); + } + } + } protected hasOlderMessages(): boolean { const state = this.state; @@ -60,6 +233,10 @@ export abstract class ChatPaneHistory extends ChatPaneSession { protected resetOlderMessagesViewport(): void { this.olderLoadGeneration += 1; + this.activeOlderLoad = null; + this.activeReplyNavigation = null; + this.replyNavigationSessionKey = null; + this.replyNavigationId = null; this.loadingOlder = false; this.historyObserverArmed = false; this.historyAutoLoadBlocked = false; @@ -259,11 +436,26 @@ export abstract class ChatPaneHistory extends ChatPaneSession { this.syncHistoryObserver(); } - protected async loadOlderMessages(): Promise { + protected async loadOlderMessages(): Promise { + if (this.activeOlderLoad) { + return this.activeOlderLoad; + } + const load = this.performOlderMessagesLoad(); + this.activeOlderLoad = load; + try { + return await load; + } finally { + if (this.activeOlderLoad === load) { + this.activeOlderLoad = null; + } + } + } + + private async performOlderMessagesLoad(): Promise { const state = this.state; const catalogKey = state ? parseCatalogSessionKey(state.sessionKey) : null; if (!state || this.loadingOlder || !this.hasOlderMessages()) { - return; + return false; } const generation = ++this.olderLoadGeneration; this.loadingOlder = true; @@ -275,7 +467,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession { } else { const pagination = state.chatHistoryPagination; if (!pagination?.hasMore) { - return; + return false; } const requestedOffset = pagination.nextOffset; const expectedSessionId = @@ -283,7 +475,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession { this.olderOffsetsSeen.add(requestedOffset); const result = await loadOlderChatHistoryPage(state, requestedOffset); if (!result || generation !== this.olderLoadGeneration) { - return; + return false; } const resultSessionId = typeof result.sessionInfo?.sessionId === "string" && result.sessionInfo.sessionId.trim() @@ -296,7 +488,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession { // key, so replace the tail instead of mixing two session IDs. await loadChatHistory(state); prepended = true; - return; + return true; } const nextPagination = resolveChatHistoryPagination(result); const exhausted = @@ -336,6 +528,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession { state.requestUpdate(); } } + return prepended; } protected async continueCatalogSession(key: CatalogSessionKey) { diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index c259aed2c4c2..d20e1ed6909e 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -567,6 +567,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { state.chatReplyTarget = target; state.requestUpdate?.(); }, + replyMessageAccess: catalogKey ? undefined : this.currentReplyMessageAccess(state.sessionKey), onRewindMessage: sessionActionCallbacks.onRewindMessage, onForkMessage: sessionActionCallbacks.onForkMessage, onNewSession: () => void this.createSession(), diff --git a/ui/src/pages/chat/chat-thread-items.ts b/ui/src/pages/chat/chat-thread-items.ts index 1a4a854e4334..ee4a262e4d50 100644 --- a/ui/src/pages/chat/chat-thread-items.ts +++ b/ui/src/pages/chat/chat-thread-items.ts @@ -58,11 +58,10 @@ export function appendCanvasBlockToAssistantMessage( export function messageMatchesSearchQuery(message: unknown, query: string): boolean { const normalizedQuery = normalizeLowercaseStringOrEmpty(query); - if (!normalizedQuery) { - return true; - } - const text = normalizeLowercaseStringOrEmpty(extractTextCached(message)); - return text.includes(normalizedQuery); + return ( + !normalizedQuery || + normalizeLowercaseStringOrEmpty(extractTextCached(message)).includes(normalizedQuery) + ); } export function turnHasMatchingAssistant( @@ -534,7 +533,6 @@ export function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem return collapsed; } - export function hasRenderableNormalizedMessage(message: unknown): boolean { const normalized = safeNormalizeMessage(message); if (!normalized) { @@ -564,6 +562,7 @@ export function queuedSendThreadMessage(item: ChatQueueItem): Record { ]); }); + it("renders reply metadata on queued user turns before chat.send ACK", () => { + const groups = messageGroups({ + messages: [assistantMessage("Ready.", 1)], + queue: [ + queuedSend("pending-send-1", "follow up", 2, "sending", { + replyToId: "transcript-123", + sendSubmittedAtMs: 10, + }), + ], + }); + + expect(groupAt(groups, 1).messages[0]?.message).toMatchObject({ + __openclaw: { replyToId: "transcript-123" }, + }); + }); + it("keeps restored in-flight sends visible without process-local timing", () => { const restored = { id: "restored-send-1", diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index d0ee213978d6..257e5acfd9b3 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -1,4 +1,3 @@ -// Control UI view renders chat screen composition. import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import { styleMap } from "lit/directives/style-map.js"; @@ -56,12 +55,10 @@ import { } from "./components/chat-session-workspace.ts"; import type { SidebarContent, SidebarFullMessageLoader } from "./components/chat-sidebar.ts"; import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts"; +import { renderChatTaskSuggestionTray } from "./components/chat-task-suggestions.ts"; +import type { ChatTaskSuggestionTrayProps } from "./components/chat-task-suggestions.ts"; +import type { ChatReplyMessageAccess, ChatTranscriptController } from "./components/chat-thread.ts"; import { - renderChatTaskSuggestionTray, - type ChatTaskSuggestionTrayProps, -} from "./components/chat-task-suggestions.ts"; -import { - type ChatTranscriptController, renderChatPinnedMessages, renderChatSearchBar, renderChatThread, @@ -264,6 +261,7 @@ export type ChatProps = ChatTaskSuggestionTrayProps & replyTarget?: ChatReplyTarget | null; onClearReply?: () => void; onSetReply?: (target: ChatReplyTarget) => void; + replyMessageAccess?: ChatReplyMessageAccess; onRewindMessage?: (entryId: string) => Promise | boolean; onForkMessage?: (entryId: string) => Promise | void; sessionWorkspace?: SessionWorkspaceProps; @@ -381,10 +379,9 @@ export function renderChat(props: ChatProps) { onDraftChange: props.onDraftChange, onSend: props.onSend, onSetReply: props.onSetReply, + replyMessageAccess: props.replyMessageAccess, onRewindMessage: props.onRewindMessage, onForkMessage: props.onForkMessage, - // Archived/non-composable sessions must not offer selection actions: - // withholding the callback keeps the popup from rendering at all. onCompanionQuestion: props.canSend && !props.suggestionComposer ? props.onCompanionQuestion : undefined, onCompanionPrefill: diff --git a/ui/src/pages/chat/components/chat-message-bubble.ts b/ui/src/pages/chat/components/chat-message-bubble.ts index 7a07a9ad3a43..b50b09548c6f 100644 --- a/ui/src/pages/chat/components/chat-message-bubble.ts +++ b/ui/src/pages/chat/components/chat-message-bubble.ts @@ -1,4 +1,6 @@ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { html, nothing } from "lit"; +import { ref } from "lit/directives/ref.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { icons, type IconName } from "../../../components/icons.ts"; import type { ImageLightboxItem } from "../../../components/image-lightbox.ts"; @@ -112,18 +114,67 @@ function renderInlineToolCards( * Max characters for auto-detecting and pretty-printing JSON. * Prevents DoS from large JSON payloads in assistant/tool messages. */ -function renderReplyPill(replyTarget: NormalizedMessage["replyTarget"]) { +type ReplyPreview = { + sourceMessageId?: string; + senderLabel?: string | null; + text: string; +}; + +function renderReplyPreview( + replyTarget: NormalizedMessage["replyTarget"], + preview: ReplyPreview | undefined, + onOpenReply: ((replyToId: string) => void) | undefined, + onResolveReply: ((replyToId: string) => void) | undefined, + navigationLoading: boolean, +) { if (!replyTarget) { return nothing; } + const replyToId = replyTarget.kind === "id" ? replyTarget.id : null; + const name = preview?.senderLabel?.trim() + ? preview.senderLabel + : replyTarget.kind === "current" + ? t("chat.messages.currentMessage") + : t("chat.messages.message"); + const content = preview?.text.trim() ?? ""; + const resolveMissingPreview = (element?: Element) => { + if (element && replyToId && !preview) { + onResolveReply?.(replyToId); + } + }; + const body = html` + ${navigationLoading + ? html`` + : icons.messageSquare} + ${t("chat.messages.replyingTo", { name })} + ${content + ? html`${truncateUtf16Safe(content, 120)}${content.length > 120 ? "..." : ""}` + : nothing} + `; + if (replyToId && onOpenReply) { + return html` + + `; + } return html` -
- ${icons.messageSquare} - - ${t("chat.messages.replyingTo", { - name: replyTarget.kind === "current" ? t("chat.messages.currentMessage") : replyTarget.id, - })} - +
+ ${body}
`; } @@ -191,6 +242,10 @@ export function renderGroupedMessage( entryId?: string; /** Freshly submitted user turn: play the one-shot composer entry animation. */ entryAnimated?: boolean; + resolveReplyPreview?: (replyToId: string) => ReplyPreview | undefined; + onResolveReply?: (replyToId: string) => void; + onOpenReply?: (replyToId: string) => void; + replyNavigationId?: string | null; }, onOpenSidebar?: (content: SidebarContent) => void, ) { @@ -350,7 +405,19 @@ export function renderGroupedMessage( data-entry-id=${opts.entryId || nothing} data-message-text=${actionText || nothing} > - ${renderReplyPill(normalizedMessage.replyTarget)} + ${renderReplyPreview( + normalizedMessage.replyTarget, + normalizedMessage.replyTarget?.kind === "id" + ? (opts.resolveReplyPreview?.(normalizedMessage.replyTarget.id) ?? + normalizedMessage.replyPreview) + : undefined, + opts.onOpenReply, + opts.onResolveReply, + opts.replyNavigationId === + (normalizedMessage.replyTarget?.kind === "id" + ? normalizedMessage.replyTarget.id + : null), + )} ${renderInlineToolCards(toolCards, { messageKey, sessionKey: opts.sessionKey, @@ -385,7 +452,17 @@ export function renderGroupedMessage( data-entry-id=${opts.entryId || nothing} data-message-text=${actionText || nothing} > - ${renderReplyPill(normalizedMessage.replyTarget)} + ${renderReplyPreview( + normalizedMessage.replyTarget, + normalizedMessage.replyTarget?.kind === "id" + ? (opts.resolveReplyPreview?.(normalizedMessage.replyTarget.id) ?? + normalizedMessage.replyPreview) + : undefined, + opts.onOpenReply, + opts.onResolveReply, + opts.replyNavigationId === + (normalizedMessage.replyTarget?.kind === "id" ? normalizedMessage.replyTarget.id : null), + )} ${isStandaloneToolMessage ? html`
void; onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void; @@ -94,6 +96,10 @@ type RenderMessageGroupOptions = { allowExternalEmbedUrls?: boolean; contextWindow?: number | null; onReply?: (target: MessageReplyTarget) => void; + resolveReplyPreview?: (replyToId: string) => ReplyPreview | undefined; + onResolveReply?: (replyToId: string) => void; + onOpenReply?: (replyToId: string) => void; + replyNavigationId?: string | null; onRewind?: () => void; rewindDisabled?: boolean; activeContinuation?: ActiveContinuation; @@ -158,6 +164,10 @@ function buildGroupedMessageRenderOptions( resolveArtifactDownload: opts.resolveArtifactDownload, embedSandboxMode: opts.embedSandboxMode, allowExternalEmbedUrls: opts.allowExternalEmbedUrls, + resolveReplyPreview: opts.resolveReplyPreview, + onResolveReply: opts.onResolveReply, + onOpenReply: opts.onOpenReply, + replyNavigationId: opts.replyNavigationId, }; } @@ -326,11 +336,11 @@ export function renderActivityGroup( `; } -export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroupOptions) { +export function resolveMessageGroupSenderLabel( + group: MessageGroup, + opts: Pick, +): string { const normalizedRole = normalizeRoleForGrouping(group.role); - const isWorkspaceConflict = group.messages.every((item) => - Boolean(workspaceResultConflictFromTranscript(item.message)), - ); const assistantName = opts.assistantName ?? "Assistant"; const resolvedUserName = resolveLocalUserName({ name: opts.userName ?? null, @@ -339,20 +349,29 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup const userLabel = group.senderLabel?.trim(); const isPeerGroup = normalizedRole === "user" && isPeerSenderGroup(group, opts.userId); const isCurrentUser = normalizedRole === "user" && Boolean(group.sender) && !isPeerGroup; - const who = - normalizedRole === "user" - ? isCurrentUser - ? resolvedUserName - : (userLabel ?? resolvedUserName) - : normalizedRole === "assistant" - ? (userLabel ?? assistantName) - : normalizedRole === "tool" - ? "Tool" - : isWorkspaceConflict - ? t("chat.workspaceConflict.eventSender") - : normalizedRole; - const showAvatarGutter = opts.showAvatarGutter !== false; - const persistUserIdentity = normalizedRole === "user" && showAvatarGutter; + return normalizedRole === "user" + ? isCurrentUser + ? resolvedUserName + : (userLabel ?? resolvedUserName) + : normalizedRole === "assistant" + ? (userLabel ?? assistantName) + : normalizedRole === "tool" + ? "Tool" + : group.messages.every((item) => + Boolean(workspaceResultConflictFromTranscript(item.message)), + ) + ? t("chat.workspaceConflict.eventSender") + : normalizedRole; +} + +export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroupOptions) { + const normalizedRole = normalizeRoleForGrouping(group.role); + const isWorkspaceConflict = group.messages.every((item) => + Boolean(workspaceResultConflictFromTranscript(item.message)), + ); + const assistantName = opts.assistantName ?? "Assistant"; + const isPeerGroup = normalizedRole === "user" && isPeerSenderGroup(group, opts.userId); + const who = resolveMessageGroupSenderLabel(group, opts); const roleClass = normalizedRole === "user" ? "user" @@ -363,6 +382,8 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup : isWorkspaceConflict ? "workspace-conflict" : "other"; + const showAvatarGutter = opts.showAvatarGutter !== false; + const persistUserIdentity = normalizedRole === "user" && showAvatarGutter; // Aggregate usage/cost/model across all messages in the group const meta = extractGroupMeta(group, opts.contextWindow ?? null); diff --git a/ui/src/pages/chat/components/chat-message-markdown.ts b/ui/src/pages/chat/components/chat-message-markdown.ts index 14401fc3128d..1a80b3b53d59 100644 --- a/ui/src/pages/chat/components/chat-message-markdown.ts +++ b/ui/src/pages/chat/components/chat-message-markdown.ts @@ -83,6 +83,16 @@ export function resolveNormalizedMessageMarkdown(normalizedMessage: NormalizedMe .trim(); } +export function resolveMessageReplyText(message: unknown): string { + const normalizedMessage = normalizeMessage(message); + const markdown = resolveNormalizedMessageMarkdown(normalizedMessage); + const visibleMarkdown = + normalizeRoleForGrouping(normalizedMessage.role) === "assistant" + ? stripThinkingTags(markdown).trim() + : markdown.trim(); + return visibleMarkdown; +} + export function resolveMessageActionDetails(params: { message: unknown; messageId: string; @@ -106,10 +116,8 @@ export function resolveMessageActionDetails(params: { ? record.messageId : undefined; const normalizedMessage = normalizeMessage(message); - const normalizedMarkdown = resolveNormalizedMessageMarkdown(normalizedMessage); const role = normalizeRoleForGrouping(normalizedMessage.role); - const previewMarkdown = - role === "assistant" ? stripThinkingTags(normalizedMarkdown).trim() : normalizedMarkdown.trim(); + const previewMarkdown = resolveMessageReplyText(message); // Loaded text must not erase the preview's truncation fact or collapse its disclosure. const shouldFetchFullMessage = Boolean( canFetchFullMessage && diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 63c37d25be4f..5831e860a263 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -2983,7 +2983,7 @@ describe("grouped chat rendering", () => { }); }); - it("renders assistant MEDIA attachments, voice-note badge, and reply pill", async () => { + it("renders assistant MEDIA attachments, voice-note badge, and reply preview", async () => { const container = document.body.appendChild(document.createElement("div")); container.dataset.mediaPlayerTestFixture = ""; const onOpenImage = vi.fn(); @@ -2998,7 +2998,7 @@ describe("grouped chat rendering", () => { { showToolCalls: false, onOpenImage }, ); - expect(container.querySelector(".chat-reply-pill__label")?.textContent?.trim()).toBe( + expect(container.querySelector(".chat-reply-preview__label")?.textContent?.trim()).toBe( "Replying to current message", ); expect(container.querySelector(".chat-text")?.textContent?.trim()).toBe("Here is the image."); @@ -3019,6 +3019,77 @@ describe("grouped chat rendering", () => { ); }); + it("renders a clickable quoted preview for structured user replies", () => { + const container = document.body.appendChild(document.createElement("div")); + const onOpenReply = vi.fn(); + renderGroupedMessage( + container, + createUserMessage("Follow up", { + __openclaw: { replyToId: "transcript-123" }, + }), + "user", + { + resolveReplyPreview: () => ({ + messageId: "source-message", + sourceMessageId: "transcript-123", + senderLabel: "Marie", + text: "The original answer", + }), + onOpenReply, + }, + ); + + const preview = container.querySelector(".chat-reply-preview--message"); + expect(preview?.textContent).toContain("Replying to Marie"); + expect(preview?.textContent).toContain("The original answer"); + preview?.click(); + expect(onOpenReply).toHaveBeenCalledWith("transcript-123"); + expect(container.querySelector(".chat-text")?.textContent?.trim()).toBe("Follow up"); + }); + + it("keeps unloaded persisted previews clickable for history navigation", () => { + const container = document.body.appendChild(document.createElement("div")); + const onOpenReply = vi.fn(); + renderGroupedMessage( + container, + createUserMessage("Follow up", { + __openclaw: { + replyToId: "unloaded-message", + replyToPreview: { senderLabel: "Marie", text: "The original answer" }, + }, + }), + "user", + { onOpenReply }, + ); + + const preview = container.querySelector(".chat-reply-preview--message"); + expect(preview?.textContent).toContain("Replying to Marie"); + expect(preview?.textContent).toContain("The original answer"); + expect(preview).toBeInstanceOf(HTMLButtonElement); + preview?.click(); + expect(onOpenReply).toHaveBeenCalledWith("unloaded-message"); + }); + + it("shows a busy state while loading history for a reply target", () => { + const container = document.body.appendChild(document.createElement("div")); + renderGroupedMessage( + container, + createUserMessage("Follow up", { + __openclaw: { + replyToId: "unloaded-message", + replyToPreview: { senderLabel: "Marie", text: "The original answer" }, + }, + }), + "user", + { onOpenReply: vi.fn(), replyNavigationId: "unloaded-message" }, + ); + + const preview = container.querySelector(".chat-reply-preview--message"); + expect(preview?.disabled).toBe(true); + expect(preview?.getAttribute("aria-busy")).toBe("true"); + expect(preview?.querySelector(".session-run-spinner")).toBeInstanceOf(HTMLElement); + }); + it("notifies when assistant audio and video attachment metadata loads", async () => { const container = document.body.appendChild(document.createElement("div")); container.dataset.mediaPlayerTestFixture = ""; diff --git a/ui/src/pages/chat/components/chat-thread.measure.test.ts b/ui/src/pages/chat/components/chat-thread.measure.test.ts index b043c47762f2..ccf2d51c3e31 100644 --- a/ui/src/pages/chat/components/chat-thread.measure.test.ts +++ b/ui/src/pages/chat/components/chat-thread.measure.test.ts @@ -20,8 +20,10 @@ import { } from "./chat-message-media.ts"; import { renderChatThread, + renderChatSearchBar, resetChatThreadPresentationState, resetChatThreadSessionPresentationState, + toggleChatThreadSearch, } from "./chat-thread.ts"; const observedElements = new Set(); @@ -173,6 +175,156 @@ describe("chat transcript row measurement", () => { } }); + it("resolves persisted replies to their source and highlights it on click", async () => { + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + const props = threadProps("pane-reply-preview", "agent:main:main", [ + { + role: "assistant", + content: "The original answer", + __openclaw: { id: "source-message" }, + timestamp: 1_000, + }, + { + role: "user", + content: "Follow up", + __openclaw: { id: "reply-message", replyToId: "source-message" }, + timestamp: 2_000, + }, + ]); + render(renderChatThread(props, transcript), container); + transcript.hostConnected(); + transcript.hostUpdated(); + await flushDeferredRowPrune(); + + const preview = container.querySelector(".chat-reply-preview--message"); + expect(preview?.textContent).toContain("Replying to Molty"); + expect(preview?.textContent).toContain("The original answer"); + expect(preview?.textContent).not.toContain("source-message"); + + preview?.click(); + await Promise.resolve(); + + const sourceBubble = [...container.querySelectorAll(".chat-bubble")].find( + (bubble) => bubble.dataset.entryId === "source-message", + ); + expect(sourceBubble?.classList.contains("chat-bubble--reply-target")).toBe(true); + transcript.hostDisconnected(); + }); + + it("hydrates an unloaded reply preview without inserting its source row", async () => { + const transcript = createTestTranscript(); + const container = document.body.appendChild(document.createElement("div")); + let resolvedMessage: unknown = undefined; + const request = vi.fn(); + const open = vi.fn(); + const props = { + ...threadProps("pane-reply-hydration", "agent:main:main", [ + { + role: "user", + content: "Follow up", + __openclaw: { id: "reply-message", replyToId: "source-message" }, + timestamp: 2_000, + }, + ]), + replyMessageAccess: { + revision: 0, + navigationId: null, + read: () => resolvedMessage, + request, + open, + }, + }; + const rerender = () => { + render(renderChatThread(props, transcript), container); + transcript.hostUpdated(); + }; + rerender(); + transcript.hostConnected(); + await flushDeferredRowPrune(); + + expect(request).toHaveBeenCalledWith("source-message"); + expect(container.querySelector("[data-entry-id='source-message']")).toBeNull(); + + resolvedMessage = { + role: "assistant", + content: "The original answer", + __openclaw: { id: "source-message" }, + timestamp: 1_000, + }; + props.replyMessageAccess.revision += 1; + rerender(); + + const preview = container.querySelector(".chat-reply-preview--message"); + expect(preview?.textContent).toContain("Replying to Molty"); + expect(preview?.textContent).toContain("The original answer"); + preview?.click(); + expect(open).toHaveBeenCalledWith("source-message"); + transcript.hostDisconnected(); + }); + + it("clears search before navigating to a filtered reply target", async () => { + const transcript = createTestTranscript(); + const searchContainer = document.body.appendChild(document.createElement("div")); + const threadContainer = document.body.appendChild(document.createElement("div")); + const open = vi.fn(); + const paneId = "pane-filtered-reply-navigation"; + const props = { + ...threadProps(paneId, "agent:main:main", [ + { + role: "assistant", + content: "The original answer", + __openclaw: { id: "source-message" }, + timestamp: 1_000, + }, + { + role: "user", + content: "Follow up", + __openclaw: { + id: "reply-message", + replyToId: "source-message", + replyToPreview: { text: "The original answer", senderLabel: "Molty" }, + }, + timestamp: 2_000, + }, + ]), + replyMessageAccess: { + revision: 0, + navigationId: null, + read: () => undefined, + request: vi.fn(), + open, + }, + }; + const rerender = () => { + render(renderChatSearchBar(paneId, rerender), searchContainer); + render( + renderChatThread({ ...props, onRequestUpdate: rerender }, transcript), + threadContainer, + ); + transcript.hostUpdated(); + }; + toggleChatThreadSearch(paneId, rerender); + rerender(); + transcript.hostConnected(); + const input = searchContainer.querySelector("input"); + expect(input).not.toBeNull(); + input!.value = "Follow up"; + input!.dispatchEvent(new Event("input", { bubbles: true })); + await flushDeferredRowPrune(); + + expect(threadContainer.querySelector("[data-entry-id='source-message']")).toBeNull(); + const preview = threadContainer.querySelector( + ".chat-reply-preview--message", + ); + expect(preview).not.toBeNull(); + preview!.click(); + + expect(open).toHaveBeenCalledWith("source-message"); + expect(searchContainer.querySelector("input")).toBeNull(); + transcript.hostDisconnected(); + }); + it("loads a truncated assistant message once and keeps the full text visible", async () => { const transcript = createTestTranscript(); const container = document.body.appendChild(document.createElement("div")); diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index cf27b7ce581d..6ffa96ffff3c 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -39,6 +39,7 @@ import { buildMoreDetailsCompanionQuestion, } from "../../../lib/chat/companion-question.ts"; import { extractTextCached } from "../../../lib/chat/message-extract.ts"; +import { normalizeMessage } from "../../../lib/chat/message-normalizer.ts"; import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts"; import { copyToClipboard } from "../../../lib/clipboard.ts"; import { fnv1aUtf16 } from "../../../lib/fnv1a.ts"; @@ -81,6 +82,8 @@ import { getToolTitlesVersion } from "../tool-titles.ts"; import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts"; import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; import { renderChatDivider, renderChatNotice } from "./chat-divider.ts"; +import { resolveMessageGroupSenderLabel } from "./chat-message-group.ts"; +import { resolveMessageReplyText } from "./chat-message-markdown.ts"; import type { ArtifactDownloadResolver } from "./chat-message-media.ts"; import { dismissConfirmedActionPopovers, @@ -110,7 +113,18 @@ type ChatThreadState = { searchReturnFocusOwner: HTMLElement | null; pinnedExpanded: boolean; transcriptRenderDependencies: readonly unknown[]; - transcriptRenderContext: { onSetReply?: ChatThreadProps["onSetReply"] }; + transcriptRenderContext: { + onSetReply?: ChatThreadProps["onSetReply"]; + onOpenReply?: (replyToId: string) => void; + }; +}; + +export type ChatReplyMessageAccess = { + revision: number; + navigationId: string | null; + read: (messageId: string) => unknown; + request: (messageId: string) => void; + open: (messageId: string) => void; }; type ChatThreadProps = { @@ -177,6 +191,7 @@ type ChatThreadProps = { onDraftChange: (next: string) => void; onSend: () => void; onSetReply?: (target: MessageReplyTarget) => void; + replyMessageAccess?: ChatReplyMessageAccess; onRewindMessage?: (entryId: string) => Promise | boolean; onForkMessage?: (entryId: string) => Promise | void; onFocusComposer?: () => void; @@ -205,6 +220,40 @@ type ChatTranscriptAnnouncement = { text: string; }; +type LoadedReplySource = { + rowKey: string; + preview: MessageReplyTarget & { sourceMessageId: string }; +}; + +function projectResolvedReplyPreview( + message: unknown, + replyToId: string, + props: Pick, +): LoadedReplySource["preview"] | undefined { + const normalized = normalizeMessage(message); + const text = resolveMessageReplyText(message); + if (!text) { + return undefined; + } + const group: MessageGroup = { + kind: "group", + key: replyToId, + role: normalized.role, + senderLabel: normalized.senderLabel, + ...(normalized.sender ? { sender: normalized.sender } : {}), + messages: [{ key: replyToId, message }], + timestamp: normalized.timestamp, + isStreaming: false, + }; + const sourceMessageId = persistedMessageEntryId(message) ?? replyToId; + return { + messageId: sourceMessageId, + sourceMessageId, + senderLabel: resolveMessageGroupSenderLabel(group, props), + text, + }; +} + const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120; const CHAT_TRANSCRIPT_OVERSCAN = 6; const CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS = 500; @@ -317,6 +366,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost { } private rowKeys: readonly string[] = []; private rowIndexesByKey = new Map(); + private messageRowKeysById = new Map(); private focusedRowKey: string | null = null; private announcementInitialized = false; private announcementKey: string | null = null; @@ -458,6 +508,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost { this.measureRowRefs.clear(); this.rowKeys = []; this.rowIndexesByKey.clear(); + this.messageRowKeysById.clear(); this.focusedRowKey = null; this.pendingScrollOffset = null; } @@ -538,6 +589,42 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost { this.virtualizerController.getVirtualizer().scrollToOffset(offset); } + syncMessageRows(messageRowKeysById: ReadonlyMap): void { + this.messageRowKeysById = new Map(messageRowKeysById); + } + + revealMessage(messageId: string): boolean { + const rowKey = this.messageRowKeysById.get(messageId); + if (!rowKey) { + return false; + } + const rowIndex = this.rowIndexesByKey.get(rowKey); + if (rowIndex === undefined) { + return false; + } + this.virtualizerController.getVirtualizer().scrollToIndex(rowIndex, { align: "center" }); + this.host.requestUpdate(); + void this.host.updateComplete.then(() => { + const bubble = [ + ...(this.threadInnerElement?.querySelectorAll(".chat-bubble") ?? []), + ].find((candidate) => candidate.dataset.entryId === messageId); + if (!bubble) { + return; + } + this.threadInnerElement + ?.querySelector(".chat-bubble--reply-target") + ?.classList.remove("chat-bubble--reply-target"); + bubble.scrollIntoView?.({ behavior: "smooth", block: "center" }); + bubble.classList.add("chat-bubble--reply-target"); + bubble.addEventListener( + "animationend", + () => bubble.classList.remove("chat-bubble--reply-target"), + { once: true }, + ); + }); + return true; + } + getScrollOffset(): number | null { return this.scrollElement?.scrollTop ?? null; } @@ -757,6 +844,10 @@ export class ChatTranscriptController implements ReactiveController { this.sessionVirtualizer?.restoreScrollOffset(offset, onSettled); } + revealMessage(messageId: string): boolean { + return this.sessionVirtualizer?.revealMessage(messageId) ?? false; + } + pendingScrollOffsetFor(sessionKey: string): number | null { return this.activeSessionKey !== null && areUiSessionKeysEquivalent(this.activeSessionKey, sessionKey) @@ -1582,6 +1673,21 @@ function renderChatThreadContents( { parts: StreamGroupPart[]; options: StreamGroupOptions } >(); const turnRecapByGroupKey = new Map(); + const loadedReplySources = new Map(); + const resolvedReplyPreviews = new Map(); + const resolveReplyPreview = (replyToId: string) => { + const loaded = loadedReplySources.get(replyToId)?.preview; + if (loaded) { + return loaded; + } + if (resolvedReplyPreviews.has(replyToId)) { + return resolvedReplyPreviews.get(replyToId); + } + const message = props.replyMessageAccess?.read(replyToId); + const preview = message ? projectResolvedReplyPreview(message, replyToId, props) : undefined; + resolvedReplyPreviews.set(replyToId, preview); + return preview; + }; const sharedMessageRenderOptions = { onOpenSidebar: props.onOpenSidebar, sessionKey: props.sessionKey, @@ -1646,6 +1752,10 @@ function renderChatThreadContents( onReply: props.onSetReply ? (target) => state.transcriptRenderContext.onSetReply?.(target) : undefined, + resolveReplyPreview, + onResolveReply: props.replyMessageAccess?.request, + onOpenReply: (replyToId: string) => state.transcriptRenderContext.onOpenReply?.(replyToId), + replyNavigationId: props.replyMessageAccess?.navigationId, onRewind: rewindEntryId && props.onRewindMessage ? () => { @@ -1779,6 +1889,35 @@ function renderChatThreadContents( }); return false; }); + for (const item of transcriptItems) { + if (item.kind !== "group") { + continue; + } + const senderLabel = resolveMessageGroupSenderLabel(item, { + assistantName: props.assistantName, + userId: props.userId, + userName: props.userName, + userAvatar: props.userAvatar, + }); + for (const source of item.messages) { + const sourceMessageId = persistedMessageEntryId(source.message); + const text = resolveMessageReplyText(source.message); + if (sourceMessageId && text) { + loadedReplySources.set(sourceMessageId, { + rowKey: item.key, + preview: { + messageId: source.key, + sourceMessageId, + senderLabel, + text, + }, + }); + } + } + } + transcript.syncMessageRows( + new Map([...loadedReplySources].map(([messageId, source]) => [messageId, source.rowKey])), + ); let turnRecapOwnerKey: string | null = null; if (turnRecap !== null) { const lastItem = transcriptItems.at(-1); @@ -1860,9 +1999,21 @@ function renderChatThreadContents( props.allowExternalEmbedUrls ?? false, threadContextWindow, Boolean(props.onSetReply), + props.replyMessageAccess?.revision ?? 0, + props.replyMessageAccess?.navigationId ?? "", turnRecap === null ? "" : `${turnRecap.runtimeMs}:${turnRecap.outputTokens ?? ""}`, ]); state.transcriptRenderContext.onSetReply = props.onSetReply; + state.transcriptRenderContext.onOpenReply = (replyToId) => { + if (loadedReplySources.has(replyToId)) { + transcript.revealMessage(replyToId); + return; + } + if (searchFiltering) { + closeChatThreadSearch(state, requestUpdate); + } + props.replyMessageAccess?.open(replyToId); + }; const transcriptContents = showLoadingSkeleton || isEmpty ? html` diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index ceab28ce9e20..13c85c2d8d55 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1441,8 +1441,7 @@ openclaw-chat-video-player { height: 16px; } -.chat-assistant-attachment-badge, -.chat-reply-pill { +.chat-assistant-attachment-badge { display: inline-flex; align-items: center; gap: 6px; @@ -1459,25 +1458,6 @@ openclaw-chat-video-player { border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); } -.chat-reply-pill { - margin-bottom: 8px; - padding: 5px 10px; - color: var(--muted); - border: 1px solid var(--border); - background: color-mix(in srgb, var(--bg) 70%, transparent); -} - -.chat-reply-pill__icon { - display: inline-flex; - width: 14px; - height: 14px; -} - -.chat-reply-pill__icon svg { - width: 14px; - height: 14px; -} - /* Reply preview bar above composer */ .chat-reply-preview { display: flex; @@ -1545,6 +1525,48 @@ openclaw-chat-video-player { stroke-width: 2px; } +.chat-reply-preview--message { + width: 100%; + margin: 0 0 8px; + font: inherit; + text-align: left; +} + +button.chat-reply-preview--message { + cursor: var(--cursor-action); +} + +button.chat-reply-preview--message:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} + +button.chat-reply-preview--message:disabled { + cursor: var(--cursor-default); + opacity: 0.8; +} + +.chat-reply-preview--unavailable { + border-color: var(--border); + background: color-mix(in srgb, var(--bg) 70%, transparent); +} + +.chat-bubble--reply-target { + animation: chat-reply-target-highlight 1.4s ease-out; +} + +@keyframes chat-reply-target-highlight { + 0%, + 35% { + outline: 2px solid color-mix(in srgb, var(--accent) 80%, transparent); + outline-offset: 3px; + } + 100% { + outline: 2px solid transparent; + outline-offset: 6px; + } +} + /* Right-click reply context menu */ .chat-reply-context-menu { position: fixed;