diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index 82908fd8..2b8a1cb6 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react'; import { useLocation } from 'react-router'; -import { useDock, usePanelFullscreen } from 'officerdev'; +import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev'; import { usePermissions } from 'hooks/usePermissions'; import { ErrorBoundary } from '@/components/ErrorBoundary'; import { ScreenErrorFallback } from './ScreenErrorFallback'; @@ -33,6 +33,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const isTouch = useIsTouch(); const { pathname } = useLocation(); usePageTitleSync(); + useChatNotifications(); // 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 // "full screen" is cooperative: the panel asks, and the chrome steps aside. diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index c91db337..2eefff85 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -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 (
@@ -109,6 +133,7 @@ function DetailBar({ sessionTitle, cwd, partCount, isConnected, isGenerating, on )} {!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''} + {sessionId && } {isConnected && onDisconnect && ( + + + {OPTIONS.map((opt) => ( + pick(opt.value)}> + + {opt.label} + + ))} + + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionRow.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionRow.tsx index a32bea89..64e5fdb2 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionRow.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionRow.tsx @@ -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 }) => ) : 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' ? ( + + + + ) : null; + /** * This conversation has an agent behind it right now. * @@ -241,6 +252,7 @@ export const SessionRow = ({ , , , + , // 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". diff --git a/src/workspaces/officerdev/src/hooks/index.ts b/src/workspaces/officerdev/src/hooks/index.ts index b38c0dc9..46925f9f 100644 --- a/src/workspaces/officerdev/src/hooks/index.ts +++ b/src/workspaces/officerdev/src/hooks/index.ts @@ -1,5 +1,6 @@ export * from './useFilesAPI'; export * from './useFileViewerPanels'; export * from './useChat'; +export * from './useChatNotifications'; export * from './useDock'; export * from './useServiceConnection'; diff --git a/src/workspaces/officerdev/src/hooks/useChatNotifications.ts b/src/workspaces/officerdev/src/hooks/useChatNotifications.ts new file mode 100644 index 00000000..b3fe2d17 --- /dev/null +++ b/src/workspaces/officerdev/src/hooks/useChatNotifications.ts @@ -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; + 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 }); +} diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 1ceff244..a3bbc004 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -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, }; }