bell: notify a chat's page and list row, any chat, any time

Client half of the per-chat notification preference — DetailBar gets a bell
dropdown (every end of turn / only when input is needed / none), a global
useChatNotifications hook holds one connection to /api/chat/notify/ws for
the whole session so a background chat can still ring the bell and fire a
browser Notification, and the session list shows a badge on any row set to
notify. The server side (schema, the notify-delivery socket, the sidecar
signal at turn-end/plan-gate/question-gate) landed already in 5cba1b65,
swept in from a concurrent session sharing this working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 21:32:06 +00:00
co-authored by Claude Opus 5
parent 5cba1b65a6
commit 73cdc2cb77
7 changed files with 165 additions and 5 deletions
@@ -3,11 +3,13 @@ import { useLocation, useParams } from 'react-router';
import { Unplug } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { EditableTitle } from '@/components/EditableTitle';
import { NotifyMenu } from './NotifyMenu';
import { useSelectedChatSession } from '../../channels';
import { usePublishPageTitle } from '../../page-title';
import { useAuth } from 'hooks/useAuth';
import { errorText } from 'helpers/error-text';
import { useClaudeSessions } from 'state/useClaudeSessions';
import type { NotifyPreference } from 'state/useClaudeSessions';
import { useChat, EmbeddableChat } from '../Chat';
import type { ChatMessage } from '../Chat/types';
@@ -32,6 +34,7 @@ export type SelectedSession = {
partCount?: number;
/** Whether this conversation was left in plan mode, so the composer toggle comes back lit. */
planMode?: boolean;
notifyPreference?: NotifyPreference;
} | null;
type ChatLocationState = {
@@ -56,9 +59,18 @@ type DetailBarProps = {
isConnected: boolean;
isGenerating: boolean;
onDisconnect?: () => void;
notifyPreference?: NotifyPreference;
};
function DetailBar({ sessionTitle, cwd, partCount, isConnected, isGenerating, onDisconnect }: DetailBarProps) {
function DetailBar({
sessionTitle,
cwd,
partCount,
isConnected,
isGenerating,
onDisconnect,
notifyPreference,
}: DetailBarProps) {
// The id to rename comes from the URL, not from `resumeSessionId`. They are the same for an ordinary
// conversation and not for a merged `/clear` chain, where the resume target is the tail transcript
// while the list, the links and the server all address the chain by its head. Renaming the tail would
@@ -69,7 +81,7 @@ function DetailBar({ sessionTitle, cwd, partCount, isConnected, isGenerating, on
// The same rename the list's pencil calls, so both surfaces write one place and the invalidation that
// follows refreshes the row too. A failure toasts rather than reverting silently, matching `SessionList`.
const { renameSession } = useClaudeSessions(cwd);
const { renameSession, setNotifyPreference } = useClaudeSessions(cwd);
const commitRename = useCallback(
async (next: string) => {
if (!sessionId) return;
@@ -82,6 +94,18 @@ function DetailBar({ sessionTitle, cwd, partCount, isConnected, isGenerating, on
[renameSession, sessionId],
);
const commitNotifyPreference = useCallback(
async (preference: NotifyPreference) => {
if (!sessionId) return;
try {
await setNotifyPreference(sessionId, preference);
} catch (err) {
toast.error(errorText(err));
}
},
[setNotifyPreference, sessionId],
);
return (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-border bg-background/60">
<div className="flex-1 min-w-0 px-3 text-center">
@@ -109,6 +133,7 @@ function DetailBar({ sessionTitle, cwd, partCount, isConnected, isGenerating, on
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
)}
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
{sessionId && <NotifyMenu preference={notifyPreference ?? 'none'} onChange={commitNotifyPreference} />}
{isConnected && onDisconnect && (
<button
type="button"
@@ -144,6 +169,7 @@ type NewChatProps = {
partCount?: number;
/** Plan mode as this session was left. Only the stored row knows — the transcript records nothing. */
planMode?: boolean;
notifyPreference?: NotifyPreference;
};
function NewChat(props: NewChatProps) {
@@ -198,6 +224,7 @@ function NewChat(props: NewChatProps) {
sessionTitle={props.sessionTitle ?? undefined}
cwd={props.sessionCwd}
partCount={props.partCount}
notifyPreference={props.notifyPreference}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
@@ -241,6 +268,10 @@ export const ChatDetailPanel = () => {
// down: renaming from either surface now retitles both, and the list's own pencil retitles an open pane.
const { sessions } = useClaudeSessions(selected?.cwd);
const title = sessions.find((session) => session.id === sessionId)?.title ?? selected?.title ?? null;
// Same "list's copy wins" reasoning as title above — a preference change made from the list's own
// bell (once that exists) must reach an already-open pane, not just the row.
const notifyPreference =
sessions.find((session) => session.id === sessionId)?.notifyPreference ?? selected?.notifyPreference ?? 'none';
// The id rides along so the shell can tell a rename of this conversation from opening a different one
// — only the first should override a tab name typed earlier. See usePageTitle.
@@ -267,6 +298,7 @@ export const ChatDetailPanel = () => {
sessionTitle={title}
partCount={selected.partCount}
planMode={selected.planMode}
notifyPreference={notifyPreference}
/>
);
};
@@ -0,0 +1,58 @@
import { Bell, BellOff, Check } from 'lucide-react';
import * as Dropdown from '@/components/ui/dropdown-menu';
import type { NotifyPreference } from 'state/useClaudeSessions';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown;
const OPTIONS: { value: NotifyPreference; label: string }[] = [
{ value: 'every_turn', label: 'Every end of turn' },
{ value: 'input_needed', label: 'Only when input is needed' },
{ value: 'none', label: 'None' },
];
type NotifyMenuProps = {
preference: NotifyPreference;
onChange: (preference: NotifyPreference) => void;
};
/**
* The bell — a chat's notification preference, same header row as its editable title.
*
* Manual-checkmark `DropdownMenuItem`s rather than `DropdownMenuRadioItem`/`Group`: this codebase's
* house pattern for "N exclusive choices in an icon menu" (see `ThemeSwitcher`), not the Radix radio
* variant, which nothing here uses.
*/
export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => {
const pick = (value: NotifyPreference) => {
onChange(value);
// Lazy, first-opt-in request — the only place this fires. A denial or an already-decided answer is a
// no-op; the delivery hook re-checks `Notification.permission` before every actual notify, so nothing
// downstream depends on this call having succeeded.
if (value !== 'none' && 'Notification' in window && Notification.permission === 'default') {
void Notification.requestPermission();
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={`Notifications: ${OPTIONS.find((o) => o.value === preference)?.label ?? 'None'}`}
aria-label="Notification preference"
className="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
{preference === 'none' ? <BellOff className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{OPTIONS.map((opt) => (
<DropdownMenuItem key={opt.value} className="cursor-pointer" onClick={() => pick(opt.value)}>
<Check className={`mr-2 h-4 w-4 ${opt.value === preference ? 'opacity-100' : 'opacity-0'}`} />
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -1,9 +1,9 @@
import { useCallback, useRef, useState } from 'react';
import { Check, ClipboardList, Folder, Layers, Loader2, Pencil, Trash2, X } from 'lucide-react';
import { Bell, Check, ClipboardList, Folder, Layers, Loader2, Pencil, Trash2, X } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { DataRow, MetaItem, RelativeTime } from '@/components/Data';
import { errorText } from 'helpers/error-text';
import type { ClaudeSessionSummary } from 'state/useClaudeSessions';
import type { ClaudeSessionSummary, NotifyPreference } from 'state/useClaudeSessions';
import { chatSessionPath } from './chat-routes';
/*
@@ -63,6 +63,17 @@ export const PlanBadge = ({ planMode }: { planMode?: boolean }) =>
</span>
) : null;
/** This conversation is set to tell you about itself — the row-level reflection of the bell menu. */
export const NotifyBadge = ({ preference }: { preference?: NotifyPreference }) =>
preference && preference !== 'none' ? (
<span
className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary"
title={preference === 'every_turn' ? 'Notifies every end of turn' : 'Notifies when input is needed'}
>
<Bell className="h-3 w-3" />
</span>
) : null;
/**
* This conversation has an agent behind it right now.
*
@@ -241,6 +252,7 @@ export const SessionRow = ({
<HarnessBadge key="h" harness={session.harness} />,
<ModelBadge key="m" model={session.model} />,
<PlanBadge key="pm" planMode={session.planMode} />,
<NotifyBadge key="nb" preference={session.notifyPreference} />,
// Claude's transcripts give a real turn count. OpenCode's session list has no count field, so the
// number on those rows is a hardcoded 0 and costs an HTTP call per row to make real — the badge
// stands in its place rather than beside a zero that means "not asked".
@@ -1,5 +1,6 @@
export * from './useFilesAPI';
export * from './useFileViewerPanels';
export * from './useChat';
export * from './useChatNotifications';
export * from './useDock';
export * from './useServiceConnection';
@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { notificationPopSound } from 'sounds';
type NotifyMessage = { type: 'notify'; sessionId: string; kind: 'turn-ended' | 'input-needed'; title: string };
/**
* The other half of the bell menu (see `NotifyMenu` in `ChatHistory`) — one connection, held open at the
* shell level regardless of which chat (if any) is on screen, so a chat set to notify can reach you even
* when its own panel isn't open. See `notify-delivery.ts` for the server side and why this rides its own
* socket rather than the per-session chat one.
*
* Scope, stated plainly: this only works while an Officer tab is open somewhere — doesn't need focus,
* does need an open tab. A fully closed browser gets nothing; that gap belongs to the separate phone-push
* system (`src/servers/sidecar/notify/`), not this.
*/
export function useChatNotifications() {
const onMessage = useCallback((data: unknown) => {
const msg = data as Partial<NotifyMessage>;
if (msg?.type !== 'notify' || !msg.sessionId || !msg.kind) return;
new Audio(notificationPopSound.dataUri).play().catch(() => {
// Autoplay can be refused before the user has interacted with the page at all — the browser
// Notification below is the half that still gets through.
});
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(msg.title ?? 'Chat', {
body: msg.kind === 'input-needed' ? 'Needs your input' : 'Finished its turn',
tag: msg.sessionId, // collapses repeats for the same session instead of stacking
});
}
}, []);
// No `isAuthenticated` guard: this hook is mounted once in `DashboardLayout`, which only renders under
// the authenticated route tree (`App.tsx`), so a token is already guaranteed here — same posture
// `useChat` itself takes on its own, identically-built `wsUrl`.
const token = localStorage.getItem('BEARER_TOKEN');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/api/chat/notify/ws?token=${token}`;
useChatWebSocket({ url, onMessage });
}
@@ -39,8 +39,12 @@ export type ClaudeSessionSummary = {
model?: string | null;
/** Whether this conversation is in plan mode. Absent on older rows; false is the default everywhere. */
planMode?: boolean;
/** The bell preference — from `chat_notification_prefs`. Absent/`'none'` means no row yet. */
notifyPreference?: NotifyPreference;
};
export type NotifyPreference = 'every_turn' | 'input_needed' | 'none';
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
export type ClaudeSessionMessage =
| { role: 'user'; text: string }
@@ -167,6 +171,14 @@ export function useClaudeSessions(cwd?: string | null, options?: { all?: boolean
[client, sessionQ, invalidate],
);
const setNotifyPreference = useCallback(
async (id: string, preference: NotifyPreference) => {
await client.patch(`/chat/sessions/${id}/notify-pref${sessionQ}`, { preference });
invalidate();
},
[client, sessionQ, invalidate],
);
return {
sessions: data?.sessions ?? [],
isLoading,
@@ -177,6 +189,7 @@ export function useClaudeSessions(cwd?: string | null, options?: { all?: boolean
loadSession,
deleteSession,
renameSession,
setNotifyPreference,
invalidate,
};
}