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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user