feat(chat): attach dropped images and style tables, menus, and link prompts in replies (#955)
* feat(chat): attach images by dropping them on the chat Images could be attached with the attach button or by pasting, but a drop on the chat did nothing: the window's document drop handler left images to the canvas, so they were dropped silently. Images dropped anywhere on the chat panel now attach, through the same path as the attach button and paste, with the same limits and errors. While images are dragged over the panel, an outline over the composer says where they go, or why they cannot be attached while a reply runs or four images are already attached. SVG images are drawn as PNGs, since vision models take raster images. A drag without images, such as a .fig, is left to the window, which opens documents. The canvas and the chat share AppDropOverlay, a typed feedback component with a light tint over surfaces and a cover over fields. * fix(chat): style tables, menus, and link prompts in AI replies vue-stream-markdown drew tables with semibold, filled rows at 14px, with copy, download, and fullscreen buttons above each; its tooltips doubled with the browser's, and the link confirmation it teleports out of the message had no app colors, since the token mapping sat on the message element only. The token mapping now lives in markdown.css on the renderer's .stream-markdown elements, so teleported dialogs and menus use app colors. Tables use regular weight, a light header, and the chat density's size, with copy as their only action; task items show their checkbox in place of the bullet; the copy menu and link prompt follow the app's menus and dialogs. Hover labels use the app's tooltip, while click menus keep the renderer's. Links no longer request each site's favicon. * fix(chat): keep an SVG drawn after sending out of the next message Drawing a dropped SVG as PNG takes a moment. A message sent or cleared meanwhile no longer gets the image added to the following draft: each draft has a generation that sending and clearing advance, and an image finished for an earlier one is dropped.
This commit is contained in:
parent
099e5d7a15
commit
f8d2dbc7e6
|
|
@ -36,6 +36,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Attach images to an AI chat message by dropping them anywhere on the chat panel: an outline over the composer shows where they go, or why they cannot be attached while a reply is running or four images are already attached. SVG images are drawn as PNGs when dropped, pasted, or chosen. Documents dropped on the chat still open in a new tab.
|
||||
- Share only the selected layers with MCP clients: **Share only the selection** under **Settings → MCP → Local server**, or `OPENPENCIL_MCP_SCOPE=selection` for a server you start yourself, limits clients to `get_selection`, `get_node`, `get_page_tree`, `describe`, and `export_image` on the selected layers and what they hold, enforced by the server for MCP sessions, stdio clients, and `/rpc` alike.
|
||||
- Set up AI in a few steps with guided setup. On first launch OpenPencil asks what AI should help with and what you already use: a coding agent on this computer (Claude Code, Codex, Gemini CLI, or Pi, in the desktop app), an API account (Anthropic, OpenAI, Google AI, OpenRouter, DeepSeek, Z.ai, or MiniMax), or a local or company server such as Ollama or LM Studio. Sign in to OpenRouter from the browser or the desktop app instead of pasting a key. In the desktop app, setup checks whether the agent, its OpenPencil adapter, OpenPencil's MCP server, and Pi's Harness companion are installed and match the app, installs or updates them with one click through npm, and links a [setup guide](https://openpencil.dev/programmable/coding-agents) or copies a prompt that asks the agent to set itself up. Setup tests each connection, proposes a model for each role that you can change, and keeps anything configured by hand. Skip it to start designing, or run it again from **Settings → AI & agents**.
|
||||
- Print the installed version of OpenPencil's MCP server and Harness companion with `openpencil-mcp-http --version`, `openpencil-mcp --version`, and `openpencil-harness --version`. The desktop app asks it to check that they match the app on every platform, including npm `.cmd` shims on Windows and version managers such as Volta and mise.
|
||||
|
|
@ -122,6 +123,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Show tables in AI chat replies at the chat's text size and weight, with a light header and copy as their only action, and task lists with a checkbox in place of the bullet. Tooltips, the copy menu, and the confirmation before opening a link follow the app's style, and links no longer load each site's favicon.
|
||||
- Keep an opened `.fig` file saved until it is edited. Laying out its first page, which recomputes auto-layout sizes and positions, marked it unsaved, so closing it asked to save changes nobody made.
|
||||
- Fill one axis of a grid cell: a grid child set to fill its width or its height no longer fills both, and an auto-layout child of a grid keeps its own size unless it fills, as in Figma. Design JSX renders and exports `w="fill"` and `h="fill"` in grids, and HTML and Tailwind export leave out the size a child fills, so it stretches in the browser too.
|
||||
- Resize auto-layout frames that fill across their parent with it: one with a fixed size on that axis kept its old size. A filling child still counts toward a hugging parent's size, as in Figma.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"sendMessage": "Nachricht senden",
|
||||
"attachImages": "Bilder anhängen",
|
||||
"removeImageAttachment": "Bild {name} entfernen",
|
||||
"dropImagesToAttach": "Bilder zum Anhängen ablegen",
|
||||
"dropImagesAfterReply": "Bilder nach der Antwort anhängen",
|
||||
"dropImagesLimit": "Bis zu {count} Bilder pro Nachricht",
|
||||
"viewAttachment": "Anhang {name} anzeigen",
|
||||
"attachmentPreview": "Anhangsvorschau",
|
||||
"closeAttachmentPreview": "Anhangsvorschau schließen",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"sendMessage": "Enviar mensaje",
|
||||
"attachImages": "Adjuntar imágenes",
|
||||
"removeImageAttachment": "Quitar imagen {name}",
|
||||
"dropImagesToAttach": "Suelta imágenes para adjuntarlas",
|
||||
"dropImagesAfterReply": "Adjunta imágenes después de la respuesta",
|
||||
"dropImagesLimit": "Hasta {count} imágenes por mensaje",
|
||||
"viewAttachment": "Ver adjunto {name}",
|
||||
"attachmentPreview": "Vista previa del adjunto",
|
||||
"closeAttachmentPreview": "Cerrar vista previa del adjunto",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"sendMessage": "Envoyer le message",
|
||||
"attachImages": "Joindre des images",
|
||||
"removeImageAttachment": "Retirer l’image {name}",
|
||||
"dropImagesToAttach": "Déposez des images pour les joindre",
|
||||
"dropImagesAfterReply": "Joignez des images après la réponse",
|
||||
"dropImagesLimit": "Jusqu’à {count} images par message",
|
||||
"viewAttachment": "Afficher la pièce jointe {name}",
|
||||
"attachmentPreview": "Aperçu de la pièce jointe",
|
||||
"closeAttachmentPreview": "Fermer l’aperçu de la pièce jointe",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"sendMessage": "Invia messaggio",
|
||||
"attachImages": "Allega immagini",
|
||||
"removeImageAttachment": "Rimuovi immagine {name}",
|
||||
"dropImagesToAttach": "Rilascia immagini per allegarle",
|
||||
"dropImagesAfterReply": "Allega immagini dopo la risposta",
|
||||
"dropImagesLimit": "Fino a {count} immagini per messaggio",
|
||||
"viewAttachment": "Visualizza allegato {name}",
|
||||
"attachmentPreview": "Anteprima allegato",
|
||||
"closeAttachmentPreview": "Chiudi anteprima allegato",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"sendMessage": "メッセージを送信",
|
||||
"attachImages": "画像を添付",
|
||||
"removeImageAttachment": "画像 {name} を削除",
|
||||
"dropImagesToAttach": "ドロップして画像を添付",
|
||||
"dropImagesAfterReply": "返信が終わってから画像を添付",
|
||||
"dropImagesLimit": "1 件のメッセージに画像は {count} 枚まで",
|
||||
"viewAttachment": "添付ファイル {name} を表示",
|
||||
"attachmentPreview": "添付ファイルのプレビュー",
|
||||
"closeAttachmentPreview": "添付ファイルのプレビューを閉じる",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"sendMessage": "Wyślij wiadomość",
|
||||
"attachImages": "Dołącz obrazy",
|
||||
"removeImageAttachment": "Usuń obraz {name}",
|
||||
"dropImagesToAttach": "Upuść obrazy, aby je dołączyć",
|
||||
"dropImagesAfterReply": "Dołącz obrazy po odpowiedzi",
|
||||
"dropImagesLimit": "Do {count} obrazów na wiadomość",
|
||||
"viewAttachment": "Wyświetl załącznik {name}",
|
||||
"attachmentPreview": "Podgląd załącznika",
|
||||
"closeAttachmentPreview": "Zamknij podgląd załącznika",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"sendMessage": "Отправить сообщение",
|
||||
"attachImages": "Прикрепить изображения",
|
||||
"removeImageAttachment": "Удалить изображение {name}",
|
||||
"dropImagesToAttach": "Перетащите изображения, чтобы прикрепить",
|
||||
"dropImagesAfterReply": "Прикрепите изображения после ответа",
|
||||
"dropImagesLimit": "До {count} изображений в сообщении",
|
||||
"viewAttachment": "Просмотреть вложение {name}",
|
||||
"attachmentPreview": "Предпросмотр вложения",
|
||||
"closeAttachmentPreview": "Закрыть предпросмотр вложения",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"sendMessage": "发送消息",
|
||||
"attachImages": "附加图片",
|
||||
"removeImageAttachment": "移除图片 {name}",
|
||||
"dropImagesToAttach": "拖放图片以添加附件",
|
||||
"dropImagesAfterReply": "回复完成后再添加图片",
|
||||
"dropImagesLimit": "每条消息最多 {count} 张图片",
|
||||
"viewAttachment": "查看附件 {name}",
|
||||
"attachmentPreview": "附件预览",
|
||||
"closeAttachmentPreview": "关闭附件预览",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ export const aiMessageDefaults = {
|
|||
sendMessage: 'Send message',
|
||||
attachImages: 'Attach images',
|
||||
removeImageAttachment: params('Remove image {name}'),
|
||||
dropImagesToAttach: 'Drop images to attach',
|
||||
dropImagesAfterReply: 'Attach images after the reply',
|
||||
dropImagesLimit: params('Up to {count} images per message'),
|
||||
viewAttachment: params('View attachment {name}'),
|
||||
attachmentPreview: 'Attachment preview',
|
||||
closeAttachmentPreview: 'Close attachment preview',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,50 @@ function canvasToBlob(
|
|||
})
|
||||
}
|
||||
|
||||
/** Whether a file is an SVG image, which some systems report without a media type. */
|
||||
export function isSVGFile(file: File): boolean {
|
||||
return (
|
||||
file.type === 'image/svg+xml' || (file.type === '' && file.name.toLowerCase().endsWith('.svg'))
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a file can be attached as an image, as it is or after drawing an SVG as PNG. */
|
||||
export function isAttachableImageFile(file: File): boolean {
|
||||
return file.type.startsWith('image/') || isSVGFile(file)
|
||||
}
|
||||
|
||||
/** The square an SVG without its own size is drawn into, in pixels. */
|
||||
const SVG_FALLBACK_EDGE = 512
|
||||
|
||||
/**
|
||||
* An SVG drawn as a PNG, since vision models take raster images. Its longer edge is drawn at the
|
||||
* attachment size, so small icons stay sharp; an `<img>` never runs the SVG's scripts.
|
||||
*/
|
||||
export async function rasterizeSVGAttachment(
|
||||
file: File,
|
||||
maxEdge = IMAGE_ATTACHMENT_MAX_EDGE
|
||||
): Promise<File> {
|
||||
if (file.size > MAX_IMAGE_FILE_BYTES) throw new Error('Images must be 20 MB or smaller.')
|
||||
const sourceURL = createImagePreviewURL(new Blob([file], { type: 'image/svg+xml' }))
|
||||
try {
|
||||
const image = await loadImage(sourceURL)
|
||||
const naturalWidth = image.naturalWidth || SVG_FALLBACK_EDGE
|
||||
const naturalHeight = image.naturalHeight || SVG_FALLBACK_EDGE
|
||||
const scale = maxEdge / Math.max(naturalWidth, naturalHeight)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.max(1, Math.round(naturalWidth * scale))
|
||||
canvas.height = Math.max(1, Math.round(naturalHeight * scale))
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Could not prepare the image.')
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
||||
const blob = await canvasToBlob(canvas, 'image/png')
|
||||
const name = file.name.replace(/\.svg$/i, '') + '.png'
|
||||
return new File([blob], name, { type: 'image/png' })
|
||||
} finally {
|
||||
revokeImagePreviewURL(sourceURL)
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareImageAttachment(
|
||||
file: File,
|
||||
maxEdge = IMAGE_ATTACHMENT_MAX_EDGE
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import ChatInput from '@/components/chat/ChatInput.vue'
|
|||
import ChatRunLocation from '@/components/chat/ChatRunLocation.vue'
|
||||
import ChatTranscript from '@/components/chat/ChatTranscript.vue'
|
||||
import FollowAgentsToggle from '@/components/chat/FollowAgentsToggle.vue'
|
||||
import { useImageDrop } from '@/components/chat/input/useImageDrop'
|
||||
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
|
||||
|
||||
const { isConfigured, ensureChat, history, chatFailure, clearChatFailure } = useAIChat()
|
||||
|
|
@ -54,9 +55,16 @@ const submission = useChatSubmission({
|
|||
openSetup: () => openAISetup()
|
||||
})
|
||||
|
||||
const chatInput = useTemplateRef<{ restoreDraft: (submission: ChatSubmission) => void }>(
|
||||
'chatInput'
|
||||
)
|
||||
const chatInput = useTemplateRef<{
|
||||
restoreDraft: (submission: ChatSubmission) => void
|
||||
dropFiles: (files: File[]) => void
|
||||
}>('chatInput')
|
||||
const panel = useTemplateRef<HTMLElement>('panel')
|
||||
// Images dropped anywhere on the chat go to the composer, when there is one to take them.
|
||||
const { dragging: draggingImages } = useImageDrop(panel, {
|
||||
enabled: () => chatInput.value !== null,
|
||||
onImages: (files) => chatInput.value?.dropFiles(files)
|
||||
})
|
||||
let viewGeneration = 0
|
||||
|
||||
/** Puts an unsent message back only in the conversation it was written in. */
|
||||
|
|
@ -188,7 +196,11 @@ function handleStop() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="chat-panel" class="flex min-w-0 flex-1 flex-col overflow-hidden select-text">
|
||||
<div
|
||||
ref="panel"
|
||||
data-test-id="chat-panel"
|
||||
class="flex min-w-0 flex-1 flex-col overflow-hidden select-text"
|
||||
>
|
||||
<ChatHistory
|
||||
:saved="history.conversations.value.some((row) => row.id === history.current.value?.id)"
|
||||
:conversations="historyOptions"
|
||||
|
|
@ -250,6 +262,7 @@ function handleStop() {
|
|||
v-if="isConfigured && !agentHistoryReadOnly && !history.readOnly.value"
|
||||
ref="chatInput"
|
||||
:status="status"
|
||||
:dragging="draggingImages"
|
||||
:disabled="submission.busy.value || history.busy.value"
|
||||
@submit="submitMessage"
|
||||
@stop="handleStop"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { appRuntimeConfig } from '@/app/runtime/config'
|
|||
import IssueMarkerTooltip from '@/components/design-check/IssueMarkerTooltip.vue'
|
||||
import PreparationOverlay from '@/components/preparation/canvas/Overlay.vue'
|
||||
import FollowFrame from '@/components/presence/FollowFrame.vue'
|
||||
import AppDropOverlay from '@/components/ui/feedback/AppDropOverlay.vue'
|
||||
import { motionStyles } from '@/theme/motion/styles'
|
||||
import { floatingSurface } from '@/theme/overlay'
|
||||
|
||||
|
|
@ -230,17 +231,7 @@ const cursor = computed(() =>
|
|||
class="absolute inset-0 block size-full touch-none outline-none"
|
||||
/>
|
||||
<PlayIslands :view="paneView" :canvas="canvasRef" />
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
leave-active-class="transition-opacity duration-150"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isDraggingOver"
|
||||
class="pointer-events-none absolute inset-0 z-40 border-2 border-dashed border-accent/60 bg-accent/5"
|
||||
/>
|
||||
</Transition>
|
||||
<AppDropOverlay :visible="isDraggingOver" />
|
||||
<IssueMarkerTooltip :marker="hoveredIssueMarker" :canvas="canvasRef" />
|
||||
<CanvasLabelEditor
|
||||
:edit="canvasLabelEdit"
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ defineExpose({ restoreDraft })
|
|||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="shrink-0 border-t border-border p-2.5">
|
||||
<form @submit="handleSubmit" @paste.stop="emit('paste', $event)">
|
||||
<form class="relative" @submit="handleSubmit" @paste.stop="emit('paste', $event)">
|
||||
<InputGroup :disabled="isStreaming">
|
||||
<template v-if="$slots.attachment" #attachment><slot name="attachment" /></template>
|
||||
|
||||
|
|
@ -104,6 +104,8 @@ defineExpose({ restoreDraft })
|
|||
</IconButton>
|
||||
</template>
|
||||
</InputGroup>
|
||||
<!-- Over the input while files are dragged onto the chat. -->
|
||||
<slot name="overlay" />
|
||||
</form>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue'
|
|||
import ChatThinkingSelect from '@/components/chat/ChatThinkingSelect.vue'
|
||||
import { useAttachmentDrafts } from '@/components/chat/input/useAttachments'
|
||||
import IconButton from '@/components/ui/button/IconButton.vue'
|
||||
import AppDropOverlay from '@/components/ui/feedback/AppDropOverlay.vue'
|
||||
|
||||
import ChatComposer from './ChatComposer.vue'
|
||||
|
||||
|
|
@ -22,9 +23,15 @@ const { providerID, providerDef, modelID, customModelID } = useAIChat()
|
|||
const { editor, selectedIds } = useSelectionState()
|
||||
const { ai } = useI18n()
|
||||
|
||||
const { status, disabled = false } = defineProps<{
|
||||
const {
|
||||
status,
|
||||
disabled = false,
|
||||
dragging = false
|
||||
} = defineProps<{
|
||||
status: 'ready' | 'submitted' | 'streaming' | 'error'
|
||||
disabled?: boolean
|
||||
/** Whether images are being dragged over the chat, which shows where they will go. */
|
||||
dragging?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -44,6 +51,7 @@ const {
|
|||
canToggleSelection: canAddSelection,
|
||||
selectionActive: selectionContextActive,
|
||||
openImageDialog,
|
||||
addFiles,
|
||||
removeImage,
|
||||
removeNode: removeReferencedNode,
|
||||
toggleSelection: toggleCurrentSelection,
|
||||
|
|
@ -59,9 +67,29 @@ function restoreDraft(submission: ChatSubmission): void {
|
|||
if (composer.value?.restoreDraft(submission.displayText)) restoreSubmission(submission)
|
||||
else for (const image of submission.images) revokeImagePreviewURL(image.previewURL)
|
||||
}
|
||||
defineExpose({ restoreDraft })
|
||||
|
||||
const isStreaming = computed(() => disabled || status === 'streaming' || status === 'submitted')
|
||||
|
||||
/** Why images dropped now would not be attached, or null when they would. */
|
||||
const dropRefusal = computed(() => {
|
||||
if (isStreaming.value) return ai.value.dropImagesAfterReply
|
||||
if (images.value.length >= MAX_IMAGE_ATTACHMENTS) {
|
||||
return ai.value.dropImagesLimit({ count: MAX_IMAGE_ATTACHMENTS })
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
/** Attaches images dropped on the chat, unless they cannot be attached right now. */
|
||||
function dropFiles(files: File[]): void {
|
||||
if (dropRefusal.value) {
|
||||
emit('error', dropRefusal.value)
|
||||
return
|
||||
}
|
||||
void addFiles(files)
|
||||
}
|
||||
|
||||
defineExpose({ restoreDraft, dropFiles })
|
||||
|
||||
const isAgentProvider = computed(
|
||||
() => providerID.value.startsWith('acp:') || providerID.value === 'harness:pi'
|
||||
)
|
||||
|
|
@ -145,6 +173,14 @@ const selectedProfileName = computed(
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #overlay>
|
||||
<AppDropOverlay
|
||||
shape="field"
|
||||
:visible="dragging"
|
||||
:accepts="!dropRefusal"
|
||||
:label="dropRefusal ?? ai.dropImagesToAttach"
|
||||
/>
|
||||
</template>
|
||||
<template #leading>
|
||||
<IconButton
|
||||
:label="ai.addSelectionContext"
|
||||
|
|
|
|||
|
|
@ -93,6 +93,27 @@ const rows = 4
|
|||
}
|
||||
}
|
||||
|
||||
export const Table: Story = {
|
||||
args: {
|
||||
content: `The button now has these states:
|
||||
|
||||
| State | Look |
|
||||
| --- | --- |
|
||||
| Default | Burnt orange \`#c2410c\` with the soft glow |
|
||||
| Hover | Brighter \`#ea580c\` with a bigger glow |
|
||||
| Disabled | Muted \`#374151\` with grey text |`
|
||||
}
|
||||
}
|
||||
|
||||
export const TaskList: Story = {
|
||||
args: {
|
||||
content: `- [x] Variants created
|
||||
- [ ] Checked in preview
|
||||
|
||||
See [the guide](https://openpencil.dev/guide).`
|
||||
}
|
||||
}
|
||||
|
||||
export const Streaming: Story = {
|
||||
args: {
|
||||
mode: 'streaming',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import type { Editor } from '@open-pencil/core/editor'
|
|||
|
||||
import {
|
||||
createImagePreviewURL,
|
||||
isAttachableImageFile,
|
||||
isSVGFile,
|
||||
rasterizeSVGAttachment,
|
||||
revokeImagePreviewURL,
|
||||
validateImageAttachmentFile
|
||||
} from '@/app/ai/attachment/image/prepare'
|
||||
|
|
@ -24,6 +27,8 @@ interface AttachmentDraftOptions {
|
|||
}
|
||||
|
||||
export function useAttachmentDrafts(options: AttachmentDraftOptions) {
|
||||
/** Advances when a message is sent or the draft is cleared, so late images skip it. */
|
||||
let draftGeneration = 0
|
||||
const images = ref<ImageAttachmentDraft[]>([])
|
||||
const nodeIds = ref<string[]>([])
|
||||
const nodes = computed(() => resolveReferencedNodes(options.editor.graph, nodeIds.value))
|
||||
|
|
@ -45,28 +50,42 @@ export function useAttachmentDrafts(options: AttachmentDraftOptions) {
|
|||
reset: resetImageDialog,
|
||||
onChange: onImageChange
|
||||
} = useFileDialog({
|
||||
accept: 'image/png,image/jpeg,image/webp',
|
||||
accept: 'image/png,image/jpeg,image/webp,image/svg+xml',
|
||||
multiple: true,
|
||||
reset: true
|
||||
})
|
||||
|
||||
function addImages(files: File[]): void {
|
||||
const available = MAX_IMAGE_ATTACHMENTS - images.value.length
|
||||
if (available <= 0) {
|
||||
options.reportError(`You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`)
|
||||
/**
|
||||
* Attaches images chosen, pasted, or dropped, up to the limit: SVGs are drawn as PNGs first,
|
||||
* and each file that cannot be attached says why.
|
||||
*/
|
||||
async function addFiles(files: File[]): Promise<void> {
|
||||
const draft = draftGeneration
|
||||
const limitMessage = `You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`
|
||||
if (images.value.length >= MAX_IMAGE_ATTACHMENTS) {
|
||||
options.reportError(limitMessage)
|
||||
resetImageDialog()
|
||||
return
|
||||
}
|
||||
for (const file of files.slice(0, available)) {
|
||||
const validationError = validateImageAttachmentFile(file)
|
||||
if (validationError) {
|
||||
options.reportError(validationError)
|
||||
continue
|
||||
for (const file of files) {
|
||||
try {
|
||||
const image = isSVGFile(file) ? await rasterizeSVGAttachment(file) : file
|
||||
// The message was sent or cleared while the SVG was drawn: it belongs to no draft now.
|
||||
if (draft !== draftGeneration) return
|
||||
// Checked after the await, since another drop may have filled the slots meanwhile.
|
||||
if (images.value.length >= MAX_IMAGE_ATTACHMENTS) {
|
||||
options.reportError(limitMessage)
|
||||
break
|
||||
}
|
||||
const validationError = validateImageAttachmentFile(image)
|
||||
if (validationError) {
|
||||
options.reportError(validationError)
|
||||
continue
|
||||
}
|
||||
images.value.push({ file: image, previewURL: createImagePreviewURL(image) })
|
||||
} catch (error) {
|
||||
options.reportError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
images.value.push({ file, previewURL: createImagePreviewURL(file) })
|
||||
}
|
||||
if (files.length > available) {
|
||||
options.reportError(`You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`)
|
||||
}
|
||||
resetImageDialog()
|
||||
}
|
||||
|
|
@ -95,13 +114,14 @@ export function useAttachmentDrafts(options: AttachmentDraftOptions) {
|
|||
|
||||
function handlePaste(event: ClipboardEvent): void {
|
||||
const files = event.clipboardData?.files
|
||||
const pastedImages = files ? [...files].filter((file) => file.type.startsWith('image/')) : []
|
||||
const pastedImages = files ? [...files].filter(isAttachableImageFile) : []
|
||||
if (pastedImages.length === 0) return
|
||||
event.preventDefault()
|
||||
addImages(pastedImages)
|
||||
void addFiles(pastedImages)
|
||||
}
|
||||
|
||||
function takeSubmission(text: string): ChatSubmission {
|
||||
draftGeneration++
|
||||
const submittedImages = images.value
|
||||
const submittedNodes = nodes.value
|
||||
images.value = []
|
||||
|
|
@ -122,6 +142,7 @@ export function useAttachmentDrafts(options: AttachmentDraftOptions) {
|
|||
}
|
||||
|
||||
function clear(): void {
|
||||
draftGeneration++
|
||||
for (const image of images.value) revokeImagePreviewURL(image.previewURL)
|
||||
images.value = []
|
||||
nodeIds.value = []
|
||||
|
|
@ -129,7 +150,7 @@ export function useAttachmentDrafts(options: AttachmentDraftOptions) {
|
|||
}
|
||||
|
||||
onImageChange((files) => {
|
||||
if (files) addImages([...files])
|
||||
if (files) void addFiles([...files])
|
||||
})
|
||||
onBeforeUnmount(clear)
|
||||
|
||||
|
|
@ -139,7 +160,7 @@ export function useAttachmentDrafts(options: AttachmentDraftOptions) {
|
|||
canToggleSelection,
|
||||
selectionActive,
|
||||
openImageDialog,
|
||||
addImages,
|
||||
addFiles,
|
||||
removeImage,
|
||||
removeNode,
|
||||
toggleSelection,
|
||||
|
|
|
|||
26
src/components/chat/input/useImageDrop.ts
Normal file
26
src/components/chat/input/useImageDrop.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useDropZone } from '@vueuse/core'
|
||||
import { toValue, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||
|
||||
import { isAttachableImageFile } from '@/app/ai/attachment/image/prepare'
|
||||
|
||||
/**
|
||||
* Takes images dropped anywhere on `target` for the chat. A drag carrying no image, such as a
|
||||
* `.fig`, is left to the window, which opens documents; the other files of a mixed drop go there
|
||||
* too, since nothing here stops the event.
|
||||
*/
|
||||
export function useImageDrop(
|
||||
target: Ref<HTMLElement | null>,
|
||||
options: { enabled: MaybeRefOrGetter<boolean>; onImages: (files: File[]) => void }
|
||||
) {
|
||||
const { isOverDropZone } = useDropZone(target, {
|
||||
// Only the item types are readable while dragging; the files arrive with the drop.
|
||||
checkValidity: (items) =>
|
||||
toValue(options.enabled) &&
|
||||
Array.from(items).some((item) => item.kind === 'file' && item.type.startsWith('image/')),
|
||||
onDrop: (files) => {
|
||||
const images = (files ?? []).filter(isAttachableImageFile)
|
||||
if (images.length > 0) options.onImages(images)
|
||||
}
|
||||
})
|
||||
return { dragging: isOverDropZone }
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { createMarkdownHardenOptions, markdownExtensions } from '@/app/shell/mar
|
|||
import { animationsEnabled } from '@/app/shell/motion'
|
||||
import { resolvedAppTheme } from '@/app/shell/theme'
|
||||
import InlineCode from '@/components/markdown/InlineCode.vue'
|
||||
import MarkdownTooltip from '@/components/markdown/MarkdownTooltip.vue'
|
||||
import { markdownTheme, type MarkdownDensity } from '@/theme/markdown'
|
||||
|
||||
const {
|
||||
|
|
@ -28,6 +29,16 @@ const {
|
|||
const isDark = computed(() => resolvedAppTheme.value === 'dark')
|
||||
const ui = markdownTheme()
|
||||
const markdownComponents = { code: InlineCode }
|
||||
// The renderer's own tooltip doubles up with ours and does not follow the app's style.
|
||||
const uiComponents = { Tooltip: MarkdownTooltip }
|
||||
// Copy is the one table action that fits a chat reply; images open in the attachment viewer.
|
||||
const controls = {
|
||||
code: { download: false, fullscreen: false },
|
||||
table: { copy: true, download: false, fullscreen: false },
|
||||
image: false
|
||||
}
|
||||
// Favicons would request `/favicon.ico` from every site a reply links to.
|
||||
const linkOptions = { favicon: false }
|
||||
const extensions = computed(() => (highlightCode ? markdownExtensions : undefined))
|
||||
const hardenOptions = computed(() =>
|
||||
createMarkdownHardenOptions(IS_BROWSER ? window.location.origin : 'http://localhost/')
|
||||
|
|
@ -45,7 +56,9 @@ const hardenOptions = computed(() =>
|
|||
:extensions="extensions"
|
||||
:harden-options="hardenOptions"
|
||||
:previewers="false"
|
||||
:controls="{ code: { download: false, fullscreen: false } }"
|
||||
:controls="controls"
|
||||
:link-options="linkOptions"
|
||||
:ui-components="uiComponents"
|
||||
:data-markdown-mode="mode"
|
||||
:data-markdown-density="density"
|
||||
:class="ui.markdown()"
|
||||
|
|
|
|||
61
src/components/markdown/MarkdownTooltip.vue
Normal file
61
src/components/markdown/MarkdownTooltip.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, useSlots, useTemplateRef } from 'vue'
|
||||
import { UI } from 'vue-stream-markdown'
|
||||
|
||||
import Tip from '@/components/ui/overlay/Tip.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const BuiltinTooltip = UI.Tooltip
|
||||
type Placement = NonNullable<InstanceType<typeof BuiltinTooltip>['$props']['placement']>
|
||||
|
||||
/**
|
||||
* The markdown renderer's tooltip. A hover label uses the app's tooltip, in its style and without
|
||||
* the browser's own title tooltip beside it; menus, which the renderer builds from a click tooltip
|
||||
* with slot content, keep the renderer's.
|
||||
*/
|
||||
const {
|
||||
content,
|
||||
trigger = 'hover',
|
||||
placement = 'top',
|
||||
delay
|
||||
} = defineProps<{
|
||||
content?: string
|
||||
trigger?: 'hover' | 'click'
|
||||
placement?: Placement
|
||||
delay?: number | [number, number]
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const builtin = useTemplateRef<{ show: () => void; hide: () => void }>('builtin')
|
||||
const usesBuiltin = computed(() => trigger === 'click' || slots.content !== undefined)
|
||||
|
||||
const side = computed(() => {
|
||||
const base = placement.split('-')[0]
|
||||
return base === 'bottom' || base === 'left' || base === 'right' ? base : 'top'
|
||||
})
|
||||
|
||||
// The renderer's dropdown closes its menu through these.
|
||||
defineExpose({
|
||||
show: () => builtin.value?.show(),
|
||||
hide: () => builtin.value?.hide()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="BuiltinTooltip"
|
||||
v-if="usesBuiltin"
|
||||
ref="builtin"
|
||||
v-bind="$attrs"
|
||||
:content="content"
|
||||
:trigger="trigger"
|
||||
:placement="placement"
|
||||
:delay="delay"
|
||||
>
|
||||
<template v-for="(_, name) in $slots" #[name]="scope"
|
||||
><slot :name="name" v-bind="scope"
|
||||
/></template>
|
||||
</component>
|
||||
<Tip v-else :label="content" :side="side" :disabled="!content"><slot /></Tip>
|
||||
</template>
|
||||
43
src/components/ui/feedback/AppDropOverlay.stories.ts
Normal file
43
src/components/ui/feedback/AppDropOverlay.stories.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AppDropOverlay from './AppDropOverlay.vue'
|
||||
import type { DropOverlayProps } from './drop-overlay'
|
||||
|
||||
const meta = {
|
||||
title: 'Design System/Drop Overlay',
|
||||
component: AppDropOverlay,
|
||||
parameters: { layout: 'centered' }
|
||||
} satisfies Meta<DropOverlayProps>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<DropOverlayProps>
|
||||
|
||||
/** A field, such as the chat composer, with images dragged over it. */
|
||||
function fieldStory(accepts: boolean, label: string): Story {
|
||||
return {
|
||||
args: { visible: true, shape: 'field', accepts, label },
|
||||
render: (args) => ({
|
||||
components: { AppDropOverlay },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<div class="relative w-72 rounded-xl border border-border bg-input p-3 text-xs text-muted">
|
||||
Describe a change…
|
||||
<div class="mt-6 h-6" />
|
||||
<AppDropOverlay v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const FieldAccepts = fieldStory(true, 'Drop images to attach')
|
||||
export const FieldRefuses = fieldStory(false, 'Up to 4 images per message')
|
||||
|
||||
export const Surface: Story = {
|
||||
args: { visible: true },
|
||||
render: (args) => ({
|
||||
components: { AppDropOverlay },
|
||||
setup: () => ({ args }),
|
||||
template: `<div class="relative h-48 w-80 bg-canvas"><AppDropOverlay v-bind="args" /></div>`
|
||||
})
|
||||
}
|
||||
25
src/components/ui/feedback/AppDropOverlay.vue
Normal file
25
src/components/ui/feedback/AppDropOverlay.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { dropOverlay, type DropOverlayProps } from './drop-overlay'
|
||||
|
||||
const { visible, label, shape = 'surface', accepts = true, ui } = defineProps<DropOverlayProps>()
|
||||
|
||||
const cls = computed(() => dropOverlay({ shape, accepts }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
leave-active-class="transition-opacity duration-150"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="visible" data-slot="drop-overlay" :class="cls.root({ class: ui?.root })">
|
||||
<span v-if="label" role="status" :class="cls.label({ class: ui?.label })">
|
||||
<icon-lucide-image-plus v-if="accepts" class="size-3.5" />
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
18
src/components/ui/feedback/drop-overlay.ts
Normal file
18
src/components/ui/feedback/drop-overlay.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { ComponentUI } from '@/components/ui/types'
|
||||
import theme from '@/theme/feedback/drop-overlay'
|
||||
|
||||
export const dropOverlay = tv(theme)
|
||||
type DropOverlayVariants = VariantProps<typeof dropOverlay>
|
||||
|
||||
export interface DropOverlayProps {
|
||||
/** Whether files are being dragged over the surface. */
|
||||
visible: boolean
|
||||
/** What dropping does, or why it cannot; the outline alone when omitted. */
|
||||
label?: string
|
||||
shape?: NonNullable<DropOverlayVariants['shape']>
|
||||
/** Whether the surface takes the drop; a refused drop shows muted. */
|
||||
accepts?: boolean
|
||||
ui?: ComponentUI<typeof theme>
|
||||
}
|
||||
25
src/theme/feedback/drop-overlay.ts
Normal file
25
src/theme/feedback/drop-overlay.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* The outline over a surface that files are dragged onto. Over the canvas it is a light tint, so
|
||||
* the drop point stays visible; over a field, such as the chat composer, it covers the content so
|
||||
* the label reads on its own.
|
||||
*/
|
||||
const dropOverlayTheme = {
|
||||
slots: {
|
||||
root: 'pointer-events-none absolute inset-0 z-40 flex items-center justify-center border-2 border-dashed',
|
||||
label: 'flex items-center gap-1.5 text-xs font-medium'
|
||||
},
|
||||
variants: {
|
||||
accepts: {
|
||||
true: { root: 'border-accent/60 bg-accent/5', label: 'text-accent' },
|
||||
false: { root: 'border-border bg-panel/70', label: 'text-muted' }
|
||||
},
|
||||
// After `accepts`, so a field's own fill replaces the tint.
|
||||
shape: {
|
||||
surface: { label: 'rounded-md bg-panel px-2 py-1 shadow-sm' },
|
||||
field: { root: 'rounded-xl bg-input/95' }
|
||||
}
|
||||
},
|
||||
defaultVariants: { shape: 'surface' as const, accepts: true }
|
||||
} as const
|
||||
|
||||
export default dropOverlayTheme
|
||||
|
|
@ -2,24 +2,12 @@ import { tv } from 'tailwind-variants'
|
|||
|
||||
export type MarkdownDensity = 'compact' | 'comfortable'
|
||||
|
||||
// vue-stream-markdown styles itself with shadcn variables; map them to app tokens.
|
||||
// Density rules live in markdown.css, keyed by `data-markdown-density`.
|
||||
// vue-stream-markdown styles itself with shadcn variables; markdown.css maps them to app tokens
|
||||
// on its `.stream-markdown` elements, including the dialogs it teleports out of a message.
|
||||
// Density rules live there too, keyed by `data-markdown-density`.
|
||||
export const markdownTheme = tv({
|
||||
slots: {
|
||||
root: 'markdown-root',
|
||||
markdown: [
|
||||
'markdown-content',
|
||||
'[--accent:var(--color-hover)]',
|
||||
'[--accent-foreground:var(--color-surface)]',
|
||||
'[--background:var(--color-input)]',
|
||||
'[--border:var(--color-border)]',
|
||||
'[--foreground:var(--color-surface)]',
|
||||
'[--muted:var(--color-hover)]',
|
||||
'[--muted-foreground:var(--color-muted)]',
|
||||
'[--popover:var(--color-panel)]',
|
||||
'[--popover-foreground:var(--color-surface)]',
|
||||
'[--primary:var(--color-accent)]',
|
||||
'[--primary-foreground:white]'
|
||||
]
|
||||
markdown: 'markdown-content'
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,48 @@
|
|||
@reference 'tailwindcss';
|
||||
|
||||
/* The renderer's shadcn variables, mapped to app tokens on everything it draws: messages and the
|
||||
tooltips and dialogs it teleports out of them. */
|
||||
.stream-markdown {
|
||||
--accent: var(--color-hover);
|
||||
--accent-foreground: var(--color-surface);
|
||||
--background: var(--color-input);
|
||||
--border: var(--color-border);
|
||||
--foreground: var(--color-surface);
|
||||
--muted: var(--color-hover);
|
||||
--muted-foreground: var(--color-muted);
|
||||
--popover: var(--color-panel);
|
||||
--popover-foreground: var(--color-surface);
|
||||
--primary: var(--color-accent);
|
||||
--primary-foreground: white;
|
||||
}
|
||||
|
||||
/* The confirmation before opening a link, sized like the app's dialogs. */
|
||||
[data-stream-markdown='alert'] {
|
||||
@apply max-w-80 gap-3 rounded-lg bg-panel p-4 text-surface shadow-lg;
|
||||
}
|
||||
|
||||
[data-stream-markdown='alert-title'] {
|
||||
@apply mb-1 text-sm;
|
||||
}
|
||||
|
||||
[data-stream-markdown='alert-description'] {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
[data-stream-markdown='alert'] [data-stream-markdown='link-url'] {
|
||||
@apply bg-input p-2 text-[11px] break-all text-surface;
|
||||
}
|
||||
|
||||
/* Menus such as a table's copy formats, sized like the app's menus. The renderer sets the item
|
||||
size inline, so these override it. */
|
||||
[data-stream-markdown='dropdown-overlay'] [data-stream-markdown='button'] {
|
||||
@apply !min-w-24 !gap-2 rounded px-2 py-1 !text-xs !leading-4 text-surface;
|
||||
}
|
||||
|
||||
[data-stream-markdown='alert-backdrop'] {
|
||||
@apply bg-black/40 backdrop-blur-none;
|
||||
}
|
||||
|
||||
.markdown-root [data-stream-markdown='code-block'],
|
||||
.markdown-root .shiki {
|
||||
background-color: var(--color-input);
|
||||
|
|
@ -17,7 +60,69 @@
|
|||
}
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
/* Tables read as part of the reply: regular weight, a light header, no fills on the rows. */
|
||||
[data-stream-markdown='table-wrapper'] {
|
||||
@apply items-stretch gap-1;
|
||||
}
|
||||
|
||||
[data-stream-markdown='table'] {
|
||||
@apply rounded-md;
|
||||
}
|
||||
|
||||
[data-stream-markdown='thead'] {
|
||||
@apply bg-hover/60;
|
||||
}
|
||||
|
||||
[data-stream-markdown='th'] {
|
||||
@apply text-left align-bottom font-medium text-muted;
|
||||
}
|
||||
|
||||
[data-stream-markdown='tbody'] {
|
||||
@apply border-y-0 bg-transparent font-normal;
|
||||
}
|
||||
|
||||
[data-stream-markdown='td'] {
|
||||
@apply align-top;
|
||||
}
|
||||
|
||||
[data-stream-markdown='table-controls'] [data-stream-markdown='button'] {
|
||||
@apply size-5 p-0;
|
||||
|
||||
& svg {
|
||||
@apply size-3;
|
||||
}
|
||||
}
|
||||
|
||||
[data-stream-markdown='error-component'],
|
||||
[data-stream-markdown='image-caption'] {
|
||||
@apply text-[11px];
|
||||
}
|
||||
|
||||
/* A task item shows its checkbox in place of the bullet. */
|
||||
[data-stream-markdown='li'].task-list-item {
|
||||
@apply list-none;
|
||||
}
|
||||
|
||||
[data-stream-markdown='input'][type='checkbox'] {
|
||||
@apply mr-1.5 size-3 align-[-0.125em] accent-accent;
|
||||
}
|
||||
|
||||
[data-stream-markdown='footnote-definition-button'] {
|
||||
@apply text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-content[data-markdown-density='compact'] {
|
||||
[data-stream-markdown='table-wrapper'] {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
[data-stream-markdown='th'],
|
||||
[data-stream-markdown='td'] {
|
||||
@apply px-2 py-1 text-[11px] leading-4;
|
||||
}
|
||||
|
||||
@apply text-[11px] leading-4;
|
||||
|
||||
[data-stream-markdown='blockquote'],
|
||||
|
|
@ -98,6 +203,15 @@
|
|||
.markdown-content[data-markdown-density='comfortable'] {
|
||||
@apply text-xs leading-5;
|
||||
|
||||
[data-stream-markdown='table-wrapper'] {
|
||||
@apply my-3;
|
||||
}
|
||||
|
||||
[data-stream-markdown='th'],
|
||||
[data-stream-markdown='td'] {
|
||||
@apply px-2.5 py-1.5 text-xs leading-5;
|
||||
}
|
||||
|
||||
[data-stream-markdown='heading-1'] {
|
||||
@apply mt-4 mb-1.5 text-base font-semibold first:mt-0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '#tests/helpers/chat/fixture'
|
||||
|
||||
const PILOT = 'tests/fixtures/vectorize/pilot_avatar.png'
|
||||
|
|
@ -79,3 +81,90 @@ test('node context stays hidden when combined with an image', async ({ configure
|
|||
await expect(chat.userMessage()).toContainText('Use this reference')
|
||||
await expect(chat.userMessage()).not.toContainText('[Referenced nodes')
|
||||
})
|
||||
|
||||
type DroppedFile = { name: string; type: string; content: 'svg' | 'png' | 'text' }
|
||||
|
||||
/**
|
||||
* Drags files onto the chat transcript; `drop: false` stops at the drag, so what it shows over
|
||||
* the composer can be checked. Returns whether the page took the drag.
|
||||
*/
|
||||
async function dragOntoChat(
|
||||
page: Page,
|
||||
files: DroppedFile[],
|
||||
{ drop = true }: { drop?: boolean } = {}
|
||||
) {
|
||||
return page.getByTestId('chat-panel').evaluate(
|
||||
async (panel, { files, drop }) => {
|
||||
async function png(): Promise<Blob> {
|
||||
const canvas = new OffscreenCanvas(8, 8)
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Canvas context unavailable')
|
||||
context.fillStyle = '#08f'
|
||||
context.fillRect(0, 0, 8, 8)
|
||||
return canvas.convertToBlob({ type: 'image/png' })
|
||||
}
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="12"><rect width="24" height="12" fill="#f00"/></svg>'
|
||||
const bodies = { svg: async () => svg, png, text: async () => 'notes' }
|
||||
const transfer = new DataTransfer()
|
||||
for (const file of files) {
|
||||
const body = await bodies[file.content]()
|
||||
transfer.items.add(new File([body], file.name, { type: file.type }))
|
||||
}
|
||||
const bounds = panel.getBoundingClientRect()
|
||||
const options = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: bounds.left + bounds.width / 2,
|
||||
clientY: bounds.top + 40,
|
||||
dataTransfer: transfer
|
||||
}
|
||||
panel.dispatchEvent(new DragEvent('dragenter', options))
|
||||
const accepted = !panel.dispatchEvent(new DragEvent('dragover', options))
|
||||
if (drop) panel.dispatchEvent(new DragEvent('drop', options))
|
||||
return accepted
|
||||
},
|
||||
{ files, drop }
|
||||
)
|
||||
}
|
||||
|
||||
test('images dropped anywhere on the chat attach, with SVGs drawn as PNGs', async ({
|
||||
configuredChat: chat
|
||||
}) => {
|
||||
const files: DroppedFile[] = [
|
||||
{ name: 'photo.png', type: 'image/png', content: 'png' },
|
||||
{ name: 'icon.svg', type: 'image/svg+xml', content: 'svg' }
|
||||
]
|
||||
expect(await dragOntoChat(chat.page, files, { drop: false })).toBe(true)
|
||||
const overlay = chat.page.locator('[data-slot="drop-overlay"]')
|
||||
await expect(overlay).toContainText('Drop images to attach')
|
||||
|
||||
await dragOntoChat(chat.page, files)
|
||||
await expect(overlay).toBeHidden()
|
||||
await expect(chat.page.getByText('photo.png', { exact: true })).toBeVisible()
|
||||
await expect(chat.page.getByText('icon.png', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('a drag without images is left to the window', async ({ configuredChat: chat }) => {
|
||||
// The window still takes it, to open documents, but the chat does not offer to attach it.
|
||||
const notes: DroppedFile[] = [{ name: 'notes.txt', type: 'text/plain', content: 'text' }]
|
||||
await dragOntoChat(chat.page, notes, { drop: false })
|
||||
await expect(chat.page.locator('[data-slot="drop-overlay"]')).toBeHidden()
|
||||
})
|
||||
|
||||
test('a full message shows why more images cannot be dropped', async ({ configuredChat: chat }) => {
|
||||
const chooser = chat.page.waitForEvent('filechooser')
|
||||
await chat.page.getByRole('button', { name: 'Attach images' }).click()
|
||||
await (await chooser).setFiles([PILOT, PYTHON, PILOT, PYTHON])
|
||||
await expect(chat.page.getByRole('button', { name: /Remove image/ })).toHaveCount(4)
|
||||
|
||||
const photo: DroppedFile[] = [{ name: 'photo.png', type: 'image/png', content: 'png' }]
|
||||
await dragOntoChat(chat.page, photo, { drop: false })
|
||||
await expect(chat.page.locator('[data-slot="drop-overlay"]')).toContainText(
|
||||
'Up to 4 images per message'
|
||||
)
|
||||
await dragOntoChat(chat.page, photo)
|
||||
await expect(chat.page.locator('[data-slot="drop-overlay"]')).toBeHidden()
|
||||
await expect(chat.page.getByRole('button', { name: /Remove image/ })).toHaveCount(4)
|
||||
await expect(chat.page.getByText('Up to 4 images per message', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -59,3 +59,18 @@ test('Markdown blocks unsafe links and cross-origin images', async ({ configured
|
|||
'https://openpencil.dev/'
|
||||
)
|
||||
})
|
||||
|
||||
test('tables read as part of the reply, with copy as their only action', async ({
|
||||
configuredChat: chat
|
||||
}) => {
|
||||
await chat.submit('Show a markdown table')
|
||||
const assistant = chat.assistantMessage()
|
||||
const cell = assistant.getByRole('cell', { name: 'Hover', exact: true })
|
||||
await expect(cell).toHaveCSS('font-weight', '400')
|
||||
await expect(cell).toHaveCSS('font-size', '11px')
|
||||
const controls = assistant.locator('[data-stream-markdown="table-controls"] button')
|
||||
await expect(controls).toHaveCount(1)
|
||||
// Links show no favicon: that would request `/favicon.ico` from every linked site.
|
||||
await expect(assistant.getByRole('link', { name: 'the guide' })).toBeVisible()
|
||||
await expect(assistant.locator('[data-stream-markdown="link-favicon"]')).toHaveCount(0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export async function injectMockChatTransport(page: Page): Promise<void> {
|
|||
const tool = normalized.includes('frame') || normalized.includes('rectangle')
|
||||
const code = normalized.includes('code block')
|
||||
const unsafeMarkdown = normalized.includes('unsafe markdown')
|
||||
const table = normalized.includes('markdown table')
|
||||
const multipleParts = normalized.includes('multiple parts')
|
||||
const reasoning = normalized.includes('reasoning')
|
||||
|
||||
|
|
@ -48,6 +49,21 @@ export async function injectMockChatTransport(page: Page): Promise<void> {
|
|||
if (tool) response = 'Created a frame called "Card".'
|
||||
else if (unsafeMarkdown) {
|
||||
response = `[unsafe](javascript:alert(1)) ></svg>)    [safe](https://openpencil.dev)`
|
||||
} else if (table) {
|
||||
response = [
|
||||
'The button now has these states:',
|
||||
'',
|
||||
'| State | Look |',
|
||||
'| --- | --- |',
|
||||
'| Default | Burnt orange `#c2410c` with the soft glow |',
|
||||
'| Hover | Brighter `#ea580c` with a bigger glow |',
|
||||
'| Disabled | Muted `#374151` with grey text |',
|
||||
'',
|
||||
'- [x] Variants created',
|
||||
'- [ ] Checked in preview',
|
||||
'',
|
||||
'See [the guide](https://openpencil.dev/guide).'
|
||||
].join('\n')
|
||||
} else if (code) response = '```typescript\nconst greeting = "Hello"\n```'
|
||||
enqueueText(controller, response)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue