mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge selected chat context references
This commit is contained in:
commit
523db62b3b
67
desktop/src/components/chat/AttachmentGallery.test.tsx
Normal file
67
desktop/src/components/chat/AttachmentGallery.test.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentGallery } from './AttachmentGallery'
|
||||
|
||||
describe('AttachmentGallery', () => {
|
||||
it('renders a compact quote preview for selected workspace text', () => {
|
||||
render(
|
||||
<AttachmentGallery
|
||||
variant="composer"
|
||||
attachments={[{
|
||||
id: 'selection-1',
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: 'src/App.tsx',
|
||||
lineStart: 10,
|
||||
lineEnd: 12,
|
||||
quote: 'const value = calculate(input)\nreturn value',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(document.body.textContent).toContain('App.tsx:L10-L12')
|
||||
expect(document.body.textContent).toContain('const value = calculate(input) return value')
|
||||
})
|
||||
|
||||
it('keeps plain file chips on the one-line treatment', () => {
|
||||
render(
|
||||
<AttachmentGallery
|
||||
variant="composer"
|
||||
attachments={[{
|
||||
id: 'file-1',
|
||||
type: 'file',
|
||||
name: 'README.md',
|
||||
path: 'README.md',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(document.body.textContent).toContain('README.md')
|
||||
expect(document.body.textContent).not.toContain(':L')
|
||||
})
|
||||
|
||||
it('removes a quoted workspace attachment by id', () => {
|
||||
const onRemove = vi.fn()
|
||||
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
variant="composer"
|
||||
onRemove={onRemove}
|
||||
attachments={[{
|
||||
id: 'selection-1',
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: 'src/App.tsx',
|
||||
lineStart: 10,
|
||||
quote: 'const value = 1',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'Remove App.tsx' }))
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith('selection-1')
|
||||
})
|
||||
})
|
||||
@ -83,29 +83,41 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
)
|
||||
}
|
||||
|
||||
const lineLabel = attachment.lineStart
|
||||
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
|
||||
: ''
|
||||
const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ')
|
||||
const hasQuotePreview = !!quotePreview
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id || `${attachment.name}-${index}`}
|
||||
className={[
|
||||
'group/file inline-flex max-w-full min-w-0 items-center gap-2 rounded-full border border-[var(--color-border)]',
|
||||
'group/file inline-flex max-w-full min-w-0 border border-[var(--color-border)]',
|
||||
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
|
||||
isComposer ? 'h-9 px-3' : 'h-9 px-3',
|
||||
hasQuotePreview
|
||||
? 'items-start gap-2 rounded-[8px] px-2.5 py-2'
|
||||
: 'h-9 items-center gap-2 rounded-full px-3',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
|
||||
{attachment.lineStart ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'}
|
||||
<span className={`material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)] ${hasQuotePreview ? 'mt-0.5' : ''}`}>
|
||||
{hasQuotePreview ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'}
|
||||
</span>
|
||||
<span className="min-w-0 max-w-[220px] truncate text-[13px] font-medium leading-none text-[var(--color-text-primary)]">
|
||||
{attachment.name}
|
||||
{attachment.lineStart
|
||||
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
|
||||
: ''}
|
||||
<span className="min-w-0">
|
||||
<span className="block min-w-0 max-w-[260px] truncate text-[13px] font-medium leading-5 text-[var(--color-text-primary)]">
|
||||
{attachment.name}{lineLabel}
|
||||
</span>
|
||||
{hasQuotePreview && (
|
||||
<span className="mt-0.5 block max-w-[320px] truncate font-[var(--font-mono)] text-[11px] leading-4 text-[var(--color-text-tertiary)]">
|
||||
{quotePreview}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{onRemove && attachment.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.id!)}
|
||||
className="ml-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
className={`${hasQuotePreview ? 'mt-0.5' : 'ml-0.5'} flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]`}
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[17px]">close</span>
|
||||
|
||||
@ -66,7 +66,7 @@ function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Atta
|
||||
id: reference.id,
|
||||
name: reference.name,
|
||||
type: 'file',
|
||||
path: reference.path,
|
||||
path: reference.kind === 'chat-selection' ? undefined : reference.path,
|
||||
isDirectory: reference.isDirectory,
|
||||
lineStart: reference.lineStart,
|
||||
lineEnd: reference.lineEnd,
|
||||
@ -519,7 +519,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
const contentForModel = [workspaceReferencePrompt, text].filter(Boolean).join('\n\n')
|
||||
const displayContent = text || (
|
||||
workspaceReferences.length > 0
|
||||
? t('chat.workspaceReferencesOnly', { count: workspaceReferences.length })
|
||||
? t('chat.contextReferencesOnly', { count: workspaceReferences.length })
|
||||
: ''
|
||||
)
|
||||
const uploadAttachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
||||
@ -533,22 +533,24 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
note: attachment.note,
|
||||
quote: attachment.quote,
|
||||
}))
|
||||
const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences.map((reference) => ({
|
||||
type: 'file' as const,
|
||||
name: reference.name,
|
||||
path: reference.absolutePath ?? reference.path,
|
||||
isDirectory: reference.isDirectory,
|
||||
lineStart: reference.lineStart,
|
||||
lineEnd: reference.lineEnd,
|
||||
note: reference.note,
|
||||
quote: reference.quote,
|
||||
}))
|
||||
const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences
|
||||
.filter((reference) => reference.kind !== 'chat-selection')
|
||||
.map((reference) => ({
|
||||
type: 'file' as const,
|
||||
name: reference.name,
|
||||
path: reference.absolutePath ?? reference.path,
|
||||
isDirectory: reference.isDirectory,
|
||||
lineStart: reference.lineStart,
|
||||
lineEnd: reference.lineEnd,
|
||||
note: reference.note,
|
||||
quote: reference.quote,
|
||||
}))
|
||||
const visibleAttachmentPayload: AttachmentRef[] = [
|
||||
...uploadAttachmentPayload,
|
||||
...workspaceReferences.map((reference) => ({
|
||||
type: 'file' as const,
|
||||
name: reference.name,
|
||||
path: reference.path,
|
||||
path: reference.kind === 'chat-selection' ? undefined : reference.path,
|
||||
isDirectory: reference.isDirectory,
|
||||
lineStart: reference.lineStart,
|
||||
lineEnd: reference.lineEnd,
|
||||
|
||||
@ -4,6 +4,7 @@ import { MessageList, buildRenderModel } from './MessageList'
|
||||
import { relativizeWorkspacePath } from './CurrentTurnChangeCard'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
@ -42,12 +43,67 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
|
||||
}
|
||||
}
|
||||
|
||||
function findTextNodeContaining(container: Element, text: string) {
|
||||
const walker = document.createTreeWalker(container, 4)
|
||||
let current = walker.nextNode()
|
||||
while (current) {
|
||||
if (current.textContent?.includes(text)) return current
|
||||
current = walker.nextNode()
|
||||
}
|
||||
throw new Error(`Unable to find text node containing ${text}`)
|
||||
}
|
||||
|
||||
async function selectMessageText(element: Element, text: string) {
|
||||
const textNode = findTextNodeContaining(element, text)
|
||||
const startOffset = textNode.textContent?.indexOf(text) ?? -1
|
||||
const range = document.createRange()
|
||||
range.setStart(textNode, startOffset)
|
||||
range.setEnd(textNode, startOffset + text.length)
|
||||
Object.assign(range, {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 160,
|
||||
top: 80,
|
||||
right: 280,
|
||||
bottom: 98,
|
||||
width: 120,
|
||||
height: 18,
|
||||
x: 160,
|
||||
y: 80,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
|
||||
const selectableRoot = element.closest('[data-message-shell]')?.parentElement?.parentElement
|
||||
Object.assign(selectableRoot ?? element, {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 120,
|
||||
top: 48,
|
||||
right: 620,
|
||||
bottom: 240,
|
||||
width: 500,
|
||||
height: 192,
|
||||
x: 120,
|
||||
y: 48,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
|
||||
window.getSelection()?.removeAllRanges()
|
||||
window.getSelection()?.addRange(range)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseUp(element, { clientX: 260, clientY: 104 })
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('MessageList nested tool calls', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||||
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
|
||||
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
)
|
||||
@ -429,6 +485,76 @@ describe('MessageList nested tool calls', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('adds selected user message text to the composer context', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: 'Please inspect the workspace selection behavior.',
|
||||
timestamp: 1,
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
const userText = screen.getByText('Please inspect the workspace selection behavior.')
|
||||
await selectMessageText(userText, 'workspace selection behavior')
|
||||
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
|
||||
|
||||
expect(floatingAddButton.style.left).toBe('260px')
|
||||
expect(floatingAddButton.style.top).toBe('112px')
|
||||
|
||||
fireEvent.click(floatingAddButton)
|
||||
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]).toMatchObject([
|
||||
{
|
||||
kind: 'chat-selection',
|
||||
path: 'chat://user/user-1',
|
||||
name: 'User message',
|
||||
messageId: 'user-1',
|
||||
sourceRole: 'user',
|
||||
quote: 'workspace selection behavior',
|
||||
},
|
||||
])
|
||||
expect(window.getSelection()?.toString()).toBe('')
|
||||
})
|
||||
|
||||
it('adds selected assistant reply text to the composer context', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'First inspect the file tree. Then quote the selected lines.',
|
||||
timestamp: 1,
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
const assistantText = screen.getByText(/First inspect the file tree/)
|
||||
await selectMessageText(assistantText, 'quote the selected lines')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to chat' }))
|
||||
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]).toMatchObject([
|
||||
{
|
||||
kind: 'chat-selection',
|
||||
path: 'chat://assistant/assistant-1',
|
||||
name: 'Assistant message',
|
||||
messageId: 'assistant-1',
|
||||
sourceRole: 'assistant',
|
||||
quote: 'quote the selected lines',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not force-scroll to the bottom while the user is reading history', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect } from 'react'
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
@ -50,6 +51,157 @@ type TurnChangeCardModel = {
|
||||
isLatest: boolean
|
||||
}
|
||||
|
||||
type ChatMessageRole = 'user' | 'assistant'
|
||||
|
||||
type ChatSelectionState = {
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const CHAT_SELECTION_MENU_OFFSET = 8
|
||||
|
||||
function clampValue(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
function getElementForNode(node: Node | null): Element | null {
|
||||
if (!node) return null
|
||||
return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement
|
||||
}
|
||||
|
||||
function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { clientX: number; clientY: number }) {
|
||||
const rect = typeof range.getBoundingClientRect === 'function'
|
||||
? range.getBoundingClientRect()
|
||||
: null
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const pointerInsideRoot =
|
||||
pointer.clientX >= rootRect.left &&
|
||||
pointer.clientX <= rootRect.right &&
|
||||
pointer.clientY >= rootRect.top &&
|
||||
pointer.clientY <= rootRect.bottom
|
||||
const fallbackX = rect && rect.width > 0
|
||||
? rect.left + rect.width / 2
|
||||
: rect?.left ?? rootRect.left + 24
|
||||
const fallbackY = rect
|
||||
? rect.bottom + CHAT_SELECTION_MENU_OFFSET
|
||||
: rootRect.top + 24
|
||||
const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX
|
||||
const unclampedY = pointerInsideRoot ? pointer.clientY + CHAT_SELECTION_MENU_OFFSET : fallbackY
|
||||
const minX = Math.max(12, rootRect.left + 8)
|
||||
const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136))
|
||||
const minY = Math.max(12, rootRect.top + 8)
|
||||
const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40))
|
||||
|
||||
return {
|
||||
x: clampValue(unclampedX, minX, maxX),
|
||||
y: clampValue(unclampedY, minY, maxY),
|
||||
}
|
||||
}
|
||||
|
||||
function getChatSelectionFromContainer(
|
||||
root: HTMLElement | null,
|
||||
pointer: { clientX: number; clientY: number },
|
||||
): ChatSelectionState | null {
|
||||
if (!root) return null
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const startElement = getElementForNode(range.startContainer)
|
||||
const endElement = getElementForNode(range.endContainer)
|
||||
if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = selection.toString().trim()
|
||||
if (!text) return null
|
||||
|
||||
return {
|
||||
...getChatSelectionPosition(range, root, pointer),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
function ChatSelectionMenu({
|
||||
selection,
|
||||
onAdd,
|
||||
}: {
|
||||
selection: ChatSelectionState | null
|
||||
onAdd: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
if (!selection) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onAdd}
|
||||
className="fixed z-50 inline-flex h-8 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[12px] font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
style={{ left: selection.x, top: selection.y }}
|
||||
>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">person_add</span>
|
||||
<span>{t('chat.addSelectionToChat')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectableChatMessage({
|
||||
sessionId,
|
||||
messageId,
|
||||
role,
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
sessionId?: string | null
|
||||
messageId: string
|
||||
role: ChatMessageRole
|
||||
content: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
|
||||
const t = useTranslation()
|
||||
const sourceName = role === 'assistant'
|
||||
? t('chat.assistantMessageReference')
|
||||
: t('chat.userMessageReference')
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionMenu(null)
|
||||
}, [content, messageId])
|
||||
|
||||
const addCurrentSelectionToChat = useCallback(() => {
|
||||
if (!sessionId || !selectionMenu) return
|
||||
addReference(sessionId, {
|
||||
kind: 'chat-selection',
|
||||
path: `chat://${role}/${messageId}`,
|
||||
name: sourceName,
|
||||
quote: selectionMenu.text,
|
||||
sourceRole: role,
|
||||
messageId,
|
||||
})
|
||||
setSelectionMenu(null)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
onMouseUp={(event) => {
|
||||
setSelectionMenu(getChatSelectionFromContainer(rootRef.current, event))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setSelectionMenu(null)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function appendChildToolCall(
|
||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||
parentToolUseId: string,
|
||||
@ -671,13 +823,29 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
switch (message.type) {
|
||||
case 'user_text':
|
||||
return (
|
||||
<UserMessage
|
||||
<SelectableChatMessage
|
||||
sessionId={sessionId}
|
||||
messageId={message.id}
|
||||
role="user"
|
||||
content={message.content}
|
||||
attachments={message.attachments}
|
||||
/>
|
||||
>
|
||||
<UserMessage
|
||||
content={message.content}
|
||||
attachments={message.attachments}
|
||||
/>
|
||||
</SelectableChatMessage>
|
||||
)
|
||||
case 'assistant_text':
|
||||
return <AssistantMessage content={message.content} />
|
||||
return (
|
||||
<SelectableChatMessage
|
||||
sessionId={sessionId}
|
||||
messageId={message.id}
|
||||
role="assistant"
|
||||
content={message.content}
|
||||
>
|
||||
<AssistantMessage content={message.content} />
|
||||
</SelectableChatMessage>
|
||||
)
|
||||
case 'thinking':
|
||||
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
|
||||
case 'tool_use':
|
||||
|
||||
@ -98,6 +98,118 @@ async function clickElement(element: Element) {
|
||||
await flushReactWork()
|
||||
}
|
||||
|
||||
function findTextNodeContaining(container: Element, text: string) {
|
||||
const walker = document.createTreeWalker(container, 4)
|
||||
let current = walker.nextNode()
|
||||
while (current) {
|
||||
if (current.textContent?.includes(text)) return current
|
||||
current = walker.nextNode()
|
||||
}
|
||||
throw new Error(`Unable to find text node containing ${text}`)
|
||||
}
|
||||
|
||||
async function selectWorkspaceCodeText(
|
||||
view: Awaited<ReturnType<typeof renderPanel>>,
|
||||
startLine: number,
|
||||
startText: string,
|
||||
endLine: number,
|
||||
endText: string,
|
||||
) {
|
||||
const code = view.getByTestId('workspace-code')
|
||||
const startRow = code.querySelector(`[data-workspace-line-number="${startLine}"]`)
|
||||
const endRow = code.querySelector(`[data-workspace-line-number="${endLine}"]`)
|
||||
if (!startRow || !endRow) throw new Error('Selection rows were not rendered')
|
||||
|
||||
Object.assign(code.parentElement?.parentElement ?? code, {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100,
|
||||
top: 24,
|
||||
right: 520,
|
||||
bottom: 420,
|
||||
width: 420,
|
||||
height: 396,
|
||||
x: 100,
|
||||
y: 24,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
|
||||
const startNode = findTextNodeContaining(startRow, startText)
|
||||
const endNode = findTextNodeContaining(endRow, endText)
|
||||
const startOffset = startNode.textContent?.indexOf(startText) ?? -1
|
||||
const endOffset = (endNode.textContent?.indexOf(endText) ?? -1) + endText.length
|
||||
const range = document.createRange()
|
||||
range.setStart(startNode, startOffset)
|
||||
range.setEnd(endNode, endOffset)
|
||||
Object.assign(range, {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 120,
|
||||
top: 40,
|
||||
right: 240,
|
||||
bottom: 58,
|
||||
width: 120,
|
||||
height: 18,
|
||||
x: 120,
|
||||
y: 40,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseUp(code, { clientX: 180, clientY: 72 })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flushReactWork()
|
||||
}
|
||||
|
||||
async function selectRenderedText(container: Element, text: string, target?: Element) {
|
||||
const textNode = findTextNodeContaining(container, text)
|
||||
const startOffset = textNode.textContent?.indexOf(text) ?? -1
|
||||
const range = document.createRange()
|
||||
range.setStart(textNode, startOffset)
|
||||
range.setEnd(textNode, startOffset + text.length)
|
||||
Object.assign(range, {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 130,
|
||||
top: 60,
|
||||
right: 260,
|
||||
bottom: 78,
|
||||
width: 130,
|
||||
height: 18,
|
||||
x: 130,
|
||||
y: 60,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
Object.assign(target ?? container, {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100,
|
||||
top: 24,
|
||||
right: 520,
|
||||
bottom: 420,
|
||||
width: 420,
|
||||
height: 396,
|
||||
x: 100,
|
||||
y: 24,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseUp(target ?? container, { clientX: 190, clientY: 80 })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flushReactWork()
|
||||
}
|
||||
|
||||
function classNameContains(element: Element | null, needle: string) {
|
||||
let current = element
|
||||
while (current) {
|
||||
@ -1145,6 +1257,184 @@ describe('WorkspacePanel', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('adds selected code from a preview to the chat context without requiring a note', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-code-selection': {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-code-selection': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-code-selection': [{
|
||||
id: 'file:src/App.ts',
|
||||
path: 'src/App.ts',
|
||||
kind: 'file',
|
||||
title: 'App.ts',
|
||||
language: 'text',
|
||||
content: 'const title = "Todo"\nexport default title',
|
||||
state: 'ok',
|
||||
size: 42,
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-code-selection': 'file:src/App.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-code-selection')
|
||||
|
||||
await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 2, 'export default title')
|
||||
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
|
||||
await clickElement(addButtons[addButtons.length - 1]!)
|
||||
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-code-selection']).toMatchObject([
|
||||
{
|
||||
kind: 'code-selection',
|
||||
path: 'src/App.ts',
|
||||
absolutePath: '/repo/src/App.ts',
|
||||
name: 'App.ts',
|
||||
lineStart: 1,
|
||||
lineEnd: 2,
|
||||
quote: 'const title = "Todo"\nexport default title',
|
||||
},
|
||||
])
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-code-selection']?.[0]?.note).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the selected-code action near the preview instead of the file tree', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-selection-position': {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-selection-position': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-selection-position': [{
|
||||
id: 'file:src/App.ts',
|
||||
path: 'src/App.ts',
|
||||
kind: 'file',
|
||||
title: 'App.ts',
|
||||
language: 'text',
|
||||
content: 'const title = "Todo"\nexport default title',
|
||||
state: 'ok',
|
||||
size: 42,
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-selection-position': 'file:src/App.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-selection-position')
|
||||
|
||||
await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 1, 'const title = "Todo"')
|
||||
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
|
||||
const floatingAddButton = addButtons[addButtons.length - 1]!
|
||||
|
||||
expect(floatingAddButton.style.left).toBe('180px')
|
||||
expect(floatingAddButton.style.top).toBe('80px')
|
||||
|
||||
fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), {
|
||||
key: 'Escape',
|
||||
})
|
||||
await flushReactWork()
|
||||
expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adds selected markdown text from a preview to the chat context', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-markdown-selection': {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-markdown-selection': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-markdown-selection': [{
|
||||
id: 'file:docs/guide.md',
|
||||
path: 'docs/guide.md',
|
||||
kind: 'file',
|
||||
title: 'guide.md',
|
||||
language: 'markdown',
|
||||
content: '# Guide\nAlpha note and Beta note',
|
||||
state: 'ok',
|
||||
size: 33,
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-markdown-selection': 'file:docs/guide.md',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-markdown-selection')
|
||||
const paragraph = view.getByText('Alpha note and Beta note')
|
||||
const markdownSurface = paragraph.closest('.min-h-0') ?? paragraph
|
||||
|
||||
await selectRenderedText(paragraph, 'Alpha note and Beta note', markdownSurface)
|
||||
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
|
||||
const floatingAddButton = addButtons[addButtons.length - 1]!
|
||||
await clickElement(floatingAddButton)
|
||||
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-markdown-selection']).toMatchObject([
|
||||
{
|
||||
kind: 'code-selection',
|
||||
path: 'docs/guide.md',
|
||||
absolutePath: '/repo/docs/guide.md',
|
||||
name: 'guide.md',
|
||||
lineStart: 2,
|
||||
lineEnd: 2,
|
||||
quote: 'Alpha note and Beta note',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the localized view menu label', async () => {
|
||||
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
|
||||
await setWorkspaceState((state) => ({
|
||||
|
||||
@ -82,6 +82,7 @@ const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className:
|
||||
const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {}
|
||||
const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = []
|
||||
const EMPTY_EXPANDED_PATHS: string[] = []
|
||||
const SELECTION_MENU_OFFSET = 8
|
||||
const FILE_BADGE_META: Record<string, { label: string; className: string }> = {
|
||||
ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||
tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||
@ -207,6 +208,138 @@ function treeEntryMatchesFilter(
|
||||
return childTree.entries.some((child) => treeEntryMatchesFilter(child, query, treeByPath))
|
||||
}
|
||||
|
||||
type WorkspaceTextSelection = {
|
||||
text: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
}
|
||||
|
||||
type FloatingSelectionMenuState = WorkspaceTextSelection & {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type SelectionPointer = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
|
||||
function getElementForNode(node: Node | null): Element | null {
|
||||
if (!node) return null
|
||||
return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement
|
||||
}
|
||||
|
||||
function getLineNumberFromNode(node: Node | null, root: HTMLElement) {
|
||||
const element = getElementForNode(node)
|
||||
const row = element?.closest('[data-workspace-line-number]')
|
||||
if (!row || !root.contains(row)) return undefined
|
||||
const line = Number(row.getAttribute('data-workspace-line-number'))
|
||||
return Number.isFinite(line) ? line : undefined
|
||||
}
|
||||
|
||||
function clampValue(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) {
|
||||
const rect = typeof range.getBoundingClientRect === 'function'
|
||||
? range.getBoundingClientRect()
|
||||
: null
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const pointerInsideRoot = pointer
|
||||
&& pointer.clientX >= rootRect.left
|
||||
&& pointer.clientX <= rootRect.right
|
||||
&& pointer.clientY >= rootRect.top
|
||||
&& pointer.clientY <= rootRect.bottom
|
||||
const fallbackX = rect && rect.width > 0
|
||||
? rect.left + rect.width / 2
|
||||
: rect?.left ?? rootRect.left + 24
|
||||
const fallbackY = rect
|
||||
? rect.bottom + SELECTION_MENU_OFFSET
|
||||
: rootRect.top + 24
|
||||
const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX
|
||||
const unclampedY = pointerInsideRoot ? pointer.clientY + SELECTION_MENU_OFFSET : fallbackY
|
||||
const minX = Math.max(12, rootRect.left + 8)
|
||||
const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136))
|
||||
const minY = Math.max(12, rootRect.top + 8)
|
||||
const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40))
|
||||
|
||||
return {
|
||||
x: clampValue(unclampedX, minX, maxX),
|
||||
y: clampValue(unclampedY, minY, maxY),
|
||||
}
|
||||
}
|
||||
|
||||
function getTextSelectionFromContainer(
|
||||
root: HTMLElement | null,
|
||||
resolveLines?: (text: string, range: Range) => { startLine?: number; endLine?: number },
|
||||
pointer?: SelectionPointer,
|
||||
): FloatingSelectionMenuState | null {
|
||||
if (!root) return null
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const startElement = getElementForNode(range.startContainer)
|
||||
const endElement = getElementForNode(range.endContainer)
|
||||
if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = selection.toString().trim()
|
||||
if (!text) return null
|
||||
|
||||
const nodeLines = {
|
||||
startLine: getLineNumberFromNode(range.startContainer, root),
|
||||
endLine: getLineNumberFromNode(range.endContainer, root),
|
||||
}
|
||||
const resolvedLines = resolveLines?.(text, range) ?? nodeLines
|
||||
const startLine = resolvedLines.startLine ?? nodeLines.startLine
|
||||
const endLine = resolvedLines.endLine ?? nodeLines.endLine ?? startLine
|
||||
const orderedStart = startLine && endLine ? Math.min(startLine, endLine) : startLine
|
||||
const orderedEnd = startLine && endLine ? Math.max(startLine, endLine) : endLine
|
||||
|
||||
return {
|
||||
...getSelectionPosition(range, root, pointer),
|
||||
text,
|
||||
...(orderedStart ? { startLine: orderedStart } : {}),
|
||||
...(orderedEnd ? { endLine: orderedEnd } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function getLineRangeForText(value: string, text: string) {
|
||||
const index = value.indexOf(text)
|
||||
if (index < 0) return {}
|
||||
const startLine = value.slice(0, index).split('\n').length
|
||||
const endLine = startLine + text.split('\n').length - 1
|
||||
return { startLine, endLine }
|
||||
}
|
||||
|
||||
function FloatingSelectionMenu({
|
||||
selection,
|
||||
onAdd,
|
||||
}: {
|
||||
selection: FloatingSelectionMenuState | null
|
||||
onAdd: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
if (!selection) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onAdd}
|
||||
className="fixed z-50 inline-flex h-8 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[12px] font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
style={{ left: selection.x, top: selection.y }}
|
||||
>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">person_add</span>
|
||||
<span>{t('workspace.addSelectionToChat')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelMessage({
|
||||
icon,
|
||||
message,
|
||||
@ -308,15 +441,19 @@ function CodeSurface({
|
||||
value,
|
||||
language,
|
||||
onAddLineComment,
|
||||
onAddSelection,
|
||||
}: {
|
||||
value: string
|
||||
language: string
|
||||
onAddLineComment: (line: number, note: string, quote: string) => void
|
||||
onAddSelection: (selection: WorkspaceTextSelection) => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const surfaceRef = useRef<HTMLDivElement>(null)
|
||||
const [commentLine, setCommentLine] = useState<number | null>(null)
|
||||
const [commentDraft, setCommentDraft] = useState('')
|
||||
const [showAllLines, setShowAllLines] = useState(false)
|
||||
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
|
||||
const lines = value.split('\n')
|
||||
const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
|
||||
const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : ''
|
||||
@ -327,6 +464,7 @@ function CodeSurface({
|
||||
setShowAllLines(false)
|
||||
setCommentLine(null)
|
||||
setCommentDraft('')
|
||||
setSelectionMenu(null)
|
||||
}, [language, value])
|
||||
|
||||
const submitLineComment = () => {
|
||||
@ -336,6 +474,30 @@ function CodeSurface({
|
||||
setCommentDraft('')
|
||||
}
|
||||
|
||||
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const selection = getTextSelectionFromContainer(surfaceRef.current, undefined, event)
|
||||
if (!selection?.startLine || !selection.endLine || selection.startLine === selection.endLine) {
|
||||
setSelectionMenu(selection)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectionMenu({
|
||||
...selection,
|
||||
text: visibleLines.slice(selection.startLine - 1, selection.endLine).join('\n').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
const addCurrentSelectionToChat = () => {
|
||||
if (!selectionMenu) return
|
||||
onAddSelection({
|
||||
text: selectionMenu.text,
|
||||
startLine: selectionMenu.startLine,
|
||||
endLine: selectionMenu.endLine,
|
||||
})
|
||||
setSelectionMenu(null)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
const renderLineCommentEditor = (lineNumber: number) => {
|
||||
if (commentLine !== lineNumber) return null
|
||||
|
||||
@ -398,7 +560,14 @@ function CodeSurface({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]">
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className="min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]"
|
||||
onMouseUp={handleSelectionMouseUp}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setSelectionMenu(null)
|
||||
}}
|
||||
>
|
||||
<div className="relative min-w-max py-2">
|
||||
{usePlainLargePreview ? (
|
||||
<pre
|
||||
@ -411,7 +580,10 @@ function CodeSurface({
|
||||
const lineNumber = index + 1
|
||||
return (
|
||||
<div key={lineNumber}>
|
||||
<div className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]">
|
||||
<div
|
||||
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
|
||||
data-workspace-line-number={lineNumber}
|
||||
>
|
||||
{renderLineNumberButton(lineNumber)}
|
||||
<span className="whitespace-pre pr-6">{line || ' '}</span>
|
||||
</div>
|
||||
@ -440,6 +612,7 @@ function CodeSurface({
|
||||
<div key={String(lineKey)}>
|
||||
<div
|
||||
{...lineProps}
|
||||
data-workspace-line-number={lineNumber}
|
||||
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{renderLineNumberButton(lineNumber)}
|
||||
@ -475,13 +648,53 @@ function CodeSurface({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownSurface({ value }: { value: string }) {
|
||||
function MarkdownSurface({
|
||||
value,
|
||||
onAddSelection,
|
||||
}: {
|
||||
value: string
|
||||
onAddSelection: (selection: WorkspaceTextSelection) => void
|
||||
}) {
|
||||
const surfaceRef = useRef<HTMLDivElement>(null)
|
||||
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionMenu(null)
|
||||
}, [value])
|
||||
|
||||
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
|
||||
setSelectionMenu(getTextSelectionFromContainer(
|
||||
surfaceRef.current,
|
||||
(text) => getLineRangeForText(value, text),
|
||||
event,
|
||||
))
|
||||
}
|
||||
|
||||
const addCurrentSelectionToChat = () => {
|
||||
if (!selectionMenu) return
|
||||
onAddSelection({
|
||||
text: selectionMenu.text,
|
||||
startLine: selectionMenu.startLine,
|
||||
endLine: selectionMenu.endLine,
|
||||
})
|
||||
setSelectionMenu(null)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)]">
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)]"
|
||||
onMouseUp={handleSelectionMouseUp}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setSelectionMenu(null)
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[860px] px-6 py-5">
|
||||
<MarkdownRenderer
|
||||
content={value}
|
||||
@ -489,6 +702,7 @@ function MarkdownSurface({ value }: { value: string }) {
|
||||
className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4"
|
||||
/>
|
||||
</div>
|
||||
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -815,6 +1029,18 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const addSelectionToChat = (path: string, selection: WorkspaceTextSelection) => {
|
||||
addWorkspaceReference(sessionId, {
|
||||
kind: 'code-selection',
|
||||
path,
|
||||
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
|
||||
name: path.split('/').pop() || path,
|
||||
lineStart: selection.startLine,
|
||||
lineEnd: selection.endLine,
|
||||
quote: selection.text,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetActiveView = (view: 'changed' | 'all') => {
|
||||
setActiveView(sessionId, view)
|
||||
setIsViewMenuOpen(false)
|
||||
@ -996,12 +1222,16 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
path={activePreviewTab.path}
|
||||
/>
|
||||
) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? (
|
||||
<MarkdownSurface value={activePreviewTab.content ?? ''} />
|
||||
<MarkdownSurface
|
||||
value={activePreviewTab.content ?? ''}
|
||||
onAddSelection={(selection) => addSelectionToChat(activePreviewTab.path, selection)}
|
||||
/>
|
||||
) : state === 'ok' ? (
|
||||
<CodeSurface
|
||||
value={activePreviewTab.content ?? ''}
|
||||
language={activePreviewTab.language ?? 'text'}
|
||||
onAddLineComment={(line, note, quote) => addLineCommentToChat(activePreviewTab.path, line, note, quote)}
|
||||
onAddSelection={(selection) => addSelectionToChat(activePreviewTab.path, selection)}
|
||||
/>
|
||||
) : (
|
||||
<PanelMessage
|
||||
|
||||
@ -85,6 +85,7 @@ export const en = {
|
||||
'workspace.showAllLoadedLines': 'Show all loaded lines',
|
||||
'workspace.collapsePreview': 'Collapse preview',
|
||||
'workspace.addToChat': 'Add to chat',
|
||||
'workspace.addSelectionToChat': 'Add to chat',
|
||||
'workspace.copyPath': 'Copy path',
|
||||
'workspace.localComment': 'Local comment',
|
||||
'workspace.commentLine': 'Comment line {line}',
|
||||
@ -806,6 +807,10 @@ export const en = {
|
||||
'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.',
|
||||
'chat.addFiles': 'Add files or photos',
|
||||
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
|
||||
'chat.contextReferencesOnly': 'Added {count} references',
|
||||
'chat.addSelectionToChat': 'Add to chat',
|
||||
'chat.userMessageReference': 'User message',
|
||||
'chat.assistantMessageReference': 'Assistant message',
|
||||
'chat.slashCommands': 'Slash commands',
|
||||
'slash.mcp.title': 'Available MCP tools',
|
||||
'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.',
|
||||
|
||||
@ -87,6 +87,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.showAllLoadedLines': '显示全部已加载行',
|
||||
'workspace.collapsePreview': '收起预览',
|
||||
'workspace.addToChat': '添加到聊天',
|
||||
'workspace.addSelectionToChat': '添加至对话',
|
||||
'workspace.copyPath': '复制路径',
|
||||
'workspace.localComment': '本地评论',
|
||||
'workspace.commentLine': '评论第 {line} 行',
|
||||
@ -808,6 +809,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。',
|
||||
'chat.addFiles': '添加文件或图片',
|
||||
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
||||
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
||||
'chat.addSelectionToChat': '添加至对话',
|
||||
'chat.userMessageReference': '用户消息',
|
||||
'chat.assistantMessageReference': 'AI 回复',
|
||||
'chat.slashCommands': '斜杠命令',
|
||||
'slash.mcp.title': '可用 MCP 工具',
|
||||
'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。',
|
||||
|
||||
@ -449,7 +449,7 @@ describe('chatStore history mapping', () => {
|
||||
|
||||
useChatStore.getState().sendMessage(
|
||||
TEST_SESSION_ID,
|
||||
'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
'Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```',
|
||||
[{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
@ -477,7 +477,7 @@ describe('chatStore history mapping', () => {
|
||||
{
|
||||
type: 'user_text',
|
||||
content: '改这里',
|
||||
modelContent: '@"/repo/src/App.tsx" Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
modelContent: '@"/repo/src/App.tsx" Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
@ -493,7 +493,7 @@ describe('chatStore history mapping', () => {
|
||||
TEST_SESSION_ID,
|
||||
{
|
||||
type: 'user_message',
|
||||
content: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
content: 'Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
|
||||
@ -45,14 +45,54 @@ describe('workspaceChatContextStore', () => {
|
||||
},
|
||||
])
|
||||
|
||||
expect(prompt).toContain('Notes for attached workspace files:')
|
||||
expect(prompt).toContain('- src/App.tsx:L12')
|
||||
expect(prompt).toContain('Referenced workspace context:')
|
||||
expect(prompt).toContain('@"src/App.tsx:L12":')
|
||||
expect(prompt).toContain('Comment: Use a clearer name')
|
||||
expect(prompt).toContain('Selected code: const value = 1')
|
||||
expect(prompt).toContain('```tsx\nconst value = 1\n```')
|
||||
expect(prompt).not.toContain('Use the Read tool')
|
||||
expect(prompt).not.toContain('Path: /repo/src/App.tsx')
|
||||
})
|
||||
|
||||
it('formats selected code without requiring a comment', () => {
|
||||
const prompt = formatWorkspaceReferencePrompt([
|
||||
{
|
||||
id: 'ref-1',
|
||||
kind: 'code-selection',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
lineStart: 10,
|
||||
lineEnd: 12,
|
||||
quote: 'const value = 1\nreturn value',
|
||||
},
|
||||
])
|
||||
|
||||
expect(prompt).toContain('Referenced workspace context:')
|
||||
expect(prompt).toContain('@"src/App.tsx:L10-L12":')
|
||||
expect(prompt).toContain('```tsx\nconst value = 1\nreturn value\n```')
|
||||
expect(prompt).not.toContain('Comment:')
|
||||
})
|
||||
|
||||
it('formats selected chat messages as chat context instead of file context', () => {
|
||||
const prompt = formatWorkspaceReferencePrompt([
|
||||
{
|
||||
id: 'chat-ref-1',
|
||||
kind: 'chat-selection',
|
||||
path: 'chat://assistant/assistant-1',
|
||||
name: 'Assistant message',
|
||||
messageId: 'assistant-1',
|
||||
sourceRole: 'assistant',
|
||||
quote: 'Use the workspace panel selection menu.',
|
||||
},
|
||||
])
|
||||
|
||||
expect(prompt).toContain('Referenced chat context:')
|
||||
expect(prompt).toContain('Assistant message:')
|
||||
expect(prompt).toContain('```\nUse the workspace panel selection menu.\n```')
|
||||
expect(prompt).not.toContain('@"chat://assistant/assistant-1"')
|
||||
expect(prompt).not.toContain('Referenced workspace context:')
|
||||
})
|
||||
|
||||
it('does not add prompt text for plain file attachments', () => {
|
||||
const prompt = formatWorkspaceReferencePrompt([
|
||||
{
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type WorkspaceChatReferenceKind = 'file' | 'code-comment'
|
||||
export type WorkspaceChatReferenceKind = 'file' | 'code-comment' | 'code-selection' | 'chat-selection'
|
||||
|
||||
export type WorkspaceChatReference = {
|
||||
id: string
|
||||
@ -13,6 +13,8 @@ export type WorkspaceChatReference = {
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
sourceRole?: 'user' | 'assistant'
|
||||
messageId?: string
|
||||
}
|
||||
|
||||
type WorkspaceChatContextStore = {
|
||||
@ -29,17 +31,29 @@ type WorkspaceChatContextStore = {
|
||||
function makeReferenceId(reference: Omit<WorkspaceChatReference, 'id'>) {
|
||||
const linePart = reference.lineStart
|
||||
? `${reference.lineStart}-${reference.lineEnd ?? reference.lineStart}`
|
||||
: 'file'
|
||||
const notePart = reference.note ? reference.note.slice(0, 48) : ''
|
||||
: reference.messageId ?? 'file'
|
||||
const notePart = (reference.note?.trim() || reference.quote?.trim() || '').slice(0, 48)
|
||||
return `${reference.kind}:${reference.path}:${linePart}:${notePart}`
|
||||
}
|
||||
|
||||
function getReferenceDedupKey(reference: WorkspaceChatReference) {
|
||||
if (reference.kind === 'file') return `${reference.kind}:${reference.path}`
|
||||
return `${reference.kind}:${reference.path}:${reference.lineStart ?? ''}:${reference.lineEnd ?? ''}:${reference.note ?? ''}`
|
||||
return [
|
||||
reference.kind,
|
||||
reference.path,
|
||||
reference.messageId ?? '',
|
||||
reference.sourceRole ?? '',
|
||||
reference.lineStart ?? '',
|
||||
reference.lineEnd ?? '',
|
||||
reference.note?.trim() ?? '',
|
||||
reference.quote?.trim() ?? '',
|
||||
].join(':')
|
||||
}
|
||||
|
||||
export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReference) {
|
||||
if (reference.kind === 'chat-selection') {
|
||||
return reference.sourceRole === 'assistant' ? 'Assistant message' : 'User message'
|
||||
}
|
||||
if (!reference.lineStart) return reference.path
|
||||
const lineEnd = reference.lineEnd && reference.lineEnd !== reference.lineStart
|
||||
? `-L${reference.lineEnd}`
|
||||
@ -47,27 +61,79 @@ export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReferen
|
||||
return `${reference.path}:L${reference.lineStart}${lineEnd}`
|
||||
}
|
||||
|
||||
function getFenceForQuote(quote: string) {
|
||||
const runs = quote.match(/`+/g) ?? []
|
||||
const longestRun = runs.reduce((max, run) => Math.max(max, run.length), 0)
|
||||
return '`'.repeat(Math.max(3, longestRun + 1))
|
||||
}
|
||||
|
||||
function getLanguageHint(reference: WorkspaceChatReference) {
|
||||
if (reference.kind === 'chat-selection') return ''
|
||||
const extension = reference.name.split('.').pop()?.toLowerCase()
|
||||
if (!extension || extension === reference.name.toLowerCase()) return ''
|
||||
const aliases: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
md: 'markdown',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
rs: 'rust',
|
||||
sh: 'bash',
|
||||
yml: 'yaml',
|
||||
}
|
||||
return aliases[extension] ?? extension
|
||||
}
|
||||
|
||||
export function formatWorkspaceReferencePrompt(references: WorkspaceChatReference[]) {
|
||||
const referencesWithContext = references.filter((reference) =>
|
||||
const workspaceReferencesWithContext = references.filter((reference) =>
|
||||
reference.kind === 'code-comment' ||
|
||||
reference.kind === 'code-selection' ||
|
||||
!!reference.lineStart ||
|
||||
!!reference.note?.trim() ||
|
||||
!!reference.quote?.trim(),
|
||||
).filter((reference) => reference.kind !== 'chat-selection')
|
||||
const chatReferencesWithContext = references.filter((reference) =>
|
||||
reference.kind === 'chat-selection' && !!reference.quote?.trim(),
|
||||
)
|
||||
if (referencesWithContext.length === 0) return ''
|
||||
if (workspaceReferencesWithContext.length === 0 && chatReferencesWithContext.length === 0) return ''
|
||||
|
||||
const lines = [
|
||||
'Notes for attached workspace files:',
|
||||
...referencesWithContext.map((reference) => {
|
||||
const workspaceLines = workspaceReferencesWithContext.length > 0
|
||||
? [
|
||||
'Referenced workspace context:',
|
||||
...workspaceReferencesWithContext.map((reference) => {
|
||||
const location = formatWorkspaceReferenceLocation(reference)
|
||||
const parts = [`@"${location}":`]
|
||||
if (reference.note?.trim()) parts.push(`Comment: ${reference.note.trim()}`)
|
||||
if (reference.quote?.trim()) {
|
||||
const fence = getFenceForQuote(reference.quote)
|
||||
const languageHint = getLanguageHint(reference)
|
||||
parts.push(`${fence}${languageHint}`)
|
||||
parts.push(reference.quote.trim())
|
||||
parts.push(fence)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}),
|
||||
]
|
||||
: []
|
||||
const chatLines = chatReferencesWithContext.length > 0
|
||||
? [
|
||||
'Referenced chat context:',
|
||||
...chatReferencesWithContext.map((reference) => {
|
||||
const location = formatWorkspaceReferenceLocation(reference)
|
||||
const parts = [`- ${location}`]
|
||||
if (reference.note?.trim()) parts.push(`Comment: ${reference.note.trim()}`)
|
||||
if (reference.quote?.trim()) parts.push(`Selected code: ${reference.quote.trim()}`)
|
||||
return parts.join('\n ')
|
||||
}),
|
||||
]
|
||||
const fence = getFenceForQuote(reference.quote ?? '')
|
||||
return [
|
||||
`${location}:`,
|
||||
fence,
|
||||
reference.quote?.trim() ?? '',
|
||||
fence,
|
||||
].join('\n')
|
||||
}),
|
||||
]
|
||||
: []
|
||||
|
||||
return lines.join('\n')
|
||||
return [...workspaceLines, ...chatLines].join('\n')
|
||||
}
|
||||
|
||||
export const useWorkspaceChatContextStore = create<WorkspaceChatContextStore>((set) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user