cc-haha/desktop/src/stores/workspaceChatContextStore.ts
程序员阿江(Relakkes) 52b11697e1 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.
2026-05-13 14:08:12 +08:00

186 lines
6.0 KiB
TypeScript

import { create } from 'zustand'
export type WorkspaceChatReferenceKind = 'file' | 'code-comment' | 'code-selection' | 'chat-selection'
export type WorkspaceChatReference = {
id: string
kind: WorkspaceChatReferenceKind
path: string
absolutePath?: string
name: string
isDirectory?: boolean
lineStart?: number
lineEnd?: number
note?: string
quote?: string
sourceRole?: 'user' | 'assistant'
messageId?: string
}
type WorkspaceChatContextStore = {
referencesBySession: Record<string, WorkspaceChatReference[] | undefined>
addReference: (
sessionId: string,
reference: Omit<WorkspaceChatReference, 'id'> & { id?: string },
) => void
removeReference: (sessionId: string, referenceId: string) => void
clearReferences: (sessionId: string) => void
clearSession: (sessionId: string) => void
}
function makeReferenceId(reference: Omit<WorkspaceChatReference, 'id'>) {
const linePart = reference.lineStart
? `${reference.lineStart}-${reference.lineEnd ?? reference.lineStart}`
: 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.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}`
: ''
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 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 (workspaceReferencesWithContext.length === 0 && chatReferencesWithContext.length === 0) return ''
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 fence = getFenceForQuote(reference.quote ?? '')
return [
`${location}:`,
fence,
reference.quote?.trim() ?? '',
fence,
].join('\n')
}),
]
: []
return [...workspaceLines, ...chatLines].join('\n')
}
export const useWorkspaceChatContextStore = create<WorkspaceChatContextStore>((set) => ({
referencesBySession: {},
addReference: (sessionId, input) =>
set((state) => {
const reference: WorkspaceChatReference = {
...input,
id: input.id ?? makeReferenceId(input),
}
const existing = state.referencesBySession[sessionId] ?? []
const nextKey = getReferenceDedupKey(reference)
const withoutDuplicate = existing.filter((item) => getReferenceDedupKey(item) !== nextKey)
return {
referencesBySession: {
...state.referencesBySession,
[sessionId]: [...withoutDuplicate, reference],
},
}
}),
removeReference: (sessionId, referenceId) =>
set((state) => {
const existing = state.referencesBySession[sessionId] ?? []
return {
referencesBySession: {
...state.referencesBySession,
[sessionId]: existing.filter((reference) => reference.id !== referenceId),
},
}
}),
clearReferences: (sessionId) =>
set((state) => ({
referencesBySession: {
...state.referencesBySession,
[sessionId]: [],
},
})),
clearSession: (sessionId) =>
set((state) => {
if (!(sessionId in state.referencesBySession)) return state
const { [sessionId]: _removed, ...rest } = state.referencesBySession
return { referencesBySession: rest }
}),
}))