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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 65 additions & 16 deletions src/components/MlEphantConversation.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import {
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'

const bootMockState = vi.hoisted<{
registry: Registry | undefined
}>(() => ({
registry: undefined,
}))

// Mock modules that access localStorage at import time
vi.mock('@src/routes/utils', () => ({
getAppVersion: () => 'test',
Expand All @@ -23,6 +29,9 @@ vi.mock('@src/lib/desktop', () => ({

// Mock useSingletons which requires heavy initialization
vi.mock('@src/lib/boot', () => ({
useApp: () => {
return { registry: bootMockState.registry }
},
useSingletons: () => ({
kclManager: {
astSignal: { value: null },
Expand All @@ -39,6 +48,8 @@ vi.mock('@src/lib/screenshot', async (importOriginal) => {
}
})

import { Registry } from '@kittycad/registry'
import { useSignals } from '@preact/signals-react/runtime'
import { MAKEATHON_ANNOUNCEMENT_DISMISSED_STORAGE_KEY } from '@src/components/MakeathonAnnouncement'
import { MlEphantConversation } from '@src/components/MlEphantConversation'
import { takeViewportScreenshot } from '@src/lib/screenshot'
Expand All @@ -49,6 +60,40 @@ import type {
MlCopilotModeId,
MlCopilotModeOption,
} from '@src/machines/mlEphantManagerMachine'
import {
type EngineSceneExtensionContext,
engineSceneRuntimeExtensionsSlot,
engineSceneStreamLayersValueSpec,
} from '@src/registry/contracts/engineScene'

const configureTestRegistry = () => {
const registry = new Registry()
registry.configure([engineSceneRuntimeExtensionsSlot.of()])
bootMockState.registry = registry
return registry
}

const testEngineSceneContext = {
modelingState: { matches: () => false },
modelingSend: vi.fn(),
sketchSolveStreamDimming: 0.8,
setSketchSolveStreamDimming: vi.fn(),
} as unknown as EngineSceneExtensionContext

const TestEngineSceneStreamLayers = () => {
useSignals()
const registry = bootMockState.registry as Registry
const layers = registry.signal(engineSceneStreamLayersValueSpec).value

return (
<>
{layers.map((layer) => {
const Component = layer.Component
return <Component key={layer.id} {...testEngineSceneContext} />
})}
</>
)
}

const SERVER_MODE_OPTIONS: MlCopilotModeOption[] = [
{
Expand All @@ -69,6 +114,7 @@ const SERVER_MODE_OPTIONS: MlCopilotModeOption[] = [

describe('MlEphantConversation', () => {
beforeEach(() => {
configureTestRegistry()
window.localStorage.removeItem(MAKEATHON_ANNOUNCEMENT_DISMISSED_STORAGE_KEY)
})

Expand Down Expand Up @@ -977,22 +1023,25 @@ describe('MlEphantConversation', () => {

const renderConversation = (handleProcess = vi.fn(), disabled = false) => {
return render(
<MlEphantConversation
isLoading={false}
conversation={{ exchanges: [] }}
onProcess={handleProcess}
onClickClearChat={() => {}}
onReconnect={() => {}}
onCancel={() => {}}
needsReconnect={false}
disabled={disabled}
hasPromptCompleted={true}
contexts={[]}
isProcessing={false}
queue={[]}
onRemoveFromQueue={() => {}}
onSteer={() => {}}
/>
<>
<MlEphantConversation
isLoading={false}
conversation={{ exchanges: [] }}
onProcess={handleProcess}
onClickClearChat={() => {}}
onReconnect={() => {}}
onCancel={() => {}}
needsReconnect={false}
disabled={disabled}
hasPromptCompleted={true}
contexts={[]}
isProcessing={false}
queue={[]}
onRemoveFromQueue={() => {}}
onSteer={() => {}}
/>
<TestEngineSceneStreamLayers />
</>
)
}

Expand Down
54 changes: 35 additions & 19 deletions src/components/MlEphantConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { isExternalFileDrag } from '@src/components/Explorer/utils'
import Loading from '@src/components/Loading'
import { MakeathonAnnouncement } from '@src/components/MakeathonAnnouncement'
import Tooltip from '@src/components/Tooltip'
import { ViewportAnnotationOverlay } from '@src/components/ViewportAnnotationOverlay'
import { noAutofillInputProps } from '@src/lib/autofill'
import { useApp } from '@src/lib/boot'
import { dataUrlToFile, takeViewportScreenshot } from '@src/lib/screenshot'
import { err } from '@src/lib/trap'
import { isNonNullable } from '@src/lib/utils'
Expand All @@ -17,8 +17,12 @@ import type {
MlCopilotModeOption,
} from '@src/machines/mlEphantManagerMachine'
import type { Selections } from '@src/machines/modelingSharedTypes'
import {
activateZoodleRuntimeExtension,
deactivateZoodleRuntimeExtension,
} from '@src/registry/extensions/engineScene/zoodleRuntimeExtension'
import type { ChangeEvent, ReactNode } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'

const noop = () => {}

Expand Down Expand Up @@ -263,6 +267,7 @@ interface MlEphantConversationInputProps {
export const MlEphantConversationInput = (
props: MlEphantConversationInputProps
) => {
const { registry } = useApp()
const refDiv = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [value, setValue] = useState<string>('')
Expand All @@ -273,9 +278,22 @@ export const MlEphantConversationInput = (
const lastModeScopeKey = useRef(props.modeScopeKey)
const [attachments, setAttachments] = useState<File[]>([])
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [annotationImageDataUrl, setAnnotationImageDataUrl] = useState<
string | null
>(null)
const zoodleRuntimeExtensionActive = useRef(false)

const stopZoodleRuntimeExtension = useCallback(() => {
if (!zoodleRuntimeExtensionActive.current) {
return
}

zoodleRuntimeExtensionActive.current = false
deactivateZoodleRuntimeExtension(registry)
}, [registry])

useEffect(() => {
return () => {
stopZoodleRuntimeExtension()
}
}, [stopZoodleRuntimeExtension])

// Without this the cursor ends up at the start of the text
useEffect(() => setValue(props.defaultPrompt || ''), [props.defaultPrompt])
Expand Down Expand Up @@ -395,7 +413,18 @@ export const MlEphantConversationInput = (
try {
const dataUrl = takeViewportScreenshot()
if (!dataUrl) return
setAnnotationImageDataUrl(dataUrl)
zoodleRuntimeExtensionActive.current = true
activateZoodleRuntimeExtension(registry, {
imageDataUrl: dataUrl,
onCancel: stopZoodleRuntimeExtension,
onSend: (annotatedDataUrl) => {
appendDataUrlAttachment(
annotatedDataUrl,
'annotated-viewport-screenshot.png'
)
stopZoodleRuntimeExtension()
},
})
} catch (e) {
console.error('Failed to capture viewport screenshot for annotation', e)
}
Expand Down Expand Up @@ -590,19 +619,6 @@ export const MlEphantConversationInput = (
Zookeeper can make mistakes. We send selection context to help. Always
verify information.
</div>
{annotationImageDataUrl && (
<ViewportAnnotationOverlay
imageDataUrl={annotationImageDataUrl}
onCancel={() => setAnnotationImageDataUrl(null)}
onSend={(annotatedDataUrl) => {
appendDataUrlAttachment(
annotatedDataUrl,
'annotated-viewport-screenshot.png'
)
setAnnotationImageDataUrl(null)
}}
/>
)}
</div>
)
}
Expand Down
54 changes: 19 additions & 35 deletions src/components/ViewportAnnotationOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { TRIM_PREVIEW_LINE_COLOR } from '@src/lib/constants'
import { getTrimPreviewLineWidth } from '@src/lib/freehandLineDrawing'
import { getVisibleViewportRect } from '@src/lib/viewportElement'
import type { PointerEvent as ReactPointerEvent } from 'react'
import type { PointerEvent as ReactPointerEvent, SyntheticEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'

const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
Expand Down Expand Up @@ -43,6 +41,10 @@ const getCanvasPoint = (
}
}

const stopOverlayEventPropagation = (event: SyntheticEvent) => {
event.stopPropagation()
}

interface ViewportAnnotationOverlayProps {
imageDataUrl: string
onCancel: () => void
Expand All @@ -54,7 +56,6 @@ export const ViewportAnnotationOverlay = (
) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const lastPoint = useRef<{ x: number; y: number } | null>(null)
const [viewportRect, setViewportRect] = useState(getVisibleViewportRect)
const [imageSize, setImageSize] = useState<{
width: number
height: number
Expand Down Expand Up @@ -90,27 +91,6 @@ export const ViewportAnnotationOverlay = (
}
}, [props.imageDataUrl])

useEffect(() => {
const updateViewportRect = () => setViewportRect(getVisibleViewportRect())
const viewport = document.querySelector('[data-engine]')
const resizeObserver =
typeof ResizeObserver === 'undefined'
? undefined
: new ResizeObserver(updateViewportRect)

if (viewport instanceof HTMLElement) {
resizeObserver?.observe(viewport)
}
window.addEventListener('resize', updateViewportRect)
window.addEventListener('scroll', updateViewportRect, true)

return () => {
resizeObserver?.disconnect()
window.removeEventListener('resize', updateViewportRect)
window.removeEventListener('scroll', updateViewportRect, true)
}
}, [])

const drawTo = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas || !lastPoint.current) {
Expand Down Expand Up @@ -204,16 +184,21 @@ export const ViewportAnnotationOverlay = (
})
}

return createPortal(
return (
<div
role="presentation"
data-testid="viewport-annotation-overlay"
className="fixed z-50 bg-chalkboard-100"
style={{
left: viewportRect.left,
top: viewportRect.top,
width: viewportRect.width,
height: viewportRect.height,
}}
className="absolute inset-0 z-50 bg-chalkboard-100"
onPointerDown={stopOverlayEventPropagation}
onPointerMove={stopOverlayEventPropagation}
onPointerUp={stopOverlayEventPropagation}
onPointerCancel={stopOverlayEventPropagation}
onMouseDown={stopOverlayEventPropagation}
onMouseMove={stopOverlayEventPropagation}
onMouseUp={stopOverlayEventPropagation}
onClick={stopOverlayEventPropagation}
onDoubleClick={stopOverlayEventPropagation}
onWheel={stopOverlayEventPropagation}
>
<img
src={props.imageDataUrl}
Expand Down Expand Up @@ -257,7 +242,6 @@ export const ViewportAnnotationOverlay = (
Send
</button>
</div>
</div>,
document.body
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ vi.mock('@src/lib/desktop', () => ({
}))

vi.mock('@src/lib/boot', () => ({
useApp: () => ({
registry: {
reconfigure: vi.fn(),
},
}),
useSingletons: () => ({
kclManager: {
astSignal: { value: null },
Expand Down
2 changes: 2 additions & 0 deletions src/lib/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
commandSystemService,
provideCommand,
} from '@src/registry/contracts/commands'
import { engineSceneRuntimeExtensionsSlot } from '@src/registry/contracts/engineScene'
import { executingEditorService } from '@src/registry/contracts/executingEditor'
import { keymapService } from '@src/registry/contracts/keymap'
import { layoutContributionsValueSpec } from '@src/registry/contracts/layout'
Expand Down Expand Up @@ -163,6 +164,7 @@ function createAppRegistryItems({
: []),
appCommandsSlot.of(),
appRegistryServicesSlot.of(),
engineSceneRuntimeExtensionsSlot.of(),
...coreRegistryItems,
]
}
Expand Down
4 changes: 3 additions & 1 deletion src/registry/contracts/engineScene.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineContract, defineValueSpec } from '@kittycad/registry'
import { Slot, defineContract, defineValueSpec } from '@kittycad/registry'
import type { modelingMachine } from '@src/machines/modelingMachine'
import type { ComponentType, Dispatch, SetStateAction } from 'react'
import { twMerge } from 'tailwind-merge'
Expand All @@ -19,6 +19,8 @@ export const engineSceneViewExtensionZones = [
export type EngineSceneViewExtensionZone =
(typeof engineSceneViewExtensionZones)[number]

export const engineSceneRuntimeExtensionsSlot = new Slot()

export type EngineSceneExtensionContext = {
modelingState: StateFrom<typeof modelingMachine>
modelingSend: (event: EventFrom<typeof modelingMachine>) => void
Expand Down
Loading
Loading