diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts index 53840a3..e26e884 100644 --- a/v2/src/lib/agent-dispatcher.test.ts +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -171,6 +171,15 @@ vi.mock('./stores/notifications.svelte', () => ({ notify: (...args: unknown[]) => mockNotify(...args), })); +vi.mock('./stores/conflicts.svelte', () => ({ + recordFileWrite: vi.fn().mockReturnValue(false), + clearSessionWrites: vi.fn(), +})); + +vi.mock('./utils/tool-files', () => ({ + extractWritePaths: vi.fn().mockReturnValue([]), +})); + // Use fake timers to control setTimeout in sidecar crash recovery beforeEach(() => { vi.useFakeTimers(); diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 749b45e..f2ccd22 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -25,6 +25,8 @@ import { } from './adapters/groups-bridge'; import { tel } from './adapters/telemetry-bridge'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; +import { recordFileWrite, clearSessionWrites } from './stores/conflicts.svelte'; +import { extractWritePaths } from './utils/tool-files'; let unlistenMsg: (() => void) | null = null; let unlistenExit: (() => void) | null = null; @@ -180,7 +182,18 @@ function handleAgentEvent(sessionId: string, event: Record): vo } // Health: record tool start const projId = sessionProjectMap.get(sessionId); - if (projId) recordActivity(projId, tc.name); + if (projId) { + recordActivity(projId, tc.name); + // Conflict detection: track file writes + const writePaths = extractWritePaths(tc); + for (const filePath of writePaths) { + const isNewConflict = recordFileWrite(projId, sessionId, filePath); + if (isNewConflict) { + const shortName = filePath.split('/').pop() ?? filePath; + notify('warning', `File conflict: ${shortName} — multiple agents writing`); + } + } + } break; } @@ -214,6 +227,8 @@ function handleAgentEvent(sessionId: string, event: Record): vo if (costProjId) { recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd); recordToolDone(costProjId); + // Conflict tracking: clear session writes on completion + clearSessionWrites(costProjId, sessionId); } // Persist session state for project-scoped sessions persistSessionForProject(sessionId); diff --git a/v2/src/lib/components/StatusBar/StatusBar.svelte b/v2/src/lib/components/StatusBar/StatusBar.svelte index 5074e7a..4cd036e 100644 --- a/v2/src/lib/components/StatusBar/StatusBar.svelte +++ b/v2/src/lib/components/StatusBar/StatusBar.svelte @@ -2,6 +2,7 @@ import { getAgentSessions } from '../../stores/agents.svelte'; import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte'; import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte'; + import { getTotalConflictCount } from '../../stores/conflicts.svelte'; let agentSessions = $derived(getAgentSessions()); let activeGroup = $derived(getActiveGroup()); @@ -15,6 +16,7 @@ let health = $derived(getHealthAggregates()); let attentionQueue = $derived(getAttentionQueue(5)); + let totalConflicts = $derived(getTotalConflictCount()); let showAttention = $state(false); function projectName(projectId: string): string { @@ -67,6 +69,12 @@ {/if} + {#if totalConflicts > 0} + + ⚠ {totalConflicts} conflict{totalConflicts > 1 ? 's' : ''} + + + {/if} {#if attentionQueue.length > 0} @@ -173,6 +181,11 @@ font-weight: 600; } + .state-conflict { + color: var(--ctp-red); + font-weight: 600; + } + .pulse { width: 6px; height: 6px; diff --git a/v2/src/lib/components/Workspace/ContextTab.svelte b/v2/src/lib/components/Workspace/ContextTab.svelte index 9bf2943..79d981c 100644 --- a/v2/src/lib/components/Workspace/ContextTab.svelte +++ b/v2/src/lib/components/Workspace/ContextTab.svelte @@ -1,6 +1,7 @@