feat(desktop): support batch element selection #1164

This commit is contained in:
程序员阿江(Relakkes)
2026-08-01 14:30:40 +08:00
parent fde63d94e7
commit 5f8bae08db
29 changed files with 1209 additions and 92 deletions
+9 -2
View File
@@ -3,7 +3,7 @@ const MAX_PREVIEW_TEXT_LENGTH = 32_768
export type PreviewAgentMessage =
| { v: 1, type: 'ready' }
| { v: 1, type: 'picker-exited' }
| { v: 1, type: 'picker-exited', reason?: 'cancel-current' | 'host' | 'invalid-target' }
| { v: 1, type: 'navigated', url: string, title: string }
| { v: 1, type: 'error', message: string }
| { v: 1, type: 'screenshot', dataUrl: string, kind: 'full' | 'viewport' | 'element' }
@@ -51,8 +51,15 @@ export function parsePreviewAgentMessage(raw: string): PreviewAgentMessage | nul
switch (parsed.type) {
case 'ready':
return { v: 1, type: 'ready' }
case 'picker-exited':
return { v: 1, type: parsed.type }
if (
parsed.reason !== undefined &&
parsed.reason !== 'cancel-current' &&
parsed.reason !== 'host' &&
parsed.reason !== 'invalid-target'
) return null
return { v: 1, type: 'picker-exited', ...(parsed.reason ? { reason: parsed.reason } : {}) }
case 'navigated':
if (!isBoundedString(parsed.url) || !isBoundedString(parsed.title)) return null
try {
@@ -733,6 +733,9 @@ describe('Electron preview service', () => {
it('parses only bounded preview agent message shapes', () => {
expect(parsePreviewAgentMessage('{"v":1,"type":"ready"}')).toEqual({ v: 1, type: 'ready' })
expect(parsePreviewAgentMessage('{"v":1,"type":"picker-exited","reason":"cancel-current"}'))
.toEqual({ v: 1, type: 'picker-exited', reason: 'cancel-current' })
expect(parsePreviewAgentMessage('{"v":1,"type":"picker-exited","reason":"unknown"}')).toBeNull()
expect(parsePreviewAgentMessage(JSON.stringify({
v: 1,
type: 'navigated',
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ beforeAll(() => {
})
})
const { bridge } = vi.hoisted(() => ({
const { bridge, sendMessage } = vi.hoisted(() => ({
bridge: {
open: vi.fn(),
navigate: vi.fn(),
@@ -19,8 +19,12 @@ const { bridge } = vi.hoisted(() => ({
close: vi.fn(),
message: vi.fn(),
},
sendMessage: vi.fn(),
}))
vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge }))
vi.mock('../../stores/chatStore', () => ({
useChatStore: { getState: () => ({ sendMessage }) },
}))
vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}) }))
import { BrowserSurface } from './BrowserSurface'
@@ -29,6 +33,7 @@ import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useOverlayStore } from '../../stores/overlayStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { usePreviewSelectionStore } from '../../stores/previewSelectionStore'
beforeEach(() => {
useSettingsStore.setState({ locale: 'zh' })
@@ -42,7 +47,9 @@ afterEach(() => {
// browserPanelStore.open() now also opens the unified workbench; keep it isolated.
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
usePreviewSelectionStore.setState({ bySession: {} })
useSettingsStore.setState({ uiZoom: 1 })
sendMessage.mockReset()
setBaseUrl(getDefaultBaseUrl())
})
@@ -216,7 +223,12 @@ describe('BrowserSurface', () => {
render(<BrowserSurface sessionId="s1" />)
fireEvent.click(screen.getByLabelText('选择元素'))
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(true)
expect(bridge.message).toHaveBeenCalledWith({ v: 1, type: 'enter-picker' })
expect(bridge.message).toHaveBeenCalledWith(expect.objectContaining({
v: 1,
type: 'enter-picker',
mode: 'single',
label: 1,
}))
fireEvent.click(screen.getByLabelText('选择元素'))
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false)
expect(bridge.message).toHaveBeenLastCalledWith({ v: 1, type: 'exit-picker' })
@@ -339,4 +351,85 @@ describe('BrowserSurface', () => {
act(() => { useOverlayStore.getState().pop() })
expect(bridge.setVisible).toHaveBeenLastCalledWith(true)
})
it('shows a compact batch rail with stable numbering and undo/clear actions', async () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
usePreviewSelectionStore.getState().add('s1', {
pageUrl: 'http://localhost:5173/',
draftItemId: 'one',
element: { selector: '#title', tag: 'h1', classes: [] } as never,
})
usePreviewSelectionStore.getState().add('s1', {
pageUrl: 'http://localhost:5173/',
draftItemId: 'two',
element: { selector: '#cta', tag: 'button', classes: [] } as never,
})
render(<BrowserSurface sessionId="s1" />)
const rail = screen.getByTestId('browser-selection-draft')
expect(rail).toHaveTextContent('已选 2 个')
expect(rail).toHaveTextContent('#2 <button>')
fireEvent.click(screen.getByLabelText('撤销上一个选择'))
expect(bridge.message).toHaveBeenCalledWith({ v: 1, type: 'undo-selection', itemId: 'two' })
await waitFor(() => {
expect(screen.getByTestId('browser-selection-draft')).toHaveTextContent('#1 <h1>')
})
fireEvent.click(screen.getByLabelText('清空选择'))
await waitFor(() => expect(screen.queryByTestId('browser-selection-draft')).not.toBeInTheDocument())
expect(bridge.message).toHaveBeenCalledWith({ v: 1, type: 'clear-selection-draft' })
})
it('sends all selected screenshots as one numbered chat turn', async () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
for (const [id, tag, data] of [
['one', 'h1', 'data:image/png;base64,AAAA'],
['two', 'button', 'data:image/png;base64,BBBB'],
] as const) {
usePreviewSelectionStore.getState().add('s1', {
pageUrl: 'http://localhost:5173/',
draftItemId: id,
element: { selector: `#${id}`, tag, classes: [] } as never,
change: { description: `${id} note` } as never,
screenshot: { dataUrl: data, kind: 'region' },
})
}
render(<BrowserSurface sessionId="s1" />)
fireEvent.click(screen.getByRole('button', { name: '发送 2 个' }))
expect(sendMessage).toHaveBeenCalledWith(
's1',
expect.stringContaining('[元素 2]'),
[
expect.objectContaining({ name: '<h1>', selectionNumber: 1, data: 'data:image/png;base64,AAAA' }),
expect.objectContaining({ name: '<button>', selectionNumber: 2, data: 'data:image/png;base64,BBBB' }),
],
expect.objectContaining({ displayContent: '2 个页面修改' }),
)
await waitFor(() => expect(screen.queryByTestId('browser-selection-draft')).not.toBeInTheDocument())
expect(bridge.message).toHaveBeenCalledWith({ v: 1, type: 'commit-selection-draft' })
})
it('protects a selection batch before navigating to another page', async () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
usePreviewSelectionStore.getState().add('s1', {
pageUrl: 'http://localhost:5173/',
draftItemId: 'one',
element: { selector: '#title', tag: 'h1', classes: [] } as never,
})
render(<BrowserSurface sessionId="s1" />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'http://localhost:3000/' } })
fireEvent.submit(input.closest('form')!)
expect(screen.getByText('离开本次批量选择?')).toBeInTheDocument()
expect(bridge.navigate).not.toHaveBeenCalledWith('http://localhost:3000/')
fireEvent.click(screen.getByRole('button', { name: '丢弃并继续' }))
await waitFor(() => expect(bridge.navigate).toHaveBeenCalledWith('http://localhost:3000/'))
expect(bridge.message).toHaveBeenCalledWith({ v: 1, type: 'clear-selection-draft' })
})
})
+170 -12
View File
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { Camera, Minus, MousePointer2, Plus, RotateCcw } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Camera, Minus, MousePointer2, Plus, RotateCcw, Send, Trash2, Undo2 } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { IconButton } from '@/components/ui/IconButton'
import { Spinner } from '@/components/ui/Spinner'
import { useTranslation } from '../../i18n'
@@ -10,6 +13,8 @@ import { classifyPreviewLink } from '../../lib/previewLinkRouter'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink'
import { previewBridge } from '../../lib/previewBridge'
import { subscribePreviewEvents } from '../../lib/previewEvents'
import { buildPreviewPickerMessage } from '../../lib/previewSelectionPicker'
import { buildSelectionBatchMessage, formatElementLabel } from '../../lib/selectionComposer'
import {
BROWSER_ZOOM_STEP,
DEFAULT_BROWSER_ZOOM,
@@ -19,7 +24,9 @@ import {
useBrowserPanelStore,
} from '../../stores/browserPanelStore'
import { useOverlayStore } from '../../stores/overlayStore'
import { usePreviewSelectionStore } from '../../stores/previewSelectionStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
const LOCAL_PREVIEW_PATH_PREFIXES = ['/preview-fs/', '/local-file/']
const LOCAL_PREVIEW_READY_TIMEOUT_MS = 2500
@@ -72,7 +79,10 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
const loadSeqRef = useRef(0)
const requestedUrlRef = useRef<string | null>(null)
const hasNativePreviewRef = useRef(false)
const selectionSendInFlightRef = useRef(false)
const [pendingNavigation, setPendingNavigation] = useState<{ run: () => void } | null>(null)
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
const selectionDraft = usePreviewSelectionStore((s) => s.bySession[sessionId])
const appZoom = useSettingsStore((s) => s.uiZoom)
const store = useBrowserPanelStore.getState()
const overlayCount = useOverlayStore((s) => s.count)
@@ -139,6 +149,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
loadSeqRef.current += 1
requestedUrlRef.current = null
hasNativePreviewRef.current = false
usePreviewSelectionStore.getState().clear(sessionId)
previewBridge.close()
}
// The visibility-sync effect below owns setVisible() — including the
@@ -200,11 +211,87 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
if (!session) return null
const selectedItems = selectionDraft?.items ?? []
const selectionCount = selectedItems.length
const lastSelection = selectedItems.at(-1)
const discardSelectionDraft = async () => {
const current = useBrowserPanelStore.getState().bySession[sessionId]
if (current?.pickerActive) {
useBrowserPanelStore.getState().setPicker(sessionId, false)
await previewBridge.message({ v: 1, type: 'exit-picker' })
}
await previewBridge.message({ v: 1, type: 'clear-selection-draft' })
usePreviewSelectionStore.getState().clear(sessionId)
}
const requestNavigation = (run: () => void) => {
if (usePreviewSelectionStore.getState().bySession[sessionId]?.items.length) {
setPendingNavigation({ run })
return
}
run()
}
const openOrNavigate = (inputUrl: string) => {
const url = resolveBrowserNavigationUrl(inputUrl, sessionId)
if (!url) return
store.navigate(sessionId, url)
requestNativePreview(url)
requestNavigation(() => {
store.navigate(sessionId, url)
requestNativePreview(url)
})
}
const undoLastSelection = async () => {
const draft = usePreviewSelectionStore.getState().bySession[sessionId]
const last = draft?.items.at(-1)
if (!draft || !last) return
if (draft.items.length === 1 && useBrowserPanelStore.getState().bySession[sessionId]?.pickerActive) {
store.setPicker(sessionId, false)
await previewBridge.message({ v: 1, type: 'exit-picker' })
}
await previewBridge.message({ v: 1, type: 'undo-selection', itemId: last.id })
usePreviewSelectionStore.getState().undoLast(sessionId)
}
const sendSelectionBatch = async () => {
if (selectionSendInFlightRef.current) return
const draft = usePreviewSelectionStore.getState().bySession[sessionId]
if (!draft?.items.length) return
selectionSendInFlightRef.current = true
try {
if (useBrowserPanelStore.getState().bySession[sessionId]?.pickerActive) {
store.setPicker(sessionId, false)
await previewBridge.message({ v: 1, type: 'exit-picker' })
}
const batch = buildSelectionBatchMessage(draft.items)
const attachments = draft.items.flatMap((entry, index) => {
const data = entry.payload.screenshot?.dataUrl
if (!data) return []
const item = batch.items[index]!
return [{
type: 'image' as const,
name: item.displayName,
mimeType: 'image/png',
data,
note: item.note,
quote: item.selector,
selectionNumber: entry.number,
}]
})
useChatStore.getState().sendMessage(sessionId, batch.modelText, attachments, {
displayContent: t('browser.selection.batchMessage', { count: draft.items.length }),
displayAttachments: attachments,
})
try {
await previewBridge.message({ v: 1, type: 'commit-selection-draft' })
} finally {
usePreviewSelectionStore.getState().clear(sessionId)
}
} finally {
selectionSendInFlightRef.current = false
}
}
const setPreviewZoom = (nextZoom: number) => {
@@ -273,7 +360,15 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
const cur = useBrowserPanelStore.getState().bySession[sessionId]
const next = !cur?.pickerActive
store.setPicker(sessionId, next)
previewBridge.message({ v: 1, type: next ? 'enter-picker' : 'exit-picker' })
if (!next) {
void previewBridge.message({ v: 1, type: 'exit-picker' })
return
}
const draft = usePreviewSelectionStore.getState().bySession[sessionId]
void previewBridge.message(buildPreviewPickerMessage(
draft?.items.length ? 'batch' : 'single',
draft?.nextNumber ?? 1,
))
}}
/>
</>
@@ -287,22 +382,24 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
canGoForward={session.canGoForward}
loading={session.loading}
onNavigate={openOrNavigate}
onBack={() => {
onBack={() => requestNavigation(() => {
store.goBack(sessionId)
store.setLoading(sessionId, true)
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
requestNativePreview(url)
}}
onForward={() => {
})}
onForward={() => requestNavigation(() => {
store.goForward(sessionId)
store.setLoading(sessionId, true)
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
requestNativePreview(url)
}}
})}
onReload={() => {
if (!session.url) return
store.setLoading(sessionId, true)
requestNativePreview(session.url, { force: true })
requestNavigation(() => {
store.setLoading(sessionId, true)
requestNativePreview(session.url, { force: true })
})
}}
rightActions={previewActions}
/>
@@ -318,14 +415,75 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
</div>
<div
data-testid="browser-preview-floating-controls"
className="pointer-events-none absolute inset-x-0 bottom-0 z-[var(--z-raised)] flex h-12 items-end justify-end px-3 pb-2"
className="pointer-events-none absolute inset-x-0 bottom-0 z-[var(--z-raised)] flex h-12 items-center justify-end gap-2 px-3 py-1"
>
{selectionCount > 0 && (
<div
data-testid="browser-selection-draft"
role="region"
aria-label={t('browser.selection.draftCount', { count: selectionCount })}
aria-live="polite"
className="pointer-events-auto flex h-10 min-w-0 flex-1 items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 shadow-[var(--shadow-overlay)]"
>
<Badge tone="brand" size="sm" pill={false} mono bordered>
{selectionCount}
</Badge>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-[var(--color-text-primary)]">
{t('browser.selection.draftCount', { count: selectionCount })}
</span>
{lastSelection && (
<span className="block truncate font-mono text-[10px] text-[var(--color-text-tertiary)]">
#{lastSelection.number} {formatElementLabel(lastSelection.payload.element)}
</span>
)}
</span>
<IconButton
icon={<Undo2 size={14} />}
label={t('browser.selection.undo')}
size="sm"
tone="muted"
onClick={() => { void undoLastSelection() }}
/>
<IconButton
icon={<Trash2 size={14} />}
label={t('browser.selection.clear')}
size="sm"
tone="muted"
onClick={() => { void discardSelectionDraft() }}
/>
<Button
variant="primary"
size="base"
icon={<Send size={13} />}
onClick={() => { void sendSelectionBatch() }}
>
{t('browser.selection.sendBatch', { count: selectionCount })}
</Button>
</div>
)}
<div className="pointer-events-auto">
{zoomControls}
</div>
</div>
</div>
</div>
<ConfirmDialog
open={pendingNavigation !== null}
onClose={() => setPendingNavigation(null)}
onConfirm={async () => {
const pending = pendingNavigation
if (!pending) return
await discardSelectionDraft()
setPendingNavigation(null)
pending.run()
}}
title={t('browser.selection.navigationTitle')}
body={t('browser.selection.navigationBody', { count: selectionCount })}
confirmLabel={t('browser.selection.navigationContinue')}
cancelLabel={t('browser.selection.cancel')}
confirmVariant="danger"
/>
</div>
)
}
@@ -274,6 +274,38 @@ describe('AttachmentGallery', () => {
expect(view.queryByRole('tooltip')).toBeNull()
})
it('groups numbered selection screenshots into one compact batch mosaic', () => {
const view = render(
<AttachmentGallery
variant="message"
attachments={[
{
id: 'selection-1',
type: 'image',
name: '<h1>',
data: 'data:image/png;base64,AAAA',
note: 'Make the title lighter',
selectionNumber: 1,
},
{
id: 'selection-3',
type: 'image',
name: '<button>',
data: 'data:image/png;base64,BBBB',
note: 'Increase emphasis',
selectionNumber: 3,
},
]}
/>,
)
expect(view.getByTestId('preview-selection-batch')).toHaveAttribute('aria-label', '2 selected page changes')
expect(view.getByRole('button', { name: 'Selected element 1: <h1>' })).toHaveAttribute('data-selection-number', '1')
expect(view.getByRole('button', { name: 'Selected element 3: <button>' })).toHaveAttribute('data-selection-number', '3')
expect(view.getByText('Make the title lighter')).toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Open <h1>' })).not.toBeInTheDocument()
})
it('previews a path-only pasted image instead of a file chip', () => {
const path = '/Users/nanmi/Desktop/6代码仓库.png'
const view = render(
@@ -9,6 +9,7 @@ import { buildOpenWithItems, describeFileType, type OpenWithItem } from '../../l
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useUIStore } from '../../stores/uiStore'
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
import { Badge } from '@/components/ui/Badge'
import { IconButton } from '@/components/ui/IconButton'
import { Tooltip } from '@/components/ui/Tooltip'
import { ImageGalleryModal } from './ImageGalleryModal'
@@ -28,6 +29,7 @@ export type AttachmentPreview = {
hunkId?: string
note?: string
quote?: string
selectionNumber?: number
}
const FILE_ICON_ACCENTS: Record<string, string> = {
@@ -89,6 +91,8 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
if (attachments.length === 0) return null
const isComposer = variant === 'composer'
const isSelectionBatch = !isComposer && attachments.length > 1 && attachments.every((attachment) =>
attachment.type === 'image' && typeof attachment.selectionNumber === 'number')
const showOpenFailure = (name: string) => {
useUIStore.getState().addToast({
@@ -139,6 +143,67 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
return (
<>
{isSelectionBatch ? (
<div
data-testid="preview-selection-batch"
aria-label={t('attachments.selectionBatch', { count: attachments.length })}
className="grid w-full max-w-[520px] grid-cols-2 gap-px overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-border)] shadow-[var(--shadow-card)]"
>
{attachments.map((attachment, index) => {
const src = attachmentImageSource(attachment)
if (!src || unloadableImageSources.has(src)) return null
const isLastOddItem = attachments.length % 2 === 1 && index === attachments.length - 1
const note = attachment.note?.trim()
return (
<button
key={attachment.id || `${attachment.name}-${index}`}
type="button"
data-selection-number={attachment.selectionNumber}
aria-label={t('attachments.selectionItem', {
number: attachment.selectionNumber!,
name: attachment.name,
})}
title={note || attachment.name}
onClick={() => setActiveImageIndex(images.findIndex((image) => image.src === src))}
className={[
'group/selection min-w-0 bg-[var(--color-surface-container-low)] text-left',
'focus-visible:relative focus-visible:z-[1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
isLastOddItem ? 'col-span-2' : '',
].join(' ')}
>
<span className="relative block h-28 overflow-hidden bg-[var(--color-surface)]">
<img
src={src}
alt=""
onError={() => markImageUnloadable(src)}
className="h-full w-full object-cover transition-transform duration-150 group-hover/selection:scale-[1.015] motion-reduce:transition-none"
/>
<Badge
tone="brand"
size="sm"
pill={false}
mono
bordered
className="absolute left-2 top-2 shadow-[var(--shadow-card)]"
>
{attachment.selectionNumber}
</Badge>
</span>
<span className="flex min-h-11 min-w-0 items-center gap-2 border-t border-[var(--color-border)] px-2.5 py-1.5">
<span className="shrink-0 font-mono text-[11px] font-semibold text-[var(--color-text-secondary)]">
{attachment.name}
</span>
{note && (
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
{note}
</span>
)}
</span>
</button>
)
})}
</div>
) : (
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
{attachments.map((attachment, index) => {
const imageSrc = attachment.type === 'image' ? attachmentImageSource(attachment) : undefined
@@ -373,6 +438,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
)
})}
</div>
)}
{activeImageIndex !== null && activeImageIndex >= 0 && (
<ImageGalleryModal
+17
View File
@@ -26,6 +26,8 @@ export const en = {
'h5Connect.subtitle': 'Enter the server URL and H5 access token from the desktop app.',
'h5Connect.title': 'Connect to H5 Access',
'attachments.selectionNoteTitle': 'Requested changes',
'attachments.selectionBatch': '{count} selected page changes',
'attachments.selectionItem': 'Selected element {number}: {name}',
'chat.pendingMessageQueuedLabel': 'Queued',
'repoLaunch.worktreeIsolatedDesc': 'Create an isolated worktree for this session; experiment safely without touching {branch} until merge.',
'repoLaunch.worktreeCurrentDesc': 'Work directly in the project directory; changes land on {branch} immediately.',
@@ -2654,6 +2656,21 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'browser.zoomReset': 'Reset preview zoom',
'browser.capture': 'Screenshot',
'browser.pickElement': 'Select element',
'browser.selection.cancel': 'Cancel',
'browser.selection.sendOne': 'Send',
'browser.selection.queueAndContinue': 'Add & continue',
'browser.selection.add': 'Add',
'browser.selection.descriptionPlaceholder': 'Describe these changes…',
'browser.selection.draftCount': '{count} selected',
'browser.selection.undo': 'Undo last selection',
'browser.selection.clear': 'Clear selections',
'browser.selection.sendBatch': 'Send {count}',
'browser.selection.batchMessage': '{count} page changes',
'browser.selection.limitReached': 'Selection limit reached ({count}). Send or clear this batch to continue.',
'browser.selection.navigationTitle': 'Leave this selection batch?',
'browser.selection.navigationBody': 'Navigating will discard the {count} selected elements and revert their previews.',
'browser.selection.navigationContinue': 'Discard and continue',
'browser.selection.navigationDiscarded': 'Cleared {count} selections after the page changed.',
} as const
export type TranslationKey = keyof typeof en
+17
View File
@@ -28,6 +28,8 @@ export const jp: Record<TranslationKey, string> = {
'h5Connect.subtitle': 'デスクトップアプリのサーバー URL と H5 アクセストークンを入力してください。',
'h5Connect.title': 'H5 アクセスに接続',
'attachments.selectionNoteTitle': '修正内容',
'attachments.selectionBatch': '{count} 件のページ修正',
'attachments.selectionItem': '選択要素 {number}:{name}',
'chat.pendingMessageQueuedLabel': 'キュー済み',
'repoLaunch.worktreeIsolatedDesc': 'このセッション用に分離された worktree を作成し、マージまで {branch} に影響しません。',
'repoLaunch.worktreeCurrentDesc': 'プロジェクトディレクトリで直接作業し、変更はすぐに {branch} に反映されます。',
@@ -2656,4 +2658,19 @@ export const jp: Record<TranslationKey, string> = {
'browser.zoomReset': 'プレビューのズームをリセット',
'browser.capture': 'スクリーンショット',
'browser.pickElement': '要素を選択',
'browser.selection.cancel': 'キャンセル',
'browser.selection.sendOne': '送信',
'browser.selection.queueAndContinue': '追加して続行',
'browser.selection.add': '追加',
'browser.selection.descriptionPlaceholder': '変更内容を入力…',
'browser.selection.draftCount': '{count} 件選択済み',
'browser.selection.undo': '直前の選択を元に戻す',
'browser.selection.clear': '選択をクリア',
'browser.selection.sendBatch': '{count} 件を送信',
'browser.selection.batchMessage': '{count} 件のページ修正',
'browser.selection.limitReached': '選択上限の {count} 件に達しました。送信またはクリアして続行してください。',
'browser.selection.navigationTitle': 'この一括選択を終了しますか?',
'browser.selection.navigationBody': '移動すると、選択した {count} 件が破棄され、ページ上のプレビュー変更も元に戻ります。',
'browser.selection.navigationContinue': '破棄して続行',
'browser.selection.navigationDiscarded': 'ページが変わったため、{count} 件の選択をクリアしました。',
}
+17
View File
@@ -28,6 +28,8 @@ export const kr: Record<TranslationKey, string> = {
'h5Connect.subtitle': '데스크톱 앱의 서버 URL과 H5 액세스 토큰을 입력하세요.',
'h5Connect.title': 'H5 액세스에 연결',
'attachments.selectionNoteTitle': '수정 내용',
'attachments.selectionBatch': '페이지 수정 {count}개',
'attachments.selectionItem': '선택한 요소 {number}: {name}',
'chat.pendingMessageQueuedLabel': '대기 중',
'repoLaunch.worktreeIsolatedDesc': '이 세션용 격리 worktree를 만들어 병합 전까지 {branch}에 영향을 주지 않습니다.',
'repoLaunch.worktreeCurrentDesc': '프로젝트 디렉터리에서 직접 작업하며 변경 사항이 즉시 {branch}에 반영됩니다.',
@@ -2656,4 +2658,19 @@ export const kr: Record<TranslationKey, string> = {
'browser.zoomReset': '미리 보기 확대/축소 초기화',
'browser.capture': '스크린샷',
'browser.pickElement': '요소 선택',
'browser.selection.cancel': '취소',
'browser.selection.sendOne': '보내기',
'browser.selection.queueAndContinue': '추가하고 계속',
'browser.selection.add': '추가',
'browser.selection.descriptionPlaceholder': '변경 내용을 설명하세요…',
'browser.selection.draftCount': '{count}개 선택됨',
'browser.selection.undo': '마지막 선택 실행 취소',
'browser.selection.clear': '선택 지우기',
'browser.selection.sendBatch': '{count}개 보내기',
'browser.selection.batchMessage': '페이지 수정 {count}개',
'browser.selection.limitReached': '선택 한도({count}개)에 도달했습니다. 보내거나 지운 후 계속하세요.',
'browser.selection.navigationTitle': '이 일괄 선택을 종료할까요?',
'browser.selection.navigationBody': '이동하면 선택한 요소 {count}개가 삭제되고 페이지의 미리보기 수정도 되돌아갑니다.',
'browser.selection.navigationContinue': '삭제하고 계속',
'browser.selection.navigationDiscarded': '페이지가 변경되어 선택 {count}개를 지웠습니다.',
}
+17
View File
@@ -28,6 +28,8 @@ export const zh: Record<TranslationKey, string> = {
'h5Connect.subtitle': '輸入桌面端提供的伺服器位址與 H5 存取權杖。',
'h5Connect.title': '連接 H5 存取',
'attachments.selectionNoteTitle': '修改內容',
'attachments.selectionBatch': '{count} 個頁面修改',
'attachments.selectionItem': '所選元素 {number}:{name}',
'chat.pendingMessageQueuedLabel': '已排隊',
'repoLaunch.worktreeIsolatedDesc': '為本會話建立隔離 worktree,安全實驗,合併前不影響 {branch}。',
'repoLaunch.worktreeCurrentDesc': '直接在專案目錄中工作,改動立即出現在 {branch} 上。',
@@ -2655,4 +2657,19 @@ export const zh: Record<TranslationKey, string> = {
'browser.zoomReset': '重設預覽縮放',
'browser.capture': '螢幕截圖',
'browser.pickElement': '選取元素',
'browser.selection.cancel': '取消',
'browser.selection.sendOne': '傳送',
'browser.selection.queueAndContinue': '加入並繼續',
'browser.selection.add': '加入',
'browser.selection.descriptionPlaceholder': '描述這些修改…',
'browser.selection.draftCount': '已選 {count} 個',
'browser.selection.undo': '復原上一個選取',
'browser.selection.clear': '清除選取',
'browser.selection.sendBatch': '傳送 {count} 個',
'browser.selection.batchMessage': '{count} 個頁面修改',
'browser.selection.limitReached': '已選滿 {count} 個,請先傳送或清除這批選取。',
'browser.selection.navigationTitle': '離開這次批次選取?',
'browser.selection.navigationBody': '繼續導覽會捨棄已選的 {count} 個元素,並還原頁面中的預覽修改。',
'browser.selection.navigationContinue': '捨棄並繼續',
'browser.selection.navigationDiscarded': '頁面已變更,已清除 {count} 個選取。',
}
+17
View File
@@ -28,6 +28,8 @@ export const zh: Record<TranslationKey, string> = {
'h5Connect.subtitle': '输入桌面端提供的服务器地址与 H5 访问令牌。',
'h5Connect.title': '连接 H5 访问',
'attachments.selectionNoteTitle': '修改内容',
'attachments.selectionBatch': '{count} 个页面修改',
'attachments.selectionItem': '所选元素 {number}:{name}',
'chat.pendingMessageQueuedLabel': '已排队',
'repoLaunch.worktreeIsolatedDesc': '为本会话创建隔离 worktree,安全实验,合并前不影响 {branch}。',
'repoLaunch.worktreeCurrentDesc': '直接在项目目录中工作,改动立即出现在 {branch} 上。',
@@ -2655,4 +2657,19 @@ export const zh: Record<TranslationKey, string> = {
'browser.zoomReset': '重置预览缩放',
'browser.capture': '截图',
'browser.pickElement': '选择元素',
'browser.selection.cancel': '取消',
'browser.selection.sendOne': '发送',
'browser.selection.queueAndContinue': '添加并继续',
'browser.selection.add': '添加',
'browser.selection.descriptionPlaceholder': '描述这些更改…',
'browser.selection.draftCount': '已选 {count} 个',
'browser.selection.undo': '撤销上一个选择',
'browser.selection.clear': '清空选择',
'browser.selection.sendBatch': '发送 {count} 个',
'browser.selection.batchMessage': '{count} 个页面修改',
'browser.selection.limitReached': '已选满 {count} 个,请先发送或清空本批选择。',
'browser.selection.navigationTitle': '离开本次批量选择?',
'browser.selection.navigationBody': '继续导航会丢弃已选的 {count} 个元素,并还原页面中的预览修改。',
'browser.selection.navigationContinue': '丢弃并继续',
'browser.selection.navigationDiscarded': '页面已变化,已清空 {count} 个选择。',
}
+18 -2
View File
@@ -149,8 +149,24 @@ export type PreviewCaptureMessage = {
export type PreviewPickerMessage = {
v: 1
type: 'enter-picker' | 'exit-picker'
}
} & (
| {
type: 'enter-picker'
mode?: 'single' | 'batch'
label?: number
copy?: {
cancel: string
send: string
queueAndContinue: string
add: string
descriptionPlaceholder: string
}
}
| { type: 'exit-picker' }
| { type: 'undo-selection'; itemId: string }
| { type: 'clear-selection-draft' }
| { type: 'commit-selection-draft' }
)
export type PreviewHostMessage = PreviewCaptureMessage | PreviewPickerMessage
+107 -1
View File
@@ -3,9 +3,10 @@ import { browserHost } from './desktopHost/browserHost'
let previewHandler: ((payload: unknown) => void) | null = null
const { prefill, sendMessage } = vi.hoisted(() => ({
const { prefill, sendMessage, previewMessage } = vi.hoisted(() => ({
prefill: vi.fn(),
sendMessage: vi.fn(),
previewMessage: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../stores/chatStore', () => ({
useChatStore: {
@@ -18,13 +19,20 @@ vi.mock('../stores/chatStore', () => ({
import { subscribePreviewEvents } from './previewEvents'
import { useBrowserPanelStore } from '../stores/browserPanelStore'
import { usePreviewSelectionStore } from '../stores/previewSelectionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
describe('subscribePreviewEvents', () => {
beforeEach(() => {
previewHandler = null
prefill.mockClear()
sendMessage.mockClear()
previewMessage.mockClear()
useBrowserPanelStore.setState({ bySession: {} })
usePreviewSelectionStore.setState({ bySession: {} })
useSettingsStore.setState({ locale: 'zh' })
useUIStore.setState({ toasts: [] })
window.desktopHost = {
...browserHost,
kind: 'electron',
@@ -35,6 +43,7 @@ describe('subscribePreviewEvents', () => {
},
preview: {
...browserHost.preview,
message: previewMessage,
onEvent: async (handler) => {
previewHandler = handler
return () => {
@@ -137,4 +146,101 @@ describe('subscribePreviewEvents', () => {
previewHandler!(JSON.stringify({ v: 1, type: 'picker-exited' }))
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false)
})
it('queues a selection and explicitly rearms the next one without sending chat', async () => {
useBrowserPanelStore.getState().open('s1', 'http://x/a')
useBrowserPanelStore.getState().setPicker('s1', true)
await subscribePreviewEvents('s1')
previewHandler!(JSON.stringify({
v: 1,
type: 'selection',
payload: {
pageUrl: 'http://x/',
delivery: 'queue',
draftItemId: 'queued-1',
element: { selector: '#title', tag: 'h1', classes: [] },
change: { description: '更轻一点' },
screenshot: { dataUrl: 'data:image/png;base64,AAAA', kind: 'region' },
},
}))
expect(sendMessage).not.toHaveBeenCalled()
expect(usePreviewSelectionStore.getState().bySession.s1?.items).toMatchObject([
{ id: 'queued-1', number: 1, payload: { selectionNumber: 1 } },
])
expect(useBrowserPanelStore.getState().bySession.s1?.pickerActive).toBe(true)
expect(previewMessage).toHaveBeenCalledWith(expect.objectContaining({
v: 1,
type: 'enter-picker',
mode: 'batch',
label: 2,
}))
})
it('resumes a queued batch after cancelling only the current element', async () => {
useBrowserPanelStore.getState().open('s1', 'http://x/a')
usePreviewSelectionStore.getState().add('s1', {
pageUrl: 'http://x/',
draftItemId: 'queued-1',
element: { selector: '#title', tag: 'h1', classes: [] } as never,
})
useBrowserPanelStore.getState().setPicker('s1', true)
await subscribePreviewEvents('s1')
previewHandler!(JSON.stringify({ v: 1, type: 'picker-exited', reason: 'cancel-current' }))
expect(useBrowserPanelStore.getState().bySession.s1?.pickerActive).toBe(true)
expect(previewMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'enter-picker',
mode: 'batch',
label: 2,
}))
})
it('stops rearming at the batch limit and explains the next action', async () => {
useBrowserPanelStore.getState().open('s1', 'http://x/a')
for (let index = 1; index < 5; index += 1) {
usePreviewSelectionStore.getState().add('s1', {
pageUrl: 'http://x/',
draftItemId: `queued-${index}`,
element: { selector: `#item-${index}`, tag: 'div', classes: [] } as never,
})
}
useBrowserPanelStore.getState().setPicker('s1', true)
await subscribePreviewEvents('s1')
previewHandler!(JSON.stringify({
v: 1,
type: 'selection',
payload: {
pageUrl: 'http://x/',
delivery: 'queue',
draftItemId: 'queued-5',
element: { selector: '#item-5', tag: 'div', classes: [] },
},
}))
expect(usePreviewSelectionStore.getState().bySession.s1?.items).toHaveLength(5)
expect(useBrowserPanelStore.getState().bySession.s1?.pickerActive).toBe(false)
expect(useUIStore.getState().toasts.at(-1)?.message).toBe('已选满 5 个,请先发送或清空本批选择。')
})
it('clears a stale batch and warns when the page navigates itself', async () => {
useBrowserPanelStore.getState().open('s1', 'http://x/a')
usePreviewSelectionStore.getState().add('s1', {
pageUrl: 'http://x/a',
draftItemId: 'queued-1',
element: { selector: '#title', tag: 'h1', classes: [] } as never,
})
useBrowserPanelStore.getState().setPicker('s1', true)
await subscribePreviewEvents('s1')
previewHandler!(JSON.stringify({ v: 1, type: 'navigated', url: 'http://x/b', title: 'B' }))
expect(usePreviewSelectionStore.getState().bySession.s1).toBeUndefined()
expect(useBrowserPanelStore.getState().bySession.s1?.pickerActive).toBe(false)
expect(previewMessage).toHaveBeenCalledWith({ v: 1, type: 'clear-selection-draft' })
expect(useUIStore.getState().toasts.at(-1)?.message).toBe('页面已变化,已清空 1 个选择。')
})
})
+50 -3
View File
@@ -1,7 +1,12 @@
import { useBrowserPanelStore } from '../stores/browserPanelStore'
import { useChatStore } from '../stores/chatStore'
import { MAX_PREVIEW_SELECTIONS, usePreviewSelectionStore } from '../stores/previewSelectionStore'
import { useUIStore } from '../stores/uiStore'
import { t } from '../i18n'
import { getDesktopHost } from './desktopHost'
import { buildSelectionDirectMessage, type SelectionPayload } from './selectionComposer'
import { previewBridge } from './previewBridge'
import { buildPreviewPickerMessage } from './previewSelectionPicker'
function kindLabel(kind?: string): string {
if (kind === 'viewport') return 'viewport'
@@ -14,14 +19,36 @@ export async function subscribePreviewEvents(sessionId: string): Promise<() => v
if (!host.capabilities.previewWebview) return () => {}
return host.preview.onEvent((payload) => {
let msg: { type?: string; url?: string; title?: string; dataUrl?: string; kind?: string; payload?: unknown }
let msg: {
type?: string
url?: string
title?: string
dataUrl?: string
kind?: string
reason?: string
payload?: unknown
}
try {
msg = typeof payload === 'string'
? JSON.parse(payload)
: payload as typeof msg
} catch { return }
const store = useBrowserPanelStore.getState()
if (msg.type === 'navigated' && msg.url) store.setNavigated(sessionId, msg.url, msg.title ?? '')
if (msg.type === 'navigated' && msg.url) {
// A page navigation destroys the injected agent and its reversible live edits,
// so a draft from the previous document must never follow the new page.
const discardedCount = usePreviewSelectionStore.getState().bySession[sessionId]?.items.length ?? 0
if (discardedCount > 0) {
void previewBridge.message({ v: 1, type: 'clear-selection-draft' })
useUIStore.getState().addToast({
type: 'warning',
message: t('browser.selection.navigationDiscarded', { count: discardedCount }),
})
}
usePreviewSelectionStore.getState().clear(sessionId)
store.setPicker(sessionId, false)
store.setNavigated(sessionId, msg.url, msg.title ?? '')
}
else if (msg.type === 'ready') store.setReady(sessionId)
else if (msg.type === 'screenshot' && msg.dataUrl) {
useChatStore.getState().queueComposerPrefill(sessionId, {
@@ -34,8 +61,23 @@ export async function subscribePreviewEvents(sessionId: string): Promise<() => v
if (!store.bySession[sessionId]?.pickerActive) return
// 选区事件意味着页面侧已结束一次性拾取——同步关闭宿主侧 picker 态,避免按钮卡在按下态
store.setPicker(sessionId, false)
const p = msg.payload as (SelectionPayload & { screenshot?: { dataUrl?: string; kind?: string } }) | undefined
const p = msg.payload as SelectionPayload | undefined
if (!p || typeof p !== 'object' || !p.element) return
if (p.delivery === 'queue') {
const item = usePreviewSelectionStore.getState().add(sessionId, p)
if (!item) return
const draft = usePreviewSelectionStore.getState().bySession[sessionId]!
if (draft.items.length < MAX_PREVIEW_SELECTIONS) {
store.setPicker(sessionId, true)
void previewBridge.message(buildPreviewPickerMessage('batch', draft.nextNumber))
} else {
useUIStore.getState().addToast({
type: 'info',
message: t('browser.selection.limitReached', { count: MAX_PREVIEW_SELECTIONS }),
})
}
return
}
const selection = buildSelectionDirectMessage(p)
const attachments = p.screenshot?.dataUrl
? [{
@@ -55,6 +97,11 @@ export async function subscribePreviewEvents(sessionId: string): Promise<() => v
}
else if (msg.type === 'picker-exited') {
store.setPicker(sessionId, false)
const draft = usePreviewSelectionStore.getState().bySession[sessionId]
if (msg.reason === 'cancel-current' && draft?.items.length) {
store.setPicker(sessionId, true)
void previewBridge.message(buildPreviewPickerMessage('batch', draft.nextNumber))
}
}
else if (msg.type === 'error') {
console.warn('[preview-agent]', msg)
+21
View File
@@ -0,0 +1,21 @@
import { t } from '../i18n'
import type { PreviewPickerMessage } from './desktopHost/types'
export function buildPreviewPickerMessage(
mode: 'single' | 'batch',
label: number,
): PreviewPickerMessage {
return {
v: 1,
type: 'enter-picker',
mode,
label,
copy: {
cancel: t('browser.selection.cancel'),
send: t('browser.selection.sendOne'),
queueAndContinue: t('browser.selection.queueAndContinue'),
add: t('browser.selection.add'),
descriptionPlaceholder: t('browser.selection.descriptionPlaceholder'),
},
}
}
+31 -1
View File
@@ -1,5 +1,5 @@
import { expect, it } from 'vitest'
import { buildSelectionComposerText, buildSelectionDirectMessage } from './selectionComposer'
import { buildSelectionBatchMessage, buildSelectionComposerText, buildSelectionDirectMessage } from './selectionComposer'
it('renders only the user instruction + concrete changes (no selector/DOM/page noise)', () => {
const text = buildSelectionComposerText({
@@ -46,3 +46,33 @@ it('builds a model prompt while keeping the visible selection label compact', ()
expect(message.displayName).toBe('<h1>')
expect(message.note).toBe('这个标题更轻一点')
})
it('builds one numbered model prompt for a batch of selections', () => {
const message = buildSelectionBatchMessage([
{
id: 'one',
number: 1,
payload: {
pageUrl: 'http://localhost:5174/',
element: { selector: '#title', tag: 'h1', classes: [] } as never,
change: { description: '标题更轻一点' } as never,
},
},
{
id: 'three',
number: 3,
payload: {
pageUrl: 'http://localhost:5174/',
element: { selector: '#cta', tag: 'button', classes: [] } as never,
change: { description: '按钮更醒目' } as never,
},
},
])
expect(message.modelText).toContain('[元素 1]')
expect(message.modelText).toContain('[元素 3]')
expect(message.modelText).not.toContain('[元素 2]')
expect(message.modelText).toContain('标题更轻一点')
expect(message.modelText).toContain('按钮更醒目')
expect(message.items.map((item) => item.displayName)).toEqual(['<h1>', '<button>'])
})
+63 -3
View File
@@ -2,9 +2,14 @@ import type { ElementMetadata } from '../preview-agent/metadata'
import type { EditDiff } from '../preview-agent/popover'
export type SelectionPayload = {
pageUrl: string; sourceHint?: string
pageUrl: string
sourceHint?: string
element: ElementMetadata
change?: EditDiff & { description?: string }
delivery?: 'send' | 'queue'
draftItemId?: string
selectionNumber?: number
screenshot?: { dataUrl?: string; kind?: 'full' | 'viewport' | 'element' | 'region' }
}
export type SelectionDirectMessage = {
@@ -13,6 +18,25 @@ export type SelectionDirectMessage = {
note?: string
}
export type SelectionBatchItem = {
id: string
number: number
payload: SelectionPayload
}
export type SelectionBatchMessage = {
modelText: string
items: Array<{
number: number
displayName: string
note?: string
selector: string
}>
}
export const VISUAL_SELECTION_PROMPT_FOOTER = '请优先依据截图里的编号标注定位元素,selector 只作为辅助线索。'
export const VISUAL_SELECTION_BATCH_PROMPT_HEADER = '请一次性处理以下批量标注的本地前端修改。每张截图中的蓝色编号与下方元素编号一一对应。'
/**
* 选中元素的「引用」由随附的圈选标注截图承载(截图里已把元素圈出来)。
* 这里只产出用户的修改意见 + 具体改动(人话),**不再**把 selector / DOM 定位 /
@@ -31,7 +55,7 @@ export function buildSelectionComposerText(p: SelectionPayload): string {
return lines.join('\n')
}
function formatElementLabel(element: ElementMetadata): string {
export function formatElementLabel(element: ElementMetadata): string {
const tag = element.tag || 'element'
return `<${tag}>`
}
@@ -55,7 +79,7 @@ export function buildSelectionDirectMessage(p: SelectionPayload): SelectionDirec
} else {
lines.push('用户没有提供额外注释;请只依据截图中的选中元素理解修改目标。')
}
lines.push('请优先依据截图里的编号标注定位元素,selector 只作为辅助线索。')
lines.push(VISUAL_SELECTION_PROMPT_FOOTER)
return {
modelText: lines.join('\n'),
@@ -63,3 +87,39 @@ export function buildSelectionDirectMessage(p: SelectionPayload): SelectionDirec
note: note || undefined,
}
}
export function buildSelectionBatchMessage(entries: SelectionBatchItem[]): SelectionBatchMessage {
const items = entries.map((entry) => {
const displayName = formatElementLabel(entry.payload.element)
const note = buildSelectionComposerText(entry.payload) || undefined
return {
number: entry.number,
displayName,
note,
selector: entry.payload.element.selector,
}
})
const lines = [VISUAL_SELECTION_BATCH_PROMPT_HEADER]
entries.forEach((entry, index) => {
const item = items[index]!
const payload = entry.payload
lines.push('', `[元素 ${entry.number}]`)
lines.push(`目标元素:${item.displayName}`)
lines.push(`Selector:${item.selector}`)
if (payload.element.nthPath) lines.push(`DOM 路径:${payload.element.nthPath}`)
if (payload.sourceHint) lines.push(`页面标题:${payload.sourceHint}`)
if (payload.pageUrl) lines.push(`页面 URL:${payload.pageUrl}`)
if (payload.element.text) lines.push(`当前文本:${payload.element.text}`)
if (item.note) {
lines.push('用户注释:')
lines.push(item.note)
} else {
lines.push('用户没有提供额外注释;请只依据截图中的选中元素理解修改目标。')
}
lines.push(`[元素 ${entry.number} 结束]`)
})
lines.push('', VISUAL_SELECTION_PROMPT_FOOTER)
return { modelText: lines.join('\n'), items }
}
@@ -100,6 +100,34 @@ describe('createEditBubble', () => {
bubble.destroy()
})
it('offers add-and-continue in single mode, then a single add action in batch mode', () => {
const el = document.getElementById('t')!
const onQueue = vi.fn()
const single = createEditBubble(el, { onConfirm: vi.fn(), onQueue, onCancel: vi.fn() })
expect($(single, '[data-action="queue"]').textContent).toBe('添加并继续')
expect(single.host.shadowRoot!.querySelector('[data-action="confirm"]')).not.toBeNull()
$(single, '[data-action="queue"]').click()
expect(onQueue).toHaveBeenCalled()
single.destroy()
const batch = createEditBubble(el, { onConfirm: vi.fn(), onQueue, onCancel: vi.fn(), mode: 'batch' })
expect($(batch, '[data-action="queue"]').textContent).toBe('添加')
expect(batch.host.shadowRoot!.querySelector('[data-action="confirm"]')).toBeNull()
batch.destroy()
})
it('exposes a revert handle for queued live edits', () => {
const el = document.getElementById('t')!
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
const textInput = $(bubble, '[data-field="text"]')
textInput.value = 'Queued preview'
textInput.dispatchEvent(new Event('input'))
expect(el.textContent).toBe('Queued preview')
bubble.revert()
expect(el.textContent).toBe('Old')
bubble.destroy()
})
it('颜色行给出取色器色块,初值按 hex 展示', () => {
const el = document.getElementById('t')!
vi.spyOn(window, 'getComputedStyle').mockReturnValue({ color: 'rgb(26, 29, 41)', backgroundColor: 'rgba(0, 0, 0, 0)', opacity: '1', fontFamily: 'Arial' } as unknown as CSSStyleDeclaration)
+50 -10
View File
@@ -4,7 +4,28 @@ import { buildSelector } from './selector'
export type EditableSnapshot = { text: string; color: string; background: string; opacity: string; fontFamily: string }
export type EditBubbleChange = EditDiff & { description?: string }
type Deps = { onConfirm: (change: EditBubbleChange) => void; onCancel: () => void }
export type EditBubbleCopy = {
cancel: string
send: string
queueAndContinue: string
add: string
descriptionPlaceholder: string
}
type Deps = {
onConfirm: (change: EditBubbleChange) => void
onQueue?: (change: EditBubbleChange) => void
onCancel: () => void
mode?: 'single' | 'batch'
copy?: EditBubbleCopy
}
const DEFAULT_COPY: EditBubbleCopy = {
cancel: '取消',
send: '发送',
queueAndContinue: '添加并继续',
add: '添加',
descriptionPlaceholder: '描述这些更改…',
}
const VIEWPORT_MARGIN = 8
const BUBBLE_GAP = 8
@@ -149,14 +170,16 @@ const STYLE = `
}
.footer {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
padding: 9px 14px; margin-top: 8px; border-top: 1px solid var(--border);
}
/* 内容被压缩到需要滚动时,给页脚一道投影,提示上方还有没看完的行 */
.bubble[data-scrollable="true"] .footer { box-shadow: 0 -7px 12px -8px rgba(15, 18, 25, .45); }
.btn { border: none; border-radius: 999px; font: inherit; font-size: 12.5px; cursor: pointer; transition: background .12s ease, color .12s ease; }
.btn-ghost { padding: 7px 14px; background: transparent; color: var(--muted); }
.btn-ghost { margin-right: auto; padding: 7px 14px; background: transparent; color: var(--muted); }
.btn-ghost:hover { background: var(--field-bg); color: var(--fg); }
.btn-secondary { padding: 7px 14px; border: 1px solid var(--field-border); background: var(--bg); color: var(--fg); }
.btn-secondary:hover { background: var(--field-bg); border-color: var(--muted); }
.btn-primary { padding: 7px 18px; background: var(--accent); color: #fff; font-weight: 500; }
.btn-primary:hover { background: #1f6bf0; }
`
@@ -382,9 +405,10 @@ function buildFontRow(initial: string, onCommit: (value: string) => void): HTMLE
return row
}
export function createEditBubble(target: HTMLElement, deps: Deps): { host: HTMLElement; destroy: () => void } {
export function createEditBubble(target: HTMLElement, deps: Deps): { host: HTMLElement; destroy: () => void; revert: () => void } {
const original = snapshotEditableStyles(target)
const current: EditableSnapshot = { ...original }
const copy = deps.copy ?? DEFAULT_COPY
let description = ''
const commit = (key: keyof EditableSnapshot, value: string) => {
@@ -417,7 +441,7 @@ export function createEditBubble(target: HTMLElement, deps: Deps): { host: HTMLE
const describe = el('textarea', 'describe')
describe.setAttribute('data-field', 'description')
describe.rows = 1
describe.placeholder = '描述这些更改…'
describe.placeholder = copy.descriptionPlaceholder
describe.addEventListener('input', () => {
description = describe.value
autoGrow(describe, TEXT_AREA_MAX_HEIGHT)
@@ -456,25 +480,37 @@ export function createEditBubble(target: HTMLElement, deps: Deps): { host: HTMLE
const cancelBtn = el('button', 'btn btn-ghost')
cancelBtn.type = 'button'
cancelBtn.setAttribute('data-action', 'cancel')
cancelBtn.textContent = '取消'
cancelBtn.textContent = copy.cancel
const queueBtn = el('button', deps.mode === 'batch' ? 'btn btn-primary' : 'btn btn-secondary')
queueBtn.type = 'button'
queueBtn.setAttribute('data-action', 'queue')
queueBtn.textContent = deps.mode === 'batch' ? copy.add : copy.queueAndContinue
const confirmBtn = el('button', 'btn btn-primary')
confirmBtn.type = 'button'
confirmBtn.setAttribute('data-action', 'confirm')
confirmBtn.textContent = '发送'
confirmBtn.textContent = copy.send
const cancel = () => { applyEdit(target, original); deps.onCancel() }
const confirm = () => deps.onConfirm({ ...computeChange(original, current), description: description || undefined })
const queue = () => deps.onQueue?.({ ...computeChange(original, current), description: description || undefined })
cancelBtn.addEventListener('click', cancel)
queueBtn.addEventListener('click', queue)
confirmBtn.addEventListener('click', confirm)
// 键盘快捷键就地消化,避免漏给页面自己的监听器造成误触
wrap.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); cancel(); return }
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); confirm() }
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
event.stopPropagation()
if (deps.mode === 'batch') queue()
else confirm()
}
})
footer.appendChild(cancelBtn)
footer.appendChild(confirmBtn)
if (deps.onQueue) footer.appendChild(queueBtn)
if (deps.mode !== 'batch') footer.appendChild(confirmBtn)
wrap.appendChild(head)
wrap.appendChild(describe)
wrap.appendChild(body)
@@ -495,5 +531,9 @@ export function createEditBubble(target: HTMLElement, deps: Deps): { host: HTMLE
refreshScrollHint()
describe.focus({ preventScroll: true }) // 页面不该因为浮层拿焦点而跳位
return { host, destroy: () => { host.remove() } }
return {
host,
destroy: () => { host.remove() },
revert: () => { applyEdit(target, original) },
}
}
+39 -4
View File
@@ -3,7 +3,17 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
// 宿主两侧(主进程 pickerArmed、渲染进程 pickerActive)都把 picker-exited 当作
// 「解除本次拾取授权」,并在其后丢弃 selection。这里跑的是真实注入脚本,用来锁住
// 「确认时只发 selection、取消时才发 picker-exited」这条跨进程契约。
type PostedMessage = { v: number; type: string; payload?: { element?: { tag?: string }; change?: Record<string, unknown> } }
type PostedMessage = {
v: number
type: string
payload?: {
element?: { tag?: string }
change?: Record<string, unknown>
delivery?: 'send' | 'queue'
draftItemId?: string
selectionNumber?: number
}
}
const posted: PostedMessage[] = []
@@ -14,11 +24,12 @@ type AgentWindow = typeof window & {
const agentWindow = window as AgentWindow
function sendFromHost(type: 'enter-picker' | 'exit-picker'): void {
agentWindow.__PREVIEW_BRIDGE__!.handleHostRaw(JSON.stringify({ v: 1, type }))
function sendFromHost(message: string | Record<string, unknown>): void {
const payload = typeof message === 'string' ? { type: message } : message
agentWindow.__PREVIEW_BRIDGE__!.handleHostRaw(JSON.stringify({ v: 1, ...payload }))
}
function bubbleButton(action: 'confirm' | 'cancel'): HTMLButtonElement {
function bubbleButton(action: 'confirm' | 'queue' | 'cancel'): HTMLButtonElement {
for (const host of document.documentElement.querySelectorAll('div')) {
const button = host.shadowRoot?.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)
if (button) return button
@@ -45,6 +56,7 @@ beforeEach(() => {
if (host.shadowRoot || host.dataset.previewSelectionAnnotationRoot) host.remove()
}
sendFromHost('exit-picker') // 复位上一个用例可能残留的 picker 态
sendFromHost('clear-selection-draft')
posted.length = 0
})
@@ -95,4 +107,27 @@ describe('preview agent picker flow', () => {
expect(types()).not.toContain('selection')
})
it('批量添加携带稳定编号,并能按 itemId 撤销页面上的实时预览', () => {
sendFromHost({ type: 'enter-picker', mode: 'batch', label: 3 })
const target = document.getElementById('t')!
pick(target)
const root = bubbleButton('queue').getRootNode() as ShadowRoot
const textInput = root.querySelector<HTMLInputElement>('[data-field="text"]')!
textInput.value = 'Queued'
textInput.dispatchEvent(new Event('input'))
bubbleButton('queue').click()
const selection = posted.find((message) => message.type === 'selection')!
expect(selection.payload).toMatchObject({
delivery: 'queue',
selectionNumber: 3,
change: { text: { from: 'Old', to: 'Queued' } },
})
expect(types()).not.toContain('picker-exited')
expect(target.textContent).toBe('Queued')
sendFromHost({ type: 'undo-selection', itemId: selection.payload!.draftItemId! })
expect(target.textContent).toBe('Old')
})
})
+61 -12
View File
@@ -2,7 +2,7 @@ import { createBridge } from './bridge'
import { captureToDataUrl, createAnnotationOverlay } from './screenshot'
import { createPicker } from './picker'
import { buildElementMetadata } from './metadata'
import { createEditBubble } from './editBubble'
import { createEditBubble, type EditBubbleCopy } from './editBubble'
;(() => {
;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true
@@ -41,7 +41,12 @@ import { createEditBubble } from './editBubble'
})
let pickerOn = false
let activeBubble: { destroy: () => void } | null = null
let pickerMode: 'single' | 'batch' = 'single'
let pickerLabel = 1
let pickerCopy: EditBubbleCopy | undefined
let itemSequence = 0
let activeBubble: { destroy: () => void; revert: () => void } | null = null
const queuedReverts = new Map<string, () => void>()
const picker = createPicker({ onSelect: () => {} })
// 只做页面侧清理。宿主的 picker 授权由 selection / picker-exited 之一消费,
@@ -54,15 +59,21 @@ import { createEditBubble } from './editBubble'
}
// 本次拾取结束但没有产出 selection:通知宿主解除授权、复位按钮态。
const teardown = () => {
const teardown = (reason: 'cancel-current' | 'host' | 'invalid-target') => {
activeBubble?.revert()
closePicker()
bridge.send({ type: 'picker-exited' })
bridge.send({ type: 'picker-exited', reason })
}
const emitSelection = async (el: Element, change?: unknown) => {
const emitSelection = async (
el: Element,
change: unknown,
delivery: 'send' | 'queue',
draftItemId?: string,
) => {
try {
clearSelectionOverlay()
const overlay = createAnnotationOverlay(el, 1)
const overlay = createAnnotationOverlay(el, pickerLabel)
selectionOverlayCleanup = () => { overlay.remove() }
selectionOverlayTimer = window.setTimeout(clearSelectionOverlay, 5000)
bridge.send({
@@ -72,14 +83,43 @@ import { createEditBubble } from './editBubble'
sourceHint: document.title || undefined,
element: buildElementMetadata(el),
change,
delivery,
selectionNumber: pickerLabel,
...(draftItemId ? { draftItemId } : {}),
screenshot: { kind: 'region' },
},
})
} catch (e) { bridge.reportError(String(e)) }
} catch (e) {
if (draftItemId) {
queuedReverts.get(draftItemId)?.()
queuedReverts.delete(draftItemId)
}
bridge.reportError(String(e))
}
}
bridge.on('enter-picker', () => { pickerOn = true; picker.enter() })
bridge.on('exit-picker', () => { teardown() })
bridge.on('enter-picker', (message) => {
pickerMode = message.mode ?? 'single'
pickerLabel = message.label ?? 1
pickerCopy = message.copy
pickerOn = true
picker.enter()
})
bridge.on('exit-picker', () => { teardown('host') })
bridge.on('undo-selection', (message) => {
queuedReverts.get(message.itemId)?.()
queuedReverts.delete(message.itemId)
clearSelectionOverlay()
})
bridge.on('clear-selection-draft', () => {
for (const revert of [...queuedReverts.values()].reverse()) revert()
queuedReverts.clear()
clearSelectionOverlay()
})
bridge.on('commit-selection-draft', () => {
queuedReverts.clear()
clearSelectionOverlay()
})
document.addEventListener('mousemove', (e) => {
if (!pickerOn) return
@@ -93,12 +133,21 @@ import { createEditBubble } from './editBubble'
picker.select()
const el = picker.current()
pickerOn = false // stop hovering; keep highlight on the selected element while the bubble is open
if (!(el instanceof HTMLElement)) { teardown(); return }
if (!(el instanceof HTMLElement)) { teardown('invalid-target'); return }
activeBubble = createEditBubble(el, {
// selection 自带「本次拾取结束」的语义,宿主收到后会自行复位 picker 态。
// 若这里先发 picker-exited,宿主会把授权解除在前、selection 到达在后而丢弃它。
onConfirm: (change) => { closePicker(); void emitSelection(el, change) },
onCancel: () => { teardown() },
onConfirm: (change) => { closePicker(); void emitSelection(el, change, 'send') },
onQueue: (change) => {
const itemId = `preview-selection-${++itemSequence}`
const revert = activeBubble?.revert
closePicker()
if (revert) queuedReverts.set(itemId, revert)
void emitSelection(el, change, 'queue', itemId)
},
onCancel: () => { teardown('cancel-current') },
mode: pickerMode,
copy: pickerCopy,
})
}, true)
@@ -12,4 +12,20 @@ describe('preview-agent protocol', () => {
expect(parseHostMessage('not json')).toBeNull()
expect(parseHostMessage('{"v":1,"type":"nope"}')).toBeNull()
})
it('validates batch picker context and draft commands', () => {
const copy = {
cancel: 'Cancel',
send: 'Send',
queueAndContinue: 'Add & continue',
add: 'Add',
descriptionPlaceholder: 'Describe changes…',
}
expect(parseHostMessage(JSON.stringify({ v: 1, type: 'enter-picker', mode: 'batch', label: 4, copy })))
.toEqual({ type: 'enter-picker', mode: 'batch', label: 4, copy })
expect(parseHostMessage('{"v":1,"type":"undo-selection","itemId":"item-4"}'))
.toEqual({ type: 'undo-selection', itemId: 'item-4' })
expect(parseHostMessage('{"v":1,"type":"enter-picker","mode":"forever"}')).toBeNull()
expect(parseHostMessage('{"v":1,"type":"undo-selection","itemId":""}')).toBeNull()
})
})
+50 -13
View File
@@ -4,14 +4,35 @@ export type AgentMessage =
| { type: 'error'; message: string }
| { type: 'selection'; payload: unknown } // M5 填充结构
| { type: 'screenshot'; dataUrl: string; kind: 'full' | 'viewport' | 'element' } // M4
| { type: 'picker-exited' }
| { type: 'picker-exited'; reason?: 'cancel-current' | 'host' | 'invalid-target' }
export type PickerCopy = {
cancel: string
send: string
queueAndContinue: string
add: string
descriptionPlaceholder: string
}
export type HostMessage =
| { type: 'enter-picker' }
| { type: 'enter-picker'; mode?: 'single' | 'batch'; label?: number; copy?: PickerCopy }
| { type: 'exit-picker' }
| { type: 'undo-selection'; itemId: string }
| { type: 'clear-selection-draft' }
| { type: 'commit-selection-draft' }
| { type: 'capture'; kind: 'full' | 'viewport' | 'element' }
const HOST_TYPES = new Set(['enter-picker', 'exit-picker', 'capture'])
const MAX_COPY_LENGTH = 80
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isPickerCopy(value: unknown): value is PickerCopy {
if (!isRecord(value)) return false
return ['cancel', 'send', 'queueAndContinue', 'add', 'descriptionPlaceholder']
.every((key) => typeof value[key] === 'string' && value[key].length <= MAX_COPY_LENGTH)
}
export function serializeAgentMessage(msg: AgentMessage): string {
return JSON.stringify({ v: 1, ...msg })
@@ -20,17 +41,33 @@ export function serializeAgentMessage(msg: AgentMessage): string {
export function parseHostMessage(raw: string): HostMessage | null {
try {
const obj = JSON.parse(raw) as unknown
if (
typeof obj !== 'object' ||
obj === null ||
(obj as Record<string, unknown>)['v'] !== 1 ||
typeof (obj as Record<string, unknown>)['type'] !== 'string' ||
!HOST_TYPES.has((obj as Record<string, unknown>)['type'] as string)
) {
return null
if (!isRecord(obj) || obj.v !== 1 || typeof obj.type !== 'string') return null
if (obj.type === 'enter-picker') {
if (obj.mode !== undefined && obj.mode !== 'single' && obj.mode !== 'batch') return null
if (obj.label !== undefined && (!Number.isInteger(obj.label) || Number(obj.label) < 1 || Number(obj.label) > 99)) return null
if (obj.copy !== undefined && !isPickerCopy(obj.copy)) return null
return {
type: 'enter-picker',
...(obj.mode ? { mode: obj.mode } : {}),
...(typeof obj.label === 'number' ? { label: obj.label } : {}),
...(obj.copy ? { copy: obj.copy } : {}),
}
}
const { v: _v, ...rest } = obj as Record<string, unknown>
return rest as unknown as HostMessage
if (obj.type === 'exit-picker' || obj.type === 'clear-selection-draft' || obj.type === 'commit-selection-draft') {
return { type: obj.type }
}
if (obj.type === 'undo-selection') {
return typeof obj.itemId === 'string' && obj.itemId.length > 0 && obj.itemId.length <= 128
? { type: 'undo-selection', itemId: obj.itemId }
: null
}
if (obj.type === 'capture') {
return obj.kind === 'full' || obj.kind === 'viewport' || obj.kind === 'element'
? { type: 'capture', kind: obj.kind }
: null
}
return null
} catch {
return null
}
+44
View File
@@ -960,6 +960,50 @@ describe('chatStore history mapping', () => {
])
})
it('restores a batch selection as one numbered screenshot group with a compact summary', () => {
const modelPrompt = [
'请一次性处理以下批量标注的本地前端修改。每张截图中的蓝色编号与下方元素编号一一对应。',
'',
'[元素 1]',
'目标元素:<h1>',
'Selector:#title',
'用户注释:',
'标题更轻一点',
'[元素 1 结束]',
'',
'[元素 3]',
'目标元素:<button>',
'Selector:#cta',
'用户注释:',
'按钮更醒目',
'[元素 3 结束]',
'',
'请优先依据截图里的编号标注定位元素,selector 只作为辅助线索。',
].join('\n')
const mapped = mapHistoryMessagesToUiMessages([{
id: 'selection-batch-user-1',
type: 'user',
timestamp: '2026-06-10T16:20:00.000Z',
content: [
{ type: 'text', text: modelPrompt },
{ type: 'image', source: { media_type: 'image/png', data: 'FIRSTPNG' } },
{ type: 'image', source: { media_type: 'image/png', data: 'THIRDPNG' } },
],
} as MessageEntry])
expect(mapped).toMatchObject([{
id: 'selection-batch-user-1',
type: 'user_text',
content: '2 page changes',
modelContent: modelPrompt,
attachments: [
{ type: 'image', name: '<h1>', note: '标题更轻一点', quote: '#title', selectionNumber: 1 },
{ type: 'image', name: '<button>', note: '按钮更醒目', quote: '#cta', selectionNumber: 3 },
],
}])
})
it('restores /goal local command output from transcript history', () => {
const messages: MessageEntry[] = [
{
+63 -17
View File
@@ -9,6 +9,11 @@ import { useTabStore } from './tabStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
import { notifyDesktop } from '../lib/desktopNotifications'
import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
import { t } from '../i18n'
import {
VISUAL_SELECTION_BATCH_PROMPT_HEADER,
VISUAL_SELECTION_PROMPT_FOOTER,
} from '../lib/selectionComposer'
import { hasRunningBackgroundTasks, hasRunningSubagentTasks } from '../lib/backgroundTasks'
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
import type { ComposerAttachment } from '../lib/composerAttachments'
@@ -1279,6 +1284,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
hunkId: a.hunkId,
note: a.note,
quote: a.quote,
selectionNumber: a.selectionNumber,
}))
: undefined
@@ -3167,14 +3173,19 @@ const SIMPLE_IMAGE_SOURCE_RE = /^\[Image source: (.+)\]$/
const DETAILED_IMAGE_SOURCE_RE = /^\[Image: source: (.+?)(?:, original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by \d+(?:\.\d+)? to map to original image\.)?\]$/
const IMAGE_RESIZE_METADATA_RE = /^\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by \d+(?:\.\d+)? to map to original image\.\]$/
const VISUAL_SELECTION_PROMPT_HEADER = '请根据截图中编号 1 的蓝色标注修改本地前端。'
const VISUAL_SELECTION_PROMPT_FOOTER = '请优先依据截图里的编号标注定位元素,selector 只作为辅助线索。'
type VisualSelectionHistoryDisplay = {
type VisualSelectionHistoryItem = {
number: number
displayName: string
selector?: string
note?: string
}
type VisualSelectionHistoryDisplay = {
batch: boolean
items: VisualSelectionHistoryItem[]
}
function getHistoryImageMediaType(block: UserHistoryBlock): string {
const mediaType = block.source?.media_type ?? block.mimeType ?? block.media_type
return mediaType?.startsWith('image/') ? mediaType : 'image/png'
@@ -3220,13 +3231,40 @@ export function stripGeneratedImageMetadataLines(text: string): string {
function parseVisualSelectionHistoryPrompt(text: string): VisualSelectionHistoryDisplay | null {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
if (lines[0]?.trim() !== VISUAL_SELECTION_PROMPT_HEADER) return null
const header = lines[0]?.trim()
if (header === VISUAL_SELECTION_BATCH_PROMPT_HEADER) {
const items: VisualSelectionHistoryDisplay['items'] = []
for (let index = 1; index < lines.length; index += 1) {
const match = lines[index]?.trim().match(/^\[元素 (\d+)\]$/)
if (!match) continue
const number = Number(match[1])
const endMarker = `[元素 ${number} 结束]`
const block: string[] = []
index += 1
while (index < lines.length && lines[index]?.trim() !== endMarker) {
block.push(lines[index] ?? '')
index += 1
}
const parsedItem = parseVisualSelectionItem(block, number)
if (parsedItem) items.push(parsedItem)
}
return items.length ? { batch: true, items } : null
}
if (header !== VISUAL_SELECTION_PROMPT_HEADER) return null
const item = parseVisualSelectionItem(lines.slice(1), 1)
return item ? { batch: false, items: [item] } : null
}
function parseVisualSelectionItem(
lines: string[],
number: number,
): VisualSelectionHistoryItem | null {
let displayName: string | undefined
let selector: string | undefined
let note: string | undefined
for (let index = 1; index < lines.length; index += 1) {
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]?.trim() ?? ''
if (line.startsWith('目标元素:')) {
displayName = line.slice('目标元素:'.length).trim()
@@ -3245,21 +3283,24 @@ function parseVisualSelectionHistoryPrompt(text: string): VisualSelectionHistory
}
}
return displayName
? {
displayName,
...(selector ? { selector } : {}),
...(note ? { note } : {}),
}
: null
return displayName ? {
number,
displayName,
...(selector ? { selector } : {}),
...(note ? { note } : {}),
} : null
}
function applyVisualSelectionHistoryDisplay(attachments: UIAttachment[], display: VisualSelectionHistoryDisplay): void {
const imageAttachment = attachments.find((attachment) => attachment.type === 'image')
if (!imageAttachment) return
imageAttachment.name = display.displayName
if (display.selector) imageAttachment.quote = display.selector
if (display.note) imageAttachment.note = display.note
const imageAttachments = attachments.filter((attachment) => attachment.type === 'image')
display.items.forEach((item, index) => {
const imageAttachment = imageAttachments[index]
if (!imageAttachment) return
imageAttachment.name = item.displayName
if (item.selector) imageAttachment.quote = item.selector
if (item.note) imageAttachment.note = item.note
if (display.batch) imageAttachment.selectionNumber = item.number
})
}
function normalizeHistoryImageAttachment(block: UserHistoryBlock): UIAttachment {
@@ -4233,6 +4274,7 @@ function mapQueuedDisplayAttachments(attachments?: AttachmentRef[]): UIAttachmen
hunkId: attachment.hunkId,
note: attachment.note,
quote: attachment.quote,
selectionNumber: attachment.selectionNumber,
}))
}
@@ -4547,7 +4589,11 @@ export function mapHistoryMessagesToUiMessages(
applyVisualSelectionHistoryDisplay(attachments, visualSelectionDisplay)
}
const parsed = extractRestoredUserDisplay(visibleText)
const userContent = visualSelectionDisplay ? '' : parsed.content
const userContent = visualSelectionDisplay
? visualSelectionDisplay.batch
? t('browser.selection.batchMessage', { count: visualSelectionDisplay.items.length })
: ''
: parsed.content
const modelContent = visualSelectionDisplay || modelText !== visibleText ? modelText : parsed.modelContent
const allAttachments = [...(parsed.attachments ?? []), ...attachments]
uiMessages.push({
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { MAX_PREVIEW_SELECTIONS, usePreviewSelectionStore } from './previewSelectionStore'
function payload(id: string) {
return {
pageUrl: 'http://localhost:5173/',
draftItemId: id,
element: { selector: `#${id}`, tag: 'div', classes: [] },
} as never
}
describe('previewSelectionStore', () => {
beforeEach(() => usePreviewSelectionStore.setState({ bySession: {} }))
it('keeps selection numbers stable across undo and subsequent additions', () => {
const store = usePreviewSelectionStore.getState()
expect(store.add('s1', payload('one'))?.number).toBe(1)
expect(store.add('s1', payload('two'))?.number).toBe(2)
expect(store.undoLast('s1')?.id).toBe('two')
expect(store.add('s1', payload('three'))?.number).toBe(3)
expect(usePreviewSelectionStore.getState().bySession.s1?.items.map((item) => item.number)).toEqual([1, 3])
})
it('caps a batch without replacing an existing selection', () => {
const store = usePreviewSelectionStore.getState()
for (let index = 0; index < MAX_PREVIEW_SELECTIONS; index += 1) {
expect(store.add('s1', payload(`item-${index}`))).not.toBeNull()
}
expect(store.add('s1', payload('overflow'))).toBeNull()
expect(usePreviewSelectionStore.getState().bySession.s1?.items).toHaveLength(MAX_PREVIEW_SELECTIONS)
})
})
@@ -0,0 +1,62 @@
import { create } from 'zustand'
import type { SelectionBatchItem, SelectionPayload } from '../lib/selectionComposer'
export const MAX_PREVIEW_SELECTIONS = 5
export type PreviewSelectionDraft = {
items: SelectionBatchItem[]
nextNumber: number
}
type PreviewSelectionStore = {
bySession: Record<string, PreviewSelectionDraft>
add: (sessionId: string, payload: SelectionPayload) => SelectionBatchItem | null
undoLast: (sessionId: string) => SelectionBatchItem | null
clear: (sessionId: string) => SelectionBatchItem[]
}
const emptyDraft = (): PreviewSelectionDraft => ({ items: [], nextNumber: 1 })
export const usePreviewSelectionStore = create<PreviewSelectionStore>((set, get) => ({
bySession: {},
add: (sessionId, payload) => {
const current = get().bySession[sessionId] ?? emptyDraft()
if (current.items.length >= MAX_PREVIEW_SELECTIONS) return null
const item: SelectionBatchItem = {
id: payload.draftItemId || `selection-${current.nextNumber}`,
number: current.nextNumber,
payload: { ...payload, selectionNumber: current.nextNumber },
}
set((state) => ({
bySession: {
...state.bySession,
[sessionId]: {
items: [...current.items, item],
nextNumber: current.nextNumber + 1,
},
},
}))
return item
},
undoLast: (sessionId) => {
const current = get().bySession[sessionId]
const item = current?.items.at(-1)
if (!current || !item) return null
set((state) => ({
bySession: {
...state.bySession,
[sessionId]: { ...current, items: current.items.slice(0, -1) },
},
}))
return item
},
clear: (sessionId) => {
const current = get().bySession[sessionId]
if (!current) return []
const bySession = { ...get().bySession }
delete bySession[sessionId]
set({ bySession })
return current.items
},
}))
+2
View File
@@ -42,6 +42,7 @@ export type AttachmentRef = {
hunkId?: string
note?: string
quote?: string
selectionNumber?: number
}
export type PermissionUpdate =
@@ -75,6 +76,7 @@ export type UIAttachment = {
hunkId?: string
note?: string
quote?: string
selectionNumber?: number
}
// ─── Server → Client ──────────────────────────────────────────────