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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react'
import { useValue } from 'tldraw'
import { TldrawAgent } from '../../agent/TldrawAgent'
import { ChatHistorySection, getAgentHistorySections } from './ChatHistorySection'
/*
Chat history is stored as a list of history items.
Within the UI, we split this list of items into sections.
Each section contains a user prompt item, and all the agent's actions that follow it.
Each section is rendered as a separate component.
The model's actions are grouped together into collapsible groups if appropriate.
Here's an example of how the UI might look:
- Chat history
- Section
- Prompt
- Action group
- Action
- Action
- Action group
- Action
- Section
- Prompt
- Action group
- Action
*/
export function ChatHistory({ agent }: { agent: TldrawAgent }) {
const historyItems = useValue('chatHistory', () => agent.chat.getHistory(), [agent])
const sections = getAgentHistorySections(historyItems)
const historyRef = useRef<HTMLDivElement>(null)
const previousScrollDistanceFromBottomRef = useRef(0)
useEffect(() => {
if (!historyRef.current) return
// If a new prompt is submitted by the user, scroll to the bottom
if (historyItems.at(-1)?.type === 'prompt') {
if (previousScrollDistanceFromBottomRef.current <= 0) {
historyRef.current.scrollTo(0, historyRef.current.scrollHeight)
previousScrollDistanceFromBottomRef.current = 0
}
return
}
// If the user is scrolled to the bottom, keep them there while new actions appear
if (previousScrollDistanceFromBottomRef.current <= 0) {
const scrollDistanceFromBottom =
historyRef.current.scrollHeight -
historyRef.current.scrollTop -
historyRef.current.clientHeight
if (scrollDistanceFromBottom > 0) {
historyRef.current.scrollTo(0, historyRef.current.scrollHeight)
}
}
}, [historyRef, historyItems])
// Keep track of the user's scroll position
const handleScroll = () => {
if (!historyRef.current) return
const scrollDistanceFromBottom =
historyRef.current.scrollHeight -
historyRef.current.scrollTop -
historyRef.current.clientHeight
previousScrollDistanceFromBottomRef.current = scrollDistanceFromBottom
}
const isGenerating = useValue('isGenerating', () => agent.requests.isGenerating(), [agent])
return (
<div className="chat-history" ref={historyRef} onScroll={handleScroll}>
{sections.map((section, i) => {
return (
<ChatHistorySection
key={'history-section-' + i}
section={section}
loading={i === sections.length - 1 && isGenerating}
/>
)
})}
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { isRecordsDiffEmpty } from 'tldraw'
import { ChatHistoryActionItem } from '../../../shared/types/ChatHistoryItem'
import { TldrawAgent } from '../../agent/TldrawAgent'
import { ChatHistoryGroupWithDiff } from './ChatHistoryGroupWithDiff'
import { ChatHistoryGroupWithoutDiff } from './ChatHistoryGroupWithoutDiff'
import { getActionInfo } from './getActionInfo'
export interface ChatHistoryGroup {
items: ChatHistoryActionItem[]
withDiff: boolean
}
export function ChatHistoryGroup({ group }: { group: ChatHistoryGroup }) {
if (group.withDiff) {
return <ChatHistoryGroupWithDiff group={group} />
}
return <ChatHistoryGroupWithoutDiff group={group} />
}
/**
* Merge adjacent actions into groups where possible.
*/
export function getActionHistoryGroups(
items: ChatHistoryActionItem[],
agent: TldrawAgent
): ChatHistoryGroup[] {
const groups: ChatHistoryGroup[] = []
for (const item of items) {
const { description } = getActionInfo(item.action, agent)
if (description === null) {
continue
}
const group = groups[groups.length - 1]
if (group && canActionBeGrouped({ item, group, agent })) {
group.items.push(item)
} else {
groups.push({
items: [item],
withDiff: !isRecordsDiffEmpty(item.diff) && item.action.complete,
})
}
}
return groups
}
/**
* Check if an action can be merged with a group.
*/
export function canActionBeGrouped({
item,
group,
agent,
}: {
item: ChatHistoryActionItem
group: ChatHistoryGroup
agent: TldrawAgent
}) {
if (!item.action.complete) return false
if (!group) return false
const showDiff = !isRecordsDiffEmpty(item.diff)
if (showDiff !== group.withDiff) return false
const groupAcceptance = group.items[0]?.acceptance
if (groupAcceptance !== item.acceptance) return false
const prevAction = group.items.at(-1)?.action
if (!prevAction) return false
const actionInfo = getActionInfo(item.action, agent)
const prevActionInfo = getActionInfo(prevAction, agent)
if (actionInfo.canGroup(prevAction) && prevActionInfo.canGroup(item.action)) {
return true
}
return false
}

View File

@@ -0,0 +1,122 @@
import { useCallback, useMemo } from 'react'
import { reverseRecordsDiff, squashRecordDiffs } from 'tldraw'
import { AgentIcon, AgentIconType } from '../../../shared/icons/AgentIcon'
import { ChatHistoryActionItem } from '../../../shared/types/ChatHistoryItem'
import { useAgent } from '../../agent/TldrawAgentAppProvider'
import { ChatHistoryGroup } from './ChatHistoryGroup'
import { getActionInfo } from './getActionInfo'
import { TldrawDiffViewer } from './TldrawDiffViewer'
export function ChatHistoryGroupWithDiff({ group }: { group: ChatHistoryGroup }) {
const agent = useAgent()
const { items } = group
const { editor } = agent
const diff = useMemo(() => squashRecordDiffs(items.map((item) => item.diff)), [items])
// Accept all changes from this group
const handleAccept = useCallback(() => {
agent.chat.update((currentChatHistoryItems) => {
const newItems = [...currentChatHistoryItems]
for (const item of items) {
const index = newItems.findIndex((v) => v === item)
// Mark the item as accepted
if (index !== -1) {
newItems[index] = { ...item, acceptance: 'accepted' }
}
// Apply the diff if needed
if (item.acceptance === 'rejected') {
editor.store.applyDiff(item.diff)
}
}
return newItems
})
}, [items, editor, agent.chat])
// Reject all changes from this group
const handleReject = useCallback(() => {
agent.chat.update((currentChatHistoryItems) => {
const newItems = [...currentChatHistoryItems]
for (const item of items) {
const index = newItems.findIndex((v) => v === item)
// Mark the item as rejected
if (index !== -1) {
newItems[index] = { ...item, acceptance: 'rejected' }
}
// Reverse the diff if needed
if (item.acceptance !== 'rejected') {
const reverseDiff = reverseRecordsDiff(item.diff)
editor.store.applyDiff(reverseDiff)
}
}
return newItems
})
}, [items, editor, agent.chat])
// Get the acceptance status of the group
// If all items are accepted, the group is accepted
// If all items are rejected, the group is rejected
// Otherwise, the group is pending
const acceptance = useMemo<ChatHistoryActionItem['acceptance']>(() => {
if (items.length === 0) return 'pending'
const acceptance = items[0].acceptance
for (let i = 1; i < items.length; i++) {
if (items[i].acceptance !== acceptance) {
return 'pending'
}
}
return acceptance
}, [items])
const steps = useMemo(
() => items.map((item) => getActionInfo(item.action, agent)),
[items, agent]
)
return (
<div className="chat-history-change">
<div className="chat-history-change-acceptance">
<button onClick={handleReject} disabled={acceptance === 'rejected'}>
{acceptance === 'rejected' ? 'Rejected' : 'Reject'}
</button>
<button onClick={handleAccept} disabled={acceptance === 'accepted'}>
{acceptance === 'accepted' ? 'Accepted' : 'Accept'}
</button>
</div>
<DiffSteps steps={steps} />
<TldrawDiffViewer diff={diff} />
</div>
)
}
interface DiffStep {
icon: AgentIconType | null
description: string | null
}
function DiffSteps({ steps }: { steps: DiffStep[] }) {
let previousDescription = ''
return (
<div className="agent-changes">
{steps.map((step, i) => {
if (!step.description) return null
if (step.description === previousDescription) return null
previousDescription = step.description
return (
<div className="agent-change" key={'intent-' + i}>
{step.icon && (
<span className="agent-change-icon">
<AgentIcon type={step.icon} />
</span>
)}
{step.description}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useMemo, useState } from 'react'
import Markdown from 'react-markdown'
import { AgentIcon } from '../../../shared/icons/AgentIcon'
import { ChevronDownIcon } from '../../../shared/icons/ChevronDownIcon'
import { ChevronRightIcon } from '../../../shared/icons/ChevronRightIcon'
import { AgentAction } from '../../../shared/types/AgentAction'
import { ChatHistoryActionItem } from '../../../shared/types/ChatHistoryItem'
import { Streaming } from '../../../shared/types/Streaming'
import { useAgent } from '../../agent/TldrawAgentAppProvider'
import { ChatHistoryGroup } from './ChatHistoryGroup'
import { getActionInfo } from './getActionInfo'
export function ChatHistoryGroupWithoutDiff({ group }: { group: ChatHistoryGroup }) {
const agent = useAgent()
const { items } = group
const nonEmptyItems = useMemo(() => {
return items.filter((item) => {
const { description } = getActionInfo(item.action, agent)
return description !== null
})
}, [items, agent])
const [collapsed, setCollapsed] = useState(true)
const complete = useMemo(() => {
return items.every((item) => item.action.complete)
}, [items])
const summary = useMemo(() => {
const time = Math.floor(items.reduce((acc, item) => acc + item.action.time, 0) / 1000)
if (time === 0) return 'Thought for less than a second'
if (time === 1) return 'Thought for 1 second'
return `Thought for ${time} seconds`
}, [items])
if (nonEmptyItems.length === 0) {
return null
}
if (nonEmptyItems.length < 2) {
return (
<div className="chat-history-group">
{nonEmptyItems.map((item, i) => {
return <ChatHistoryItem item={item} key={'action-' + i} />
})}
</div>
)
}
const showContent = !collapsed || !complete
return (
<div className="chat-history-group">
{complete && (
<button onClick={() => setCollapsed((v) => !v)}>
<span>{showContent ? <ChevronDownIcon /> : <ChevronRightIcon />}</span>
{summary}
</button>
)}
{showContent && (
<div className="agent-actions-container">
{nonEmptyItems.map((item, i) => {
return <ChatHistoryItemExpanded action={item.action} key={'action-' + i} />
})}
</div>
)}
</div>
)
}
function ChatHistoryItem({ item }: { item: ChatHistoryActionItem }) {
const agent = useAgent()
const { action } = item
const { description, summary } = getActionInfo(action, agent)
const collapsible = summary !== null
const [collapsed, setCollapsed] = useState(collapsible)
if (!description) return null
return (
<div className="agent-actions-container">
{action.complete && collapsible && (
<button onClick={() => setCollapsed((v) => !v)}>
<span>{collapsed ? <ChevronRightIcon /> : <ChevronDownIcon />}</span>
{summary}
</button>
)}
{(!collapsed || !action.complete) && <ChatHistoryItemExpanded action={action} />}
</div>
)
}
function ChatHistoryItemExpanded({ action }: { action: Streaming<AgentAction> }) {
const agent = useAgent()
const { icon, description } = getActionInfo(action, agent)
return (
<div className={`agent-action agent-action-type-${action._type}`}>
{icon && (
<span>
<AgentIcon type={icon} />
</span>
)}
<span className="agent-action-description">
<Markdown>{description}</Markdown>
</span>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { Editor } from 'tldraw'
import { ChatHistoryPromptItem } from '../../../shared/types/ChatHistoryItem'
import { ContextItemTag } from '../ContextItemTag'
import { SelectionTag } from '../SelectionTag'
export function ChatHistoryPrompt({
item,
editor,
}: {
item: ChatHistoryPromptItem
editor: Editor
}) {
const { contextItems, agentFacingMessage, userFacingMessage, selectedShapes, promptSource } = item
const showTags = selectedShapes.length > 0 || contextItems.length > 0
// Display the user-facing message if available, otherwise fall back to the agent-facing message
const displayMessage = userFacingMessage ?? agentFacingMessage
// Get the CSS class modifier based on the prompt source
const sourceClass = `chat-history-prompt-${promptSource}`
return (
<div className="chat-history-prompt-container">
<div className={`chat-history-prompt ${sourceClass}`}>
{showTags && (
<div className="prompt-tags">
{selectedShapes.length > 0 && <SelectionTag />}
{contextItems.map((contextItem, i) => (
<ContextItemTag editor={editor} key={'context-item-' + i} item={contextItem} />
))}
</div>
)}
<span className="chat-history-prompt-content">{displayMessage}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { SmallSpinner } from '../../../shared/icons/SmallSpinner'
import {
ChatHistoryActionItem,
ChatHistoryContinuationItem,
ChatHistoryItem,
ChatHistoryPromptItem,
} from '../../../shared/types/ChatHistoryItem'
import { useAgent } from '../../agent/TldrawAgentAppProvider'
import { ChatHistoryGroup, getActionHistoryGroups } from './ChatHistoryGroup'
import { ChatHistoryPrompt } from './ChatHistoryPrompt'
export interface ChatHistorySection {
prompt: ChatHistoryPromptItem
items: (ChatHistoryActionItem | ChatHistoryContinuationItem)[]
}
export function ChatHistorySection({
section,
loading,
}: {
section: ChatHistorySection
loading: boolean
}) {
const agent = useAgent()
const actions = section.items.filter((item) => item.type === 'action')
const groups = getActionHistoryGroups(actions, agent)
return (
<div className="chat-history-section">
<ChatHistoryPrompt item={section.prompt} editor={agent.editor} />
{groups.map((group, i) => {
return <ChatHistoryGroup key={'chat-history-group-' + i} group={group} />
})}
{loading && <SmallSpinner />}
</div>
)
}
export function getAgentHistorySections(items: ChatHistoryItem[]): ChatHistorySection[] {
const sections: ChatHistorySection[] = []
for (const item of items) {
if (item.type === 'prompt') {
// Filter out 'self' prompts from the UI
if (item.promptSource === 'self') continue
sections.push({ prompt: item, items: [] })
continue
}
// Only add to the last section if one exists
if (sections.length > 0) {
sections[sections.length - 1].items.push(item)
}
}
return sections
}

View File

@@ -0,0 +1,135 @@
import { forwardRef, useMemo } from 'react'
import {
DefaultShapeWrapper,
RecordsDiff,
TLRecord,
TLShape,
TLShapeId,
TLShapeWrapperProps,
} from 'tldraw'
import { TldrawViewer } from './TldrawViewer'
export function TldrawDiffViewer({ diff }: { diff: RecordsDiff<TLRecord> }) {
const diffShapes = useMemo(() => getDiffShapesFromDiff(diff), [diff])
return <TldrawViewer shapes={diffShapes} components={{ ShapeWrapper: DiffShapeWrapper }} />
}
function getDiffShapesFromDiff(diff: RecordsDiff<TLRecord>): TLShape[] {
const diffShapes: TLShape[] = []
const numberOfShapes =
Object.keys(diff.added).length +
Object.keys(diff.updated).length +
Object.keys(diff.removed).length
// If there are many shapes in the diff, don't show shadows (for performance reasons)
const showShadows = numberOfShapes < 20
for (const key in diff.removed) {
const id = key as TLShapeId
const prevShape = diff.removed[id]
if (prevShape.typeName !== 'shape') continue
const shape = {
...prevShape,
opacity: showShadows ? prevShape.opacity : prevShape.opacity / 2,
meta: { ...prevShape.meta, changeType: showShadows ? 'delete-shadow' : 'delete' },
}
if ('dash' in shape.props) {
shape.props = {
...shape.props,
dash: 'solid',
}
}
diffShapes.push(shape)
}
for (const key in diff.updated) {
const id = key as TLShapeId
const prevBefore = diff.updated[id][0]
const prevAfter = diff.updated[id][1]
if (prevBefore.typeName !== 'shape' || prevAfter.typeName !== 'shape') continue
const before = {
...prevBefore,
id: (id + '-before') as TLShapeId,
opacity: prevAfter.opacity / 2,
meta: {
...prevBefore.meta,
changeType: showShadows ? 'update-before-shadow' : 'update-before',
},
}
const after = {
...prevAfter,
meta: {
...prevAfter.meta,
changeType: showShadows ? 'update-after-shadow' : 'update-after',
},
}
if ('dash' in before.props) {
before.props = {
...before.props,
dash: 'dashed',
}
}
if ('fill' in before.props) {
before.props = {
...before.props,
fill: 'none',
}
}
if ('dash' in after.props) {
after.props = {
...after.props,
dash: 'solid',
}
}
diffShapes.push(before)
diffShapes.push(after)
}
for (const key in diff.added) {
const id = key as TLShapeId
const prevShape = diff.added[id]
if (prevShape.typeName !== 'shape') continue
const shape = {
...prevShape,
meta: {
...prevShape.meta,
changeType: showShadows ? 'create-shadow' : 'create',
},
}
if ('dash' in shape.props) {
shape.props = {
...shape.props,
dash: 'solid',
}
}
diffShapes.push(shape)
}
return diffShapes
}
const DiffShapeWrapper = forwardRef(function DiffShapeWrapper(
{ children, shape, isBackground }: TLShapeWrapperProps,
ref: React.Ref<HTMLDivElement>
) {
const changeType = shape.meta.changeType
return (
<DefaultShapeWrapper
ref={ref}
shape={shape}
isBackground={isBackground}
className={changeType ? 'diff-shape-' + changeType : undefined}
>
{children}
</DefaultShapeWrapper>
)
})

View File

@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from 'react'
import {
defaultAddFontsFromNode,
defaultBindingUtils,
defaultEditorAssetUrls,
defaultShapeUtils,
Editor,
StateNode,
tipTapDefaultExtensions,
TLComponents,
TldrawEditor,
TldrawUiContextProvider,
TLShape,
} from 'tldraw'
export function TldrawViewer({
shapes,
components = {},
}: {
shapes: TLShape[]
components?: TLComponents
}) {
const [editor, setEditor] = useState<Editor | null>(null)
const [isVisible, setIsVisible] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Hide the component if it's outside of the current scroll area
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(entry.isIntersecting)
},
{ rootMargin: '200px', threshold: 0 }
)
const currentElement = containerRef.current
if (currentElement) observer.observe(currentElement)
return () => {
if (currentElement) observer.unobserve(currentElement)
}
}, [])
useEffect(() => {
if (!editor || !isVisible) return
editor.updateInstanceState({ isReadonly: false })
editor.setCameraOptions({ isLocked: false })
editor.deleteShapes(editor.getCurrentPageShapes())
editor.createShapes(shapes)
editor.updateInstanceState({ isReadonly: true })
editor.selectAll()
const bounds = editor.getSelectionPageBounds()
if (bounds) {
editor.zoomToBounds(bounds, { inset: 30 })
}
editor.selectNone()
editor.setCameraOptions({ isLocked: true })
}, [shapes, editor, isVisible])
if (!isVisible) {
return <div ref={containerRef} className="tldraw-viewer" />
}
return (
<div ref={containerRef} className="tldraw-viewer">
<TldrawUiContextProvider>
<TldrawEditor
autoFocus={false}
components={components ?? {}}
onMount={setEditor}
shapeUtils={defaultShapeUtils}
bindingUtils={defaultBindingUtils}
tools={tools}
options={defaultOptions}
assetUrls={defaultEditorAssetUrls}
initialState="inspect"
/>
</TldrawUiContextProvider>
</div>
)
}
class InspectTool extends StateNode {
static override id = 'inspect'
}
const tools = [InspectTool]
const defaultOptions = {
text: {
tipTapConfig: {
extensions: tipTapDefaultExtensions,
},
addFontsFromNode: defaultAddFontsFromNode,
},
}

View File

@@ -0,0 +1,27 @@
import { AgentAction } from '../../../shared/types/AgentAction'
import { ChatHistoryInfo } from '../../../shared/types/ChatHistoryInfo'
import { Streaming } from '../../../shared/types/Streaming'
import { TldrawAgent } from '../../agent/TldrawAgent'
/**
* Get the full info for an action to display in chat history UI.
* This function adds default values for any unset properties.
* If the action's util returns null, the action will not be shown in chat history.
*/
export function getActionInfo(action: Streaming<AgentAction>, agent: TldrawAgent): ChatHistoryInfo {
const util = agent.actions.getAgentActionUtil(action._type)
const info = util.getInfo(action) ?? { description: null }
const {
icon = null,
description = JSON.stringify(action, null, 2),
summary = null,
canGroup = () => true,
} = info
return {
icon,
description,
summary,
canGroup,
}
}