Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,23 @@ several decisions below.
This preserves configured asks and pattern-specific denies without blanket allows.
Activation must succeed before `prompt_async`, and exact suffix checks make repeated
same-mode prompts idempotent.
10. **Auto permissions is volatile and directory-scoped.** The BFF keeps it in memory,
10. **Notification records are persisted; the badge counts outstanding work, not unread.**
`.state/notification-history.json` (`NOTIFICATION_HISTORY_FILE`) is
written by `NotificationService`, which previously discarded everything it sent. Only
`permission` and `question` records are `actionable` and can hold the red counter;
`idle`/`error`/`abort` are logged but never counted, and `parked` escalates its parent
permission rather than adding a second count. Suppressed and failed deliveries are
still recorded — the log's job is to explain a missing ping. `delivery.desktop` is the
server-backed desktop preference, never proof of render; device-local sound/speech are
intentionally absent because the BFF cannot see them. All active records are retained;
resolved history fills the remaining space in a 500-record ring. Since records
outlive the process, the active set is reconciled against `GET /permission` and
`GET /question` on stream reconnect and (throttled) on history reads; that is the
**only** dependable path for questions, whose reply events this repo has never
observed. Lookup failures never resolve records; explicit reply/reject, successful
reconciliation, or manual dismissal are the only resolution paths. There is no bulk
clear because resolved history is the evidence this feature exists to preserve.
11. **Auto permissions is volatile and directory-scoped.** The BFF keeps it in memory,
defaults it off after every restart, and replies `once` to `permission.asked` for
every session in an enabled directory. It never mutates policy, replies `always`,
or answers questions; it can only approve requests that upstream emits as asked.
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ Constructor-based browser Notifications on iPhone and iPad still require install
and service-worker support. This feature does not add a service worker; ntfy remains the
reliable phone notification path.

A red counter appears on the nav link and the page header while work is outstanding. It is
**not** an unread count: it counts permission and question requests still awaiting a reply,
so it goes to zero by answering the agent, not by visiting the page. `idle`, `error` and
`abort` are logged but never counted — nothing about them is actionable. A parked
permission escalates the record it belongs to instead of adding a second count.

The page also lists every notification the BFF classified, including ones that were never
delivered, because "why was I never asked?" is the question that log exists to answer.
`ntfy` reports `sent`, `off` or `failed`; `desktop` reports only whether server-backed
desktop notifications were **allowed**, since the BFF cannot observe whether a tab rendered
one. Sound and speech are device-local and therefore absent from the server log.
Auto-approved permissions appear marked `suppressed by auto permissions` and never hold the
counter.

Records live in `.state/notification-history.json` (override with
`NOTIFICATION_HISTORY_FILE`). All active records are retained; resolved history fills the
remaining space in a 500-record ring. Because records outlive the process,
the BFF reconciles the outstanding set against `GET /permission` and `GET /question` on
every event-stream reconnect and, throttled, whenever the page loads. That closes requests
answered while the BFF was down. Lookup failures leave records active, and **Dismiss** is
the only manual way to clear a stuck row. History is not bulk-clearable because it is the
evidence used to explain missing or suppressed delivery.

Verification requires no live agent or model credentials:

