Skip to content

Add browser chat notifications - #2

Open
AntipodTX wants to merge 1 commit into
mainfrom
feature/browser-chat-notifications
Open

AntipodTX wants to merge 1 commit into
mainfrom
feature/browser-chat-notifications

Conversation

@AntipodTX

@AntipodTX AntipodTX commented May 18, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an opt-in browser notification setting for chat activity so users can receive system notifications alongside existing chat sound events.

What changed

  • Adds a Browser Notifications setting next to Chat Sound with Never, Unfocused, and Always choices.
  • Shows system notifications for newly unread chats and chats waiting for user input.
  • Builds notification titles from the project and chat names, and uses a concise response or pending-question preview for the body.
  • Opens the corresponding chat when the user clicks a notification.
  • Persists and normalizes the new preference with the existing app settings and local preference stores.

Notes

The setting defaults to Never and only requests browser notification permission when the user enables notification delivery.

Greptile Summary

This PR adds an opt-in browser notification system for chat activity, pairing it with the existing chat sound setting. A new ChatBrowserNotificationPreference type and chatBrowserNotificationPreference setting are wired from the shared type layer through server persistence, the WebSocket router, and the client store, and ultimately consumed in a useEffect in KannaLayout that fires showChatBrowserNotification for each newly-unread or newly-waiting chat.

  • Adds getChatNotificationEvents to compute per-chat notification events from sidebar state diffs, using pendingUserInputPreview (the active tool question/plan) as the body for waiting_for_user transitions and lastAgentMessagePreview as the body for unread transitions.
  • Adds getPendingToolPreviews() on AgentCoordinator and fixes a subtle timing bug: pendingTool is now assigned and status set to waiting_for_user synchronously inside the promise constructor, ensuring the preview is available when emitStateChange fires.
  • Wires pendingUserInputPreviews from the agent into deriveSidebarData so the read model includes the preview only while a chat is actively in waiting_for_user status.

Confidence Score: 5/5

Safe to merge; the new notification path is purely additive and the only shared-path change (the pendingTool/emitStateChange ordering in agent.ts) corrects a pre-existing race rather than introducing one.

Every changed layer — type definitions, server persistence, read-model projection, agent coordinator, client store, settings UI, and the notification dispatch loop — is independently unit-tested and follows the conventions already established for chat sounds. The timing fix in agent.ts is a strict improvement: pendingTool is now atomically set with the status change inside the promise constructor before the state-change event fires, ensuring the preview is always present when the ws-router snapshot is computed. No existing behavior is altered when the preference is "never" (the default).

No files require special attention.

Important Files Changed

Filename Overview
src/client/lib/chatBrowserNotifications.ts New module: permission request, payload building (title + truncated body), and showChatBrowserNotification with click handler; well-guarded with Notification.permission !== "granted" check at the call site.
src/client/app/chatNotifications.ts Adds getChatNotificationEvents that diffs previous/next sidebar state to produce per-chat notification events; correctly emits a single event per chat even when both becameUnread and becameWaiting are true simultaneously.
src/server/agent.ts Adds getPendingToolPreviews() and fixes a race: pendingTool is now set before status = "waiting_for_user" and emitStateChange inside the Promise constructor, so the preview is always available when the ws-router snapshot is computed.
src/server/read-models.ts Adds pendingUserInputPreviews option to deriveSidebarData; conditionally includes pendingUserInputPreview on SidebarChatRow only when the derived status is waiting_for_user.
src/server/ws-router.ts Passes agent.getPendingToolPreviews() as pendingUserInputPreviews to deriveSidebarData; one-line change with no logic risk.
src/client/app/App.tsx Integrates browser notification firing into the sidebar-diff useEffect, sharing the same previous-state ref as the sound burst count; restructured so both sound and notification paths use the same captured previousSidebarData.
src/client/app/settings/GeneralSection.tsx Adds the Chat Notifications settings row with permission-request flow; correctly falls back to "never" when permission is denied/unsupported and surfaces an error message.
src/shared/types.ts Adds ChatBrowserNotificationPreference as an alias for ChatSoundPreference, pendingUserInputPreview on SidebarChatRow, and chatBrowserNotificationPreference on AppSettingsSnapshot/AppSettingsPatch.
src/server/app-settings.ts Adds normalization, default, serialization, and deserialization for chatBrowserNotificationPreference; consistent with the existing chatSoundPreference pattern.
src/client/stores/chatSoundPreferencesStore.ts Adds chatBrowserNotificationPreference state and setter with normalization; defaults to "never" as specified.
src/client/app/settings/shared.tsx Adds resolveChatBrowserNotificationPreferenceAfterPermission helper that maps requested preference + permission result to the effective preference.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent as AgentCoordinator
    participant WsRouter as ws-router
    participant ReadModel as deriveSidebarData
    participant Client as KannaLayout (React)
    participant BrowserAPI as Browser Notification API

    Agent->>Agent: "pendingTool = { tool, resolve }"
    Agent->>Agent: "status = waiting_for_user"
    Agent->>WsRouter: emitStateChange(chatId)

    WsRouter->>Agent: getPendingToolPreviews()
    Agent-->>WsRouter: "Map<chatId, previewText>"

    WsRouter->>ReadModel: "deriveSidebarData({ pendingUserInputPreviews })"
    ReadModel-->>WsRouter: SidebarData (with pendingUserInputPreview)

    WsRouter-->>Client: sidebar update (WebSocket)

    Client->>Client: getChatNotificationEvents(previous, next)
    Client->>Client: shouldShowChatNotificationPopup(appSettings, preference)

    alt notifications enabled and conditions met
        Client->>BrowserAPI: new Notification(title, body)
        BrowserAPI-->>Client: onclick handler navigates to chat
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent as AgentCoordinator
    participant WsRouter as ws-router
    participant ReadModel as deriveSidebarData
    participant Client as KannaLayout (React)
    participant BrowserAPI as Browser Notification API

    Agent->>Agent: "pendingTool = { tool, resolve }"
    Agent->>Agent: "status = waiting_for_user"
    Agent->>WsRouter: emitStateChange(chatId)

    WsRouter->>Agent: getPendingToolPreviews()
    Agent-->>WsRouter: "Map<chatId, previewText>"

    WsRouter->>ReadModel: "deriveSidebarData({ pendingUserInputPreviews })"
    ReadModel-->>WsRouter: SidebarData (with pendingUserInputPreview)

    WsRouter-->>Client: sidebar update (WebSocket)

    Client->>Client: getChatNotificationEvents(previous, next)
    Client->>Client: shouldShowChatNotificationPopup(appSettings, preference)

    alt notifications enabled and conditions met
        Client->>BrowserAPI: new Notification(title, body)
        BrowserAPI-->>Client: onclick handler navigates to chat
    end
