diff --git a/AGENTS.md b/AGENTS.md index e93c1b76c44..537bdaea55c 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 dee6fb9368d..78014d81b74 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 85873de35d7..253063691b1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,6 +168,13 @@ import { parseJiraUrl, removeInlineJiraContextPlaceholder, } from "../lib/jiraContext"; +import { + appendQuotedContextsToPrompt, + formatQuotedContextPreview, + formatQuotedContextTooltip, + type QuotedContext, +} from "../lib/quotedContext"; +import { QuotedContextInlineChip } from "./chat/QuotedContextInlineChip"; import { jiraConnectionStatusQueryOptions, jiraIssueSearchQueryOptions, @@ -409,6 +416,7 @@ interface SubmitComposerTurnInput { images: ComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; jiraTaskContexts: JiraTaskDraft[]; + quotedContexts: QuotedContext[]; expiredTerminalContextCount: number; clearComposerDraft: boolean; } @@ -684,6 +692,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 +700,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 +735,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 +3154,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 +3482,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 +3535,7 @@ export default function ChatView({ threadId }: ChatViewProps) { images: [...composerImages], terminalContexts: [...sendableComposerTerminalContexts], jiraTaskContexts: [...composerJiraTaskContexts], + quotedContexts: [...composerQuotedContexts], expiredTerminalContextCount, clearComposerDraft: true, }); @@ -4304,6 +4331,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 +4487,7 @@ export default function ChatView({ threadId }: ChatViewProps) { images: imagesToSend, terminalContexts: [], jiraTaskContexts: [], + quotedContexts: [], expiredTerminalContextCount: 0, clearComposerDraft: false, }); @@ -4627,6 +4663,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onRemoveEditingUserMessageImage={onRemoveEditingUserMessageImage} onCancelEditUserMessage={discardUserMessageEditSession} onSubmitEditUserMessage={onSubmitEditUserMessage} + onReplyToSelection={onReplyToSelection} /> @@ -4789,6 +4826,20 @@ export default function ChatView({ threadId }: ChatViewProps) { ))} )} + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerQuotedContexts.length > 0 && ( +
+ {composerQuotedContexts.map((ctx) => ( + 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 682fcc4bcf3..e532a8ccea6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -69,6 +69,13 @@ 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, + type QuotedContext, +} from "~/lib/quotedContext"; +import { UserMessageQuotedContextLabel } from "./UserMessageQuotedContextLabel"; import { type TimestampFormat } from "@marcode/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; import { @@ -115,6 +122,7 @@ interface MessagesTimelineProps { onRemoveEditingUserMessageImage: (imageId: string) => void; onCancelEditUserMessage: () => void; onSubmitEditUserMessage: () => void | Promise; + onReplyToSelection: (context: QuotedContext) => void; onVirtualizerSnapshot?: (snapshot: { totalSize: number; measurements: ReadonlyArray<{ @@ -163,6 +171,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRemoveEditingUserMessageImage, onCancelEditUserMessage, onSubmitEditUserMessage, + onReplyToSelection, onVirtualizerSnapshot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); @@ -463,11 +472,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
- + + + {(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; @@ -867,7 +882,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 +935,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 c0ad47089d0..c43f202327e 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 00000000000..fb772167410 --- /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 00000000000..8c010e5c58c --- /dev/null +++ b/apps/web/src/components/chat/SelectionReplyToolbar.tsx @@ -0,0 +1,194 @@ +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; + turnId: TurnId | null; + containerRef: React.RefObject; + onReply: (context: QuotedContext) => void; +} + +interface ToolbarPosition { + top: number; + left: number; +} + +const TOOLBAR_HEIGHT_PX = 32; +const TOOLBAR_GAP_PX = 6; + +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; + + 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; + + const rect = range.getBoundingClientRect(); + + return { text, startOffset, endOffset, codeLanguage, rect }; +} + +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 meta = getSelectionMeta(container); + if (!meta) { + setPosition(null); + return; + } + + setPosition({ + top: meta.rect.top - TOOLBAR_HEIGHT_PX - TOOLBAR_GAP_PX, + left: meta.rect.left + meta.rect.width / 2, + }); + }; + + document.addEventListener("selectionchange", handleSelectionChange); + return () => document.removeEventListener("selectionchange", handleSelectionChange); + }, [containerRef]); + + const handleReply = useCallback(() => { + const container = containerRef.current; + if (!container) return; + + const meta = getSelectionMeta(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: randomUUID(), + 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 1a044848c29..611bd44174c 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 00000000000..b575b3d9e30 --- /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 c60fa92ddb8..f801a7fd70e 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 00000000000..9eeec23b8fe --- /dev/null +++ b/apps/web/src/lib/quotedContext.ts @@ -0,0 +1,113 @@ +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; + +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; +} + +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 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 { + 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 }; +} diff --git a/bun.lock b/bun.lock index 8830b9c4c36..bf0d6211f3e 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 d496bc9af8e..128087d4717 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ ] }, "trustedDependencies": [ + "electron", "node-pty" ] }