```bash
Expand Down
52 changes: 38 additions & 14 deletions client/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { Search, Smartphone } from "lucide-react";
import { useTheme } from "next-themes";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";

import { Badge } from "../ds/badge.js";
import { Button } from "../ds/button.js";
import { CommandPalette } from "../ds/command-palette.js";
import { api, type SessionSummary } from "../lib/api.js";
import { useNotificationCenter } from "../lib/useNotificationCenter.js";
import {
DIRECTORY_STORAGE_KEY,
buildPaletteCommands,
Expand All @@ -18,7 +20,8 @@ import { useNotifyWatcher } from "../lib/useNotifyWatcher.js";
import { PhoneTransferDialog } from "./phone-transfer-dialog.js";

export function AppShell() {
useNotifyWatcher();
const { activeCount, refresh } = useNotificationCenter();
useNotifyWatcher(refresh);
const location = useLocation();
const navigate = useNavigate();
const { setTheme } = useTheme();
Expand Down Expand Up @@ -90,7 +93,12 @@ export function AppShell() {
navigation: [
{ id: "home", title: "Home", to: scopedPath("/"), keywords: ["sessions"] },
{ id: "tools", title: "Tools", to: scopedPath("/tools"), keywords: ["mcp", "lsp", "permissions"] },
{ id: "notifications", title: "Notifications", to: scopedPath("/settings/notifications") },
{
id: "notifications",
title: "Notifications",
to: scopedPath("/settings/notifications"),
...(activeCount > 0 ? { subtitle: `${activeCount} awaiting reply` } : {}),
},
{ id: "settings", title: "Settings", to: scopedPath("/settings") },
],
actions: [
Expand Down Expand Up @@ -157,18 +165,34 @@ export function AppShell() {
["/tools", "Tools"],
["/settings/notifications", "Notifications"],
["/settings", "Settings"],
].map(([to, label]) => (
<NavLink
key={to}
to={scopedPath(to)}
className={({ isActive }) =>
`rounded px-2 py-1 text-xs ${isActive ? "bg-[var(--color-background-surface-neutral-muted)] font-semibold" : "text-[var(--color-text-muted)]"}`
}
data-testid={`opencode-nav-${label.toLowerCase()}`}
>
{label}
</NavLink>
))}
].map(([to, label]) => {
const badged = label === "Notifications" && activeCount > 0;
return (
<NavLink
key={to}
to={scopedPath(to)}
// The count is in the label so screen readers announce it; the
// badge itself is decorative.
aria-label={badged ? `${label}, ${activeCount} awaiting reply` : undefined}
className={({ isActive }) =>
`relative flex items-center rounded px-2 py-1 text-xs ${isActive ? "bg-[var(--color-background-surface-neutral-muted)] font-semibold" : "text-[var(--color-text-muted)]"}`
}
data-testid={`opencode-nav-${label.toLowerCase()}`}
>
{label}
{badged && (
<Badge
variant="counter"
className="absolute -top-1 left-full -translate-x-1/2"
aria-hidden="true"
data-testid="opencode-nav-notifications-badge"
>
{activeCount}
</Badge>
)}
</NavLink>
);
})}
</nav>
<div className="min-h-0 flex-1">
<Outlet />
Expand Down
6 changes: 5 additions & 1 deletion client/ds/badge.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from "react";
import { cn } from "./utils.js";

type BadgeVariant = "neutral" | "info" | "success" | "warning" | "danger" | "pro" | "beta";
type BadgeVariant = "neutral" | "info" | "success" | "warning" | "danger" | "pro" | "beta" | "counter";

interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
Expand All @@ -23,6 +23,10 @@ const variantClasses: Record<BadgeVariant, string> = {
"bg-[var(--color-background-surface-danger-muted)] text-[var(--color-text-danger)] border border-red-500/40",
pro: "bg-[var(--color-purple-100)] text-[var(--color-purple-700)]",
beta: "bg-[var(--color-blue-100)] text-[var(--color-blue-700)]",
// Numeric counter: solid fill so it reads as "unanswered" at nav size, and
// tabular digits so the pill does not jitter as the count changes.
counter:
"bg-[var(--color-background-surface-danger)] text-[var(--color-text-on-danger)] justify-center min-w-5 px-1.5 tabular-nums",
};

const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
Expand Down
41 changes: 41 additions & 0 deletions client/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ export interface NotificationPreferences {
parkedPermissionSeconds: number;
}

export type NotificationHistoryState = "all" | "active" | "resolved";

export interface NotificationRecord {
id: string;
kind: NotifyEvent;
at: number;
directory?: string;
sessionID?: string;
requestID?: string;
title: string;
body: string;
click?: string;
/** Only permission/question records can hold the badge above zero. */
actionable: boolean;
resolvedAt?: number;
resolvedBy?: "replied" | "reconciled" | "dismissed" | "suppressed";
parkedAt?: number;
delivery: {
ntfy: "sent" | "off" | "failed";
ntfyError?: string;
/** Desktop-notification preference; sound and speech are device-local. */
desktop: "allowed" | "off";
suppressed?: "auto-permissions";
};
}

export interface WorkspaceNode {
name: string;
path: string;
Expand Down Expand Up @@ -344,6 +370,21 @@ export const api = {
}).then((r) => json<{ preferences: NotificationPreferences; tokenConfigured: boolean }>(r)),
testNtfy: () =>
fetch("/api/notifications/test", { method: "POST" }).then((r) => json<{ sent: boolean }>(r)),
notificationHistory: (options: { limit?: number; kind?: NotifyEvent; state?: NotificationHistoryState; directory?: string } = {}) => {
const query = new URLSearchParams();
if (options.limit) query.set("limit", String(options.limit));
if (options.kind) query.set("kind", options.kind);
if (options.state && options.state !== "all") query.set("state", options.state);
if (options.directory) query.set("directory", options.directory);
const suffix = query.size ? `?${query}` : "";
return fetch(`/api/notifications/history${suffix}`).then((r) =>
json<{ records: NotificationRecord[]; activeCount: number }>(r),
);
},
dismissNotification: (id: string) =>
fetch(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: "POST" }).then((r) =>
json<{ dismissed: boolean; activeCount: number }>(r),
),

autoPermissions: (directory: string) =>
fetch(scoped("/auto-approve", directory)).then((r) => json<AutoPermissionStatus>(r)),
Expand Down
104 changes: 104 additions & 0 deletions client/lib/useNotificationCenter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useLocation } from "react-router-dom";

import { api, type NotificationRecord } from "./api.js";
import { DIRECTORY_STORAGE_KEY, resolvePaletteDirectory } from "./palette.js";

interface NotificationCenter {
activeCount: number;
records: NotificationRecord[];
loading: boolean;
error: string;
refresh: () => void;
dismiss: (id: string) => Promise<void>;
}

const NotificationCenterContext = createContext<NotificationCenter | null>(null);

/**
* SSE types that can change the active set. The stream is only a nudge — the
* server owns the count, so these trigger a refetch rather than a local
* mutation. `permission.asked` is already filtered upstream when
* auto-permissions is on, which is correct: those never become active.
*
* Consumed by useNotifyWatcher, which owns the single app-level EventSource.
*/
export const ACTIVE_SET_EVENTS = new Set([
"permission.asked",
"permission.replied",
"question.asked",
"question.replied",
"question.rejected",
"notification.parked",
]);

const HISTORY_LIMIT = 100;

export function NotificationCenterProvider({ children }: { children: ReactNode }) {
const location = useLocation();
const directory = resolvePaletteDirectory(location.search, localStorage.getItem(DIRECTORY_STORAGE_KEY));
const [records, setRecords] = useState<NotificationRecord[]>([]);
const [activeCount, setActiveCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Guards against a slow early response overwriting a newer one.
const generation = useRef(0);

const refresh = useCallback(() => {
const request = ++generation.current;
return api
.notificationHistory({ limit: HISTORY_LIMIT, ...(directory ? { directory } : {}) })
.then((result) => {
if (request !== generation.current) return;
setRecords(result.records);
setActiveCount(result.activeCount);
setError("");
})
.catch((e: Error) => {
if (request !== generation.current) return;
setError(e.message);
})
.finally(() => {
if (request === generation.current) setLoading(false);
});
}, [directory]);

// Live updates arrive via useNotifyWatcher, which already holds the one
// app-level EventSource; opening a second stream here would double every
// tab's upstream fan-out for no benefit.
useEffect(() => {
void refresh();
}, [refresh]);

const dismiss = useCallback(
async (id: string) => {
await api.dismissNotification(id);
await refresh();
},
[refresh],
);

const value = useMemo(
() => ({ activeCount, records, loading, error, refresh: () => void refresh(), dismiss }),
[activeCount, records, loading, error, refresh, dismiss],
);

return <NotificationCenterContext.Provider value={value}>{children}</NotificationCenterContext.Provider>;
}

/**
* Returns an inert centre when no provider is mounted so isolated page tests
* can render without the shell.
*/
export function useNotificationCenter(): NotificationCenter {
return (
useContext(NotificationCenterContext) ?? {
activeCount: 0,
records: [],
loading: false,
error: "",
refresh: () => {},
dismiss: async () => {},
}
);
}
33 changes: 29 additions & 4 deletions client/lib/useNotifyWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";

import { api, type NotificationPreferences, type NotifyEvent } from "./api.js";
import {
Expand All @@ -8,6 +8,7 @@ import {
type DeviceNotificationPreferences,
} from "./notificationMedia.js";
import { playNotificationSound, speakNotification, unlockNotificationAudio } from "./notificationMediaBrowser.js";
import { ACTIVE_SET_EVENTS } from "./useNotificationCenter.js";

function classify(type: string, properties: Record<string, unknown>): NotifyEvent | null {
if (type === "session.idle") return "idle";
Expand Down Expand Up @@ -43,12 +44,27 @@ export function notifyBrowser(
}
}

/** One app-level listener. SSE is a nudge; notification preferences stay server-backed. */
export function useNotifyWatcher(): void {
const ACTIVE_SET_DEBOUNCE_MS = 300;

/**
* One app-level listener. SSE is a nudge; notification preferences stay
* server-backed.
*
* `onActiveSetChanged` fires (debounced) for events that can add or clear an
* outstanding permission/question, so the badge can refetch from the server
* without this hook's consumer opening a second EventSource.
*/
export function useNotifyWatcher(onActiveSetChanged?: () => void): void {
// Kept in a ref so the effect can stay mounted for the app's lifetime
// instead of tearing the stream down whenever the callback identity changes.
const notifyActiveSet = useRef(onActiveSetChanged);
notifyActiveSet.current = onActiveSetChanged;

useEffect(() => {
let preferences: NotificationPreferences | null = null;
let devicePreferences: DeviceNotificationPreferences = loadDeviceNotificationPreferences().preferences;
const seen = new Map<string, number>();
let activeSetTimer: ReturnType<typeof setTimeout> | undefined;
const refreshPreferences = () => void api.notifications().then((result) => {
preferences = result.preferences;
devicePreferences = initializeDeviceNotificationPreferences(result.preferences.browser).preferences;
Expand All @@ -72,7 +88,15 @@ export function useNotifyWatcher(): void {
} catch {
return;
}
if (!event.type || !preferences) return;
if (!event.type) return;
// Ahead of the preferences guard on purpose: the badge must still track
// outstanding work during the first paint, before preferences load.
if (ACTIVE_SET_EVENTS.has(event.type)) {
if (activeSetTimer) clearTimeout(activeSetTimer);
// One agent turn can emit several of these; coalesce into one refetch.
activeSetTimer = setTimeout(() => notifyActiveSet.current?.(), ACTIVE_SET_DEBOUNCE_MS);
}
if (!preferences) return;
const kind = classify(event.type, event.properties ?? {});
if (!kind) return;
const properties = event.properties ?? {};
Expand All @@ -88,6 +112,7 @@ export function useNotifyWatcher(): void {
notifyBrowser(preferences, kind, undefined, event.click, devicePreferences);
};
return () => {
if (activeSetTimer) clearTimeout(activeSetTimer);
source.close();
window.removeEventListener("opencode-notification-preferences", refreshPreferences);
window.removeEventListener(NOTIFICATION_MEDIA_CHANGE_EVENT, refreshDevicePreferences);
Expand Down
Loading
Loading