Loading

Reviews (18): Last reviewed commit: "Add browser chat notifications" | Re-trigger Greptile

@AntipodTX
AntipodTX force-pushed the feature/browser-chat-notifications branch from c216d35 to 8ca1d19 Compare May 18, 2026 10:36
@AntipodTX

Copy link
Copy Markdown
Owner Author

@greptileai Updated after review:

  • Removed the redundant runtime guard around agent.getPendingToolPreviews() in ws-router and updated the router test mocks to include the method.
  • Left the current-chat notification behavior unchanged for Always; that preference is intended to ignore focus state, while When Unfocused covers users who only want notifications away from the app.

@greptile-apps

greptile-apps Bot commented May 18, 2026

Copy link
Copy Markdown

Both changes look good.

The Always / When Unfocused distinction is clear and intentional — Always is the unconditional mode (useful when monitoring chats across multiple windows or workspaces), while When Unfocused handles the common "notify me only when I've switched away" case. That's a sensible split and worth documenting somewhere like the settings tooltip if it isn't already, so users understand the difference at a glance.

The guard removal is the right call — if getPendingToolPreviews is part of the AgentCoordinator interface and the mock now reflects that, the runtime check was just noise. LGTM on both counts.

AntipodTX pushed a commit that referenced this pull request Jul 17, 2026
Fixes from PR review (jakemor#79):

- #2 Composer mutations dropped Cursor: setChatComposerModel/ModelOptions and the
  provider-default setters rewrote any non-Claude state to Codex, so picking the
  Composer 2.5 model in a new Cursor chat silently switched it to Codex. Replace the
  two-way `claude ? … : codex` branching with an exhaustive provider dispatcher
  (switch + assertNever), shared by the setters and composerFromProviderDefaults.
  Same exhaustiveness applied to getEffectiveComposerState (resolves the #5 nit).
- #3 Tool-result false-positive: a `completed` event whose `result` is absent/
  non-object was flagged as an error. Only flag when an explicit error/failure is
  present.
- #1 Disable forking for Cursor (no fork primitive) in canForkChat; simplify the
  agent cursor branch accordingly.

Adds `src/shared/assert.ts` (assertNever) and tests for each fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AntipodTX
AntipodTX force-pushed the feature/browser-chat-notifications branch from 8ca1d19 to 2886889 Compare July 19, 2026 19:17
@AntipodTX

Copy link
Copy Markdown
Owner Author

@greptileai Please re-review the latest update.

Changes since your last review:

  • Uses the current provider-agnostic assistant message preview for notification bodies, including Pi chat completions.
  • Uses the active pending user-input prompt when a chat enters waiting_for_user, with the latest assistant preview as fallback.
  • Adapts the settings and sidebar read-model integration to the current application structure.
  • Updates the related router and agent fixtures for the current coordinator dependencies.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant