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:
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { useDock, usePanelFullscreen } from 'officerdev';
|
import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
|
||||||
import { usePermissions } from 'hooks/usePermissions';
|
import { usePermissions } from 'hooks/usePermissions';
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||||
@@ -33,6 +33,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
usePageTitleSync();
|
usePageTitleSync();
|
||||||
|
useChatNotifications();
|
||||||
// A panel can maximize into the content region on its own, but it cannot paint over this header — the
|
// A panel can maximize into the content region on its own, but it cannot paint over this header — the
|
||||||
// region below is an `absolute z-2` stacking context and the header is a `fixed z-10` sibling of it. So
|
// region below is an `absolute z-2` stacking context and the header is a `fixed z-10` sibling of it. So
|
||||||
// "full screen" is cooperative: the panel asks, and the chrome steps aside.
|
// "full screen" is cooperative: the panel asks, and the chrome steps aside.
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { useLocation, useParams } from 'react-router';
|
|||||||
import { Unplug } from 'lucide-react';
|
import { Unplug } from 'lucide-react';
|
||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { EditableTitle } from '@/components/EditableTitle';
|
import { EditableTitle } from '@/components/EditableTitle';
|
||||||
|
import { NotifyMenu } from './NotifyMenu';
|
||||||
import { useSelectedChatSession } from '../../channels';
|
import { useSelectedChatSession } from '../../channels';
|
||||||
import { usePublishPageTitle } from '../../page-title';
|
import { usePublishPageTitle } from '../../page-title';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
|
import type { NotifyPreference } from 'state/useClaudeSessions';
|
||||||
import { useChat, EmbeddableChat } from '../Chat';
|
import { useChat, EmbeddableChat } from '../Chat';
|
||||||
import type { ChatMessage } from '../Chat/types';
|
import type { ChatMessage } from '../Chat/types';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export type SelectedSession = {
|
|||||||
partCount?: number;
|
partCount?: number;
|
||||||
/** Whether this conversation was left in plan mode, so the composer toggle comes back lit. */
|
/** Whether this conversation was left in plan mode, so the composer toggle comes back lit. */
|
||||||
planMode?: boolean;
|
planMode?: boolean;
|
||||||
|
notifyPreference?: NotifyPreference;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type ChatLocationState = {
|
type ChatLocationState = {
|
||||||
@@ -56,9 +59,18 @@ type DetailBarProps = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
onDisconnect?: () => void;
|
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
|
// 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
|
// 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
|
// 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
|
// 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`.
|
// 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(
|
const commitRename = useCallback(
|
||||||
async (next: string) => {
|
async (next: string) => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
@@ -82,6 +94,18 @@ function DetailBar({ sessionTitle, cwd, partCount, isConnected, isGenerating, on
|
|||||||
[renameSession, sessionId],
|
[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 (
|
return (
|
||||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-border bg-background/60">
|
<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">
|
<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 className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
||||||
)}
|
)}
|
||||||
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
||||||
|
{sessionId && <NotifyMenu preference={notifyPreference ?? 'none'} onChange={commitNotifyPreference} />}
|
||||||
{isConnected && onDisconnect && (
|
{isConnected && onDisconnect && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -144,6 +169,7 @@ type NewChatProps = {
|
|||||||
partCount?: number;
|
partCount?: number;
|
||||||
/** Plan mode as this session was left. Only the stored row knows — the transcript records nothing. */
|
/** Plan mode as this session was left. Only the stored row knows — the transcript records nothing. */
|
||||||
planMode?: boolean;
|
planMode?: boolean;
|
||||||
|
notifyPreference?: NotifyPreference;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NewChat(props: NewChatProps) {
|
function NewChat(props: NewChatProps) {
|
||||||
@@ -198,6 +224,7 @@ function NewChat(props: NewChatProps) {
|
|||||||
sessionTitle={props.sessionTitle ?? undefined}
|
sessionTitle={props.sessionTitle ?? undefined}
|
||||||
cwd={props.sessionCwd}
|
cwd={props.sessionCwd}
|
||||||
partCount={props.partCount}
|
partCount={props.partCount}
|
||||||
|
notifyPreference={props.notifyPreference}
|
||||||
isConnected={chat.isConnected}
|
isConnected={chat.isConnected}
|
||||||
isGenerating={chat.isGenerating}
|
isGenerating={chat.isGenerating}
|
||||||
onDisconnect={chat.disconnectSession}
|
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.
|
// down: renaming from either surface now retitles both, and the list's own pencil retitles an open pane.
|
||||||
const { sessions } = useClaudeSessions(selected?.cwd);
|
const { sessions } = useClaudeSessions(selected?.cwd);
|
||||||
const title = sessions.find((session) => session.id === sessionId)?.title ?? selected?.title ?? null;
|
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
|
// 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.
|
// — only the first should override a tab name typed earlier. See usePageTitle.
|
||||||
@@ -267,6 +298,7 @@ export const ChatDetailPanel = () => {
|
|||||||
sessionTitle={title}
|
sessionTitle={title}
|
||||||
partCount={selected.partCount}
|
partCount={selected.partCount}
|
||||||
planMode={selected.planMode}
|
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 { 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 { toast } from '@/components/ui/sonner';
|
||||||
import { DataRow, MetaItem, RelativeTime } from '@/components/Data';
|
import { DataRow, MetaItem, RelativeTime } from '@/components/Data';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import type { ClaudeSessionSummary } from 'state/useClaudeSessions';
|
import type { ClaudeSessionSummary, NotifyPreference } from 'state/useClaudeSessions';
|
||||||
import { chatSessionPath } from './chat-routes';
|
import { chatSessionPath } from './chat-routes';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -63,6 +63,17 @@ export const PlanBadge = ({ planMode }: { planMode?: boolean }) =>
|
|||||||
</span>
|
</span>
|
||||||
) : null;
|
) : 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.
|
* This conversation has an agent behind it right now.
|
||||||
*
|
*
|
||||||
@@ -241,6 +252,7 @@ export const SessionRow = ({
|
|||||||
<HarnessBadge key="h" harness={session.harness} />,
|
<HarnessBadge key="h" harness={session.harness} />,
|
||||||
<ModelBadge key="m" model={session.model} />,
|
<ModelBadge key="m" model={session.model} />,
|
||||||
<PlanBadge key="pm" planMode={session.planMode} />,
|
<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
|
// 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
|
// 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".
|
// stands in its place rather than beside a zero that means "not asked".
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './useFilesAPI';
|
export * from './useFilesAPI';
|
||||||
export * from './useFileViewerPanels';
|
export * from './useFileViewerPanels';
|
||||||
export * from './useChat';
|
export * from './useChat';
|
||||||
|
export * from './useChatNotifications';
|
||||||
export * from './useDock';
|
export * from './useDock';
|
||||||
export * from './useServiceConnection';
|
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;
|
model?: string | null;
|
||||||
/** Whether this conversation is in plan mode. Absent on older rows; false is the default everywhere. */
|
/** Whether this conversation is in plan mode. Absent on older rows; false is the default everywhere. */
|
||||||
planMode?: boolean;
|
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).
|
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
|
||||||
export type ClaudeSessionMessage =
|
export type ClaudeSessionMessage =
|
||||||
| { role: 'user'; text: string }
|
| { role: 'user'; text: string }
|
||||||
@@ -167,6 +171,14 @@ export function useClaudeSessions(cwd?: string | null, options?: { all?: boolean
|
|||||||
[client, sessionQ, invalidate],
|
[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 {
|
return {
|
||||||
sessions: data?.sessions ?? [],
|
sessions: data?.sessions ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -177,6 +189,7 @@ export function useClaudeSessions(cwd?: string | null, options?: { all?: boolean
|
|||||||
loadSession,
|
loadSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
|
setNotifyPreference,
|
||||||
invalidate,
|
invalidate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user