Skip to content

Commit 9f8d2c0

Browse files
committed
feat: add system notification whenever there is new conflict
1 parent be21bd8 commit 9f8d2c0

File tree

11 files changed

+133
-4
lines changed

11 files changed

+133
-4
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,8 @@ ai-docs
3333
docs/learn
3434
docs/memory
3535
docs/plans
36+
docs/research
3637
# End AI Sync managed
38+
39+
# coding-friend
40+
.coding-friend/

docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v0.1.0
4+
5+
- Add desktop notifications (macOS, Windows, Linux) when new conflicts are detected
6+
- Notifications are deduplicated per tracked file — only new conflicts trigger a notification
7+
- Add toggle in Settings > General to enable/disable desktop notifications
8+
39
## v0.0.4
410

511
- Fix conflict filter not showing repos/services that have conflicts, and not filtering services at all

landing/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@
112112
AI Sync
113113
<span
114114
class="rounded-full bg-muted px-1.5 py-0.5 text-xs h-fit font-medium text-muted-foreground"
115-
>v0.0.4</span
115+
>v0.1.0</span
116116
>
117117
</a>
118118
<div class="flex items-center gap-3">

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ai-sync",
33
"author": "Anh-Thi Dinh",
4-
"version": "0.0.4",
4+
"version": "0.1.0",
55
"repository": "https://github.com/anhthiding/ai-sync",
66
"private": true,
77
"license": "MIT",

packages/server/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@ai-sync/server",
33
"author": "Anh-Thi Dinh",
4-
"version": "0.0.4",
4+
"version": "0.1.0",
55
"repository": "https://github.com/anhthiding/ai-sync",
66
"private": true,
77
"license": "MIT",
@@ -22,13 +22,15 @@
2222
"chokidar": "^4.0.0",
2323
"fastify": "^5.2.0",
2424
"glob": "^11.0.0",
25+
"node-notifier": "^10.0.1",
2526
"picomatch": "^4.0.3",
2627
"simple-git": "^3.27.0",
2728
"uuid": "^11.0.0"
2829
},
2930
"devDependencies": {
3031
"@types/better-sqlite3": "^7.6.12",
3132
"@types/node": "^22.10.0",
33+
"@types/node-notifier": "^8.0.5",
3234
"@types/picomatch": "^4.0.2",
3335
"@types/uuid": "^10.0.0",
3436
"tsx": "^4.19.0",

packages/server/src/routes/conflicts.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
22
import fs from 'node:fs/promises';
33
import path from 'node:path';
44
import { resolveConflict } from '../services/conflict-detector.js';
5+
import { clearNotifiedConflict } from '../services/notifier.js';
56
import { ensureDir } from '../services/repo-scanner.js';
67
import { fileChecksum } from '../services/checksum.js';
78
import { getFileMtime } from '../services/repo-scanner.js';
@@ -152,6 +153,8 @@ export function registerConflictRoutes(app: FastifyInstance, state: AppState): v
152153
)
153154
.get(req.params.id) as { tracked_file_id: string };
154155

156+
clearNotifiedConflict(conflict.tracked_file_id);
157+
155158
if (result.deleted) {
156159
// Delete both files and remove tracking
157160
try {
@@ -232,6 +235,8 @@ export function registerConflictRoutes(app: FastifyInstance, state: AppState): v
232235
)
233236
.get(conflict.id) as { tracked_file_id: string };
234237

238+
clearNotifiedConflict(tf.tracked_file_id);
239+
235240
if (result.deleted) {
236241
try {
237242
await fs.unlink(result.storeFilePath);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import notifier from 'node-notifier';
2+
import type Database from 'better-sqlite3';
3+
import type { ConflictWithDetails } from '../types/index.js';
4+
5+
// Track conflict IDs that have already been notified (in-memory).
6+
// When a conflict is resolved, remove its tracked file from the set
7+
// so a new conflict on the same file will trigger a fresh notification.
8+
const notifiedTrackedFiles = new Set<string>();
9+
10+
function isEnabled(db: Database.Database): boolean {
11+
const row = db.prepare("SELECT value FROM settings WHERE key = 'desktop_notifications'").get() as
12+
| { value: string }
13+
| undefined;
14+
// Default to enabled if not set
15+
return row ? row.value === 'true' : true;
16+
}
17+
18+
export function sendConflictNotification(
19+
db: Database.Database,
20+
conflict: ConflictWithDetails,
21+
): void {
22+
try {
23+
if (!isEnabled(db)) return;
24+
25+
// Only notify once per tracked file until it's resolved
26+
if (notifiedTrackedFiles.has(conflict.trackedFileId)) return;
27+
notifiedTrackedFiles.add(conflict.trackedFileId);
28+
29+
const target = conflict.repoName || conflict.serviceName || 'Unknown';
30+
notifier.notify({
31+
title: 'AI Sync — New Conflict',
32+
message: `${target}: ${conflict.relativePath}`,
33+
});
34+
} catch {
35+
// Never let notification errors break sync
36+
}
37+
}
38+
39+
export function clearNotifiedConflict(trackedFileId: string): void {
40+
notifiedTrackedFiles.delete(trackedFileId);
41+
}

packages/server/src/services/sync-engine.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
isSymlink,
2424
} from './repo-scanner.js';
2525
import { createConflict } from './conflict-detector.js';
26+
import { sendConflictNotification, clearNotifiedConflict } from './notifier.js';
2627
import {
2728
queueStoreCommit,
2829
getCommittedContent,
@@ -672,6 +673,7 @@ export class SyncEngine {
672673

673674
if (conflict) {
674675
this.broadcast({ type: 'conflict_created', conflict });
676+
sendConflictNotification(this.db, conflict);
675677
this.logSync(
676678
target.id,
677679
trackedFile.relativePath,
@@ -1023,6 +1025,7 @@ export class SyncEngine {
10231025
)
10241026
.run(pendingConflict.id);
10251027
this.broadcast({ type: 'conflict_resolved', conflictId: pendingConflict.id });
1028+
clearNotifiedConflict(trackedFileId);
10261029
}
10271030
}
10281031

@@ -1054,6 +1057,7 @@ export class SyncEngine {
10541057

10551058
if (conflict) {
10561059
this.broadcast({ type: 'conflict_created', conflict });
1060+
sendConflictNotification(this.db, conflict);
10571061
this.logSync(
10581062
target.id,
10591063
trackedFile.relativePath,

packages/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@ai-sync/ui",
33
"author": "Anh-Thi Dinh",
4-
"version": "0.0.4",
4+
"version": "0.1.0",
55
"repository": "https://github.com/anhthiding/ai-sync",
66
"private": true,
77
"license": "MIT",

packages/ui/src/pages/settings.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,17 @@ export function SettingsPage() {
279279
setSettings({ ...settings, auto_commit_store: checked ? 'true' : 'false' })
280280
}
281281
/>
282+
<CheckboxSettingRow
283+
label="Desktop notifications on new conflicts"
284+
settingKey="desktop_notifications"
285+
checked={settings.desktop_notifications !== 'false'}
286+
onCheckedChange={(checked) =>
287+
setSettings({
288+
...settings,
289+
desktop_notifications: checked ? 'true' : 'false',
290+
})
291+
}
292+
/>
282293
</div>
283294

284295
<div className="py-4 border-t">

0 commit comments

Comments
 (0)