snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
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
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
This commit is contained in:
383
templates/agent/client/overlays/AgentHighlightOverlayUtil.ts
Normal file
383
templates/agent/client/overlays/AgentHighlightOverlayUtil.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user