From f5871de5e780b1b954dcdda9a159b30344e806ff Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:00:42 -0400 Subject: [PATCH 1/3] fix(desktop): improve active canvas transcript feedback Add native-style canvas word selection and restore the working indicator when an active response goes quiet. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- src/renderer/src/canvas/CanvasSurface.tsx | 42 ++++++++++++- src/renderer/src/canvas/CanvasTranscript.tsx | 22 +++++-- src/renderer/src/canvas/renderer.ts | 44 +++++++++++++ src/renderer/src/canvas/scene.ts | 8 +++ src/renderer/src/canvas/transcript.ts | 31 ++++++++-- src/renderer/src/components/ChatView.tsx | 16 +++++ test/canvas/diff.ts | 65 +++++++++++++++++++- test/canvas/smoke.cjs | 21 +++++++ 8 files changed, 234 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/canvas/CanvasSurface.tsx b/src/renderer/src/canvas/CanvasSurface.tsx index 3e2f5b9..1b7f040 100644 --- a/src/renderer/src/canvas/CanvasSurface.tsx +++ b/src/renderer/src/canvas/CanvasSurface.tsx @@ -19,6 +19,7 @@ import { paintScene, selectionCollapsed, selectionText, + wordSelection, type SelectionRange } from './renderer' import { CanvasMenu, type CanvasMenuItem } from './CanvasMenu' @@ -69,6 +70,7 @@ type Press = { anchor: ReturnType dragged: boolean touch: boolean + wordSelected: boolean scrollbar?: { region: ScrollRegion axis: 'x' | 'y' @@ -121,6 +123,12 @@ export function CanvasSurface({ const scene = useRef({ width: 0, height: 0, blocks: [] }) const selection = useRef(null) const press = useRef(null) + const lastClick = useRef<{ + at: number + x: number + y: number + anchor: NonNullable> + } | null>(null) const hovered = useRef(null) const pointer = useRef<{ x: number; y: number } | null>(null) const focusedScroll = useRef(null) @@ -228,6 +236,7 @@ export function CanvasSurface({ } selection.current = null press.current = null + lastClick.current = null hovered.current = null focusedScroll.current = null stick.current = followTail @@ -661,13 +670,23 @@ export function CanvasSurface({ const inner = scrollAt(pos.x, y) focusedScroll.current = inner?.id ?? null const anchor = hitText(scene.current, pos.x, y, metrics) + const previousClick = lastClick.current + const doubleClick = + !!anchor && + !!previousClick && + event.pointerType !== 'touch' && + event.timeStamp - previousClick.at <= 500 && + Math.hypot(pos.x - previousClick.x, pos.y - previousClick.y) <= 4 && + anchor.line === previousClick.anchor.line && + anchor.group === previousClick.anchor.group const pending: Press = { pointerId: event.pointerId, ...pos, action: region?.action, anchor, dragged: false, - touch: event.pointerType === 'touch' + touch: event.pointerType === 'touch', + wordSelected: false } if (inner) { const vertical = pos.x >= inner.x + inner.w - 10 && inner.contentHeight > inner.h @@ -696,8 +715,13 @@ export function CanvasSurface({ setScroll(inner, vertical ? inner.left : next, vertical ? next : inner.top) } } + const selectedWord = + doubleClick && !pending.action && !pending.scrollbar && anchor + ? wordSelection(scene.current, anchor) + : null + pending.wordSelected = !!selectedWord press.current = pending - selection.current = null + selection.current = selectedWord if (!pending.touch) el.setPointerCapture(event.pointerId) requestPaint() }} @@ -752,6 +776,20 @@ export function CanvasSurface({ if (released && JSON.stringify(released.action) === JSON.stringify(pending.action)) act(pending.action) } + if ( + !pending.dragged && + !pending.scrollbar && + !pending.action && + !pending.touch && + pending.anchor + ) + lastClick.current = { + at: event.timeStamp, + x: pending.x, + y: pending.y, + anchor: pending.anchor + } + else lastClick.current = null if (selection.current && selectionCollapsed(selection.current)) selection.current = null updateHover() requestPaint() diff --git a/src/renderer/src/canvas/CanvasTranscript.tsx b/src/renderer/src/canvas/CanvasTranscript.tsx index 6171642..d55983d 100644 --- a/src/renderer/src/canvas/CanvasTranscript.tsx +++ b/src/renderer/src/canvas/CanvasTranscript.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Message, MessagePart } from '@shared/types' import { getTool } from '@shared/tools' +import { streamSignature } from '@shared/parts' import { CanvasSurface, type CanvasLayoutContext } from './CanvasSurface' import { transcriptCache, layoutTranscript } from './transcript' import type { HitAction } from './scene' @@ -54,7 +55,10 @@ export function CanvasTranscript({ const mounted = useRef(false) const [logo, setLogo] = useState(() => decodedLogo) const [clock, setClock] = useState(0) + const [quietSignature, setQuietSignature] = useState(null) const prompts = useMemo(() => promptEntries(messages), [messages]) + const signature = streaming === null ? null : streamSignature(streaming) + const quiet = signature !== null && quietSignature === signature useEffect(() => () => cache.detach(), [cache]) @@ -74,11 +78,18 @@ export function CanvasTranscript({ }, []) useEffect(() => { - if (!streaming) return - // Reveal cancellation for a long-running tool even if no new tokens arrive. - const timer = setTimeout(() => setClock((n) => n + 1), 1250) + if (signature === null) { + setQuietSignature(null) + return + } + // After visible output goes quiet, restore the working row. The same tick + // also reveals cancellation for a long-running tool with no new deltas. + const timer = setTimeout(() => { + setQuietSignature(signature) + setClock((n) => n + 1) + }, 1250) return () => clearTimeout(timer) - }, [streaming]) + }, [signature]) const buildScene = useCallback( (context: CanvasLayoutContext) => { @@ -90,6 +101,7 @@ export function CanvasTranscript({ ...context, messages, streaming, + quiet, canCancel: (part) => { if (part.tool === 'task') return Boolean(part.subChatId) return ( @@ -101,7 +113,7 @@ export function CanvasTranscript({ cache ) }, - [messages, streaming, clock, logo, cache] + [messages, streaming, quiet, clock, logo, cache] ) const onAction = (action: HitAction): void => { diff --git a/src/renderer/src/canvas/renderer.ts b/src/renderer/src/canvas/renderer.ts index 250503f..531949c 100644 --- a/src/renderer/src/canvas/renderer.ts +++ b/src/renderer/src/canvas/renderer.ts @@ -164,6 +164,15 @@ function paintNode(node: Node, paint: PaintContext, pen: Pen): void { return } + case 'elapsed': { + const seconds = Math.floor((paint.now - node.startedAt) / 1000) + if (seconds < 1) return + pen.setFont(fontCss(node.font, theme)) + pen.setFill(node.color) + ctx.fillText(`${seconds}s`, node.x, node.y + baselineOffset(node.font)) + return + } + case 'lines': { for (const line of node.lines) { const y = node.y + line.y @@ -633,8 +642,43 @@ function charAt(line: SelectableLine, x: number, metrics: TextMetrics): number { } const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +const wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' }) const graphemes = new WeakMap() +/** Select the word-like segment under a canvas text hit, matching native double-click selection. */ +export function wordSelection( + scene: Scene, + hit: { line: number; char: number; group?: string } +): SelectionRange | null { + const line = scene.blocks + .flatMap((block) => block.selectable) + .find((candidate) => candidate.index === hit.line && candidate.group === hit.group) + if (!line?.text) return null + const offset = Math.max(0, Math.min(hit.char, line.text.length)) + let last: { index: number; segment: string } | null = null + for (const part of wordSegmenter.segment(line.text)) { + last = part + const end = part.index + part.segment.length + if (offset >= part.index && offset < end) { + return { + startLine: line.index, + startChar: part.index, + endLine: line.index, + endChar: end, + group: line.group + } + } + } + if (!last || offset !== line.text.length) return null + return { + startLine: line.index, + startChar: last.index, + endLine: line.index, + endChar: line.text.length, + group: line.group + } +} + /** The text of a selection, for the clipboard. */ export function selectionText(scene: Scene, selection: SelectionRange): string { if (selection.all && scene.copyText) return scene.copyText() diff --git a/src/renderer/src/canvas/scene.ts b/src/renderer/src/canvas/scene.ts index 32fd595..df43501 100644 --- a/src/renderer/src/canvas/scene.ts +++ b/src/renderer/src/canvas/scene.ts @@ -116,6 +116,14 @@ export type Node = align?: 'left' | 'right' | 'center' maxWidth?: number } + | { + kind: 'elapsed' + x: number + y: number + startedAt: number + font: Font + color: string + } | { kind: 'lines'; x: number; y: number; lines: WrappedLine[] } | { kind: 'icon' diff --git a/src/renderer/src/canvas/transcript.ts b/src/renderer/src/canvas/transcript.ts index 0e48acf..ab4eadd 100644 --- a/src/renderer/src/canvas/transcript.ts +++ b/src/renderer/src/canvas/transcript.ts @@ -29,6 +29,8 @@ export interface LayoutInput { messages: Message[] /** The live turn's parts, or null when nothing is streaming. */ streaming: MessagePart[] | null + /** True once a live turn has produced no visible update for a short interval. */ + quiet?: boolean width: number metrics: TextMetrics theme: CanvasTheme @@ -57,9 +59,12 @@ export interface LayoutInput { /** How long a call must run before its cancel button appears. */ const CANCEL_REVEAL_MS = 1200 +const TURN_STARTED_AT = '__turn__' export function layoutTranscript(input: LayoutInput, cache: BlockCache): Scene { const { messages, streaming, width, theme, view } = input + if (streaming === null) view.startedAt.delete(TURN_STARTED_AT) + else if (!view.startedAt.has(TURN_STARTED_AT)) view.startedAt.set(TURN_STARTED_AT, input.now) const availableWidth = width - (messages.some((message) => message.role === 'user') ? PROMPT_GUTTER : 0) const column = Math.max(1, Math.min(SPACE.columnMax, availableWidth - SPACE.columnPadX * 2)) @@ -363,12 +368,13 @@ export function layoutParts( const last = parts[parts.length - 1] const runningTool = last?.type === 'tool' && last.state === 'running' const liveText = (last?.type === 'text' || last?.type === 'reasoning') && last.text.trim() !== '' - if (indicator && streaming && !runningTool && !liveText) { + if (indicator && streaming && !runningTool && (!liveText || input.quiet)) { cursor += layoutThinking( builder, x, cursor, - builder.t(last === undefined ? 'transcript.thinking' : 'transcript.working') + builder.t(last === undefined ? 'transcript.thinking' : 'transcript.working'), + input.view.startedAt.get(TURN_STARTED_AT) ?? input.now ) } @@ -405,9 +411,7 @@ function layoutReasoning( width: number ): number { const palette = builder.palette - // Streaming forces it open: watching the model think is the point while it is - // happening, and reading it back afterwards rarely is. - const expanded = open || streaming + const expanded = open const headerHeight = 26 const frame = builder.slot() @@ -469,9 +473,16 @@ function layoutReasoning( } /** The braille spinner + label shown while a turn is live but silent. */ -function layoutThinking(builder: Builder, x: number, y: number, label: string): number { +function layoutThinking( + builder: Builder, + x: number, + y: number, + label: string, + startedAt: number +): number { const palette = builder.palette const f = font(FONT_SIZE.body, 400, 'sans') + const timerFont = font(FONT_SIZE.small, 400, 'mono') const height = builder.metrics.lineHeight(f) + 8 const centerY = y + height / 2 builder.push({ @@ -484,6 +495,14 @@ function layoutThinking(builder: Builder, x: number, y: number, label: string): builder.pulsing(() => { builder.text(x + 20, centerY - builder.metrics.lineHeight(f) / 2, label, f, palette.textMuted) }) + builder.push({ + kind: 'elapsed', + x: x + 20 + builder.metrics.measure(label, f) + 8, + y: centerY - builder.metrics.lineHeight(timerFont) / 2, + startedAt, + font: timerFont, + color: palette.textSubtle + }) builder.animate() return height } diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index d501b52..13a30f5 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -93,6 +93,17 @@ export function ChatView(): JSX.Element { const subagentRunning = useRoxyStore((s) => s.activeChatId ? !!s.runningSubagents[s.activeChatId] : false ) + const [subagentSeconds, setSubagentSeconds] = useState(0) + useEffect(() => { + setSubagentSeconds(0) + if (!subagentRunning) return + const startedAt = Date.now() + const clock = setInterval( + () => setSubagentSeconds(Math.floor((Date.now() - startedAt) / 1000)), + 1000 + ) + return () => clearInterval(clock) + }, [activeChatId, subagentRunning]) const cancelSubagent = useRoxyStore((s) => s.cancelSubagent) const cancelBackgroundTask = useRoxyStore((s) => s.cancelBackgroundTask) const cancelToolCall = useRoxyStore((s) => s.cancelToolCall) @@ -192,6 +203,11 @@ export function ChatView(): JSX.Element { {t('chat.working')} + {subagentSeconds > 0 && ( + + {subagentSeconds}s + + )} {t('chat.cancel')} )} diff --git a/test/canvas/diff.ts b/test/canvas/diff.ts index 7ec33ba..fc656a4 100644 --- a/test/canvas/diff.ts +++ b/test/canvas/diff.ts @@ -11,7 +11,12 @@ import { import { layoutDiffViewer } from '../../src/renderer/src/components/diff/layout' import { Builder } from '../../src/renderer/src/canvas/builder' import { font, wrapSpans, type TextMetrics } from '../../src/renderer/src/canvas/text' -import { hitTest, hitText, selectionText } from '../../src/renderer/src/canvas/renderer' +import { + hitTest, + hitText, + selectionText, + wordSelection +} from '../../src/renderer/src/canvas/renderer' import { layoutMarkdown, layoutPlainText } from '../../src/renderer/src/canvas/prose' import { BlockCache, layoutTranscript } from '../../src/renderer/src/canvas/transcript' import { linkUrl } from '../../src/renderer/src/canvas/links' @@ -255,6 +260,23 @@ check('selection measures proportional glyphs instead of average widths', () => assert.equal(hitText(sceneOf(builder, 20), 5, 4, metrics)?.char, 0) assert.equal(hitText(sceneOf(builder, 20), 7, 4, metrics)?.char, 1) }) +check('double-click selection uses native word boundaries', () => { + const builder = new Builder(metrics, theme, { value: 0 }, t) + const text = "alpha can't 日本語, omega" + builder.selectableRow( + 0, + 0, + 20, + [{ text, font: font(14), color: '#fff', x: 0, width: metrics.measure(text, font(14)), offset: 0 }], + text, + { group: 'after' } + ) + const scene = sceneOf(builder, 20) + const selection = wordSelection(scene, { line: 0, char: 8, group: 'after' }) + assert.ok(selection) + assert.equal(selectionText(scene, selection), "can't") + assert.equal(selection.group, 'after') +}) check('clipping limits both link and selectable hit regions', () => { const builder = new Builder(metrics, theme, { value: 0 }, t) builder.clipped(10, 10, 30, 20, 0, () => { @@ -633,9 +655,48 @@ check('a new canvas host cannot reuse stale expanded diff controls', () => { assert.ok(scene.blocks[0].scrollRegions.length > 0) }) check('an empty streaming turn remains visible beside windowed history', () => { - const scene = layoutTranscript({ ...longInput(longMessages), streaming: [] }, new BlockCache()) + const state = view() + const input = { ...longInput(longMessages, state), streaming: [], now: 1234 } + const scene = layoutTranscript(input, new BlockCache()) assert.ok(scene.blocks.at(-1)!.animated) assert.ok(JSON.stringify(scene.blocks.at(-1)!.nodes).includes('braille')) + assert.ok(JSON.stringify(scene.blocks.at(-1)!.nodes).includes('elapsed')) + assert.equal(state.startedAt.get('__turn__'), 1234) + layoutTranscript({ ...input, streaming: null, now: 5000 }, new BlockCache()) + assert.equal(state.startedAt.has('__turn__'), false) +}) +check('a quiet live turn restores working after visible prose', () => { + const input = { + ...longInput(longMessages), + streaming: [{ type: 'text' as const, text: 'I found the integration issue.' }] + } + const active = layoutTranscript(input, new BlockCache()) + assert.equal(JSON.stringify(active.blocks.at(-1)!.nodes).includes('braille'), false) + + const quiet = layoutTranscript({ ...input, quiet: true }, new BlockCache()) + assert.ok(quiet.blocks.at(-1)!.animated) + assert.ok(JSON.stringify(quiet.blocks.at(-1)!.nodes).includes('braille')) +}) +check('live reasoning starts collapsed and can be toggled closed again', () => { + const state = view() + const input = { + ...longInput([], state), + streaming: [{ type: 'reasoning' as const, text: 'Private planning details.' }], + viewport: undefined + } + const closed = layoutTranscript(input, new BlockCache()) + const toggle = closed.blocks[0].regions.find((region) => region.action.type === 'toggle') + assert.deepEqual(toggle?.action, { type: 'toggle', id: '__streaming__/0' }) + assert.equal(closed.blocks[0].selectable.some((line) => line.text.includes('Private planning')), false) + + state.open.add('__streaming__/0') + const opened = layoutTranscript(input, new BlockCache()) + assert.ok(opened.blocks[0].height > closed.blocks[0].height) + assert.ok(opened.blocks[0].selectable.some((line) => line.text.includes('Private planning'))) + + state.open.delete('__streaming__/0') + const closedAgain = layoutTranscript(input, new BlockCache()) + assert.equal(closedAgain.blocks[0].height, closed.blocks[0].height) }) check('a dragged selection retains its source rows across viewport boundaries', () => { const cache = new BlockCache() diff --git a/test/canvas/smoke.cjs b/test/canvas/smoke.cjs index b079205..b40b0df 100644 --- a/test/canvas/smoke.cjs +++ b/test/canvas/smoke.cjs @@ -54,6 +54,13 @@ async function click(x, y, button = 'left') { await mouse('mouseDown', x, y, button) await mouse('mouseUp', x, y, button) } +async function doubleClick(x, y) { + await mouse('mouseMove', x, y) + await mouse('mouseDown', x, y, 'left', { clickCount: 1 }) + await mouse('mouseUp', x, y, 'left', { clickCount: 1 }) + await mouse('mouseDown', x, y, 'left', { clickCount: 2 }) + await mouse('mouseUp', x, y, 'left', { clickCount: 2 }) +} async function clickDom(selector) { const rect = await evaluate( `(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 } })()` @@ -269,6 +276,20 @@ async function run() { check('sub-threshold movement stays unselected', await probe('return probe.selection() === null')) await mouse('mouseUp', textPoint.x + 1, textPoint.y + 1) check('single click remains unselected', await probe('return probe.selection() === null')) + await wait(550) + await doubleClick(textPoint.x, textPoint.y) + const doubleClickSelection = await probe(` + const s=probe.selection(); + const line=s && scene.blocks.flatMap(b=>b.selectable).find(line=>line.index===s.startLine); + return { selection:s, text:line && s ? line.text.slice(s.startChar,s.endChar) : null }; + `) + check( + `double click selects one canvas word: ${JSON.stringify(doubleClickSelection)}`, + doubleClickSelection.selection && + doubleClickSelection.selection.startLine === doubleClickSelection.selection.endLine && + doubleClickSelection.selection.startChar !== doubleClickSelection.selection.endChar && + doubleClickSelection.text.trim().split(/\s+/).length === 1 + ) await mouse('mouseDown', textPoint.x, textPoint.y) await mouse('mouseMove', textPoint.x + 100, textPoint.y) await mouse('mouseUp', textPoint.x + 100, textPoint.y) From 480b8cc7d146c652d96cf4858768dc27d825fe0b Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:31:46 -0400 Subject: [PATCH 2/3] feat(desktop): select the whole line on canvas triple-click Extend the native click cycle so a third click selects the full logical line, including every visual row of a wrapped paragraph. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- src/renderer/src/canvas/CanvasSurface.tsx | 25 ++++++++++------ src/renderer/src/canvas/renderer.ts | 36 +++++++++++++++++++++++ test/canvas/diff.ts | 21 +++++++++++++ test/canvas/smoke.cjs | 35 ++++++++++++++++++---- 4 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/canvas/CanvasSurface.tsx b/src/renderer/src/canvas/CanvasSurface.tsx index 1b7f040..1f4b1c3 100644 --- a/src/renderer/src/canvas/CanvasSurface.tsx +++ b/src/renderer/src/canvas/CanvasSurface.tsx @@ -19,6 +19,7 @@ import { paintScene, selectionCollapsed, selectionText, + paragraphSelection, wordSelection, type SelectionRange } from './renderer' @@ -70,7 +71,8 @@ type Press = { anchor: ReturnType dragged: boolean touch: boolean - wordSelected: boolean + /** 1 = plain press, 2 = word selection, 3+ = whole logical line. */ + clicks: number scrollbar?: { region: ScrollRegion axis: 'x' | 'y' @@ -128,6 +130,7 @@ export function CanvasSurface({ x: number y: number anchor: NonNullable> + count: number } | null>(null) const hovered = useRef(null) const pointer = useRef<{ x: number; y: number } | null>(null) @@ -671,7 +674,7 @@ export function CanvasSurface({ focusedScroll.current = inner?.id ?? null const anchor = hitText(scene.current, pos.x, y, metrics) const previousClick = lastClick.current - const doubleClick = + const repeated = !!anchor && !!previousClick && event.pointerType !== 'touch' && @@ -679,6 +682,8 @@ export function CanvasSurface({ Math.hypot(pos.x - previousClick.x, pos.y - previousClick.y) <= 4 && anchor.line === previousClick.anchor.line && anchor.group === previousClick.anchor.group + // Native click cycle: the second selects a word, the third the line. + const clicks = repeated ? previousClick!.count + 1 : 1 const pending: Press = { pointerId: event.pointerId, ...pos, @@ -686,7 +691,7 @@ export function CanvasSurface({ anchor, dragged: false, touch: event.pointerType === 'touch', - wordSelected: false + clicks } if (inner) { const vertical = pos.x >= inner.x + inner.w - 10 && inner.contentHeight > inner.h @@ -715,13 +720,14 @@ export function CanvasSurface({ setScroll(inner, vertical ? inner.left : next, vertical ? next : inner.top) } } - const selectedWord = - doubleClick && !pending.action && !pending.scrollbar && anchor - ? wordSelection(scene.current, anchor) + const selected = + clicks >= 2 && !pending.action && !pending.scrollbar && anchor + ? clicks >= 3 + ? paragraphSelection(scene.current, anchor) + : wordSelection(scene.current, anchor) : null - pending.wordSelected = !!selectedWord press.current = pending - selection.current = selectedWord + selection.current = selected if (!pending.touch) el.setPointerCapture(event.pointerId) requestPaint() }} @@ -787,7 +793,8 @@ export function CanvasSurface({ at: event.timeStamp, x: pending.x, y: pending.y, - anchor: pending.anchor + anchor: pending.anchor, + count: pending.clicks } else lastClick.current = null if (selection.current && selectionCollapsed(selection.current)) selection.current = null diff --git a/src/renderer/src/canvas/renderer.ts b/src/renderer/src/canvas/renderer.ts index 531949c..d86d0be 100644 --- a/src/renderer/src/canvas/renderer.ts +++ b/src/renderer/src/canvas/renderer.ts @@ -679,6 +679,42 @@ export function wordSelection( } } +/** + * Select the whole logical line under a canvas text hit, matching native + * triple-click. A wrapped paragraph is one logical line, so this spans every + * visual row joined by `breakAfter: false`. + */ +export function paragraphSelection( + scene: Scene, + hit: { line: number; group?: string } +): SelectionRange | null { + const lines = scene.blocks.flatMap((block) => block.selectable) + const start = lines.find( + (candidate) => candidate.index === hit.line && candidate.group === hit.group + ) + if (!start) return null + const at = (index: number): SelectableLine | undefined => + lines.find((candidate) => candidate.index === index && candidate.group === start.group) + let first = start + for (let previous = at(first.index - 1); previous && !previous.breakAfter; ) { + first = previous + previous = at(first.index - 1) + } + let last = start + while (!last.breakAfter) { + const next = at(last.index + 1) + if (!next) break + last = next + } + return { + startLine: first.index, + startChar: 0, + endLine: last.index, + endChar: last.text.length, + group: start.group + } +} + /** The text of a selection, for the clipboard. */ export function selectionText(scene: Scene, selection: SelectionRange): string { if (selection.all && scene.copyText) return scene.copyText() diff --git a/test/canvas/diff.ts b/test/canvas/diff.ts index fc656a4..9f71e87 100644 --- a/test/canvas/diff.ts +++ b/test/canvas/diff.ts @@ -15,6 +15,7 @@ import { hitTest, hitText, selectionText, + paragraphSelection, wordSelection } from '../../src/renderer/src/canvas/renderer' import { layoutMarkdown, layoutPlainText } from '../../src/renderer/src/canvas/prose' @@ -277,6 +278,26 @@ check('double-click selection uses native word boundaries', () => { assert.equal(selectionText(scene, selection), "can't") assert.equal(selection.group, 'after') }) +check('triple-click selection spans a whole wrapped paragraph', () => { + const builder = new Builder(metrics, theme, { value: 0 }, t) + const rows = ['first visual row', 'second visual row', 'next paragraph'] + rows.forEach((text, i) => + builder.selectableRow( + 0, + i * 20, + 20, + [{ text, font: font(14), color: '#fff', x: 0, width: metrics.measure(text, font(14)), offset: 0 }], + text, + { breakAfter: i !== 0 } + ) + ) + const scene = sceneOf(builder, 60) + const selection = paragraphSelection(scene, { line: 1 }) + assert.ok(selection) + assert.equal(selectionText(scene, selection), 'first visual rowsecond visual row') + assert.equal(selection.startLine, 0) + assert.equal(selection.endLine, 1) +}) check('clipping limits both link and selectable hit regions', () => { const builder = new Builder(metrics, theme, { value: 0 }, t) builder.clipped(10, 10, 30, 20, 0, () => { diff --git a/test/canvas/smoke.cjs b/test/canvas/smoke.cjs index b40b0df..b90e215 100644 --- a/test/canvas/smoke.cjs +++ b/test/canvas/smoke.cjs @@ -54,12 +54,15 @@ async function click(x, y, button = 'left') { await mouse('mouseDown', x, y, button) await mouse('mouseUp', x, y, button) } -async function doubleClick(x, y) { +async function multiClick(x, y, times) { await mouse('mouseMove', x, y) - await mouse('mouseDown', x, y, 'left', { clickCount: 1 }) - await mouse('mouseUp', x, y, 'left', { clickCount: 1 }) - await mouse('mouseDown', x, y, 'left', { clickCount: 2 }) - await mouse('mouseUp', x, y, 'left', { clickCount: 2 }) + for (let i = 1; i <= times; i++) { + await mouse('mouseDown', x, y, 'left', { clickCount: i }) + await mouse('mouseUp', x, y, 'left', { clickCount: i }) + } +} +async function doubleClick(x, y) { + await multiClick(x, y, 2) } async function clickDom(selector) { const rect = await evaluate( @@ -290,6 +293,28 @@ async function run() { doubleClickSelection.selection.startChar !== doubleClickSelection.selection.endChar && doubleClickSelection.text.trim().split(/\s+/).length === 1 ) + await wait(550) + await multiClick(textPoint.x, textPoint.y, 3) + const tripleClickSelection = await probe(` + const s=probe.selection(); + const lines=scene.blocks.flatMap(b=>b.selectable); + const first=s && lines.find(line=>line.index===s.startLine); + const last=s && lines.find(line=>line.index===s.endLine); + return { + selection:s, + startsAtLineStart: !!s && s.startChar === 0, + endsAtLineEnd: !!last && s.endChar === last.text.length, + wholeLine: !!first && first.text.length > 0 + }; + `) + check( + `triple click selects the whole line: ${JSON.stringify(tripleClickSelection.selection)}`, + tripleClickSelection.selection && + tripleClickSelection.startsAtLineStart && + tripleClickSelection.endsAtLineEnd && + tripleClickSelection.wholeLine + ) + await wait(550) await mouse('mouseDown', textPoint.x, textPoint.y) await mouse('mouseMove', textPoint.x + 100, textPoint.y) await mouse('mouseUp', textPoint.x + 100, textPoint.y) From abda5fb9e8e07eb56075311d428e807ca4089fab Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:42:10 -0400 Subject: [PATCH 3/3] style(desktop): apply prettier formatting to canvas selection tests Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- test/canvas/diff.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/test/canvas/diff.ts b/test/canvas/diff.ts index 9f71e87..c87ff61 100644 --- a/test/canvas/diff.ts +++ b/test/canvas/diff.ts @@ -268,7 +268,16 @@ check('double-click selection uses native word boundaries', () => { 0, 0, 20, - [{ text, font: font(14), color: '#fff', x: 0, width: metrics.measure(text, font(14)), offset: 0 }], + [ + { + text, + font: font(14), + color: '#fff', + x: 0, + width: metrics.measure(text, font(14)), + offset: 0 + } + ], text, { group: 'after' } ) @@ -286,7 +295,16 @@ check('triple-click selection spans a whole wrapped paragraph', () => { 0, i * 20, 20, - [{ text, font: font(14), color: '#fff', x: 0, width: metrics.measure(text, font(14)), offset: 0 }], + [ + { + text, + font: font(14), + color: '#fff', + x: 0, + width: metrics.measure(text, font(14)), + offset: 0 + } + ], text, { breakAfter: i !== 0 } ) @@ -708,7 +726,10 @@ check('live reasoning starts collapsed and can be toggled closed again', () => { const closed = layoutTranscript(input, new BlockCache()) const toggle = closed.blocks[0].regions.find((region) => region.action.type === 'toggle') assert.deepEqual(toggle?.action, { type: 'toggle', id: '__streaming__/0' }) - assert.equal(closed.blocks[0].selectable.some((line) => line.text.includes('Private planning')), false) + assert.equal( + closed.blocks[0].selectable.some((line) => line.text.includes('Private planning')), + false + ) state.open.add('__streaming__/0') const opened = layoutTranscript(input, new BlockCache())