feat(chat): unify composer attachments

- Render node references and images with one attachment-card layout\n- Generate live previews for referenced scene nodes\n- Make the current-selection control toggle its active selection\n\nCo-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
This commit is contained in:
Danila Poyarkov 2026-09-03 19:05:10 +03:00
parent 743078255f
commit a8c7fedadb
3 changed files with 83 additions and 23 deletions

View file

@ -5,6 +5,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n, useSelectionState } from '@open-pencil/vue'
import ChatNodePreview from '@/components/chat/ChatNodePreview.vue'
import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue'
import ProviderModelSelect from '@/components/chat/ProviderModelSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
@ -13,8 +14,7 @@ import { useAIChat } from '@/app/ai/chat/use'
import {
appendReferencedNodeContext,
MAX_REFERENCED_NODES,
resolveReferencedNodes,
type ReferencedNode
resolveReferencedNodes
} from '@/app/ai/chat/context'
import { designModelProfile, designModelProfiles } from '@/app/ai/models'
import {
@ -23,7 +23,6 @@ import {
validateImageAttachmentFile
} from '@/app/ai/attachment/image/prepare'
import { MAX_IMAGE_ATTACHMENTS, type ImageAttachmentDraft } from '@/app/ai/attachment/image/types'
import { nodeIcon } from '@/app/editor/icons'
import { openSettingsDialog } from '@/app/settings/dialog'
import { ACP_AGENTS } from '@open-pencil/core/constants'
@ -51,11 +50,18 @@ const referencedNodeIds = ref<string[]>([])
const referencedNodes = computed(() =>
resolveReferencedNodes(editor.graph, referencedNodeIds.value)
)
const selectedReferencedNodeIds = computed(() =>
[...selectedIds.value].filter((id) => referencedNodeIds.value.includes(id))
)
const canAddSelection = computed(
() =>
selectedIds.value.size > 0 &&
referencedNodes.value.length < MAX_REFERENCED_NODES &&
[...selectedIds.value].some((id) => !referencedNodeIds.value.includes(id))
(selectedReferencedNodeIds.value.length > 0 ||
referencedNodes.value.length < MAX_REFERENCED_NODES)
)
const selectionContextActive = computed(
() =>
selectedIds.value.size > 0 && selectedReferencedNodeIds.value.length === selectedIds.value.size
)
const {
open: openImageDialog,
@ -157,18 +163,18 @@ function removeReferencedNode(id: string): void {
referencedNodeIds.value = referencedNodeIds.value.filter((candidate) => candidate !== id)
}
function addCurrentSelection(): void {
function toggleCurrentSelection(): void {
if (selectedReferencedNodeIds.value.length > 0) {
const selected = new Set(selectedIds.value)
referencedNodeIds.value = referencedNodeIds.value.filter((id) => !selected.has(id))
return
}
referencedNodeIds.value = resolveReferencedNodes(editor.graph, [
...referencedNodeIds.value,
...selectedIds.value
]).map((node) => node.id)
}
function referencedNodeIcon(node: ReferencedNode) {
const liveNode = editor.graph.getNode(node.id)
return liveNode ? nodeIcon(liveNode) : undefined
}
function handleSubmit(e: Event) {
e.preventDefault()
const text = input.value.trim()
@ -190,29 +196,25 @@ function handleSubmit(e: Event) {
<form @submit="handleSubmit" @paste.stop="handlePaste">
<InputGroup :disabled="isStreaming">
<template v-if="images.length || referencedNodes.length" #attachment>
<div v-if="referencedNodes.length" class="flex flex-wrap gap-1">
<div class="flex flex-wrap gap-1.5">
<div
v-for="node in referencedNodes"
:key="node.id"
data-slot="chat-context-chip"
class="flex min-w-0 items-center gap-1 rounded-md border border-border bg-hover px-1.5 py-0.5 text-[10px] text-surface"
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
>
<component
:is="referencedNodeIcon(node)"
class="size-2.5 shrink-0 text-muted"
aria-hidden="true"
/>
<span class="max-w-24 truncate">{{ node.name || node.type }}</span>
<ChatNodePreview :editor="editor" :node="node" />
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
{{ node.name || node.type }}
</span>
<IconButton
:label="ai.removeNodeContext"
size="xs"
@click="removeReferencedNode(node.id)"
>
<icon-lucide-x class="size-2.5" />
<icon-lucide-x class="size-3" />
</IconButton>
</div>
</div>
<div v-if="images.length" class="flex flex-wrap gap-1.5">
<div
v-for="(image, index) in images"
:key="image.previewURL"
@ -257,9 +259,10 @@ function handleSubmit(e: Event) {
<IconButton
:label="ai.addSelectionContext"
size="sm"
:active="selectionContextActive"
:disabled="isStreaming || !canAddSelection"
data-slot="chat-add-selection-context"
@click="addCurrentSelection"
@click="toggleCurrentSelection"
>
<icon-lucide-mouse-pointer-2 class="size-4" />
</IconButton>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import { useObjectUrl } from '@vueuse/core'
import { shallowRef, watch } from 'vue'
import { renderNodesToImage } from '@open-pencil/core/io'
import type { ReferencedNode } from '@/app/ai/chat/context'
import type { Editor } from '@open-pencil/core/editor'
const { editor, node } = defineProps<{ editor: Editor; node: ReferencedNode }>()
const blob = shallowRef<Blob | null>(null)
const previewURL = useObjectUrl(blob)
let requestId = 0
watch(
() => node.id,
async () => {
const request = ++requestId
const renderer = editor.renderer
const liveNode = editor.graph.getNode(node.id)
if (!renderer || !liveNode) return
const maxDimension = Math.max(liveNode.width, liveNode.height, 1)
const data = await renderNodesToImage(
renderer.ck,
renderer,
editor.graph,
editor.state.currentPageId,
[node.id],
{ scale: 40 / maxDimension, format: 'PNG' }
)
if (request === requestId) blob.value = data ? new Blob([data], { type: 'image/png' }) : null
},
{ immediate: true }
)
</script>
<template>
<img
v-if="previewURL"
:src="previewURL"
alt=""
class="size-10 shrink-0 rounded-md border border-border bg-canvas object-contain"
/>
<div
v-else
class="flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-canvas"
>
<icon-lucide-box class="size-4 text-muted" aria-hidden="true" />
</div>
</template>

View file

@ -238,6 +238,14 @@ test('selected nodes can be pinned without exposing context metadata in the chat
})
await page.getByRole('button', { name: 'Add current selection as context' }).click()
const contextButton = page.getByRole('button', { name: 'Add current selection as context' })
await expect(contextButton).toHaveAttribute('data-state', 'on')
await expect(contextButton).toBeEnabled()
await expect(page.locator('[data-slot="chat-context-chip"]')).toContainText('Pinned hero')
await expect(page.locator('[data-slot="chat-context-chip"] img')).toBeVisible()
await contextButton.click()
await expect(page.locator('[data-slot="chat-context-chip"]')).toHaveCount(0)
await contextButton.click()
await expect(page.locator('[data-slot="chat-context-chip"]')).toContainText('Pinned hero')
await chatInput().fill('Make it larger')
await chatInput().press('Enter')