feat(webchat): reply-to a message with hydrated reply context (#110791)

* feat(webchat): reply-to a message with hydrated reply context

Control UI replies now carry the target transcript id as replyToId on
chat.send. The Gateway resolves the replied-to message from session
history and hydrates the channel-agnostic ReplyToId/ReplyToBody/
ReplyToSender envelope fields, so agents receive reply_to_id,
has_reply_context, and the untrusted reply-target block exactly like
Discord replies (mirrors #90263). Reply targets without a persisted
transcript id keep the inline-quote fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webchat): keep non-reply chat.send dispatch ordering and satisfy CI gates

* docs(webchat): match reply-context doc to webchat conversation-info policy

* fix(webchat): hydrate reply bodies from display-visible content only

---------

Co-authored-by: openclaw-clawsweeper[bot] <openclaw-clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-07-18 15:57:59 -06:00
committed by GitHub
co-authored by openclaw-clawsweeper[bot] <openclaw-clawsweeper[bot]@users.noreply.github.com> Claude Fable 5 fuller-stack-dev
parent 4d683904df
commit b88646cf78
14 changed files with 447 additions and 6 deletions
@@ -14043,6 +14043,7 @@ public struct ChatSendParams: Codable, Sendable {
public let originatingto: String?
public let originatingaccountid: String?
public let originatingthreadid: String?
public let replytoid: String?
public let attachments: [AnyCodable]?
public let toolbindings: [String: AnyCodable]?
public let timeoutms: Int?
@@ -14066,6 +14067,7 @@ public struct ChatSendParams: Codable, Sendable {
originatingto: String? = nil,
originatingaccountid: String? = nil,
originatingthreadid: String? = nil,
replytoid: String? = nil,
attachments: [AnyCodable]? = nil,
toolbindings: [String: AnyCodable]? = nil,
timeoutms: Int? = nil,
@@ -14088,6 +14090,7 @@ public struct ChatSendParams: Codable, Sendable {
self.originatingto = originatingto
self.originatingaccountid = originatingaccountid
self.originatingthreadid = originatingthreadid
self.replytoid = replytoid
self.attachments = attachments
self.toolbindings = toolbindings
self.timeoutms = timeoutms
@@ -14111,6 +14114,7 @@ public struct ChatSendParams: Codable, Sendable {
originatingto: String? = nil,
originatingaccountid: String? = nil,
originatingthreadid: String? = nil,
replytoid: String? = nil,
attachments: [AnyCodable]? = nil,
toolbindings: [String: AnyCodable]? = nil,
timeoutms: Int? = nil,
@@ -14134,6 +14138,7 @@ public struct ChatSendParams: Codable, Sendable {
originatingto: originatingto,
originatingaccountid: originatingaccountid,
originatingthreadid: originatingthreadid,
replytoid: replytoid,
attachments: attachments,
toolbindings: toolbindings,
timeoutms: timeoutms,
@@ -14158,6 +14163,7 @@ public struct ChatSendParams: Codable, Sendable {
case originatingto = "originatingTo"
case originatingaccountid = "originatingAccountId"
case originatingthreadid = "originatingThreadId"
case replytoid = "replyToId"
case attachments
case toolbindings = "toolBindings"
case timeoutms = "timeoutMs"
+1
View File
@@ -29,6 +29,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. N
- Compaction entries render as a "Compacted history" divider explaining that the compacted transcript is preserved as a checkpoint, with an action to open session checkpoints (branch or restore, when permissions allow).
- Control UI remembers the backing Gateway `sessionId` returned by `chat.history` and includes it on follow-up `chat.send` calls, so reconnects and page refreshes continue the same stored conversation unless the user starts or resets a session.
- `chat.send` takes an idempotency key (Control UI uses the run id); the Gateway dedupes repeated requests that reuse the same key, so retried or duplicate in-flight submits for the same session/message/attachments do not create a second run.
- Replying to a specific message (right-click → Reply) sends the target's transcript id as `replyToId` on `chat.send`. The Gateway resolves that message from session history and hydrates the same channel-agnostic reply context metadata Discord replies use: agents see `has_reply_context` plus the untrusted "Reply target of current user message" block with sender label and body. (Webchat prompts keep volatile conversation ids such as `reply_to_id` suppressed, per the existing byte-stable prompt policy for direct webchat sessions.) Reply targets without a persisted transcript id (for example pending sends) fall back to an inline quote in the message body.
- Workspace startup files and pending `BOOTSTRAP.md` instructions are supplied through the agent system prompt's `# Project Context` section, not copied into the WebChat user message. If bootstrap content is truncated, the system prompt gets a short "Bootstrap Context Notice" instead; detailed counts and config knobs stay on diagnostic surfaces.
- Display normalization on `chat.history` strips: runtime-only OpenClaw context, inbound envelope wrappers, inline delivery directive tags such as `[[reply_to_current]]`, `[[reply_to:<id>]]`, and `[[audio_as_voice]]`, plain-text tool-call XML payloads (`<tool_call>`, `<function_call>`, `<tool_calls>`, `<function_calls>`, including truncated blocks), and leaked ASCII/full-width model control tokens. Assistant entries whose whole visible text is only the silent token `NO_REPLY` (case-insensitive) are omitted.
- Reasoning-flagged reply payloads (`isReasoning: true`) are excluded from WebChat assistant content, transcript replay text, and audio content blocks, so thinking-only payloads do not surface as visible assistant messages or playable audio.
@@ -110,6 +110,9 @@ export const ChatSendParamsSchema = closedObject({
originatingTo: Type.Optional(Type.String()),
originatingAccountId: Type.Optional(Type.String()),
originatingThreadId: Type.Optional(Type.String()),
// Transcript id of the message this send replies to; the Gateway hydrates
// channel-agnostic reply context metadata from session history.
replyToId: Type.Optional(NonEmptyString),
attachments: Type.Optional(ChatAttachmentsSchema),
toolBindings: Type.Optional(RunToolBindingsSchema),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -0,0 +1,185 @@
// Covers webchat reply-target hydration into the channel-agnostic ReplyTo*
// envelope fields consumed by inbound-meta reply context blocks.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
applyChatSendReplyContextFields,
resolveChatSendReplyContext,
} from "./chat-send-reply-context.js";
const readSessionMessageByIdAsyncMock = vi.fn();
const resolveAssistantIdentityMock = vi.fn((..._args: unknown[]) => ({
agentId: "main",
name: "Molty",
avatar: "M",
}));
vi.mock("../session-transcript-readers.js", () => ({
readSessionMessageByIdAsync: (...args: unknown[]) => readSessionMessageByIdAsyncMock(...args),
}));
vi.mock("../assistant-identity.js", () => ({
resolveAssistantIdentity: (...args: unknown[]) => resolveAssistantIdentityMock(...args),
}));
const cfg = {} as OpenClawConfig;
function baseParams(overrides: Partial<Parameters<typeof resolveChatSendReplyContext>[0]> = {}) {
return {
replyToId: "msg-1",
cfg,
agentId: "main",
sessionKey: "agent:main:webchat",
sessionEntry: { sessionFile: "session.jsonl", sessionId: "session-1" },
storePath: "/tmp/sessions.json",
...overrides,
};
}
describe("resolveChatSendReplyContext", () => {
beforeEach(() => {
readSessionMessageByIdAsyncMock.mockReset();
});
it("returns no fields without a reply id", async () => {
expect(await resolveChatSendReplyContext(baseParams({ replyToId: undefined }))).toEqual({});
expect(await resolveChatSendReplyContext(baseParams({ replyToId: " " }))).toEqual({});
expect(readSessionMessageByIdAsyncMock).not.toHaveBeenCalled();
});
it("hydrates assistant reply targets with the assistant identity label", async () => {
readSessionMessageByIdAsyncMock.mockResolvedValue({
found: true,
message: {
role: "assistant",
content: [{ type: "text", text: "the replied-to answer" }],
__openclaw: { id: "msg-1" },
},
});
const fields = await resolveChatSendReplyContext(baseParams());
expect(readSessionMessageByIdAsyncMock).toHaveBeenCalledWith(
{
agentId: "main",
sessionEntry: { sessionFile: "session.jsonl", sessionId: "session-1" },
sessionId: "session-1",
sessionKey: "agent:main:webchat",
storePath: "/tmp/sessions.json",
},
"msg-1",
{ allowResetArchiveFallback: true },
);
expect(fields).toEqual({
ReplyToId: "msg-1",
ReplyToBody: "the replied-to answer",
ReplyToSender: "Molty",
});
});
it("labels user reply targets with the client display name", async () => {
readSessionMessageByIdAsyncMock.mockResolvedValue({
found: true,
message: { role: "user", content: "an earlier question" },
});
const fields = await resolveChatSendReplyContext(baseParams({ userSenderLabel: "Ada" }));
expect(fields).toEqual({
ReplyToId: "msg-1",
ReplyToBody: "an earlier question",
ReplyToSender: "Ada",
});
});
it("keeps only the reply id when the target message is missing", async () => {
readSessionMessageByIdAsyncMock.mockResolvedValue({ found: false });
expect(await resolveChatSendReplyContext(baseParams())).toEqual({ ReplyToId: "msg-1" });
});
it("keeps only the reply id when no session exists yet", async () => {
expect(await resolveChatSendReplyContext(baseParams({ sessionEntry: undefined }))).toEqual({
ReplyToId: "msg-1",
});
expect(readSessionMessageByIdAsyncMock).not.toHaveBeenCalled();
});
it("tolerates read failures and reports them through warn", async () => {
readSessionMessageByIdAsyncMock.mockRejectedValue(new Error("transcript unavailable"));
const warn = vi.fn();
expect(await resolveChatSendReplyContext(baseParams({ warn }))).toEqual({
ReplyToId: "msg-1",
});
expect(warn).toHaveBeenCalledOnce();
});
it("hydrates only display-visible content, not raw transcript payloads", async () => {
readSessionMessageByIdAsyncMock.mockResolvedValue({
found: true,
message: {
role: "assistant",
content: [
{ type: "text", text: "visible answer" },
{ type: "text", text: '<tool_call>{"name":"exec","arguments":{}}</tool_call>' },
],
__openclaw: { id: "msg-1" },
},
});
const fields = await resolveChatSendReplyContext(baseParams());
expect(fields.ReplyToBody).toContain("visible answer");
expect(fields.ReplyToBody).not.toContain("tool_call");
});
it("strips inbound envelope wrappers from user reply targets", async () => {
readSessionMessageByIdAsyncMock.mockResolvedValue({
found: true,
message: {
role: "user",
content: "[Sat 2026-07-18 11:31 MDT] Which stage runs the integration tests?",
},
});
const fields = await resolveChatSendReplyContext(baseParams({ userSenderLabel: "Ada" }));
expect(fields.ReplyToBody).toBe("Which stage runs the integration tests?");
});
it("keeps only the reply id when the target is not display-visible", async () => {
readSessionMessageByIdAsyncMock.mockResolvedValue({
found: true,
message: {
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
__openclaw: { id: "msg-1" },
},
});
expect(await resolveChatSendReplyContext(baseParams())).toEqual({ ReplyToId: "msg-1" });
});
it("bounds oversized reply bodies", async () => {
readSessionMessageByIdAsyncMock.mockResolvedValue({
found: true,
message: { role: "user", content: "x".repeat(5000) },
});
const fields = await resolveChatSendReplyContext(baseParams());
expect(fields.ReplyToBody?.length).toBeLessThanOrEqual(2000);
});
});
describe("applyChatSendReplyContextFields", () => {
it("assigns only hydrated fields", () => {
const ctx = { Body: "hi" } as MsgContext;
applyChatSendReplyContextFields(ctx, { ReplyToId: "msg-1", ReplyToBody: "quoted" });
expect(ctx.ReplyToId).toBe("msg-1");
expect(ctx.ReplyToBody).toBe("quoted");
expect("ReplyToSender" in ctx).toBe(false);
});
});
@@ -0,0 +1,145 @@
// Hydrates Control UI (webchat) reply targets into the channel-agnostic
// ReplyTo* envelope fields so downstream reply-context handling matches the
// Discord path (reply_to_id + "Reply target of current user message" block).
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { sanitizeAssistantVisibleTextWithProfile } from "../../shared/text/assistant-visible-text.js";
import { resolveAssistantIdentity } from "../assistant-identity.js";
import { projectChatDisplayMessage } from "../chat-display-projection.js";
import {
readSessionMessageByIdAsync,
type SessionTranscriptReadScope,
} from "../session-transcript-readers.js";
// Reply targets are quoted context, not primary input: bound the body so a
// reply to a huge transcript entry cannot flood the prompt metadata.
const REPLY_CONTEXT_BODY_MAX_CHARS = 2000;
type ChatSendReplyContextFields = Partial<
Pick<MsgContext, "ReplyToId" | "ReplyToBody" | "ReplyToSender">
>;
function extractReplyTargetText(message: unknown): string | undefined {
const entry = asOptionalRecord(message);
if (!entry) {
return undefined;
}
if (typeof entry.text === "string" && entry.text.trim()) {
return entry.text;
}
if (typeof entry.content === "string" && entry.content.trim()) {
return entry.content;
}
if (!Array.isArray(entry.content)) {
return undefined;
}
const parts = entry.content
.map((block) => {
const record = asOptionalRecord(block);
return record && typeof record.text === "string" ? record.text : "";
})
.filter((text) => text.trim());
return parts.length > 0 ? parts.join("\n") : undefined;
}
function resolveReplyTargetSenderLabel(params: {
message: unknown;
cfg: OpenClawConfig;
agentId?: string;
userSenderLabel?: string;
}): string {
const role = asOptionalRecord(params.message)?.role;
if (role === "assistant") {
return resolveAssistantIdentity({ cfg: params.cfg, agentId: params.agentId }).name;
}
const userLabel = params.userSenderLabel?.trim();
return userLabel || "User";
}
/** Copies hydrated reply fields onto the inbound context without clobbering unset keys. */
export function applyChatSendReplyContextFields(
ctx: MsgContext,
fields: ChatSendReplyContextFields,
): void {
if (fields.ReplyToId !== undefined) {
ctx.ReplyToId = fields.ReplyToId;
}
if (fields.ReplyToBody !== undefined) {
ctx.ReplyToBody = fields.ReplyToBody;
}
if (fields.ReplyToSender !== undefined) {
ctx.ReplyToSender = fields.ReplyToSender;
}
}
/**
* Resolves a webchat reply target from session history. Always preserves the
* 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<ChatSendReplyContextFields> {
const replyToId = params.replyToId?.trim();
if (!replyToId) {
return {};
}
const fields: ChatSendReplyContextFields = { ReplyToId: replyToId };
const sessionId = params.sessionEntry?.sessionId;
if (!sessionId) {
return fields;
}
try {
const resolved = await readSessionMessageByIdAsync(
{
agentId: params.agentId,
sessionEntry: params.sessionEntry,
sessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
},
replyToId,
{ allowResetArchiveFallback: true },
);
if (!resolved.found) {
return fields;
}
// Hydrate only what webchat displays: project the stored message through the
// same chat.history display normalization so envelope wrappers, runtime
// context, tool payloads, and reasoning-only content stay out of the prompt.
const displayMessage = projectChatDisplayMessage(resolved.message);
if (!displayMessage) {
return fields;
}
const rawBody = extractReplyTargetText(displayMessage)?.trim();
// Assistant targets additionally scrub tool-call markers, thinking tags,
// and internal scaffolding, matching stored-message history text handling.
const body =
rawBody && displayMessage.role === "assistant"
? sanitizeAssistantVisibleTextWithProfile(rawBody, "history").trim()
: rawBody;
if (!body) {
return fields;
}
fields.ReplyToBody = truncateUtf16Safe(body, REPLY_CONTEXT_BODY_MAX_CHARS);
fields.ReplyToSender = resolveReplyTargetSenderLabel({
message: displayMessage,
cfg: params.cfg,
agentId: params.agentId,
userSenderLabel: params.userSenderLabel,
});
return fields;
} catch (err) {
params.warn?.(`chat.send reply context hydration failed for ${replyToId}: ${String(err)}`);
return fields;
}
}
@@ -41,6 +41,7 @@ type ChatSendRequestParams = {
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string;
replyToId?: string;
attachments?: Array<{
type?: string;
mimeType?: string;
+23
View File
@@ -123,6 +123,10 @@ import {
respondChatSessionRoutingChanged,
runChatSendPreAdmission,
} from "./chat-send-pre-admission.js";
import {
applyChatSendReplyContextFields,
resolveChatSendReplyContext,
} from "./chat-send-reply-context.js";
import { createChatSendReplyDispatch } from "./chat-send-reply-dispatch.js";
import { normalizeChatSendRequest } from "./chat-send-request.js";
import { prepareChatSendSession } from "./chat-send-session.js";
@@ -1199,6 +1203,22 @@ export const chatHandlers: GatewayRequestHandlers = {
logGateway: context.logGateway,
userTurn,
});
// Resolve the reply target from session history in parallel with the
// remaining dispatch prep so replies do not delay the first model call.
// Skipped entirely for non-reply sends so their dispatch path keeps its
// existing await ordering.
const replyContextFieldsPromise = p.replyToId
? resolveChatSendReplyContext({
replyToId: p.replyToId,
cfg,
agentId,
sessionKey,
sessionEntry: entry,
storePath,
userSenderLabel: clientInfo?.displayName,
warn: (message) => context.logGateway.warn(message),
})
: undefined;
let agentRunStarted = false;
const { deliveredReplies, dispatcher, hasAppendedWebchatAgentMedia, onModelSelected } =
@@ -1262,6 +1282,9 @@ export const chatHandlers: GatewayRequestHandlers = {
"gateway.chat_send.dispatch_inbound",
async () => {
applyChatSendManagedMediaFields(ctx, await pluginBoundMediaFieldsPromise);
if (replyContextFieldsPromise) {
applyChatSendReplyContextFields(ctx, await replyContextFieldsPromise);
}
const dispatchResult = await dispatchInboundMessage({
ctx,
cfg,
+2
View File
@@ -20,6 +20,8 @@ export type ChatQueueItem = {
kind?: "queued" | "steered";
attachments?: ChatAttachment[];
refreshSessions?: boolean;
/** Transcript id of the replied-to message; Gateway hydrates reply context. */
replyToId?: string;
localCommandArgs?: string;
localCommandName?: string;
pendingRunId?: string;
+33
View File
@@ -4935,6 +4935,39 @@ describe("handleSendChat", () => {
expect(host.chatReplyTarget).toBeNull();
});
it("sends replyToId instead of an inline quote when the reply target has a transcript id", async () => {
const sent = createDeferred<unknown>();
const request = vi.fn((method: string, _params?: unknown) => {
if (method === "chat.send") {
return sent.promise;
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "continue",
chatReplyTarget: {
messageId: "id:transcript-abc",
text: "quoted body",
senderLabel: "Molty",
sourceMessageId: "transcript-abc",
},
});
const send = handleSendChat(host);
await Promise.resolve();
expect(host.chatQueue[0]?.text).toBe("continue");
expect(host.chatQueue[0]?.replyToId).toBe("transcript-abc");
sent.resolve({ runId: host.chatQueue[0]?.sendRunId, status: "started" });
await send;
const sendCall = request.mock.calls.find(([method]) => method === "chat.send");
expect(sendCall?.[1]).toMatchObject({ message: "continue", replyToId: "transcript-abc" });
expect(host.chatReplyTarget).toBeNull();
});
it("keeps reply state when chat.send fails before acceptance", async () => {
const request = vi.fn((method: string) => {
if (method === "chat.send") {
+17 -2
View File
@@ -135,7 +135,12 @@ export type ChatHost = ChatInputHistoryState &
/** Prepared from the browser override and current Gateway effective queue mode. */
chatFollowUpMode?: ControlUiFollowUpMode;
/** Selected message to reply to (right-click / keyboard shortcut). */
chatReplyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
chatReplyTarget?: {
messageId: string;
text: string;
senderLabel?: string | null;
sourceMessageId?: string | null;
} | null;
/** Placeholder for an in-flight /btw side question awaiting chat.side_result. */
chatSideResultPending?: ChatSideResultPending | null;
/** Retired/handled BTW run ids whose late events must not reach the transcript. */
@@ -262,6 +267,7 @@ async function requestChatSend(
sessionKey?: string;
agentId?: string;
queueMode?: QueueMode;
replyToId?: string;
},
): Promise<ChatSendAck> {
const routing = resolveChatSendRouting(state, params);
@@ -277,6 +283,7 @@ async function requestChatSend(
...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}),
message: params.message,
deliver: false,
...(params.replyToId ? { replyToId: params.replyToId } : {}),
...(params.queueMode ? { queueMode: params.queueMode } : {}),
idempotencyKey: params.runId,
attachments: buildChatApiAttachments(params.attachments),
@@ -440,6 +447,7 @@ function enqueuePendingSendMessage(
submittedAtMs = controlUiNowMs(),
sendState?: ChatQueueItem["sendState"],
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision,
replyToId?: string,
): ChatQueueItem | null {
const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0);
@@ -459,6 +467,7 @@ function enqueuePendingSendMessage(
sessionKey: host.sessionKey,
agentId: scopedAgentIdForSession(host, host.sessionKey),
...(skillWorkshopRevision ? { skillWorkshopRevision } : {}),
...(replyToId ? { replyToId } : {}),
};
host.chatQueue = [...host.chatQueue, pending];
recordChatSendTiming(host, pending, "pending-visible", submittedAtMs);
@@ -835,6 +844,7 @@ async function sendQueuedChatMessage(
runId,
sessionKey,
agentId: prepared.agentId,
...(prepared.replyToId ? { replyToId: prepared.replyToId } : {}),
});
updateChatSendAckTiming(host, runId, ack, sendingItem, requestStartedAtMs);
recordChatSendTiming(host, sendingItem, "ack", sendingItem.sendSubmittedAtMs, {
@@ -2357,7 +2367,11 @@ export async function handleSendChat(
}
const replyTarget = host.chatReplyTarget;
const effectiveMessage = replyTarget ? prependReplyQuote(message, replyTarget) : message;
// Persisted transcript ids ride chat.send as replyToId so the Gateway can
// hydrate reply context like Discord; synthetic ids fall back to a quote.
const replyToId = replyTarget?.sourceMessageId?.trim() || undefined;
const effectiveMessage =
replyTarget && !replyToId ? prependReplyQuote(message, replyTarget) : message;
const refreshSessions = shouldInterpretChatCommands && isChatResetCommand(message);
const submitKey = chatSubmitKey(
@@ -2392,6 +2406,7 @@ export async function handleSendChat(
submittedAtMs,
initialSendState,
skillWorkshopRevision,
replyToId,
);
if (!queued) {
return;
+12 -2
View File
@@ -196,9 +196,19 @@ export type ChatProps = {
onChatScroll?: (event: Event) => void;
basePath?: string;
composerControls?: TemplateResult | typeof nothing;
replyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
replyTarget?: {
messageId: string;
text: string;
senderLabel?: string | null;
sourceMessageId?: string | null;
} | null;
onClearReply?: () => void;
onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void;
onSetReply?: (target: {
messageId: string;
text: string;
senderLabel?: string | null;
sourceMessageId?: string | null;
}) => void;
onRewindMessage?: (entryId: string) => Promise<boolean> | boolean;
onForkMessage?: (entryId: string) => Promise<void> | void;
sessionWorkspace?: SessionWorkspaceProps;
@@ -108,7 +108,12 @@ type ChatComposerProps = {
followUpMode?: ControlUiFollowUpMode;
attachments?: ChatAttachment[];
getAttachments?: () => ChatAttachment[];
replyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
replyTarget?: {
messageId: string;
text: string;
senderLabel?: string | null;
sourceMessageId?: string | null;
} | null;
realtimeTalkActive?: boolean;
realtimeTalkStatus?: RealtimeTalkStatus;
realtimeTalkDetail?: string | null;
+8 -1
View File
@@ -81,6 +81,8 @@ type ReplyTarget = {
messageId: string;
text: string;
senderLabel?: string | null;
/** Persisted transcript id; when present chat.send carries it as replyToId. */
sourceMessageId?: string | null;
};
type ChatThreadState = {
@@ -810,7 +812,12 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
const messageId =
(bubble as HTMLElement).dataset.messageId?.trim() || stableReplyMessageId(senderLabel, text);
const replyButton = createReplyContextMenuButton(() => {
props.onSetReply?.({ messageId, text, senderLabel });
props.onSetReply?.({
messageId,
text,
senderLabel,
...(entryId ? { sourceMessageId: entryId } : {}),
});
removeReplyContextMenu();
props.onFocusComposer?.();
});
@@ -784,6 +784,7 @@ function serializeQueueItem(item: ChatQueueItem): ChatQueueItem | null {
...(item.kind === "queued" || item.kind === "steered" ? { kind: item.kind } : {}),
...(attachments.length ? { attachments: attachments as ChatAttachment[] } : {}),
...(typeof item.refreshSessions === "boolean" ? { refreshSessions: item.refreshSessions } : {}),
...(item.replyToId ? { replyToId: item.replyToId } : {}),
...(item.localCommandArgs ? { localCommandArgs: item.localCommandArgs } : {}),
...(item.localCommandName ? { localCommandName: item.localCommandName } : {}),
...(item.sessionKey ? { sessionKey: item.sessionKey } : {}),
@@ -828,6 +829,10 @@ function normalizeQueueItem(value: unknown): ChatQueueItem | null {
if (refreshSessions !== undefined) {
item.refreshSessions = refreshSessions;
}
const replyToId = normalizeOptionalString(entry.replyToId);
if (replyToId) {
item.replyToId = replyToId;
}
if (
entry.sendState === "failed" ||
entry.sendState === "unconfirmed" ||