mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
The workspace panel already had the attachment model for both files and directories, but directory rows lacked the same right-click path and there was no low-risk handoff from the panel into the active composer text. This keeps context attachment as the stable source of file content while adding a small composer insertion event for inline citations. Constraint: Keep the existing attachment pipeline unchanged so file and directory contents still flow through the proven workspace reference path Rejected: Build a rich inline chip editor immediately | larger composer and history-synchronization surface than these issues require Confidence: high Scope-risk: moderate Directive: Do not replace workspace attachments with inline text-only mentions without rechecking model-content generation and history restore behavior Tested: cd desktop && bunx vitest run src/components/workspace/WorkspacePanel.test.tsx Tested: cd desktop && bunx vitest run src/components/chat/ChatInput.test.tsx Tested: bun run check:desktop Not-tested: Live provider response quality with inline citation wording
This commit is contained in:
parent
82a47d051a
commit
1b2bded1a2
@ -594,6 +594,69 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('inserts queued inline workspace citations at the current cursor and keeps file context attached', async () => {
|
||||
render(<ChatInput compact />)
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
value: '请看实现',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 2,
|
||||
},
|
||||
})
|
||||
input.setSelectionRange(2, 2)
|
||||
|
||||
act(() => {
|
||||
useChatStore.getState().queueComposerInsertion(sessionId, {
|
||||
text: '@"src/App.tsx"',
|
||||
reference: {
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.value).toBe('请看 @"src/App.tsx" 实现')
|
||||
})
|
||||
expect(screen.getByText('App.tsx')).toBeInTheDocument()
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession[sessionId]).toMatchObject([
|
||||
{
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
},
|
||||
])
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||
type: 'user_message',
|
||||
content: '请看 @"src/App.tsx" 实现',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: '/repo/src/App.tsx',
|
||||
isDirectory: undefined,
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
note: undefined,
|
||||
quote: undefined,
|
||||
}],
|
||||
})
|
||||
const messages = useChatStore.getState().sessions[sessionId]?.messages ?? []
|
||||
expect(messages[messages.length - 1]).toMatchObject({
|
||||
type: 'user_text',
|
||||
content: '请看 @"src/App.tsx" 实现',
|
||||
modelContent: '@"/repo/src/App.tsx" 请看 @"src/App.tsx" 实现',
|
||||
attachments: [{ name: 'App.tsx', path: 'src/App.tsx' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('turns a selected @ directory into a workspace chip and model path reference', async () => {
|
||||
mocks.search.mockResolvedValueOnce({
|
||||
currentPath: '/repo',
|
||||
|
||||
@ -65,6 +65,21 @@ function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Atta
|
||||
}
|
||||
}
|
||||
|
||||
function insertComposerTokenAtRange(value: string, start: number, end: number, token: string) {
|
||||
const boundedStart = Math.max(0, Math.min(start, value.length))
|
||||
const boundedEnd = Math.max(boundedStart, Math.min(end, value.length))
|
||||
const before = value.slice(0, boundedStart)
|
||||
const after = value.slice(boundedEnd)
|
||||
const leadingSpace = before.length > 0 && !/\s$/.test(before) ? ' ' : ''
|
||||
const trailingSpace = after.length > 0 && !/^\s/.test(after) ? ' ' : ''
|
||||
const insertion = `${leadingSpace}${token}${trailingSpace}`
|
||||
|
||||
return {
|
||||
value: `${before}${insertion}${after}`,
|
||||
cursorPos: before.length + insertion.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) {
|
||||
const t = useTranslation()
|
||||
const isMobileComposer = useMobileViewport() && !isTauriRuntime()
|
||||
@ -105,12 +120,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const { sendMessage, stopGeneration } = useChatStore()
|
||||
const { sendMessage, stopGeneration, clearComposerInsertion } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const slashCommands = sessionState?.slashCommands ?? []
|
||||
const composerPrefill = sessionState?.composerPrefill ?? null
|
||||
const composerInsertion = sessionState?.composerInsertion ?? null
|
||||
const runtimeSelection = useSessionRuntimeStore((state) =>
|
||||
activeTabId ? state.selections[activeTabId] : undefined,
|
||||
)
|
||||
@ -242,6 +258,38 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
})
|
||||
}, [composerPrefill, setComposerAttachments, setComposerInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (!composerInsertion || !activeTabId || isMemberSession) return
|
||||
|
||||
const el = textareaRef.current
|
||||
const currentInput = inputRef.current
|
||||
const start = el?.selectionStart ?? currentInput.length
|
||||
const end = el?.selectionEnd ?? start
|
||||
const next = insertComposerTokenAtRange(currentInput, start, end, composerInsertion.text)
|
||||
|
||||
if (composerInsertion.reference) {
|
||||
addWorkspaceReference(activeTabId, composerInsertion.reference)
|
||||
}
|
||||
setComposerInput(next.value)
|
||||
setFileSearchOpen(false)
|
||||
setSlashMenuOpen(false)
|
||||
setAtFilter('')
|
||||
setAtCursorPos(-1)
|
||||
clearComposerInsertion(activeTabId, composerInsertion.nonce)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
textareaRef.current?.setSelectionRange(next.cursorPos, next.cursorPos)
|
||||
})
|
||||
}, [
|
||||
activeTabId,
|
||||
addWorkspaceReference,
|
||||
clearComposerInsertion,
|
||||
composerInsertion,
|
||||
isMemberSession,
|
||||
setComposerInput,
|
||||
])
|
||||
|
||||
const refreshGitInfo = useCallback(() => {
|
||||
if (!activeTabId) {
|
||||
setGitInfo(null)
|
||||
|
||||
@ -1195,6 +1195,139 @@ describe('WorkspacePanel', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('adds a workspace directory to the chat context from the file tree menu', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-add-directory': {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-add-directory': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
treeBySessionPath: {
|
||||
...state.treeBySessionPath,
|
||||
'session-add-directory': {
|
||||
'': {
|
||||
state: 'ok',
|
||||
path: '',
|
||||
entries: [{ name: 'src', path: 'src', isDirectory: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-add-directory')
|
||||
|
||||
await act(() => {
|
||||
fireEvent.contextMenu(view.getByRole('button', { name: /src/i }), {
|
||||
clientX: 260,
|
||||
clientY: 80,
|
||||
})
|
||||
})
|
||||
|
||||
await clickElement(view.getByRole('menuitem', { name: 'Add to chat' }))
|
||||
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-add-directory']).toMatchObject([
|
||||
{
|
||||
kind: 'file',
|
||||
path: 'src',
|
||||
absolutePath: '/repo/src',
|
||||
name: 'src/',
|
||||
isDirectory: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('queues an inline workspace citation from the file tree menu', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'session-cite-file': {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-cite-file': {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-cite-file': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
treeBySessionPath: {
|
||||
...state.treeBySessionPath,
|
||||
'session-cite-file': {
|
||||
'': {
|
||||
state: 'ok',
|
||||
path: '',
|
||||
entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-cite-file')
|
||||
|
||||
await act(() => {
|
||||
fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), {
|
||||
clientX: 260,
|
||||
clientY: 80,
|
||||
})
|
||||
})
|
||||
|
||||
await clickElement(view.getByRole('menuitem', { name: 'Cite in message' }))
|
||||
|
||||
expect(useChatStore.getState().sessions['session-cite-file']?.composerInsertion).toMatchObject({
|
||||
text: '@"src/App.tsx"',
|
||||
reference: {
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
isDirectory: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('copies file paths from the file tree menu with the legacy clipboard fallback', async () => {
|
||||
const originalClipboard = navigator.clipboard
|
||||
const originalExecCommand = document.execCommand
|
||||
|
||||
@ -44,10 +44,17 @@ type TreeNodeProps = {
|
||||
filterQuery: string
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
onFileContextMenu: (event: MouseEvent, path: string) => void
|
||||
onFileContextMenu: (event: MouseEvent, path: string, isDirectory: boolean) => void
|
||||
activePath: string | null
|
||||
}
|
||||
|
||||
type FileContextMenuState = {
|
||||
path: string
|
||||
isDirectory: boolean
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className: string }> = {
|
||||
modified: {
|
||||
label: 'M',
|
||||
@ -143,6 +150,15 @@ function resolveWorkspaceAttachmentPath(workDir: string | undefined, filePath: s
|
||||
return `${workDir.replace(/[\\/]+$/, '')}/${filePath.replace(/^[/\\]+/, '')}`
|
||||
}
|
||||
|
||||
function getWorkspaceReferenceName(path: string, isDirectory = false) {
|
||||
const name = path.split('/').filter(Boolean).pop() || path
|
||||
return isDirectory && !name.endsWith('/') ? `${name}/` : name
|
||||
}
|
||||
|
||||
function formatInlineWorkspaceReference(path: string) {
|
||||
return `@${JSON.stringify(path)}`
|
||||
}
|
||||
|
||||
function isMarkdownPreview(tab: WorkspacePreviewTab) {
|
||||
if (tab.kind !== 'file') return false
|
||||
const language = (tab.language ?? '').toLowerCase()
|
||||
@ -800,7 +816,7 @@ function TreeNode({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenFile(entry.path)}
|
||||
onContextMenu={(event) => onFileContextMenu(event, entry.path)}
|
||||
onContextMenu={(event) => onFileContextMenu(event, entry.path, false)}
|
||||
className={`group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1.5px_var(--color-border-focus)]'
|
||||
@ -819,6 +835,7 @@ function TreeNode({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(entry.path)}
|
||||
onContextMenu={(event) => onFileContextMenu(event, entry.path, true)}
|
||||
aria-expanded={isVisuallyExpanded}
|
||||
className="group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ paddingLeft: indent }}
|
||||
@ -898,7 +915,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [isViewMenuOpen, setIsViewMenuOpen] = useState(false)
|
||||
const [previewTabContextMenu, setPreviewTabContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
const [fileContextMenu, setFileContextMenu] = useState<{ path: string; x: number; y: number } | null>(null)
|
||||
const [fileContextMenu, setFileContextMenu] = useState<FileContextMenuState | null>(null)
|
||||
const width = useWorkspacePanelStore((state) => state.width)
|
||||
const isOpen = useWorkspacePanelStore((state) => state.isPanelOpen(sessionId))
|
||||
const activeView = useWorkspacePanelStore((state) => state.getActiveView(sessionId))
|
||||
@ -924,6 +941,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
|
||||
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
|
||||
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||
const queueComposerInsertion = useChatStore((state) => state.queueComposerInsertion)
|
||||
const chatState = useChatStore((state) => state.sessions[sessionId]?.chatState ?? 'idle')
|
||||
const refreshLifecycleRef = useRef({
|
||||
sessionId,
|
||||
@ -1015,12 +1033,26 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
void openPreview(sessionId, path, 'file')
|
||||
}
|
||||
|
||||
const addFileToChat = (path: string) => {
|
||||
const addWorkspacePathToChat = (path: string, isDirectory = false) => {
|
||||
addWorkspaceReference(sessionId, {
|
||||
kind: 'file',
|
||||
path,
|
||||
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
|
||||
name: path.split('/').pop() || path,
|
||||
name: getWorkspaceReferenceName(path, isDirectory),
|
||||
isDirectory,
|
||||
})
|
||||
}
|
||||
|
||||
const citeWorkspacePathInMessage = (path: string, isDirectory = false) => {
|
||||
queueComposerInsertion(sessionId, {
|
||||
text: formatInlineWorkspaceReference(path),
|
||||
reference: {
|
||||
kind: 'file',
|
||||
path,
|
||||
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
|
||||
name: getWorkspaceReferenceName(path, isDirectory),
|
||||
isDirectory,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1061,11 +1093,11 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
setPreviewTabContextMenu({ tabId, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const handleFileContextMenu = (event: MouseEvent, path: string) => {
|
||||
const handleFileContextMenu = (event: MouseEvent, path: string, isDirectory = false) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setPreviewTabContextMenu(null)
|
||||
setFileContextMenu({ path, x: event.clientX, y: event.clientY })
|
||||
setFileContextMenu({ path, isDirectory, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const handleClosePreviewTabs = (scope: WorkspacePreviewCloseScope) => {
|
||||
@ -1213,7 +1245,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addFileToChat(activePreviewTab.path)}
|
||||
onClick={() => addWorkspacePathToChat(activePreviewTab.path)}
|
||||
className="ml-auto inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] px-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">person_add</span>
|
||||
@ -1461,7 +1493,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
addFileToChat(fileContextMenu.path)
|
||||
addWorkspacePathToChat(fileContextMenu.path, fileContextMenu.isDirectory)
|
||||
setFileContextMenu(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
||||
@ -1469,6 +1501,18 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">person_add</span>
|
||||
<span>{t('workspace.addToChat')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
citeWorkspacePathInMessage(fileContextMenu.path, fileContextMenu.isDirectory)
|
||||
setFileContextMenu(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">chat_bubble</span>
|
||||
<span>{t('workspace.citeInMessage')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@ -123,6 +123,7 @@ export const en = {
|
||||
'workspace.collapsePreview': 'Collapse preview',
|
||||
'workspace.addToChat': 'Add to chat',
|
||||
'workspace.addSelectionToChat': 'Add to chat',
|
||||
'workspace.citeInMessage': 'Cite in message',
|
||||
'workspace.copyPath': 'Copy path',
|
||||
'workspace.pathCopied': 'Path copied.',
|
||||
'workspace.localComment': 'Local comment',
|
||||
|
||||
@ -125,6 +125,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.collapsePreview': '收起预览',
|
||||
'workspace.addToChat': '添加到聊天',
|
||||
'workspace.addSelectionToChat': '添加到对话',
|
||||
'workspace.citeInMessage': '引用到对话',
|
||||
'workspace.copyPath': '复制路径',
|
||||
'workspace.pathCopied': '路径已复制。',
|
||||
'workspace.localComment': '本地评论',
|
||||
|
||||
@ -41,6 +41,18 @@ export type ComposerDraftState = {
|
||||
attachments: ComposerAttachment[]
|
||||
}
|
||||
|
||||
export type ComposerReferenceInsertion = {
|
||||
text: string
|
||||
reference?: {
|
||||
kind: 'file'
|
||||
path: string
|
||||
absolutePath?: string
|
||||
name: string
|
||||
isDirectory?: boolean
|
||||
}
|
||||
nonce: number
|
||||
}
|
||||
|
||||
export type PerSessionState = {
|
||||
messages: UIMessage[]
|
||||
chatState: ChatState
|
||||
@ -75,6 +87,7 @@ export type PerSessionState = {
|
||||
attachments?: UIAttachment[]
|
||||
nonce: number
|
||||
} | null
|
||||
composerInsertion?: ComposerReferenceInsertion | null
|
||||
composerDraft?: ComposerDraftState | null
|
||||
}
|
||||
|
||||
@ -99,6 +112,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
|
||||
activeGoal: null,
|
||||
elapsedTimer: null,
|
||||
composerPrefill: null,
|
||||
composerInsertion: null,
|
||||
composerDraft: null,
|
||||
}
|
||||
|
||||
@ -141,6 +155,11 @@ type ChatStore = {
|
||||
sessionId: string,
|
||||
prefill: { text: string; attachments?: UIAttachment[] },
|
||||
) => void
|
||||
queueComposerInsertion: (
|
||||
sessionId: string,
|
||||
insertion: Omit<ComposerReferenceInsertion, 'nonce'>,
|
||||
) => void
|
||||
clearComposerInsertion: (sessionId: string, nonce?: number) => void
|
||||
setComposerDraft: (sessionId: string, draft: ComposerDraftState) => void
|
||||
clearComposerDraft: (sessionId: string) => void
|
||||
clearMessages: (sessionId: string) => void
|
||||
@ -1133,6 +1152,26 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
queueComposerInsertion: (sessionId, insertion) => {
|
||||
set((state) => ({
|
||||
sessions: updateSessionIn(state.sessions, sessionId, () => ({
|
||||
composerInsertion: {
|
||||
...insertion,
|
||||
nonce: Date.now(),
|
||||
},
|
||||
})),
|
||||
}))
|
||||
},
|
||||
|
||||
clearComposerInsertion: (sessionId, nonce) => {
|
||||
set((state) => ({
|
||||
sessions: updateSessionIn(state.sessions, sessionId, (session) => {
|
||||
if (nonce !== undefined && session.composerInsertion?.nonce !== nonce) return {}
|
||||
return { composerInsertion: null }
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
setComposerDraft: (sessionId, draft) => {
|
||||
set((state) => {
|
||||
const session = state.sessions[sessionId] ?? createDefaultSessionState()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user