From dc702b1fb2d9e8a72e648a4612899af4de46feb4 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 8 Dec 2025 10:16:58 +0000 Subject: [PATCH] feat: quote message selections --- .../ui/src/components/message-section.tsx | 134 +++++++++++++++++- packages/ui/src/components/prompt-input.tsx | 51 +++++++ .../src/components/session/session-view.tsx | 18 +++ .../src/styles/messaging/message-section.css | 32 +++++ packages/ui/src/styles/tokens.css | 8 +- 5 files changed, 239 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index b6b4aedb..0077e7e6 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -16,6 +16,7 @@ const SCROLL_SCOPE = "session" const SCROLL_SENTINEL_MARGIN_PX = 48 const USER_SCROLL_INTENT_WINDOW_MS = 600 const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) +const QUOTE_SELECTION_MAX_LENGTH = 2000 const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href function formatTokens(tokens: number): string { @@ -32,6 +33,7 @@ export interface MessageSectionProps { showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean + onQuoteSelection?: (text: string) => void } export default function MessageSection(props: MessageSectionProps) { @@ -137,10 +139,14 @@ export default function MessageSection(props: MessageSectionProps) { const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [topSentinelVisible, setTopSentinelVisible] = createSignal(true) const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) + const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) let containerRef: HTMLDivElement | undefined + let shellRef: HTMLDivElement | undefined let pendingScrollFrame: number | null = null + let pendingAnchorScroll: number | null = null + let pendingScrollPersist: number | null = null let userScrollIntentUntil = 0 let detachScrollIntentListeners: (() => void) | undefined @@ -185,9 +191,20 @@ export default function MessageSection(props: MessageSectionProps) { containerRef = element || undefined setScrollElement(containerRef) attachScrollIntentListeners(containerRef) + if (!containerRef) { + clearQuoteSelection() + } } + function setShellElement(element: HTMLDivElement | null) { + shellRef = element || undefined + if (!shellRef) { + clearQuoteSelection() + } + } + function updateScrollIndicatorsFromVisibility() { + const hasItems = messageIds().length > 0 setShowScrollBottomButton(hasItems && !bottomSentinelVisible()) setShowScrollTopButton(hasItems && !topSentinelVisible()) @@ -237,7 +254,74 @@ export default function MessageSection(props: MessageSectionProps) { }) } + function clearQuoteSelection() { + setQuoteSelection(null) + } + + function isSelectionWithinStream(range: Range | null) { + if (!range || !containerRef) return false + const node = range.commonAncestorContainer + if (!node) return false + return containerRef.contains(node) + } + + function updateQuoteSelectionFromSelection() { + if (!props.onQuoteSelection || typeof window === "undefined") { + clearQuoteSelection() + return + } + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + clearQuoteSelection() + return + } + const range = selection.getRangeAt(0) + if (!isSelectionWithinStream(range)) { + clearQuoteSelection() + return + } + const shell = shellRef + if (!shell) { + clearQuoteSelection() + return + } + const rawText = selection.toString().trim() + if (!rawText) { + clearQuoteSelection() + return + } + const limited = + rawText.length > QUOTE_SELECTION_MAX_LENGTH ? rawText.slice(0, QUOTE_SELECTION_MAX_LENGTH).trimEnd() : rawText + if (!limited) { + clearQuoteSelection() + return + } + const rects = range.getClientRects() + const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect() + const shellRect = shell.getBoundingClientRect() + const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8) + const maxLeft = Math.max(shell.clientWidth - 180, 8) + const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft) + setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft }) + } + + function handleStreamMouseUp() { + updateQuoteSelectionFromSelection() + } + + function handleQuoteSelectionRequest() { + const info = quoteSelection() + if (!info || !props.onQuoteSelection) return + props.onQuoteSelection(info.text) + clearQuoteSelection() + if (typeof window !== "undefined") { + const selection = window.getSelection() + selection?.removeAllRanges() + } + } + function handleContentRendered() { + scheduleAnchorScroll() } @@ -260,21 +344,53 @@ export default function MessageSection(props: MessageSectionProps) { } } + clearQuoteSelection() scheduleScrollPersist() }) } + createEffect(() => { if (props.registerScrollToBottom) { props.registerScrollToBottom(() => scrollToBottom(true)) } }) + createEffect(() => { + if (!props.onQuoteSelection) { + clearQuoteSelection() + } + }) + + createEffect(() => { + if (typeof document === "undefined") return + const handleSelectionChange = () => updateQuoteSelectionFromSelection() + const handlePointerDown = (event: PointerEvent) => { + if (!shellRef) return + if (!shellRef.contains(event.target as Node)) { + clearQuoteSelection() + } + } + document.addEventListener("selectionchange", handleSelectionChange) + document.addEventListener("pointerdown", handlePointerDown) + onCleanup(() => { + document.removeEventListener("selectionchange", handleSelectionChange) + document.removeEventListener("pointerdown", handlePointerDown) + }) + }) + + createEffect(() => { + if (props.loading) { + clearQuoteSelection() + } + }) + createEffect(() => { const target = containerRef const loading = props.loading if (!target || loading || hasRestoredScroll) return + scrollCache.restore(target, { onApplied: (snapshot) => { if (snapshot) { @@ -407,6 +523,7 @@ export default function MessageSection(props: MessageSectionProps) { if (containerRef) { scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX }) } + clearQuoteSelection() }) return ( @@ -423,8 +540,8 @@ export default function MessageSection(props: MessageSectionProps) { />
-
-
+
+
diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index e77be037..678e8223 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -25,6 +25,7 @@ interface PromptInputProps { escapeInDebounce?: boolean isSessionBusy?: boolean onAbortSession?: () => Promise + registerQuoteHandler?: (handler: (text: string) => void) => void | (() => void) } export default function PromptInput(props: PromptInputProps) { @@ -42,6 +43,7 @@ export default function PromptInput(props: PromptInputProps) { const [pasteCount, setPasteCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0) const [mode, setMode] = createSignal<"normal" | "shell">("normal") + const QUOTE_INSERT_MAX_LENGTH = 2000 let textareaRef: HTMLTextAreaElement | undefined let containerRef: HTMLDivElement | undefined @@ -51,6 +53,16 @@ export default function PromptInput(props: PromptInputProps) { const attachments = () => getAttachments(props.instanceId, props.sessionId) const instanceAgents = () => agents().get(props.instanceId) || [] + createEffect(() => { + if (!props.registerQuoteHandler) return + const cleanup = props.registerQuoteHandler((text) => insertQuotedSelection(text)) + onCleanup(() => { + if (typeof cleanup === "function") { + cleanup() + } + }) + }) + const setPrompt = (value: string) => { setPromptInternal(value) setSessionDraftPrompt(props.instanceId, props.sessionId, value) @@ -869,6 +881,45 @@ export default function PromptInput(props: PromptInputProps) { textareaRef?.focus() } + function insertQuotedSelection(rawText: string) { + const normalized = (rawText ?? "").replace(/\r/g, "").trim() + if (!normalized) return + const limited = + normalized.length > QUOTE_INSERT_MAX_LENGTH ? normalized.slice(0, QUOTE_INSERT_MAX_LENGTH).trimEnd() : normalized + const lines = limited + .split(/\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + if (lines.length === 0) return + + const blockquote = lines.map((line) => `> ${line}`).join("\n") + if (!blockquote) return + + const textarea = textareaRef + const current = prompt() + const start = textarea ? textarea.selectionStart : current.length + const end = textarea ? textarea.selectionEnd : current.length + const before = current.substring(0, start) + const after = current.substring(end) + const needsLeading = before.length > 0 && !before.endsWith("\n") ? "\n" : "" + const insertion = `${needsLeading}${blockquote}\n\n` + const nextValue = before + insertion + after + + setPrompt(nextValue) + setHistoryIndex(-1) + setHistoryDraft(null) + setShowPicker(false) + setAtPosition(null) + + if (textarea) { + setTimeout(() => { + const cursor = before.length + insertion.length + textarea.focus() + textarea.setSelectionRange(cursor, cursor) + }, 0) + } + } + const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession) const hasHistory = () => history().length > 0 diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 643c7ce6..a5d83d04 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -38,6 +38,7 @@ export const SessionView: Component = (props) => { return getSessionBusyStatus(props.instanceId, currentSession.id) }) let scrollToBottomHandle: (() => void) | undefined + let quoteHandler: ((text: string) => void) | null = null createEffect(() => { const currentSession = session() @@ -45,6 +46,21 @@ export const SessionView: Component = (props) => { loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error)) } }) + + function registerQuoteHandler(handler: (text: string) => void) { + quoteHandler = handler + return () => { + if (quoteHandler === handler) { + quoteHandler = null + } + } + } + + function handleQuoteSelection(text: string) { + if (quoteHandler) { + quoteHandler(text) + } + } async function handleSendMessage(prompt: string, attachments: Attachment[]) { @@ -183,6 +199,7 @@ export const SessionView: Component = (props) => { showSidebarToggle={props.showSidebarToggle} onSidebarToggle={props.onSidebarToggle} forceCompactStatusLayout={props.forceCompactStatusLayout} + onQuoteSelection={handleQuoteSelection} /> @@ -195,6 +212,7 @@ export const SessionView: Component = (props) => { escapeInDebounce={props.escapeInDebounce} isSessionBusy={sessionBusy()} onAbortSession={handleAbortSession} + registerQuoteHandler={registerQuoteHandler} />
) diff --git a/packages/ui/src/styles/messaging/message-section.css b/packages/ui/src/styles/messaging/message-section.css index 1729adcf..acda92d4 100644 --- a/packages/ui/src/styles/messaging/message-section.css +++ b/packages/ui/src/styles/messaging/message-section.css @@ -228,3 +228,35 @@ font-size: var(--font-size-lg); color: var(--accent-primary); } + +.message-quote-popover { + position: absolute; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.message-quote-button { + pointer-events: auto; + @apply inline-flex items-center gap-2; + padding: 0.35rem 0.85rem; + font-size: 0.8rem; + font-weight: 500; + border-radius: 9999px; + border: 1px solid var(--list-item-highlight-border); + background-color: var(--list-item-highlight-bg-solid); + color: var(--text-primary); + box-shadow: var(--panel-shadow, 0 4px 16px rgba(0, 0, 0, 0.2)); + transition: background-color 0.2s ease, color 0.2s ease; +} + +.message-quote-button:hover { + background-color: var(--surface-hover); +} + +.message-quote-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary); +} diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 9a3322f8..0d0c41b6 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -45,6 +45,7 @@ --session-status-permission-fg: #c2410c; --session-status-permission-bg: rgba(251, 191, 36, 0.25); --list-item-highlight-bg: rgba(0, 102, 255, 0.1); + --list-item-highlight-bg-solid: #e5f0ff; --list-item-highlight-border: rgba(0, 102, 255, 0.25); --attachment-chip-bg: rgba(0, 102, 255, 0.1); --attachment-chip-text: #0066ff; @@ -192,8 +193,10 @@ --session-status-idle-bg: rgba(74, 222, 128, 0.22); --session-status-permission-fg: #fbbf24; --session-status-permission-bg: rgba(251, 191, 36, 0.35); - --list-item-highlight-bg: rgba(0, 128, 255, 0.2); - --list-item-highlight-border: rgba(0, 128, 255, 0.4); + --list-item-highlight-bg: rgba(0, 128, 255, 0.2); + --list-item-highlight-bg-solid: #15324e; + --list-item-highlight-border: rgba(0, 128, 255, 0.4); + --attachment-chip-bg: rgba(0, 128, 255, 0.1); --attachment-chip-text: #0080ff; --attachment-chip-ring: rgba(0, 128, 255, 0.2); @@ -345,6 +348,7 @@ --session-status-permission-fg: #fbbf24; --session-status-permission-bg: rgba(251, 191, 36, 0.35); --list-item-highlight-bg: rgba(0, 128, 255, 0.2); + --list-item-highlight-bg-solid: #15324e; --list-item-highlight-border: rgba(0, 128, 255, 0.4); --attachment-chip-bg: rgba(0, 128, 255, 0.1); --attachment-chip-text: #0080ff;