fix(webui): show quoted context after follow-up send (#5071)
This commit is contained in:
@@ -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 ? (
|
||||
<>
|
||||
<SlashCommandText command={slashCommand.command} />
|
||||
<UserMessageText
|
||||
text={message.content.slice(slashCommand.command.length)}
|
||||
text={userContent.slice(slashCommand.command.length)}
|
||||
cliApps={mentionCliApps}
|
||||
mcpPresets={mentionMcpPresets}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<UserMessageText
|
||||
text={message.content}
|
||||
text={userContent}
|
||||
cliApps={mentionCliApps}
|
||||
mcpPresets={mentionMcpPresets}
|
||||
/>
|
||||
@@ -187,6 +192,12 @@ export function MessageBubble({
|
||||
{!hasImages && hasMedia ? (
|
||||
<MessageMedia media={media} align="right" />
|
||||
) : null}
|
||||
{quotedContext ? (
|
||||
<UserQuotedContext
|
||||
text={quotedContext}
|
||||
label={t("thread.composer.quotedContext")}
|
||||
/>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<p
|
||||
className={cn(
|
||||
@@ -305,6 +316,24 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function UserQuotedContext({ text, label }: { text: string; label: string }) {
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"ml-auto flex w-fit max-w-full min-w-0 items-start gap-2 rounded-[14px]",
|
||||
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
|
||||
)}
|
||||
aria-label={label}
|
||||
title={text}
|
||||
>
|
||||
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0 line-clamp-3 whitespace-pre-wrap text-[13px]/[1.45] [overflow-wrap:anywhere]">
|
||||
{text}
|
||||
</p>
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
isSideChannelLifecycle,
|
||||
slashCommandLifecycle,
|
||||
} from "@/lib/slash-command";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||
@@ -1466,7 +1467,7 @@ export function ThreadComposer({
|
||||
const queueGuidancePrompt = useCallback(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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(<MessageBubble message={message} />);
|
||||
|
||||
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(
|
||||
<MessageBubble
|
||||
message={{
|
||||
...message,
|
||||
content: "> 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", {
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user