fix(desktop): pin notification routing to source workspace + mark read on URL select

Two bugs GPT-Boy caught on PR #1445:

1. A notification from workspace A used `getCurrentSlug()` at click time,
   so if the user switched to workspace B before clicking the banner (macOS
   Notification Center persists banners), routing landed on `/B/inbox?issue=<A key>`
   and 404'd. Fix: round-trip the emit-time `slug` through the IPC payload
   and use it in the click handler.
2. Notification-click navigation set the URL param but never fired the
   mark-read mutation (only InboxPage's click-handler did). The row stayed
   unread and the dock badge didn't decrement. Fix: move the mark-read
   logic from handleSelect into a useEffect keyed on the selected item —
   it now covers both click-to-select and URL-param-select.

IPC payload gains `slug` and `itemId`; preload types + main handler + the
desktop bridge are updated to match.
This commit is contained in:
Jiang Bohan
2026-04-22 15:04:18 +08:00
parent 584bf232cc
commit 84fedd5924
6 changed files with 84 additions and 25 deletions

View File

@@ -211,10 +211,18 @@ if (!gotTheLock) {
(
_event,
{
slug,
itemId,
issueKey,
title,
body,
}: { issueKey: string; title: string; body: string },
}: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
},
) => {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body });
@@ -223,7 +231,14 @@ if (!gotTheLock) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
mainWindow.webContents.send("inbox:open", issueKey);
// Ship the full context back — the renderer pins the route to the
// source workspace (slug), marks the row read (itemId), and uses
// issueKey as the ?issue=<…> selector.
mainWindow.webContents.send("inbox:open", {
slug,
itemId,
issueKey,
});
});
notification.show();
},

View File

@@ -11,6 +11,8 @@ interface DesktopAPI {
setImmersiveMode: (immersive: boolean) => Promise<void>;
/** Show a native OS notification for a new inbox item. */
showNotification: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
@@ -18,7 +20,13 @@ interface DesktopAPI {
/** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
setUnreadBadge: (count: number) => void;
/** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
onInboxOpen: (callback: (issueKey: string) => void) => () => void;
onInboxOpen: (
callback: (payload: {
slug: string;
itemId: string;
issueKey: string;
}) => void,
) => () => void;
}
interface DaemonStatus {

View File

@@ -28,10 +28,15 @@ const desktopAPI = {
/**
* Show a native OS notification for a new inbox item. Fired from the
* renderer only when the app is unfocused — in-focus feedback is the
* inbox sidebar's unread styling. `issueKey` is round-tripped on click so
* the main process can route the user back to the exact inbox row.
* inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are
* all round-tripped on click: slug pins routing to the source workspace
* (the user may switch workspaces before clicking the banner), itemId
* lets the renderer mark the row read, issueKey maps to the inbox URL
* param.
*/
showNotification: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
@@ -45,12 +50,20 @@ const desktopAPI = {
/**
* Subscribe to "open this inbox row" requests sent by the main process
* when the user clicks an OS notification banner. Returns an unsubscribe
* function. The payload is the same `issueKey` that was passed to
* `showNotification`.
* function. The payload echoes the `slug`, `itemId`, and `issueKey` that
* were passed to `showNotification`.
*/
onInboxOpen: (callback: (issueKey: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, issueKey: string) =>
callback(issueKey);
onInboxOpen: (
callback: (payload: {
slug: string;
itemId: string;
issueKey: string;
}) => void,
) => {
const handler = (
_event: Electron.IpcRendererEvent,
payload: { slug: string; itemId: string; issueKey: string },
) => callback(payload);
ipcRenderer.on("inbox:open", handler);
return () => {
ipcRenderer.removeListener("inbox:open", handler);

View File

@@ -100,20 +100,24 @@ function useInternalLinkHandler() {
/**
* Bridge between the renderer and the Electron main process for inbox-level
* OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
* current workspace's id for the badge hook and its slug for click-routing.
* current workspace's id for the badge hook.
*
* Two responsibilities:
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
* 2. When the user clicks an OS notification, open a new tab on the
* workspace's inbox focused on the notified item.
* 2. When the user clicks an OS notification, open the notified
* workspace's inbox focused on that item. The route uses the `slug`
* that the notification was *emitted* with — not the currently active
* workspace — so a notification from workspace A always opens A's
* inbox even if the user has since switched to workspace B. Marking
* the row read is handled by InboxPage's selected-item effect, which
* covers both click-to-select and URL-param-select paths.
*/
function DesktopInboxBridge() {
const workspace = useCurrentWorkspace();
useDesktopUnreadBadge(workspace?.id ?? null);
useEffect(() => {
return window.desktopAPI.onInboxOpen((issueKey) => {
const slug = getCurrentSlug();
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
if (!slug) return;
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
window.dispatchEvent(

View File

@@ -199,10 +199,19 @@ export function useRealtimeSync(
// styling is enough — no need to interrupt with a banner. `desktopAPI`
// is injected by the preload script; its absence (web app) skips silently.
if (typeof document !== "undefined" && document.hasFocus()) return;
// Capture the source workspace slug at emit time. The user may switch
// workspaces before clicking the banner (macOS Notification Center
// holds banners), so routing must not read "current slug" at click
// time — otherwise notifications from workspace A click through to
// workspace B's inbox and 404.
const slug = getCurrentSlug();
if (!slug) return;
const desktopAPI = (
window as unknown as {
desktopAPI?: {
showNotification?: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
@@ -210,11 +219,12 @@ export function useRealtimeSync(
};
}
).desktopAPI;
// `issueKey` matches the inbox page's selector: it's the issue id when
// the item is attached to an issue, otherwise the inbox item id. The
// click handler in the main process round-trips this back to the
// renderer so `/inbox?issue=<key>` selects the correct row.
// `issueKey` matches the inbox page's URL selector (issue id when the
// item is attached to an issue, otherwise the inbox item id). `itemId`
// is the inbox row's own id, needed to fire markInboxRead on click.
desktopAPI?.showNotification?.({
slug,
itemId: item.id,
issueKey: item.issue_id ?? item.id,
title: item.title,
body: item.body ?? "",

View File

@@ -99,14 +99,23 @@ export function InboxPage() {
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
// Click-to-read: select + auto-mark-read
// Auto-mark-read whenever a selected item is unread — covers both click-
// to-select and URL-param-select (e.g. OS notification click on desktop).
// The mutation flips `read: true` optimistically, so this effect settles
// in one pass and can't loop. Kept in a `useEffect` rather than inlined
// in handleSelect so URL-driven selection triggers it too.
const markReadMutate = markReadMutation.mutate;
const selectedId = selected?.id;
const selectedRead = selected?.read;
useEffect(() => {
if (!selectedId || selectedRead) return;
markReadMutate(selectedId, {
onError: () => toast.error("Failed to mark as read"),
});
}, [selectedId, selectedRead, markReadMutate]);
const handleSelect = (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
if (!item.read) {
markReadMutation.mutate(item.id, {
onError: () => toast.error("Failed to mark as read"),
});
}
};
const handleArchive = (id: string) => {