Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions src/renderer/src/canvas/CanvasSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
paintScene,
selectionCollapsed,
selectionText,
paragraphSelection,
wordSelection,
type SelectionRange
} from './renderer'
import { CanvasMenu, type CanvasMenuItem } from './CanvasMenu'
Expand Down Expand Up @@ -69,6 +71,8 @@ type Press = {
anchor: ReturnType<typeof hitText>
dragged: boolean
touch: boolean
/** 1 = plain press, 2 = word selection, 3+ = whole logical line. */
clicks: number
scrollbar?: {
region: ScrollRegion
axis: 'x' | 'y'
Expand Down Expand Up @@ -121,6 +125,13 @@ export function CanvasSurface({
const scene = useRef<Scene>({ width: 0, height: 0, blocks: [] })
const selection = useRef<SelectionRange | null>(null)
const press = useRef<Press | null>(null)
const lastClick = useRef<{
at: number
x: number
y: number
anchor: NonNullable<ReturnType<typeof hitText>>
count: number
} | null>(null)
const hovered = useRef<HitRegion | null>(null)
const pointer = useRef<{ x: number; y: number } | null>(null)
const focusedScroll = useRef<string | null>(null)
Expand Down Expand Up @@ -228,6 +239,7 @@ export function CanvasSurface({
}
selection.current = null
press.current = null
lastClick.current = null
hovered.current = null
focusedScroll.current = null
stick.current = followTail
Expand Down Expand Up @@ -661,13 +673,25 @@ 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 repeated =
!!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
// 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,
action: region?.action,
anchor,
dragged: false,
touch: event.pointerType === 'touch'
touch: event.pointerType === 'touch',
clicks
}
if (inner) {
const vertical = pos.x >= inner.x + inner.w - 10 && inner.contentHeight > inner.h
Expand Down Expand Up @@ -696,8 +720,14 @@ export function CanvasSurface({
setScroll(inner, vertical ? inner.left : next, vertical ? next : inner.top)
}
}
const selected =
clicks >= 2 && !pending.action && !pending.scrollbar && anchor
? clicks >= 3
? paragraphSelection(scene.current, anchor)
: wordSelection(scene.current, anchor)
: null
press.current = pending
selection.current = null
selection.current = selected
if (!pending.touch) el.setPointerCapture(event.pointerId)
requestPaint()
}}
Expand Down Expand Up @@ -752,6 +782,21 @@ 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,
count: pending.clicks
}
else lastClick.current = null
if (selection.current && selectionCollapsed(selection.current)) selection.current = null
updateHover()
requestPaint()
Expand Down
22 changes: 17 additions & 5 deletions src/renderer/src/canvas/CanvasTranscript.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string | null>(null)
const prompts = useMemo(() => promptEntries(messages), [messages])
const signature = streaming === null ? null : streamSignature(streaming)
const quiet = signature !== null && quietSignature === signature

useEffect(() => () => cache.detach(), [cache])

Expand All @@ -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) => {
Expand All @@ -90,6 +101,7 @@ export function CanvasTranscript({
...context,
messages,
streaming,
quiet,
canCancel: (part) => {
if (part.tool === 'task') return Boolean(part.subChatId)
return (
Expand All @@ -101,7 +113,7 @@ export function CanvasTranscript({
cache
)
},
[messages, streaming, clock, logo, cache]
[messages, streaming, quiet, clock, logo, cache]
)

const onAction = (action: HitAction): void => {
Expand Down
80 changes: 80 additions & 0 deletions src/renderer/src/canvas/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -633,8 +642,79 @@ 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<TextRun, number[]>()

/** 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
}
}

/**
* 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()
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/canvas/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
31 changes: 25 additions & 6 deletions src/renderer/src/canvas/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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({
Expand All @@ -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
}
Expand Down
16 changes: 16 additions & 0 deletions src/renderer/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -192,6 +203,11 @@ export function ChatView(): JSX.Element {
<Loader2 className="h-3 w-3 animate-spin group-hover:hidden" />
<Square className="hidden h-2.5 w-2.5 fill-current group-hover:block" />
<span className="group-hover:hidden">{t('chat.working')}</span>
{subagentSeconds > 0 && (
<span className="font-mono tabular-nums text-accent/70 group-hover:hidden">
{subagentSeconds}s
</span>
)}
<span className="hidden group-hover:inline">{t('chat.cancel')}</span>
</button>
)}
Expand Down
Loading
Loading