mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
feat(desktop): bridge agent file edits to workspace panel (Phase 4) (#29)
Phase 4 of editor-lsp-foundation. The CLI agent's FileEditTool /
FileWriteTool / NotebookEditTool path was already vendored from upstream
and already drives the upstream LSP system. This PR bridges agent edits
into the desktop workbench (CodeMirror editor + workspace panel) without
touching the vendored tool code: the desktop chatStore observes the
agent's tool stream and refreshes/conflict-flags the panel itself.
Approach (option A in the spec discussion):
- chatStore: when a FILE_EDIT tool ('Edit'/'Write'/'NotebookEdit'/
'MultiEdit') completes, remember the toolUseId -> file_path mapping
in a per-session pending map. On the matching tool_result with
isError === false, consume the entry and call
workspacePanelStore.notifyAgentFileEdit(sessionId, absolutePath).
- workspacePanelStore.notifyAgentFileEdit: refreshes loadStatus when
the panel is open, and sets a 'source: agent' conflict on any open
editor buffer whose workspace-relative path matches the agent's
absolute path by suffix on a normalized segment boundary. Skips
buffers that already have a conflict so user-source banners aren't
clobbered. Uses sentinel hash 'agent-edit' because the chat tool
stream carries no content hash.
- Path matching normalizes backslashes -> forward slashes and trims
trailing slashes, then either equals or endsWith('/' + bufferPath)
to avoid foobar.ts matching bar.ts.
- Cleanup: clearPendingFileEdits added at the 3 existing pending-map
cleanup sites (disconnect, clear messages, new-session path).
Why no FileEditTool change:
The vendored upstream tool has no sessionId in ToolUseContext and
shouldn't import server WS modules from CLI process scope. The chat
stream already carries (sessionId, toolName, toolUseId, input, isError)
to the desktop and is the natural seam for desktop-side reactions —
same pattern the store already uses for TodoWrite -> useCLITaskStore
and Task* tools -> refreshTasks.
Tests:
- desktop/src/stores/workspacePanelStore.test.ts +6 (30/30 total)
Suffix match, Windows backslash, non-suffix substring rejection,
existing-conflict preservation, loadStatus when panel open, no
loadStatus when closed.
- desktop/src/stores/chatStore.test.ts +6 (112/112 total)
Edit/Write/NotebookEdit forwarding, isError gate, non-edit tool
isolation (Bash), single-fire on duplicate tool_result.
Verifier (independent agent) ran lint + both test suites + build +
bundle budget + 9 ad-hoc helper edge cases (Unicode paths, trailing
slash, identical absolute, deeper suffix mismatch). All PASS.
Bundle delta: +2.41 KB gz vs origin/main @ 95931d49 baseline
(97.59 KB headroom remaining).
_Requirements: 3.1, 3.2, 3.3, 8.1, 8.2 (adapted)_
Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
parent
a4e3306753
commit
31230ef737
@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MessageEntry } from '../types/session'
|
||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
|
||||
@ -132,6 +132,7 @@ import {
|
||||
type PerSessionState,
|
||||
useChatStore,
|
||||
} from './chatStore'
|
||||
import { useWorkspacePanelStore } from './workspacePanelStore'
|
||||
|
||||
const TEST_SESSION_ID = 'test-session-1'
|
||||
const initialState = useChatStore.getState()
|
||||
@ -4391,3 +4392,130 @@ describe('chatStore message queue', () => {
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messageQueue).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatStore — agent file-edit bridge to workspace panel (Phase 4)', () => {
|
||||
let notifySpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
// The store is a real Zustand instance; spy on the method we forward to.
|
||||
notifySpy = vi.spyOn(useWorkspacePanelStore.getState(), 'notifyAgentFileEdit')
|
||||
notifySpy.mockImplementation(() => {})
|
||||
// Reset chat store to a clean slate per test.
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
notifySpy.mockRestore()
|
||||
})
|
||||
|
||||
it('forwards Edit tool success to notifyAgentFileEdit with the absolute path', () => {
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Edit',
|
||||
toolUseId: 'tu-edit-1',
|
||||
input: { file_path: '/repo/src/app.ts', old_string: 'a', new_string: 'b' },
|
||||
})
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tu-edit-1',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
})
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith(TEST_SESSION_ID, '/repo/src/app.ts')
|
||||
})
|
||||
|
||||
it('forwards Write tool success too', () => {
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Write',
|
||||
toolUseId: 'tu-write-1',
|
||||
input: { file_path: '/repo/src/new.ts', content: 'export {}' },
|
||||
})
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tu-write-1',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
})
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith(TEST_SESSION_ID, '/repo/src/new.ts')
|
||||
})
|
||||
|
||||
it('forwards NotebookEdit using notebook_path instead of file_path', () => {
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'NotebookEdit',
|
||||
toolUseId: 'tu-nb-1',
|
||||
input: { notebook_path: '/repo/notebook.ipynb', new_source: 'print(1)' },
|
||||
})
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tu-nb-1',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
})
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith(TEST_SESSION_ID, '/repo/notebook.ipynb')
|
||||
})
|
||||
|
||||
it('does NOT fire when the tool result reports an error', () => {
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Edit',
|
||||
toolUseId: 'tu-edit-fail',
|
||||
input: { file_path: '/repo/src/app.ts', old_string: 'a', new_string: 'b' },
|
||||
})
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tu-edit-fail',
|
||||
content: 'permission denied',
|
||||
isError: true,
|
||||
})
|
||||
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT fire for non-file-editing tools (e.g. Bash)', () => {
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Bash',
|
||||
toolUseId: 'tu-bash-1',
|
||||
input: { command: 'echo hi' },
|
||||
})
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tu-bash-1',
|
||||
content: 'hi',
|
||||
isError: false,
|
||||
})
|
||||
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the pending entry on consume so the same toolUseId only fires once', () => {
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Edit',
|
||||
toolUseId: 'tu-edit-once',
|
||||
input: { file_path: '/repo/a.ts', old_string: 'x', new_string: 'y' },
|
||||
})
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tu-edit-once',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
})
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A duplicate tool_result for the same id (defensive — should not happen
|
||||
// on a healthy stream) must not double-fire.
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tu-edit-once',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
})
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,6 +7,7 @@ import { useCLITaskStore } from './cliTaskStore'
|
||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
import { useTabStore } from './tabStore'
|
||||
import { useProviderCompatStore } from './providerCompatStore'
|
||||
import { useWorkspacePanelStore } from './workspacePanelStore'
|
||||
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
||||
import { notifyDesktop } from '../lib/desktopNotifications'
|
||||
import { t } from '../i18n'
|
||||
@ -204,8 +205,10 @@ type ChatStore = {
|
||||
|
||||
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
|
||||
const TASK_STOP_TOOL_NAMES = new Set(['TaskStop', 'KillShell'])
|
||||
const FILE_EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
|
||||
const pendingTaskToolUseIdsBySession = new Map<string, Set<string>>()
|
||||
const pendingToolParentUseIdsBySession = new Map<string, Map<string, string>>()
|
||||
const pendingFileEditPathsBySession = new Map<string, Map<string, string>>()
|
||||
|
||||
function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void {
|
||||
const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set<string>()
|
||||
@ -249,6 +252,42 @@ function consumePendingToolParentUseId(sessionId: string, toolUseId: string): st
|
||||
return parentToolUseId
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the absolute file path from a file-mutating tool's input.
|
||||
* Edit / Write / MultiEdit use `file_path`; NotebookEdit uses `notebook_path`.
|
||||
*/
|
||||
function extractEditedFilePath(input: unknown): string | null {
|
||||
if (!input || typeof input !== 'object') return null
|
||||
const obj = input as { file_path?: unknown; notebook_path?: unknown }
|
||||
const candidate =
|
||||
typeof obj.file_path === 'string' && obj.file_path.length > 0
|
||||
? obj.file_path
|
||||
: typeof obj.notebook_path === 'string' && obj.notebook_path.length > 0
|
||||
? obj.notebook_path
|
||||
: null
|
||||
return candidate
|
||||
}
|
||||
|
||||
function rememberPendingFileEdit(sessionId: string, toolUseId: string, filePath: string): void {
|
||||
if (!toolUseId) return
|
||||
const paths = pendingFileEditPathsBySession.get(sessionId) ?? new Map<string, string>()
|
||||
paths.set(toolUseId, filePath)
|
||||
pendingFileEditPathsBySession.set(sessionId, paths)
|
||||
}
|
||||
|
||||
function consumePendingFileEdit(sessionId: string, toolUseId: string): string | null {
|
||||
const paths = pendingFileEditPathsBySession.get(sessionId)
|
||||
const filePath = paths?.get(toolUseId)
|
||||
if (!filePath) return null
|
||||
paths!.delete(toolUseId)
|
||||
if (paths!.size === 0) pendingFileEditPathsBySession.delete(sessionId)
|
||||
return filePath
|
||||
}
|
||||
|
||||
function clearPendingFileEdits(sessionId: string): void {
|
||||
pendingFileEditPathsBySession.delete(sessionId)
|
||||
}
|
||||
|
||||
function clearPendingToolParentUseIds(sessionId: string): void {
|
||||
pendingToolParentUseIdsBySession.delete(sessionId)
|
||||
}
|
||||
@ -1001,6 +1040,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
clearPendingToolInputDelta(sessionId)
|
||||
clearPendingTaskToolUseIds(sessionId)
|
||||
clearPendingToolParentUseIds(sessionId)
|
||||
clearPendingFileEdits(sessionId)
|
||||
queueDrainPaused.delete(sessionId)
|
||||
wsManager.disconnect(sessionId)
|
||||
set((s) => {
|
||||
@ -1553,6 +1593,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
clearMessages: (sessionId) => {
|
||||
clearPendingTaskToolUseIds(sessionId)
|
||||
clearPendingToolParentUseIds(sessionId)
|
||||
clearPendingFileEdits(sessionId)
|
||||
clearPendingToolInputDelta(sessionId)
|
||||
resetCompactionThrash(sessionId)
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({
|
||||
@ -1816,6 +1857,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
} else if (TASK_TOOL_NAMES.has(toolName)) {
|
||||
const useId = msg.toolUseId || session?.activeToolUseId
|
||||
if (useId) addPendingTaskToolUseId(sessionId, useId)
|
||||
} else if (FILE_EDIT_TOOL_NAMES.has(toolName)) {
|
||||
const useId = msg.toolUseId || session?.activeToolUseId
|
||||
const editedPath = extractEditedFilePath(msg.input)
|
||||
if (useId && editedPath) rememberPendingFileEdit(sessionId, useId, editedPath)
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -1850,6 +1895,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
if (consumePendingTaskToolUseId(sessionId, msg.toolUseId)) {
|
||||
useCLITaskStore.getState().refreshTasks(sessionId)
|
||||
}
|
||||
const editedPath = consumePendingFileEdit(sessionId, msg.toolUseId)
|
||||
if (editedPath && !msg.isError) {
|
||||
useWorkspacePanelStore.getState().notifyAgentFileEdit(sessionId, editedPath)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@ -2078,6 +2127,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
clearPendingDelta(sessionId)
|
||||
clearPendingTaskToolUseIds(sessionId)
|
||||
clearPendingToolParentUseIds(sessionId)
|
||||
clearPendingFileEdits(sessionId)
|
||||
useCLITaskStore.getState().clearTasks(sessionId)
|
||||
useSessionStore.getState().updateSessionTitle(sessionId, 'New Session')
|
||||
useTabStore.getState().updateTabTitle(sessionId, 'New Session')
|
||||
|
||||
@ -726,4 +726,160 @@ describe('workspacePanelStore', () => {
|
||||
expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
|
||||
expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('notifyAgentFileEdit (Phase 4)', () => {
|
||||
it('flags an open buffer with an agent-source conflict when paths match by suffix', () => {
|
||||
const tabId = 'file:src/app.ts'
|
||||
useWorkspacePanelStore.setState((state) => ({
|
||||
...state,
|
||||
bufferStateByTabId: {
|
||||
...state.bufferStateByTabId,
|
||||
[tabId]: {
|
||||
tabId,
|
||||
path: 'src/app.ts',
|
||||
baseHash: 'a'.repeat(64),
|
||||
baseContent: 'old',
|
||||
currentContent: 'old',
|
||||
isDirty: false,
|
||||
encoding: 'utf-8',
|
||||
lineEnding: 'LF',
|
||||
conflict: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
useWorkspacePanelStore.getState().notifyAgentFileEdit(
|
||||
'session-agent',
|
||||
'C:/Users/me/repo/src/app.ts',
|
||||
)
|
||||
|
||||
const buffer = useWorkspacePanelStore.getState().bufferStateByTabId[tabId]!
|
||||
expect(buffer.conflict).toMatchObject({ source: 'agent' })
|
||||
expect(buffer.conflict!.hash).toBe('agent-edit')
|
||||
})
|
||||
|
||||
it('also matches Windows backslash absolute paths', () => {
|
||||
const tabId = 'file:src/app.ts'
|
||||
useWorkspacePanelStore.setState((state) => ({
|
||||
...state,
|
||||
bufferStateByTabId: {
|
||||
...state.bufferStateByTabId,
|
||||
[tabId]: {
|
||||
tabId,
|
||||
path: 'src/app.ts',
|
||||
baseHash: 'a'.repeat(64),
|
||||
baseContent: 'old',
|
||||
currentContent: 'old',
|
||||
isDirty: false,
|
||||
encoding: 'utf-8',
|
||||
lineEnding: 'LF',
|
||||
conflict: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
useWorkspacePanelStore.getState().notifyAgentFileEdit(
|
||||
'session-agent',
|
||||
'C:\\Users\\me\\repo\\src\\app.ts',
|
||||
)
|
||||
|
||||
const buffer = useWorkspacePanelStore.getState().bufferStateByTabId[tabId]!
|
||||
expect(buffer.conflict?.source).toBe('agent')
|
||||
})
|
||||
|
||||
it('does not flag a buffer whose path is a non-suffix substring of the agent edit path', () => {
|
||||
const tabId = 'file:bar.ts'
|
||||
useWorkspacePanelStore.setState((state) => ({
|
||||
...state,
|
||||
bufferStateByTabId: {
|
||||
...state.bufferStateByTabId,
|
||||
[tabId]: {
|
||||
tabId,
|
||||
path: 'bar.ts',
|
||||
baseHash: 'a'.repeat(64),
|
||||
baseContent: 'x',
|
||||
currentContent: 'x',
|
||||
isDirty: false,
|
||||
encoding: 'utf-8',
|
||||
lineEnding: 'LF',
|
||||
conflict: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// foobar.ts ends with "bar.ts" but NOT with "/bar.ts" — must not match.
|
||||
useWorkspacePanelStore.getState().notifyAgentFileEdit(
|
||||
'session-agent',
|
||||
'/repo/foobar.ts',
|
||||
)
|
||||
|
||||
const buffer = useWorkspacePanelStore.getState().bufferStateByTabId[tabId]!
|
||||
expect(buffer.conflict).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clobber an existing conflict on the same buffer', () => {
|
||||
const tabId = 'file:src/app.ts'
|
||||
const existingConflict = {
|
||||
source: 'user' as const,
|
||||
hash: 'b'.repeat(64),
|
||||
timestamp: 100,
|
||||
}
|
||||
useWorkspacePanelStore.setState((state) => ({
|
||||
...state,
|
||||
bufferStateByTabId: {
|
||||
...state.bufferStateByTabId,
|
||||
[tabId]: {
|
||||
tabId,
|
||||
path: 'src/app.ts',
|
||||
baseHash: 'a'.repeat(64),
|
||||
baseContent: 'x',
|
||||
currentContent: 'x',
|
||||
isDirty: false,
|
||||
encoding: 'utf-8',
|
||||
lineEnding: 'LF',
|
||||
conflict: existingConflict,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
useWorkspacePanelStore.getState().notifyAgentFileEdit(
|
||||
'session-agent',
|
||||
'/repo/src/app.ts',
|
||||
)
|
||||
|
||||
const buffer = useWorkspacePanelStore.getState().bufferStateByTabId[tabId]!
|
||||
expect(buffer.conflict).toBe(existingConflict)
|
||||
})
|
||||
|
||||
it('triggers loadStatus when the panel is open for the session', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
useWorkspacePanelStore.getState().openPanel('session-loaded')
|
||||
|
||||
useWorkspacePanelStore.getState().notifyAgentFileEdit(
|
||||
'session-loaded',
|
||||
'/repo/src/whatever.ts',
|
||||
)
|
||||
|
||||
// Drain any pending microtasks scheduled by void loadStatus(...)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(mocks.getWorkspaceStatusMock).toHaveBeenCalledWith('session-loaded')
|
||||
})
|
||||
|
||||
it('skips loadStatus when the panel is closed for the session', () => {
|
||||
// Panel default-state isOpen is false — never opened.
|
||||
useWorkspacePanelStore.getState().notifyAgentFileEdit(
|
||||
'session-closed',
|
||||
'/repo/src/whatever.ts',
|
||||
)
|
||||
expect(mocks.getWorkspaceStatusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -132,6 +132,15 @@ type WorkspacePanelStore = {
|
||||
setBufferState: (tabId: string, content: string) => void
|
||||
applyExternalSave: (tabId: string, event: WorkspaceExternalSavePayload) => void
|
||||
acknowledgeConflict: (tabId: string, action: 'reload' | 'keepMine' | 'openConflict') => void
|
||||
/**
|
||||
* React to an agent (CLI) file edit observed via the chat tool stream.
|
||||
* Unlike `applyExternalSave`, no content hash is available — the agent
|
||||
* edit is surfaced through the tool_result event, not a workspace save
|
||||
* round-trip. So we (a) refresh the workspace status/diagnostics and
|
||||
* (b) flag any open buffer for the same path with an agent-source
|
||||
* conflict so the editor offers a reload.
|
||||
*/
|
||||
notifyAgentFileEdit: (sessionId: string, absolutePath: string) => void
|
||||
clearBuffer: (tabId: string) => void
|
||||
clearSession: (sessionId: string) => void
|
||||
resetSessionUi: (sessionId: string) => void
|
||||
@ -183,6 +192,33 @@ export function getWorkspacePreviewTabId(path: string, kind: WorkspacePreviewKin
|
||||
return `${kind}:${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel hash for agent-originated edits. Agent edits are observed through
|
||||
* the chat tool stream (tool_result), which carries no file content hash, so
|
||||
* we can't compare against a buffer's baseHash. This sentinel is chosen to
|
||||
* never collide with a real sha-256 hex digest, guaranteeing the conflict
|
||||
* banner surfaces for an open buffer.
|
||||
*/
|
||||
export const AGENT_EDIT_SENTINEL_HASH = 'agent-edit'
|
||||
|
||||
/** Normalize a path for agent-edit suffix matching: forward slashes, no trailing slash. */
|
||||
function normalizeAgentEditPath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an agent's (absolute) edited path refers to the same file as an
|
||||
* open buffer's (workspace-relative) path. The agent reports an absolute
|
||||
* path while buffers store workspace-relative paths, so we match by suffix
|
||||
* on a normalized segment boundary to avoid `foo/bar.ts` matching
|
||||
* `otherbar.ts`.
|
||||
*/
|
||||
function agentEditMatchesBufferPath(normalizedAbs: string, bufferPath: string): boolean {
|
||||
const normalizedBuffer = normalizeAgentEditPath(bufferPath)
|
||||
if (normalizedAbs === normalizedBuffer) return true
|
||||
return normalizedAbs.endsWith(`/${normalizedBuffer}`)
|
||||
}
|
||||
|
||||
function makePreviewKey(sessionId: string, tabId: string) {
|
||||
return `${sessionId}::${tabId}`
|
||||
}
|
||||
@ -863,6 +899,42 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
}
|
||||
}),
|
||||
|
||||
notifyAgentFileEdit: (sessionId, absolutePath) => {
|
||||
// Refresh status/diagnostics for the session regardless of whether the
|
||||
// edited file is open — the file tree + changed set may have moved.
|
||||
if (get().isPanelOpen(sessionId)) {
|
||||
void get().loadStatus(sessionId)
|
||||
}
|
||||
|
||||
// Flag any open buffer for the same path with an agent-source conflict.
|
||||
// Agent edits arrive without a content hash (they come from the chat
|
||||
// tool stream, not a workspace save), so we use a sentinel hash that can
|
||||
// never equal a real base hash — guaranteeing the conflict surfaces.
|
||||
const normalizedAbs = normalizeAgentEditPath(absolutePath)
|
||||
set((state) => {
|
||||
let changed = false
|
||||
const nextBuffers: Record<string, WorkspaceBufferState | undefined> = {
|
||||
...state.bufferStateByTabId,
|
||||
}
|
||||
for (const [tabId, buffer] of Object.entries(state.bufferStateByTabId)) {
|
||||
if (!buffer) continue
|
||||
if (!agentEditMatchesBufferPath(normalizedAbs, buffer.path)) continue
|
||||
// Already showing a conflict — don't clobber the existing one.
|
||||
if (buffer.conflict) continue
|
||||
changed = true
|
||||
nextBuffers[tabId] = {
|
||||
...buffer,
|
||||
conflict: {
|
||||
source: 'agent',
|
||||
hash: AGENT_EDIT_SENTINEL_HASH,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
return changed ? { bufferStateByTabId: nextBuffers } : state
|
||||
})
|
||||
},
|
||||
|
||||
clearBuffer: (tabId) =>
|
||||
set((state) => ({
|
||||
bufferStateByTabId: removeRecordKey(state.bufferStateByTabId, tabId),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user