Skip to content

Commit 626a4c4

Browse files
feat(notification): rich notification content with shared format helpers (#245)
* loop: notification-format completed after 2 iterations * refactor: deduplicate permission display logic into shared package
1 parent dbd6866 commit 626a4c4

8 files changed

Lines changed: 313 additions & 140 deletions

File tree

backend/src/services/notification.ts

Lines changed: 88 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import {
77
NotificationEventType,
88
DEFAULT_NOTIFICATION_PREFERENCES,
99
} from "@opencode-manager/shared/schemas";
10+
import {
11+
getPermissionLabel,
12+
getPermissionDetail,
13+
getQuestionText,
14+
} from "@opencode-manager/shared/notifications";
1015
import { SettingsService } from "./settings";
1116
import { sseAggregator, type SSEEvent } from "./sse-aggregator";
1217
import { getRepoByLocalPath, getRepoBySourcePath } from "../db/queries";
@@ -21,33 +26,81 @@ interface VapidConfig {
2126

2227
const EVENT_CONFIG: Record<
2328
string,
24-
{ preferencesKey: keyof typeof DEFAULT_NOTIFICATION_PREFERENCES.events; title: string; bodyFn: (props: Record<string, unknown>) => string }
29+
{
30+
preferencesKey: keyof typeof DEFAULT_NOTIFICATION_PREFERENCES.events;
31+
titleFn: (props: Record<string, unknown>) => string;
32+
bodyFn: (props: Record<string, unknown>) => string;
33+
}
2534
> = {
2635
[NotificationEventType.PERMISSION_ASKED]: {
2736
preferencesKey: "permissionAsked",
28-
title: "Permission Required",
29-
bodyFn: () => "OpenCode needs your approval to continue",
37+
titleFn: (props) =>
38+
getPermissionLabel(
39+
typeof props.permission === "string" ? props.permission : ""
40+
),
41+
bodyFn: (props) => getPermissionDetail(props).primary || "Approval required",
3042
},
3143
[NotificationEventType.QUESTION_ASKED]: {
3244
preferencesKey: "questionAsked",
33-
title: "Question from Agent",
34-
bodyFn: () => "The agent has a question for you",
45+
titleFn: () => "Question",
46+
bodyFn: (props) => getQuestionText(props) || "A question needs your answer",
3547
},
3648
[NotificationEventType.SESSION_ERROR]: {
3749
preferencesKey: "sessionError",
38-
title: "Session Error",
50+
titleFn: () => "Error",
3951
bodyFn: (props) => {
4052
const error = props.error as { message?: string } | undefined;
4153
return error?.message ?? "A session encountered an error";
4254
},
4355
},
4456
[NotificationEventType.SESSION_IDLE]: {
4557
preferencesKey: "sessionIdle",
46-
title: "Session Complete",
58+
titleFn: () => "Session complete",
4759
bodyFn: () => "Your session has finished processing",
4860
},
4961
};
5062

63+
const MAX_BODY_LENGTH = 140;
64+
65+
export function buildEventNotificationPayload(
66+
event: SSEEvent,
67+
context: {
68+
repoName?: string;
69+
repoId?: number;
70+
sessionId?: string;
71+
directory?: string;
72+
url: string;
73+
}
74+
): PushNotificationPayload | null {
75+
const config = EVENT_CONFIG[event.type];
76+
if (!config) return null;
77+
78+
const action = config.titleFn(event.properties);
79+
const title = context.repoName
80+
? `${context.repoName}: ${action}`
81+
: action;
82+
83+
const rawBody = config.bodyFn(event.properties);
84+
const body =
85+
rawBody.length > MAX_BODY_LENGTH
86+
? `${rawBody.slice(0, MAX_BODY_LENGTH - 1)}…`
87+
: rawBody;
88+
89+
return {
90+
title,
91+
body,
92+
tag: `${event.type}-${context.sessionId ?? "global"}`,
93+
data: {
94+
eventType: event.type,
95+
sessionId: context.sessionId,
96+
directory: context.directory,
97+
repoId: context.repoId,
98+
repoName: context.repoName,
99+
url: context.url,
100+
},
101+
};
102+
}
103+
51104
export class NotificationService {
52105
private vapidConfig: VapidConfig | null = null;
53106
private settingsService: SettingsService;
@@ -205,6 +258,34 @@ export class NotificationService {
205258
if (!this.isConfigured()) return;
206259

207260
const userIds = this.getAllUserIds();
261+
if (userIds.length === 0) return;
262+
263+
let notificationUrl = "/";
264+
let repoName = "";
265+
let repoId: number | undefined;
266+
267+
if (_directory) {
268+
const reposBasePath = getReposPath();
269+
const localPath = path.relative(reposBasePath, _directory);
270+
const repo = getRepoBySourcePath(this.db, path.resolve(_directory)) ?? getRepoByLocalPath(this.db, localPath);
271+
272+
if (repo) {
273+
repoId = repo.id;
274+
repoName = path.basename(repo.localPath);
275+
notificationUrl = sessionId
276+
? `/repos/${repo.id}/sessions/${sessionId}`
277+
: `/repos/${repo.id}`;
278+
}
279+
}
280+
281+
const payload = buildEventNotificationPayload(event, {
282+
repoName: repoName || undefined,
283+
repoId,
284+
sessionId,
285+
directory: _directory,
286+
url: notificationUrl,
287+
});
288+
if (!payload) return;
208289

209290
for (const userId of userIds) {
210291
const settings = this.settingsService.getSettings(userId);
@@ -214,42 +295,6 @@ export class NotificationService {
214295
if (!notifPrefs.enabled) continue;
215296
if (!notifPrefs.events[config.preferencesKey]) continue;
216297

217-
let notificationUrl = "/";
218-
let repoName = "";
219-
let repoId: number | undefined;
220-
221-
if (_directory) {
222-
const reposBasePath = getReposPath();
223-
const localPath = path.relative(reposBasePath, _directory);
224-
const repo = getRepoBySourcePath(this.db, path.resolve(_directory)) ?? getRepoByLocalPath(this.db, localPath);
225-
226-
if (repo) {
227-
repoId = repo.id;
228-
repoName = path.basename(repo.localPath);
229-
if (sessionId) {
230-
notificationUrl = `/repos/${repo.id}/sessions/${sessionId}`;
231-
} else {
232-
notificationUrl = `/repos/${repo.id}`;
233-
}
234-
}
235-
}
236-
237-
const body = config.bodyFn(event.properties);
238-
239-
const payload: PushNotificationPayload = {
240-
title: repoName ? `[${repoName.toUpperCase()}] ${config.title}` : config.title,
241-
body: body,
242-
tag: `${event.type}-${sessionId ?? "global"}`,
243-
data: {
244-
eventType: event.type,
245-
sessionId,
246-
directory: _directory,
247-
repoId,
248-
repoName,
249-
url: notificationUrl,
250-
},
251-
};
252-
253298
await this.sendToUser(userId, payload);
254299
}
255300
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { getPermissionLabel, getPermissionDetail, getQuestionText } from '@opencode-manager/shared/notifications'
3+
import { buildEventNotificationPayload } from '../../src/services/notification'
4+
5+
const ctx = { repoName: 'oc-manager', repoId: 1, sessionId: 'ses_1', directory: '/abs/repo', url: '/repos/1/sessions/ses_1' }
6+
7+
describe('getPermissionLabel', () => {
8+
it('maps known permission types to friendly labels', () => {
9+
expect(getPermissionLabel('bash')).toBe('Run Command')
10+
expect(getPermissionLabel('webfetch')).toBe('Fetch URL')
11+
expect(getPermissionLabel('edit')).toBe('Edit File')
12+
})
13+
it('capitalizes unknown types', () => {
14+
expect(getPermissionLabel('frobnicate')).toBe('Frobnicate')
15+
})
16+
})
17+
18+
describe('getPermissionDetail', () => {
19+
it('returns the bash command', () => {
20+
expect(getPermissionDetail({ permission: 'bash', metadata: { command: 'rm -rf node_modules' } }).primary).toBe('rm -rf node_modules')
21+
})
22+
it('returns the edited file path with a diff secondary', () => {
23+
const detail = getPermissionDetail({ permission: 'edit', metadata: { filePath: 'src/index.ts', diff: 'a\nb' } })
24+
expect(detail.primary).toBe('src/index.ts')
25+
expect(detail.secondary).toBe('a\nb')
26+
})
27+
it('returns the fetched url', () => {
28+
expect(getPermissionDetail({ permission: 'webfetch', metadata: { url: 'https://example.com' } }).primary).toBe('https://example.com')
29+
})
30+
it('falls back to patterns when metadata is missing', () => {
31+
expect(getPermissionDetail({ permission: 'bash', patterns: ['git *'] }).primary).toBe('git *')
32+
})
33+
it('returns empty primary when no detail available', () => {
34+
expect(getPermissionDetail({ permission: 'bash' }).primary).toBe('')
35+
})
36+
})
37+
38+
describe('getQuestionText', () => {
39+
it('returns the first question text', () => {
40+
expect(getQuestionText({ questions: [{ question: 'Deploy to prod?' }] })).toBe('Deploy to prod?')
41+
})
42+
it('returns empty string when no questions', () => {
43+
expect(getQuestionText({ questions: [] })).toBe('')
44+
expect(getQuestionText({})).toBe('')
45+
})
46+
})
47+
48+
describe('buildEventNotificationPayload', () => {
49+
it('formats a bash permission as "{repo}: Run Command" + command body', () => {
50+
const p = buildEventNotificationPayload(
51+
{ type: 'permission.asked', properties: { permission: 'bash', metadata: { command: 'rm -rf node_modules' }, patterns: ['rm *'] } },
52+
ctx,
53+
)!
54+
expect(p.title).toBe('oc-manager: Run Command')
55+
expect(p.body).toBe('rm -rf node_modules')
56+
expect(p.tag).toBe('permission.asked-ses_1')
57+
expect(p.data?.eventType).toBe('permission.asked')
58+
})
59+
60+
it('formats an edit permission with the file path', () => {
61+
const p = buildEventNotificationPayload(
62+
{ type: 'permission.asked', properties: { permission: 'edit', metadata: { filePath: 'src/index.ts' } } },
63+
ctx,
64+
)!
65+
expect(p.title).toBe('oc-manager: Edit File')
66+
expect(p.body).toBe('src/index.ts')
67+
})
68+
69+
it('uses "Approval required" body when no detail is available', () => {
70+
const p = buildEventNotificationPayload(
71+
{ type: 'permission.asked', properties: { permission: 'bash' } },
72+
ctx,
73+
)!
74+
expect(p.body).toBe('Approval required')
75+
})
76+
77+
it('formats a question as "{repo}: Question" + question text body', () => {
78+
const p = buildEventNotificationPayload(
79+
{ type: 'question.asked', properties: { questions: [{ question: 'Deploy to prod?' }] } },
80+
ctx,
81+
)!
82+
expect(p.title).toBe('oc-manager: Question')
83+
expect(p.body).toBe('Deploy to prod?')
84+
})
85+
86+
it('formats session.error as "{repo}: Error" + error message', () => {
87+
const p = buildEventNotificationPayload(
88+
{ type: 'session.error', properties: { error: { message: 'boom' } } },
89+
ctx,
90+
)!
91+
expect(p.title).toBe('oc-manager: Error')
92+
expect(p.body).toBe('boom')
93+
})
94+
95+
it('formats session.idle as "{repo}: Session complete"', () => {
96+
const p = buildEventNotificationPayload({ type: 'session.idle', properties: {} }, ctx)!
97+
expect(p.title).toBe('oc-manager: Session complete')
98+
expect(p.body).toBe('Your session has finished processing')
99+
})
100+
101+
it('omits the repo prefix when no repoName is provided', () => {
102+
const p = buildEventNotificationPayload(
103+
{ type: 'permission.asked', properties: { permission: 'bash', metadata: { command: 'ls' } } },
104+
{ url: '/' },
105+
)!
106+
expect(p.title).toBe('Run Command')
107+
expect(p.tag).toBe('permission.asked-global')
108+
})
109+
110+
it('returns null for unknown event types', () => {
111+
expect(buildEventNotificationPayload({ type: 'session.created', properties: {} }, ctx)).toBeNull()
112+
})
113+
114+
it('truncates bodies longer than 140 chars with an ellipsis', () => {
115+
const long = 'x'.repeat(300)
116+
const p = buildEventNotificationPayload(
117+
{ type: 'permission.asked', properties: { permission: 'bash', metadata: { command: long } } },
118+
ctx,
119+
)!
120+
expect(p.body.length).toBeLessThanOrEqual(140)
121+
expect(p.body.endsWith('…')).toBe(true)
122+
})
123+
})

frontend/src/components/navigation/NotificationsSheet.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BottomSheet, BottomSheetHeader, BottomSheetContent } from '@/components/ui/bottom-sheet'
22
import { usePermissions, useQuestions } from '@/contexts/EventContext'
3+
import { getQuestionText } from '@opencode-manager/shared/notifications'
34
import { Bell, HelpCircle } from 'lucide-react'
45

56
interface NotificationsSheetProps {
@@ -87,7 +88,7 @@ export function NotificationsSheet({ isOpen, onClose }: NotificationsSheetProps)
8788
className="flex flex-col items-start gap-1 p-3 rounded-lg border border-border hover:bg-accent transition-colors text-left w-full"
8889
>
8990
<span className="font-medium text-foreground">
90-
{currentQuestion.questions?.[0]?.question || 'Question'}
91+
{getQuestionText(currentQuestion) || 'Question'}
9192
</span>
9293
<span className="text-xs text-muted-foreground truncate w-full">
9394
Tap to view

0 commit comments

Comments
 (0)