Files
upstream-tldraw/templates/agent/client/overlays/AgentHighlightOverlayUtil.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

384 lines
9.4 KiB
TypeScript

import {
atom,
Box,
BoxModel,
Editor,
OverlayUtil,
PI2,
TLOverlay,
TLShapeId,
VecModel,
} from 'tldraw'
import { ContextItem } from '../../shared/types/ContextItem'
import { AgentAppAgentsManager } from '../agent/managers/AgentAppAgentsManager'
type AgentHighlightColor = 'selected' | 'tooltip'
const ANIMATION_PERIOD = 100_000
const DASH_SPEED = 20
const MINIMAP_FILL_ALPHA = 0.12
const MINIMAP_PULSE_PERIOD = 2400
interface AgentAreaHighlightOverlay extends TLOverlay {
type: 'agent-highlight'
props: {
kind: 'area'
bounds: BoxModel
color: AgentHighlightColor
generating: boolean
label?: string
}
}
interface AgentPointHighlightOverlay extends TLOverlay {
type: 'agent-highlight'
props: {
kind: 'point'
point: VecModel
color: AgentHighlightColor
generating: boolean
}
}
type AgentHighlightOverlay = AgentAreaHighlightOverlay | AgentPointHighlightOverlay
export class AgentHighlightOverlayUtil extends OverlayUtil<AgentHighlightOverlay> {
static override type = 'agent-highlight' as const
override options = { zIndex: 700 }
private readonly $animationElapsed = atom('agent highlight animation elapsed', 0)
private hasAnimatedOverlays = false
constructor(editor: Editor) {
super(editor)
editor.on('tick', this.handleTick)
}
override dispose() {
this.editor.off('tick', this.handleTick)
}
override isActive(): boolean {
return AgentAppAgentsManager.getAgents(this.editor).length > 0
}
override getOverlays(): AgentHighlightOverlay[] {
const overlays: AgentHighlightOverlay[] = []
const agents = AgentAppAgentsManager.getAgents(this.editor)
for (const agent of agents) {
const activeRequest = agent.requests.getActiveRequest()
const activeContextItems = activeRequest?.contextItems ?? []
if (
activeRequest?.bounds &&
!this.isEquivalentToPendingContextArea(activeRequest.bounds, activeContextItems)
) {
overlays.push({
id: `agent-highlight:${agent.id}:viewport`,
type: 'agent-highlight',
props: {
kind: 'area',
bounds: toBoxModel(activeRequest.bounds),
color: 'tooltip',
generating: true,
label: `Agent ${agent.id.slice(0, 6)}'s view`,
},
})
}
const selectedContextItems = agent.requests.isGenerating() ? [] : agent.context.getItems()
this.addContextItemOverlays(overlays, agent.id, 'selected', selectedContextItems, false)
this.addContextItemOverlays(overlays, agent.id, 'active', activeContextItems, true)
}
return overlays
}
override render(ctx: CanvasRenderingContext2D, overlays: AgentHighlightOverlay[]): void {
// Track whether any overlay is animating so the tick handler knows when to
// advance the animation. This is a side effect, so it belongs in render
// (an effect) rather than getOverlays (a reactive computation).
this.hasAnimatedOverlays = overlays.some((overlay) => overlay.props.generating)
const zoom = this.editor.getZoomLevel()
const colors = this.getColors()
const dashOffset = getDashOffset(this.$animationElapsed.get(), zoom)
for (const overlay of overlays) {
const color = colors[overlay.props.color]
if (overlay.props.kind === 'area') {
this.renderArea(ctx, overlay.props.bounds, {
color,
generating: overlay.props.generating,
dashOffset,
label: overlay.props.label,
labelText: colors.labelText,
zoom,
})
} else {
this.renderPoint(ctx, overlay.props.point, {
color,
generating: overlay.props.generating,
dashOffset,
zoom,
})
}
}
}
override renderMinimap(
ctx: CanvasRenderingContext2D,
overlays: AgentHighlightOverlay[],
zoom: number
): void {
const colors = this.getColors()
const animationElapsed = this.$animationElapsed.get()
for (const overlay of overlays) {
const color = colors[overlay.props.color]
if (overlay.props.kind === 'area') {
const bounds = Box.From(overlay.props.bounds).expandBy(4)
const radius = Math.min(bounds.w, bounds.h, 3 / zoom)
ctx.globalAlpha = overlay.props.generating
? getMinimapPulseAlpha(animationElapsed)
: MINIMAP_FILL_ALPHA
ctx.fillStyle = color
ctx.beginPath()
ctx.roundRect(bounds.x, bounds.y, bounds.w, bounds.h, radius)
ctx.fill()
ctx.globalAlpha = 0.85
ctx.lineWidth = 1 / zoom
ctx.strokeStyle = color
ctx.stroke()
ctx.globalAlpha = 1
} else {
const radius = 3 / zoom
ctx.beginPath()
ctx.arc(overlay.props.point.x, overlay.props.point.y, radius, 0, PI2)
ctx.fillStyle = color
ctx.fill()
}
}
}
private addContextItemOverlays(
overlays: AgentHighlightOverlay[],
agentId: string,
group: 'selected' | 'active',
contextItems: ContextItem[],
generating: boolean
): void {
const areaHighlights: AgentAreaHighlightOverlay[] = []
const pointHighlights: AgentPointHighlightOverlay[] = []
for (let i = 0; i < contextItems.length; i++) {
const item = contextItems[i]
const id = `agent-highlight:${agentId}:${group}:${i}`
switch (item.type) {
case 'area': {
areaHighlights.push({
id,
type: 'agent-highlight',
props: {
kind: 'area',
bounds: toBoxModel(item.bounds),
color: 'selected',
generating,
label: generating && item.source === 'agent' ? 'Reviewing' : undefined,
},
})
break
}
case 'shapes': {
const bounds = this.editor.getShapesPageBounds(
item.shapes.map((shape) => `shape:${shape.shapeId}` as TLShapeId)
)
if (!bounds) break
areaHighlights.push({
id,
type: 'agent-highlight',
props: {
kind: 'area',
bounds: toBoxModel(bounds),
color: 'selected',
generating,
},
})
break
}
case 'shape': {
const bounds = this.editor.getShapePageBounds(`shape:${item.shape.shapeId}` as TLShapeId)
if (!bounds) break
areaHighlights.push({
id,
type: 'agent-highlight',
props: {
kind: 'area',
bounds: toBoxModel(bounds),
color: 'selected',
generating,
},
})
break
}
case 'point': {
pointHighlights.push({
id,
type: 'agent-highlight',
props: {
kind: 'point',
point: item.point,
color: 'selected',
generating,
},
})
break
}
}
}
overlays.push(...areaHighlights, ...pointHighlights)
}
private isEquivalentToPendingContextArea(
agentViewportBounds: BoxModel,
contextItems: ContextItem[]
): boolean {
return contextItems.some(
(item) =>
item.type === 'area' &&
item.source === 'agent' &&
Box.Equals(item.bounds, agentViewportBounds)
)
}
private renderArea(
ctx: CanvasRenderingContext2D,
pageBounds: BoxModel,
{
color,
generating,
dashOffset,
label,
labelText,
zoom,
}: {
color: string
generating: boolean
dashOffset: number
label?: string
labelText: string
zoom: number
}
): void {
const bounds = Box.From(pageBounds).expandBy(4)
ctx.save()
ctx.strokeStyle = color
ctx.lineWidth = 1 / zoom
ctx.lineCap = 'round'
if (generating) {
ctx.setLineDash([4 / zoom, 4 / zoom])
ctx.lineDashOffset = dashOffset
}
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h)
ctx.restore()
if (label) {
this.renderLabel(ctx, bounds, label, color, labelText, zoom)
}
}
private renderPoint(
ctx: CanvasRenderingContext2D,
pagePoint: VecModel,
{
color,
generating,
dashOffset,
zoom,
}: { color: string; generating: boolean; dashOffset: number; zoom: number }
): void {
const radius = 3 / zoom
ctx.save()
ctx.beginPath()
ctx.arc(pagePoint.x, pagePoint.y, radius, 0, PI2)
ctx.strokeStyle = color
ctx.fillStyle = color
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
if (generating) {
ctx.setLineDash([3 / zoom, 3 / zoom])
ctx.lineDashOffset = dashOffset
ctx.lineWidth = 2 / zoom
ctx.stroke()
} else {
ctx.lineWidth = 1 / zoom
ctx.fill()
ctx.stroke()
}
ctx.restore()
}
private renderLabel(
ctx: CanvasRenderingContext2D,
bounds: Box,
label: string,
color: string,
labelText: string,
zoom: number
): void {
const scale = 1 / zoom
const padding = 4
const fontSize = 12
const fontFamily = this.editor.getCurrentTheme().fonts.sans.fontFamily
ctx.save()
ctx.translate(bounds.x, bounds.y)
ctx.scale(scale, scale)
ctx.font = `500 ${fontSize}px ${fontFamily}`
ctx.textBaseline = 'top'
const width = Math.ceil(ctx.measureText(label).width) + padding * 2
const height = fontSize + padding * 2
ctx.fillStyle = color
ctx.beginPath()
ctx.roundRect(0, 0, width, height, 4)
ctx.fill()
ctx.fillStyle = labelText
ctx.fillText(label, padding, padding)
ctx.restore()
}
private getColors() {
const themeColors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()]
return {
selected: themeColors.selectionStroke,
tooltip: themeColors.text,
labelText: themeColors.background,
}
}
private handleTick = (elapsed: number) => {
if (!this.hasAnimatedOverlays) return
this.$animationElapsed.update((phase) => (phase + elapsed) % ANIMATION_PERIOD)
}
}
function toBoxModel(box: BoxModel): BoxModel {
return { x: box.x, y: box.y, w: box.w, h: box.h }
}
function getMinimapPulseAlpha(phase: number): number {
const progress = (phase % MINIMAP_PULSE_PERIOD) / MINIMAP_PULSE_PERIOD
return MINIMAP_FILL_ALPHA + 0.06 * (0.5 + Math.sin(progress * PI2 - Math.PI / 2) * 0.5)
}
function getDashOffset(elapsed: number, zoom: number): number {
return -((elapsed / 1000) * DASH_SPEED) / zoom
}