From d9a58cf86fdab60042ddaca5bbbd4f2707aa5a6c Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Thu, 9 Apr 2026 18:58:43 +0300 Subject: [PATCH 1/3] feat(chat): add reply-to-selection with quoted context - Users can select text in assistant messages and click "Reply" in floating toolbar - Quoted text prepended to prompts as XML blocks - Keyboard shortcut: Cmd/Ctrl+Shift+R to reply, Escape to dismiss - New components: SelectionReplyToolbar, QuotedContextInlineChip, UserMessageQuotedContextLabel - Quoted contexts stored transiently in composerDraftStore, cleared on send - Expandable label displays quoted context in message timeline - Quoted text truncated at 5000 chars with ...[truncated] suffix - Code block selections automatically detect language --- AGENTS.md | 22 ++ apps/web/src/components/ChatView.logic.ts | 5 +- apps/web/src/components/ChatView.tsx | 56 ++++- .../CompactComposerControlsMenu.browser.tsx | 1 + .../components/chat/MessagesTimeline.test.tsx | 2 + .../src/components/chat/MessagesTimeline.tsx | 50 ++++- ...essagesTimeline.virtualization.browser.tsx | 1 + .../chat/QuotedContextInlineChip.tsx | 56 +++++ .../components/chat/SelectionReplyToolbar.tsx | 211 ++++++++++++++++++ .../components/chat/TraitsPicker.browser.tsx | 2 + .../chat/UserMessageQuotedContextLabel.tsx | 46 ++++ apps/web/src/composerDraftStore.ts | 76 +++++++ apps/web/src/lib/quotedContext.ts | 101 +++++++++ 13 files changed, 620 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/chat/QuotedContextInlineChip.tsx create mode 100644 apps/web/src/components/chat/SelectionReplyToolbar.tsx create mode 100644 apps/web/src/components/chat/UserMessageQuotedContextLabel.tsx create mode 100644 apps/web/src/lib/quotedContext.ts diff --git a/AGENTS.md b/AGENTS.md index e93c1b76c443..537bdaea55c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,3 +246,25 @@ Board selection stored per project via `jiraBoard` field on `OrchestrationProjec - `ComposerPromptSegment` union includes `"jira-context"` type alongside `"terminal-context"` - `ComposerCommandItem` union includes `"jira-task"` type for menu autocomplete - `Project` type in `types.ts` includes `jiraBoard: JiraBoardReference | null` + +## Reply to Selection (Quoted Context) + +MarCode supports replying to specific text selections within assistant messages. Users can select text in an agent response, click "Reply" in a floating toolbar, and the selected text is quoted as structured context in the composer. + +### Architecture + +- **`apps/web/src/lib/quotedContext.ts`** — `QuotedContext` type, prompt assembly (`appendQuotedContextsToPrompt`), extraction (`extractLeadingQuotedContexts`), dedup, truncation (5000 char limit). +- **`apps/web/src/components/chat/SelectionReplyToolbar.tsx`** — Floating toolbar that appears on text selection within assistant messages. Renders via `createPortal` to `document.body`. Detects code block selections and extracts language. +- **`apps/web/src/components/chat/QuotedContextInlineChip.tsx`** — Visual chip rendered in the composer above the editor showing quoted text preview with remove button. Uses violet color scheme. +- **`apps/web/src/components/chat/UserMessageQuotedContextLabel.tsx`** — Expandable label in the message timeline showing quoted context when a sent message includes it. +- **`apps/web/src/components/chat/MessagesTimeline.tsx`** — `AssistantMessageContentWithReply` wraps assistant message content with a ref for selection tracking and renders `SelectionReplyToolbar`. + +### Key Patterns + +- `QuotedContext` is stored in `composerDraftStore` as `quotedContexts: QuotedContext[]` on `ComposerThreadDraftState`. +- Quoted context blocks are **prepended** to the prompt (unlike terminal/jira which are appended) as `` XML blocks. +- `extractLeadingQuotedContexts()` parses leading quoted blocks from stored message text for timeline display. +- Keyboard shortcut: `Cmd/Ctrl+Shift+R` to reply to current selection, `Escape` to dismiss toolbar. +- Quoted contexts are **not** persisted to localStorage (transient draft state) — they are cleared on thread switch or send. +- Selection spanning multiple messages captures only text from the message where selection started. +- Truncation at 5000 chars with `...[truncated]` suffix. diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index dee6fb9368d6..78014d81b740 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -174,6 +174,7 @@ export function deriveComposerSendState(options: { imageCount: number; terminalContexts: ReadonlyArray; jiraTaskContexts?: ReadonlyArray; + quotedContextCount?: number; }): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; @@ -187,6 +188,7 @@ export function deriveComposerSendState(options: { const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; const jiraTaskContexts = options.jiraTaskContexts ?? []; + const quotedContextCount = options.quotedContextCount ?? 0; return { trimmedPrompt, sendableTerminalContexts, @@ -195,7 +197,8 @@ export function deriveComposerSendState(options: { trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0 || - jiraTaskContexts.length > 0, + jiraTaskContexts.length > 0 || + quotedContextCount > 0, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 85873de35d7b..09fd3cc6155a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,6 +168,12 @@ import { parseJiraUrl, removeInlineJiraContextPlaceholder, } from "../lib/jiraContext"; +import { + appendQuotedContextsToPrompt, + formatQuotedContextPreview, + type QuotedContext, +} from "../lib/quotedContext"; +import { QuotedContextInlineChip } from "./chat/QuotedContextInlineChip"; import { jiraConnectionStatusQueryOptions, jiraIssueSearchQueryOptions, @@ -409,6 +415,7 @@ interface SubmitComposerTurnInput { images: ComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; jiraTaskContexts: JiraTaskDraft[]; + quotedContexts: QuotedContext[]; expiredTerminalContextCount: number; clearComposerDraft: boolean; } @@ -684,6 +691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; const composerJiraTaskContexts = composerDraft.jiraTaskContexts; + const composerQuotedContexts = composerDraft.quotedContexts; const composerSendState = useMemo( () => deriveComposerSendState({ @@ -691,8 +699,15 @@ export default function ChatView({ threadId }: ChatViewProps) { imageCount: composerImages.length, terminalContexts: composerTerminalContexts, jiraTaskContexts: composerJiraTaskContexts, + quotedContextCount: composerQuotedContexts.length, }), - [composerImages.length, composerTerminalContexts, composerJiraTaskContexts, prompt], + [ + composerImages.length, + composerTerminalContexts, + composerJiraTaskContexts, + composerQuotedContexts.length, + prompt, + ], ); const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -719,9 +734,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const addComposerDraftJiraTaskContext = useComposerDraftStore( (store) => store.addJiraTaskContext, ); + const addComposerDraftQuotedContext = useComposerDraftStore((store) => store.addQuotedContext); const removeComposerDraftJiraTaskContext = useComposerDraftStore( (store) => store.removeJiraTaskContext, ); + const removeComposerDraftQuotedContext = useComposerDraftStore( + (store) => store.removeQuotedContext, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -3134,14 +3153,19 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImagesSnapshot = [...input.images]; const composerTerminalContextsSnapshot = [...input.terminalContexts]; const composerJiraTaskContextsSnapshot = [...input.jiraTaskContexts]; + const composerQuotedContextsSnapshot = [...input.quotedContexts]; const cleanedPromptForSend = replaceInlineJiraPlaceholdersWithLabels( input.prompt, composerJiraTaskContextsSnapshot, ); - const messageTextForSend = appendJiraContextsToPrompt( + const withTerminalAndJira = appendJiraContextsToPrompt( appendTerminalContextsToPrompt(cleanedPromptForSend, composerTerminalContextsSnapshot), composerJiraTaskContextsSnapshot, ); + const messageTextForSend = appendQuotedContextsToPrompt( + withTerminalAndJira, + composerQuotedContextsSnapshot, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ @@ -3457,6 +3481,7 @@ export default function ChatView({ threadId }: ChatViewProps) { imageCount: composerImages.length, terminalContexts: composerTerminalContexts, jiraTaskContexts: composerJiraTaskContexts, + quotedContextCount: composerQuotedContexts.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ @@ -3509,6 +3534,7 @@ export default function ChatView({ threadId }: ChatViewProps) { images: [...composerImages], terminalContexts: [...sendableComposerTerminalContexts], jiraTaskContexts: [...composerJiraTaskContexts], + quotedContexts: [...composerQuotedContexts], expiredTerminalContextCount, clearComposerDraft: true, }); @@ -4304,6 +4330,14 @@ export default function ChatView({ threadId }: ChatViewProps) { [groupId]: !existing[groupId], })); }, []); + const onReplyToSelection = useCallback( + (context: QuotedContext) => { + if (!activeThread) return; + addComposerDraftQuotedContext(activeThread.id, context); + focusComposer(); + }, + [activeThread, addComposerDraftQuotedContext, focusComposer], + ); const onSubagentSelect = useCallback((taskId: string) => { setSelectedSubagentTaskId(taskId); }, []); @@ -4452,6 +4486,7 @@ export default function ChatView({ threadId }: ChatViewProps) { images: imagesToSend, terminalContexts: [], jiraTaskContexts: [], + quotedContexts: [], expiredTerminalContextCount: 0, clearComposerDraft: false, }); @@ -4627,6 +4662,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onRemoveEditingUserMessageImage={onRemoveEditingUserMessageImage} onCancelEditUserMessage={discardUserMessageEditSession} onSubmitEditUserMessage={onSubmitEditUserMessage} + onReplyToSelection={onReplyToSelection} /> @@ -4789,6 +4825,22 @@ export default function ChatView({ threadId }: ChatViewProps) { ))} )} + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerQuotedContexts.length > 0 && ( +
+ {composerQuotedContexts.map((ctx) => ( + 300 ? `${ctx.text.slice(0, 300)}…` : ctx.text + } + onRemove={() => removeComposerDraftQuotedContext(threadId, ctx.id)} + /> + ))} +
+ )} { onRemoveEditingUserMessageImage={() => {}} onCancelEditUserMessage={() => {}} onSubmitEditUserMessage={() => {}} + onReplyToSelection={() => {}} />, ); @@ -164,6 +165,7 @@ describe("MessagesTimeline", () => { onRemoveEditingUserMessageImage={() => {}} onCancelEditUserMessage={() => {}} onSubmitEditUserMessage={() => {}} + onReplyToSelection={() => {}} />, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 682fcc4bcf37..3c01ded5cf31 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -69,6 +69,9 @@ import { import { cn } from "~/lib/utils"; import { extractTrailingJiraContexts, type ParsedJiraContextEntry } from "~/lib/jiraContext"; import { JiraTaskInlineChip } from "./JiraTaskInlineChip"; +import { SelectionReplyToolbar } from "./SelectionReplyToolbar"; +import { extractLeadingQuotedContexts, type ParsedQuotedContextEntry } from "~/lib/quotedContext"; +import { UserMessageQuotedContextLabel } from "./UserMessageQuotedContextLabel"; import { type TimestampFormat } from "@marcode/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; import { @@ -115,6 +118,7 @@ interface MessagesTimelineProps { onRemoveEditingUserMessageImage: (imageId: string) => void; onCancelEditUserMessage: () => void; onSubmitEditUserMessage: () => void | Promise; + onReplyToSelection: (context: import("../../lib/quotedContext").QuotedContext) => void; onVirtualizerSnapshot?: (snapshot: { totalSize: number; measurements: ReadonlyArray<{ @@ -163,6 +167,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRemoveEditingUserMessageImage, onCancelEditUserMessage, onSubmitEditUserMessage, + onReplyToSelection, onVirtualizerSnapshot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); @@ -463,11 +468,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
- + + + {(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; @@ -867,7 +878,10 @@ const EditableUserMessageTimelineRow = memo(function EditableUserMessageTimeline } const userImages = props.message.attachments ?? []; - const displayedUserMessage = deriveDisplayedUserMessageState(props.message.text); + const quotedExtracted = extractLeadingQuotedContexts(props.message.text); + const afterQuotedText = + quotedExtracted.contextCount > 0 ? quotedExtracted.promptText : props.message.text; + const displayedUserMessage = deriveDisplayedUserMessageState(afterQuotedText); const terminalContexts = displayedUserMessage.contexts; const jiraExtracted = extractTrailingJiraContexts(displayedUserMessage.visibleText); const visibleText = @@ -917,6 +931,9 @@ const EditableUserMessageTimelineRow = memo(function EditableUserMessageTimeline ))}
)} + {quotedExtracted.contexts.length > 0 && ( + + )} {(visibleText.trim().length > 0 || terminalContexts.length > 0) && ( void; +}) { + const containerRef = useRef(null); + + return ( +
+ {props.children} + +
+ ); +}); + function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string; diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index c0ad47089d05..c43f202327e4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -182,6 +182,7 @@ function createBaseTimelineProps(input: { onRemoveEditingUserMessageImage: () => {}, onCancelEditUserMessage: () => {}, onSubmitEditUserMessage: () => {}, + onReplyToSelection: () => {}, ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}), }; } diff --git a/apps/web/src/components/chat/QuotedContextInlineChip.tsx b/apps/web/src/components/chat/QuotedContextInlineChip.tsx new file mode 100644 index 000000000000..fb7721674107 --- /dev/null +++ b/apps/web/src/components/chat/QuotedContextInlineChip.tsx @@ -0,0 +1,56 @@ +import { QuoteIcon, XIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, +} from "../composerInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +interface QuotedContextInlineChipProps { + preview: string; + tooltipText: string; + onRemove?: () => void; +} + +export function QuotedContextInlineChip(props: QuotedContextInlineChipProps) { + const { preview, tooltipText, onRemove } = props; + + return ( + + + + + {preview} + + {onRemove && ( + + )} + + } + /> + + {tooltipText} + + + ); +} diff --git a/apps/web/src/components/chat/SelectionReplyToolbar.tsx b/apps/web/src/components/chat/SelectionReplyToolbar.tsx new file mode 100644 index 000000000000..5eb6027e554e --- /dev/null +++ b/apps/web/src/components/chat/SelectionReplyToolbar.tsx @@ -0,0 +1,211 @@ +import { CopyIcon, ReplyIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { MessageId, TurnId } from "@marcode/contracts"; +import type { QuotedContext } from "../../lib/quotedContext"; +import { truncateQuotedText } from "../../lib/quotedContext"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; + +interface SelectionReplyToolbarProps { + messageId: MessageId; + turnId: TurnId | null; + containerRef: React.RefObject; + onReply: (context: QuotedContext) => void; +} + +interface ToolbarPosition { + top: number; + left: number; +} + +let nextQuotedContextId = 0; +function newQuotedContextId(): string { + nextQuotedContextId += 1; + return `quoted-${Date.now()}-${nextQuotedContextId}`; +} + +function getSelectionTextAndMeta( + containerEl: HTMLElement, +): { + text: string; + startOffset: number; + endOffset: number; + codeLanguage: string | undefined; +} | null { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + if (!range || !containerEl.contains(range.startContainer)) return null; + + const text = selection.toString().trim(); + if (text.length === 0) return null; + + const codeBlock = findAncestorCodeBlock(range.startContainer, containerEl); + const codeLanguage = codeBlock ? extractCodeLanguageFromBlock(codeBlock) : undefined; + + const preRange = document.createRange(); + preRange.selectNodeContents(containerEl); + preRange.setEnd(range.startContainer, range.startOffset); + const startOffset = preRange.toString().length; + const endOffset = startOffset + text.length; + + return { text, startOffset, endOffset, codeLanguage }; +} + +function findAncestorCodeBlock(node: Node, boundary: HTMLElement): HTMLElement | null { + let current: Node | null = node; + while (current && current !== boundary) { + if (current instanceof HTMLElement && current.classList.contains("chat-markdown-codeblock")) { + return current; + } + current = current.parentNode; + } + return null; +} + +function extractCodeLanguageFromBlock(codeBlock: HTMLElement): string | undefined { + const codeEl = codeBlock.querySelector("code[class*='language-']"); + if (!codeEl) return undefined; + const match = codeEl.className.match(/language-(\S+)/); + return match?.[1]; +} + +export const SelectionReplyToolbar = memo(function SelectionReplyToolbar( + props: SelectionReplyToolbarProps, +) { + const { messageId, turnId, containerRef, onReply } = props; + const [position, setPosition] = useState(null); + const toolbarRef = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard(); + + useEffect(() => { + const handleSelectionChange = () => { + const container = containerRef.current; + if (!container) { + setPosition(null); + return; + } + + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + setPosition(null); + return; + } + + const range = selection.getRangeAt(0); + if (!range || !container.contains(range.startContainer)) { + setPosition(null); + return; + } + + const text = selection.toString().trim(); + if (text.length === 0) { + setPosition(null); + return; + } + + const rect = range.getBoundingClientRect(); + const toolbarHeight = 32; + const gap = 6; + + setPosition({ + top: rect.top - toolbarHeight - gap + window.scrollY, + left: rect.left + rect.width / 2 + window.scrollX, + }); + }; + + document.addEventListener("selectionchange", handleSelectionChange); + return () => document.removeEventListener("selectionchange", handleSelectionChange); + }, [containerRef]); + + const handleReply = useCallback(() => { + const container = containerRef.current; + if (!container) return; + + const meta = getSelectionTextAndMeta(container); + if (!meta) return; + + const { text: rawText, wasTruncated } = truncateQuotedText(meta.text); + if (wasTruncated) { + console.warn("Quoted text was truncated to 5000 characters"); + } + + const context: QuotedContext = { + id: newQuotedContextId(), + messageId, + turnId, + text: rawText, + codeLanguage: meta.codeLanguage, + startOffset: meta.startOffset, + endOffset: meta.endOffset, + }; + + onReply(context); + window.getSelection()?.removeAllRanges(); + setPosition(null); + }, [containerRef, messageId, turnId, onReply]); + + const handleCopy = useCallback(() => { + const selection = window.getSelection(); + if (!selection) return; + const text = selection.toString().trim(); + if (text.length > 0) { + copyToClipboard(text); + } + }, [copyToClipboard]); + + useEffect(() => { + if (!position) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + window.getSelection()?.removeAllRanges(); + setPosition(null); + return; + } + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "r") { + e.preventDefault(); + handleReply(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [position, handleReply]); + + if (!position) return null; + + return createPortal( +
e.preventDefault()} + > + + +
, + document.body, + ); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 1a044848c294..611bd44174c7 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -176,6 +176,7 @@ async function mountClaudePicker(props?: { persistedAttachments: [], terminalContexts: [], jiraTaskContexts: [], + quotedContexts: [], modelSelectionByProvider: props?.skipDraftModelOptions ? {} : { @@ -380,6 +381,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt persistedAttachments: [], terminalContexts: [], jiraTaskContexts: [], + quotedContexts: [], modelSelectionByProvider: { codex: { provider: "codex", diff --git a/apps/web/src/components/chat/UserMessageQuotedContextLabel.tsx b/apps/web/src/components/chat/UserMessageQuotedContextLabel.tsx new file mode 100644 index 000000000000..b575b3d9e30d --- /dev/null +++ b/apps/web/src/components/chat/UserMessageQuotedContextLabel.tsx @@ -0,0 +1,46 @@ +import { QuoteIcon, ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; +import type { ParsedQuotedContextEntry } from "../../lib/quotedContext"; + +export function UserMessageQuotedContextLabel({ + contexts, +}: { + contexts: ReadonlyArray; +}) { + const [expanded, setExpanded] = useState(false); + + if (contexts.length === 0) return null; + + return ( +
+ + {expanded && ( +
+ {contexts.map((ctx, idx) => ( +
0 ? "mt-2 border-t border-violet-500/10 pt-2" : ""} + > +
{ctx.header}
+ {ctx.body && ( +
{ctx.body}
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index c60fa92ddb80..f801a7fd70eb 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -26,6 +26,7 @@ import { normalizeTerminalContextText, } from "./lib/terminalContext"; import { type JiraTaskDraft, jiraTaskDedupKey } from "./lib/jiraContext"; +import { type QuotedContext, quotedContextDedupKey } from "./lib/quotedContext"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; @@ -177,6 +178,7 @@ export interface ComposerThreadDraftState { persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; jiraTaskContexts: JiraTaskDraft[]; + quotedContexts: QuotedContext[]; modelSelectionByProvider: Partial>; activeProvider: ProviderKind | null; runtimeMode: RuntimeMode | null; @@ -274,6 +276,9 @@ interface ComposerDraftStoreState { removeJiraTaskContext: (threadId: ThreadId, taskId: string) => void; setJiraTaskContexts: (threadId: ThreadId, tasks: JiraTaskDraft[]) => void; clearJiraTaskContexts: (threadId: ThreadId) => void; + addQuotedContext: (threadId: ThreadId, context: QuotedContext) => void; + removeQuotedContext: (threadId: ThreadId, contextId: string) => void; + clearQuotedContexts: (threadId: ThreadId) => void; clearPersistedAttachments: (threadId: ThreadId) => void; syncPersistedAttachments: ( threadId: ThreadId, @@ -325,6 +330,7 @@ const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; const EMPTY_JIRA_TASK_CONTEXTS: JiraTaskDraft[] = []; +const EMPTY_QUOTED_CONTEXTS: QuotedContext[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); @@ -338,6 +344,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, jiraTaskContexts: EMPTY_JIRA_TASK_CONTEXTS, + quotedContexts: EMPTY_QUOTED_CONTEXTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, @@ -352,6 +359,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { persistedAttachments: [], terminalContexts: [], jiraTaskContexts: [], + quotedContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -423,6 +431,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.jiraTaskContexts.length === 0 && + draft.quotedContexts.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -1305,6 +1314,7 @@ function toHydratedThreadDraft( ...task, attachments: [], })) ?? [], + quotedContexts: [], modelSelectionByProvider, activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -2186,6 +2196,71 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, + addQuotedContext: (threadId, context) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); + const dedupKey = quotedContextDedupKey(context); + if (existing.quotedContexts.some((c) => quotedContextDedupKey(c) === dedupKey)) { + return state; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + ...existing, + quotedContexts: [...existing.quotedContexts, context], + }, + }, + }; + }); + }, + removeQuotedContext: (threadId, contextId) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadId[threadId]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + quotedContexts: current.quotedContexts.filter((c) => c.id !== contextId), + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, + clearQuotedContexts: (threadId) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadId[threadId]; + if (!current || current.quotedContexts.length === 0) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + quotedContexts: [], + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, clearPersistedAttachments: (threadId) => { if (threadId.length === 0) { return; @@ -2256,6 +2331,7 @@ export const useComposerDraftStore = create()( persistedAttachments: [], terminalContexts: [], jiraTaskContexts: [], + quotedContexts: [], }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { diff --git a/apps/web/src/lib/quotedContext.ts b/apps/web/src/lib/quotedContext.ts new file mode 100644 index 000000000000..7417a5d295b7 --- /dev/null +++ b/apps/web/src/lib/quotedContext.ts @@ -0,0 +1,101 @@ +import type { MessageId, TurnId } from "@marcode/contracts"; + +export interface QuotedContext { + readonly id: string; + readonly messageId: MessageId; + readonly turnId: TurnId | null; + readonly text: string; + readonly codeLanguage?: string | undefined; + readonly startOffset?: number | undefined; + readonly endOffset?: number | undefined; +} + +const MAX_QUOTED_TEXT_LENGTH = 5000; +const TRUNCATION_SUFFIX = "\n...[truncated]"; + +const LEADING_QUOTED_CONTEXT_BLOCK_PATTERN = + /^(]*>\n[\s\S]*?\n<\/quoted_context>\n*)+/; + +const SINGLE_QUOTED_CONTEXT_BLOCK_PATTERN = + /]*)>\n([\s\S]*?)\n<\/quoted_context>/g; + +const ALL_QUOTED_CONTEXT_BLOCKS_PATTERN = /]*>\n[\s\S]*?\n<\/quoted_context>\n*/g; + +export interface ParsedQuotedContextEntry { + readonly header: string; + readonly body: string; +} + +export interface ExtractedQuotedContexts { + readonly promptText: string; + readonly contextCount: number; + readonly contexts: ParsedQuotedContextEntry[]; +} + +export function truncateQuotedText(text: string): { text: string; wasTruncated: boolean } { + if (text.length <= MAX_QUOTED_TEXT_LENGTH) { + return { text, wasTruncated: false }; + } + return { + text: text.slice(0, MAX_QUOTED_TEXT_LENGTH - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX, + wasTruncated: true, + }; +} + +export function quotedContextDedupKey(context: QuotedContext): string { + return `${context.messageId}\u0000${context.startOffset ?? ""}\u0000${context.endOffset ?? ""}`; +} + +export function formatQuotedContextPreview(context: QuotedContext): string { + const maxPreview = 80; + const singleLine = context.text.replace(/\n/g, " ").trim(); + return singleLine.length > maxPreview ? `${singleLine.slice(0, maxPreview - 1)}…` : singleLine; +} + +function formatSingleQuotedContextBlock(context: QuotedContext): string { + const langAttr = context.codeLanguage ? ` language="${context.codeLanguage}"` : ""; + return `\n${context.text}\n`; +} + +export function buildQuotedContextBlock(contexts: ReadonlyArray): string { + if (contexts.length === 0) return ""; + return contexts.map(formatSingleQuotedContextBlock).join("\n\n"); +} + +export function appendQuotedContextsToPrompt( + promptText: string, + contexts: ReadonlyArray, +): string { + if (contexts.length === 0) return promptText; + const block = buildQuotedContextBlock(contexts); + return promptText.trim().length > 0 ? `${block}\n\n${promptText}` : block; +} + +export function extractLeadingQuotedContexts(text: string): ExtractedQuotedContexts { + const leadingMatch = LEADING_QUOTED_CONTEXT_BLOCK_PATTERN.exec(text); + if (!leadingMatch) { + return { promptText: text, contextCount: 0, contexts: [] }; + } + + const leadingBlock = leadingMatch[0]; + const promptText = text.slice(leadingBlock.length).trimStart(); + const contexts: ParsedQuotedContextEntry[] = []; + + const blockPattern = new RegExp(SINGLE_QUOTED_CONTEXT_BLOCK_PATTERN.source, "g"); + let blockMatch = blockPattern.exec(leadingBlock); + while (blockMatch) { + const attrs = blockMatch[1] ?? ""; + const body = blockMatch[2] ?? ""; + const langMatch = attrs.match(/language="([^"]+)"/); + const language = langMatch?.[1]; + const header = language ? `Quoted code (${language})` : "Quoted text"; + contexts.push({ header, body: body.trim() }); + blockMatch = blockPattern.exec(leadingBlock); + } + + return { promptText, contextCount: contexts.length, contexts }; +} + +export function stripAllQuotedContextBlocks(text: string): string { + return text.replace(ALL_QUOTED_CONTEXT_BLOCKS_PATTERN, "").trim(); +} From 8f253eba2f3e71d51a4a8be782815b23cd05b939 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Thu, 9 Apr 2026 19:07:30 +0300 Subject: [PATCH 2/3] build(deps): add electron to trusted dependencies - Add electron to trustedDependencies in bun.lock and package.json - Consolidate tailwindcss version references in lockfile --- bun.lock | 11 ++++------- package.json | 1 + 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 8830b9c4c360..bf0d6211f3ef 100644 --- a/bun.lock +++ b/bun.lock @@ -180,6 +180,7 @@ }, }, "trustedDependencies": [ + "electron", "node-pty", ], "overrides": { @@ -1440,7 +1441,7 @@ "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -1604,12 +1605,8 @@ "@marcode/web/lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="], - "@marcode/web/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], - "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], @@ -1622,12 +1619,12 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "@tailwindcss/vite/@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], "@tailwindcss/vite/@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + "@tailwindcss/vite/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + "@tanstack/pacer/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="], diff --git a/package.json b/package.json index d496bc9af8e3..128087d4717d 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ ] }, "trustedDependencies": [ + "electron", "node-pty" ] } From 036156a05af557f16f0fac8d21164ccb664b27f0 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Thu, 9 Apr 2026 19:17:16 +0300 Subject: [PATCH 3/3] feat(quote-reply): add copy feedback and improve toolbar positioning - Display checkmark icon when selection is copied to clipboard - Fix toolbar positioning to use fixed positioning correctly - Sanitize code language attributes and escape quoted context body - Extract formatQuotedContextTooltip utility function - Add TOOLBAR_HEIGHT_PX and TOOLBAR_GAP_PX constants - Replace custom ID generation with randomUUID() - Remove unused stripAllQuotedContextBlocks() and related pattern --- apps/web/src/components/ChatView.tsx | 5 +- .../src/components/chat/MessagesTimeline.tsx | 10 ++-- .../components/chat/SelectionReplyToolbar.tsx | 53 +++++++------------ apps/web/src/lib/quotedContext.ts | 28 +++++++--- 4 files changed, 47 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 09fd3cc6155a..253063691b1c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -171,6 +171,7 @@ import { import { appendQuotedContextsToPrompt, formatQuotedContextPreview, + formatQuotedContextTooltip, type QuotedContext, } from "../lib/quotedContext"; import { QuotedContextInlineChip } from "./chat/QuotedContextInlineChip"; @@ -4833,9 +4834,7 @@ export default function ChatView({ threadId }: ChatViewProps) { 300 ? `${ctx.text.slice(0, 300)}…` : ctx.text - } + tooltipText={formatQuotedContextTooltip(ctx)} onRemove={() => removeComposerDraftQuotedContext(threadId, ctx.id)} /> ))} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3c01ded5cf31..e532a8ccea61 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -70,7 +70,11 @@ import { cn } from "~/lib/utils"; import { extractTrailingJiraContexts, type ParsedJiraContextEntry } from "~/lib/jiraContext"; import { JiraTaskInlineChip } from "./JiraTaskInlineChip"; import { SelectionReplyToolbar } from "./SelectionReplyToolbar"; -import { extractLeadingQuotedContexts, type ParsedQuotedContextEntry } from "~/lib/quotedContext"; +import { + extractLeadingQuotedContexts, + type ParsedQuotedContextEntry, + type QuotedContext, +} from "~/lib/quotedContext"; import { UserMessageQuotedContextLabel } from "./UserMessageQuotedContextLabel"; import { type TimestampFormat } from "@marcode/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; @@ -118,7 +122,7 @@ interface MessagesTimelineProps { onRemoveEditingUserMessageImage: (imageId: string) => void; onCancelEditUserMessage: () => void; onSubmitEditUserMessage: () => void | Promise; - onReplyToSelection: (context: import("../../lib/quotedContext").QuotedContext) => void; + onReplyToSelection: (context: QuotedContext) => void; onVirtualizerSnapshot?: (snapshot: { totalSize: number; measurements: ReadonlyArray<{ @@ -1087,7 +1091,7 @@ const AssistantMessageContentWithReply = memo(function AssistantMessageContentWi messageId: MessageId; turnId: TurnId | null; children: ReactNode; - onReplyToSelection: (context: import("../../lib/quotedContext").QuotedContext) => void; + onReplyToSelection: (context: QuotedContext) => void; }) { const containerRef = useRef(null); diff --git a/apps/web/src/components/chat/SelectionReplyToolbar.tsx b/apps/web/src/components/chat/SelectionReplyToolbar.tsx index 5eb6027e554e..8c010e5c58c1 100644 --- a/apps/web/src/components/chat/SelectionReplyToolbar.tsx +++ b/apps/web/src/components/chat/SelectionReplyToolbar.tsx @@ -1,10 +1,11 @@ -import { CopyIcon, ReplyIcon } from "lucide-react"; +import { CheckIcon, CopyIcon, ReplyIcon } from "lucide-react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { MessageId, TurnId } from "@marcode/contracts"; import type { QuotedContext } from "../../lib/quotedContext"; import { truncateQuotedText } from "../../lib/quotedContext"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { randomUUID } from "../../lib/utils"; interface SelectionReplyToolbarProps { messageId: MessageId; @@ -18,19 +19,15 @@ interface ToolbarPosition { left: number; } -let nextQuotedContextId = 0; -function newQuotedContextId(): string { - nextQuotedContextId += 1; - return `quoted-${Date.now()}-${nextQuotedContextId}`; -} +const TOOLBAR_HEIGHT_PX = 32; +const TOOLBAR_GAP_PX = 6; -function getSelectionTextAndMeta( - containerEl: HTMLElement, -): { +function getSelectionMeta(containerEl: HTMLElement): { text: string; startOffset: number; endOffset: number; codeLanguage: string | undefined; + rect: DOMRect; } | null { const selection = window.getSelection(); if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null; @@ -50,7 +47,9 @@ function getSelectionTextAndMeta( const startOffset = preRange.toString().length; const endOffset = startOffset + text.length; - return { text, startOffset, endOffset, codeLanguage }; + const rect = range.getBoundingClientRect(); + + return { text, startOffset, endOffset, codeLanguage, rect }; } function findAncestorCodeBlock(node: Node, boundary: HTMLElement): HTMLElement | null { @@ -87,31 +86,15 @@ export const SelectionReplyToolbar = memo(function SelectionReplyToolbar( return; } - const selection = window.getSelection(); - if (!selection || selection.isCollapsed || selection.rangeCount === 0) { - setPosition(null); - return; - } - - const range = selection.getRangeAt(0); - if (!range || !container.contains(range.startContainer)) { + const meta = getSelectionMeta(container); + if (!meta) { setPosition(null); return; } - const text = selection.toString().trim(); - if (text.length === 0) { - setPosition(null); - return; - } - - const rect = range.getBoundingClientRect(); - const toolbarHeight = 32; - const gap = 6; - setPosition({ - top: rect.top - toolbarHeight - gap + window.scrollY, - left: rect.left + rect.width / 2 + window.scrollX, + top: meta.rect.top - TOOLBAR_HEIGHT_PX - TOOLBAR_GAP_PX, + left: meta.rect.left + meta.rect.width / 2, }); }; @@ -123,7 +106,7 @@ export const SelectionReplyToolbar = memo(function SelectionReplyToolbar( const container = containerRef.current; if (!container) return; - const meta = getSelectionTextAndMeta(container); + const meta = getSelectionMeta(container); if (!meta) return; const { text: rawText, wasTruncated } = truncateQuotedText(meta.text); @@ -132,7 +115,7 @@ export const SelectionReplyToolbar = memo(function SelectionReplyToolbar( } const context: QuotedContext = { - id: newQuotedContextId(), + id: randomUUID(), messageId, turnId, text: rawText, @@ -179,12 +162,12 @@ export const SelectionReplyToolbar = memo(function SelectionReplyToolbar( return createPortal(
e.preventDefault()} > @@ -203,7 +186,7 @@ export const SelectionReplyToolbar = memo(function SelectionReplyToolbar( onClick={handleCopy} title="Copy selection" > - + {isCopied ? : }
, document.body, diff --git a/apps/web/src/lib/quotedContext.ts b/apps/web/src/lib/quotedContext.ts index 7417a5d295b7..9eeec23b8fee 100644 --- a/apps/web/src/lib/quotedContext.ts +++ b/apps/web/src/lib/quotedContext.ts @@ -19,8 +19,6 @@ const LEADING_QUOTED_CONTEXT_BLOCK_PATTERN = const SINGLE_QUOTED_CONTEXT_BLOCK_PATTERN = /]*)>\n([\s\S]*?)\n<\/quoted_context>/g; -const ALL_QUOTED_CONTEXT_BLOCKS_PATTERN = /]*>\n[\s\S]*?\n<\/quoted_context>\n*/g; - export interface ParsedQuotedContextEntry { readonly header: string; readonly body: string; @@ -52,9 +50,27 @@ export function formatQuotedContextPreview(context: QuotedContext): string { return singleLine.length > maxPreview ? `${singleLine.slice(0, maxPreview - 1)}…` : singleLine; } +const MAX_TOOLTIP_LENGTH = 300; + +export function formatQuotedContextTooltip(context: QuotedContext): string { + return context.text.length > MAX_TOOLTIP_LENGTH + ? `${context.text.slice(0, MAX_TOOLTIP_LENGTH)}…` + : context.text; +} + +function sanitizeCodeLanguage(language: string): string { + return language.replace(/[^a-zA-Z0-9+.\-_#]/g, ""); +} + +function escapeQuotedContextBody(text: string): string { + return text.replace(/<\/quoted_context>/gi, "[/quoted_context]"); +} + function formatSingleQuotedContextBlock(context: QuotedContext): string { - const langAttr = context.codeLanguage ? ` language="${context.codeLanguage}"` : ""; - return `\n${context.text}\n`; + const safeLang = context.codeLanguage ? sanitizeCodeLanguage(context.codeLanguage) : undefined; + const langAttr = safeLang ? ` language="${safeLang}"` : ""; + const safeText = escapeQuotedContextBody(context.text); + return `\n${safeText}\n`; } export function buildQuotedContextBlock(contexts: ReadonlyArray): string { @@ -95,7 +111,3 @@ export function extractLeadingQuotedContexts(text: string): ExtractedQuotedConte return { promptText, contextCount: contexts.length, contexts }; } - -export function stripAllQuotedContextBlocks(text: string): string { - return text.replace(ALL_QUOTED_CONTEXT_BLOCKS_PATTERN, "").trim(); -}