feat: make selected context reusable in desktop chat

Workspace and transcript selections now flow into the composer as lightweight references so users can carry exact snippets into the next prompt without file-only workarounds. The prompt formatter keeps workspace and chat excerpts separate, and the composer avoids sending chat snippets as fake file attachments.

Constraint: Selection references must prepare model context without polluting backend file attachment payloads.
Rejected: Reuse file attachments for chat selections | would leak chat:// pseudo paths into file-oriented payloads.
Confidence: high
Scope-risk: moderate
Directive: Keep chat-selection references prompt-only unless the backend gains a first-class chat context attachment type.
Tested: bun run check:desktop
Tested: bun run verify
Tested: agent-browser UI smoke for workspace, user-message, and assistant-message selections
Not-tested: Strict Chrome extension channel; connection timed out, local browser automation covered the flow.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 14:08:12 +08:00
parent 08747bfdd2
commit 52b11697e1
12 changed files with 1066 additions and 55 deletions

View 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')
})
})

View File

@ -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>

View File

@ -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,

View File

@ -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', {

View File

@ -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':

View File

@ -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) => ({

View File

@ -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

View File

@ -84,6 +84,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}',
@ -805,6 +806,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.',

View File

@ -86,6 +86,7 @@ export const zh: Record<TranslationKey, string> = {
'workspace.showAllLoadedLines': '显示全部已加载行',
'workspace.collapsePreview': '收起预览',
'workspace.addToChat': '添加到聊天',
'workspace.addSelectionToChat': '添加至对话',
'workspace.copyPath': '复制路径',
'workspace.localComment': '本地评论',
'workspace.commentLine': '评论第 {line} 行',
@ -807,6 +808,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。',

View File

@ -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',

View File

@ -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([
{

View File

@ -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) => ({