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"); + }); +});