diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index 105826f9..011ecacc 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -12,6 +12,7 @@ import {
Clock3,
Copy,
ImageIcon,
+ Quote,
Wrench,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -33,6 +34,7 @@ import { copyTextToClipboard } from "@/lib/clipboard";
import { formatTurnLatency } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import { matchingSlashCommand } from "@/lib/slash-command";
+import { parseQuotedUserMessage } from "@/lib/user-message-quote";
import type {
CliAppInfo,
McpPresetInfo,
@@ -158,20 +160,23 @@ export function MessageBubble({
const media = message.media ?? [];
const hasImages = images.length > 0;
const hasMedia = media.length > 0;
- const hasText = message.content.trim().length > 0;
- const slashCommand = matchingSlashCommand(message.content, slashCommands);
+ const parsedMessage = parseQuotedUserMessage(message.content);
+ const userContent = parsedMessage.content;
+ const hasText = userContent.trim().length > 0;
+ const quotedContext = parsedMessage.quotedContext;
+ const slashCommand = matchingSlashCommand(userContent, slashCommands);
const messageText = slashCommand ? (
<>
>
) : (
@@ -187,6 +192,12 @@ export function MessageBubble({
{!hasImages && hasMedia ? (
) : null}
+ {quotedContext ? (
+
+ ) : null}
{hasText ? (
+
+
+ {text}
+
+
+ );
+}
+
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
return (
{
const text = value.trim();
if (!canQueueGuidance || (!text && readyImages.length === 0)) return;
- if (utf8Bytes(text) > maxTextBytes) {
+ if (utf8Bytes(formatQuotedUserMessage(text, normalizedQuotedContext)) > maxTextBytes) {
setInlineError(textTooLargeMessage());
return;
}
@@ -1608,7 +1609,7 @@ export function ThreadComposer({
if (!canSend) return;
const trimmed = value.trim();
const content = trimmed;
- if (utf8Bytes(content) > maxTextBytes) {
+ if (utf8Bytes(formatQuotedUserMessage(content, normalizedQuotedContext)) > maxTextBytes) {
setInlineError(textTooLargeMessage());
return;
}
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index 273391fe..c78d3bcf 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -10,6 +10,7 @@ import {
} from "@/lib/tool-traces";
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import type { StreamError } from "@/lib/nanobot-client";
+import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import type {
InboundEvent,
OutboundCliAppMention,
@@ -1184,6 +1185,9 @@ export function useNanobotStream(
const sideChannel = options?.sideChannel === true;
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
const continueActiveTurn = options?.continueActiveTurn === true;
+ const outboundContent = options?.quotedContext
+ ? formatQuotedUserMessage(content, options.quotedContext)
+ : content;
flushPendingStreamEvents();
if (finalizeActiveTurn) {
cancelStreamEndTimer();
@@ -1211,7 +1215,7 @@ export function useNanobotStream(
{
id: crypto.randomUUID(),
role: "user",
- content,
+ content: outboundContent,
turnId,
turnPhase: "user",
turnSeq: 0,
@@ -1225,10 +1229,11 @@ export function useNanobotStream(
if (!sideChannel) setIsStreaming(true);
const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined;
const wireOptions = { ...options, turnId };
+ delete wireOptions.quotedContext;
delete wireOptions.sideChannel;
delete wireOptions.finalizeActiveTurn;
delete wireOptions.continueActiveTurn;
- client.sendMessage(chatId, content, wireMedia, wireOptions);
+ client.sendMessage(chatId, outboundContent, wireMedia, wireOptions);
},
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
);
diff --git a/webui/src/lib/user-message-quote.ts b/webui/src/lib/user-message-quote.ts
new file mode 100644
index 00000000..cf027360
--- /dev/null
+++ b/webui/src/lib/user-message-quote.ts
@@ -0,0 +1,58 @@
+interface ParsedUserMessageQuote {
+ quotedContext: string | null;
+ content: string;
+}
+
+const QUOTED_CONTEXT_MARKER = "> [!QUOTE]";
+
+function normalizeNewlines(value: string): string {
+ return value.replace(/\r\n?/g, "\n");
+}
+
+export function formatQuotedUserMessage(content: string, quotedContext?: string | null): string {
+ const body = content.trim();
+ const quote = normalizeNewlines(quotedContext ?? "").trim();
+ if (!quote || body.startsWith("/")) return body;
+
+ const blockquote = quote
+ .split("\n")
+ .map((line) => line ? `> ${line}` : ">")
+ .join("\n");
+ const quotedMessage = `${QUOTED_CONTEXT_MARKER}\n${blockquote}`;
+ return body ? `${quotedMessage}\n\n${body}` : quotedMessage;
+}
+
+export function parseQuotedUserMessage(content: string): ParsedUserMessageQuote {
+ if (!content.startsWith(QUOTED_CONTEXT_MARKER)) {
+ return { quotedContext: null, content };
+ }
+ const normalized = normalizeNewlines(content);
+ const quoteStart = QUOTED_CONTEXT_MARKER.length + 1;
+ if (!normalized.startsWith(`${QUOTED_CONTEXT_MARKER}\n`)) {
+ return { quotedContext: null, content };
+ }
+ const separatorIndex = normalized.indexOf("\n\n", quoteStart);
+ const quoteBlock =
+ separatorIndex === -1
+ ? normalized.slice(quoteStart)
+ : normalized.slice(quoteStart, separatorIndex);
+ const quoteLines = quoteBlock.split("\n");
+ if (
+ quoteLines.length === 0
+ || quoteLines.some((line) => line !== ">" && !line.startsWith("> "))
+ ) {
+ return { quotedContext: null, content };
+ }
+
+ const quotedContext = quoteLines
+ .map((line) => line === ">" ? "" : line.slice(2))
+ .join("\n")
+ .trim();
+ if (!quotedContext) {
+ return { quotedContext: null, content };
+ }
+ return {
+ quotedContext,
+ content: separatorIndex === -1 ? "" : normalized.slice(separatorIndex + 2),
+ };
+}
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index 2d77b9af..91189faf 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -112,6 +112,32 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
+ it("styles only generated quoted context in user messages", () => {
+ const message: UIMessage = {
+ id: "u-quote",
+ role: "user",
+ content: "> [!QUOTE]\n> selected assistant excerpt\n\nWhat about this?",
+ createdAt: Date.now(),
+ };
+
+ const { rerender } = render();
+
+ const quote = screen.getByLabelText("Quoted context");
+ expect(quote).toHaveTextContent("selected assistant excerpt");
+ expect(screen.queryByText("Quoted context")).not.toBeInTheDocument();
+ expect(screen.getByText("What about this?")).toBeInTheDocument();
+
+ rerender(
+ manually typed quote\n\nWhat about this?",
+ }}
+ />,
+ );
+ expect(screen.queryByLabelText("Quoted context")).not.toBeInTheDocument();
+ });
+
it("copies user messages from the shared message action", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index d5d5a0b4..dfbb9da3 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -1600,6 +1600,25 @@ describe("useNanobotStream", () => {
);
});
+ it("inlines quoted context into the optimistic and outbound user message", () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-quote", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ result.current.send("What about this?", undefined, {
+ quotedContext: "selected assistant excerpt",
+ });
+ });
+
+ const expectedContent = "> [!QUOTE]\n> selected assistant excerpt\n\nWhat about this?";
+ expect(result.current.messages[0].content).toBe(expectedContent);
+ const outbound = fake.client.sendMessage.mock.calls.at(-1)!;
+ expect(outbound[1]).toBe(expectedContent);
+ expect(outbound[3]).not.toHaveProperty("quotedContext");
+ });
+
it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
diff --git a/webui/src/tests/user-message-quote.test.ts b/webui/src/tests/user-message-quote.test.ts
new file mode 100644
index 00000000..793ace8e
--- /dev/null
+++ b/webui/src/tests/user-message-quote.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ formatQuotedUserMessage,
+ parseQuotedUserMessage,
+} from "@/lib/user-message-quote";
+
+describe("user message quotes", () => {
+ it("round-trips multiline quoted context through the message body", () => {
+ const content = formatQuotedUserMessage(
+ "What does this mean?",
+ "first quoted line\n\nsecond quoted line",
+ );
+
+ expect(content).toBe(
+ "> [!QUOTE]\n> first quoted line\n>\n> second quoted line\n\nWhat does this mean?",
+ );
+ expect(parseQuotedUserMessage(content)).toEqual({
+ quotedContext: "first quoted line\n\nsecond quoted line",
+ content: "What does this mean?",
+ });
+ });
+
+ it("leaves ordinary messages and manual blockquotes unchanged", () => {
+ const manualBlockquote = "> manually typed quote\n\nordinary message";
+
+ expect(parseQuotedUserMessage(manualBlockquote)).toEqual({
+ quotedContext: null,
+ content: manualBlockquote,
+ });
+ });
+
+ it("does not place quoted context ahead of slash commands", () => {
+ expect(formatQuotedUserMessage("/model", "selected answer excerpt")).toBe("/model");
+ });
+});