feat: quote message selections

This commit is contained in:
Shantur Rathore
2025-12-08 10:16:58 +00:00
parent 92d16084db
commit dc702b1fb2
5 changed files with 239 additions and 4 deletions

View File

@@ -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) {
/>
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
<div class="message-stream-shell">
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll}>
<div class="message-stream-shell" ref={setShellElement}>
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Show when={!props.loading && messageIds().length === 0}>
<div class="empty-state">
@@ -494,6 +611,19 @@ export default function MessageSection(props: MessageSectionProps) {
</Show>
</div>
</Show>
<Show when={quoteSelection()}>
{(selection) => (
<div
class="message-quote-popover"
style={{ top: `${selection().top}px`, left: `${selection().left}px` }}
>
<button type="button" class="message-quote-button" onClick={handleQuoteSelectionRequest}>
Add to prompt
</button>
</div>
)}
</Show>
</div>
<Show when={hasTimelineSegments()}>

View File

@@ -25,6 +25,7 @@ interface PromptInputProps {
escapeInDebounce?: boolean
isSessionBusy?: boolean
onAbortSession?: () => Promise<void>
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

View File

@@ -38,6 +38,7 @@ export const SessionView: Component<SessionViewProps> = (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<SessionViewProps> = (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<SessionViewProps> = (props) => {
showSidebarToggle={props.showSidebarToggle}
onSidebarToggle={props.onSidebarToggle}
forceCompactStatusLayout={props.forceCompactStatusLayout}
onQuoteSelection={handleQuoteSelection}
/>
@@ -195,6 +212,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
onAbortSession={handleAbortSession}
registerQuoteHandler={registerQuoteHandler}
/>
</div>
)

View File

@@ -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);
}

View File

@@ -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;