feat: quote message selections
This commit is contained in:
@@ -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()}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user