Merge selected chat context references

This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 14:08:24 +08:00
commit 523db62b3b
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

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

View File

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

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