Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions apps/desktop/src/lib/trpc/routers/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,50 @@
import { observable } from "@trpc/server/observable";
import {
type AgentCompleteEvent,
type NotificationIds,
notificationsEmitter,
} from "main/lib/notifications/server";
import { NOTIFICATION_EVENTS } from "shared/constants";
import { publicProcedure, router } from "..";

type NotificationEvent =
| { type: "agent-complete"; data: AgentCompleteEvent }
| { type: "focus-tab"; data: NotificationIds };
| {
type: typeof NOTIFICATION_EVENTS.AGENT_COMPLETE;
data?: AgentCompleteEvent;
}
| { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds };

export const createNotificationsRouter = () => {
return router({
subscribe: publicProcedure.subscription(() => {
return observable<NotificationEvent>((emit) => {
const onComplete = (data: AgentCompleteEvent) => {
emit.next({ type: "agent-complete", data });
};
subscribe: publicProcedure.subscription(async function* () {
const queue: NotificationEvent[] = [];

const onFocusTab = (data: NotificationIds) => {
emit.next({ type: "focus-tab", data });
};
const onComplete = (data: AgentCompleteEvent) => {
queue.push({ type: NOTIFICATION_EVENTS.AGENT_COMPLETE, data });
};

notificationsEmitter.on("agent-complete", onComplete);
notificationsEmitter.on("focus-tab", onFocusTab);
const onFocusTab = (data: NotificationIds) => {
queue.push({ type: NOTIFICATION_EVENTS.FOCUS_TAB, data });
};

return () => {
notificationsEmitter.off("agent-complete", onComplete);
notificationsEmitter.off("focus-tab", onFocusTab);
};
});
notificationsEmitter.on(NOTIFICATION_EVENTS.AGENT_COMPLETE, onComplete);
notificationsEmitter.on(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab);

try {
while (true) {
const event = queue.shift();
if (event) {
yield event;
} else {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
} finally {
notificationsEmitter.off(
NOTIFICATION_EVENTS.AGENT_COMPLETE,
onComplete,
);
notificationsEmitter.off(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab);
}
}),
});
};
3 changes: 2 additions & 1 deletion apps/desktop/src/main/lib/notifications/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EventEmitter } from "node:events";
import express from "express";
import { NOTIFICATION_EVENTS } from "shared/constants";

export interface NotificationIds {
paneId?: string;
Expand Down Expand Up @@ -36,7 +37,7 @@ app.get("/hook/complete", (req, res) => {
eventType: eventType === "PermissionRequest" ? "PermissionRequest" : "Stop",
};

notificationsEmitter.emit("agent-complete", event);
notificationsEmitter.emit(NOTIFICATION_EVENTS.AGENT_COMPLETE, event);

res.json({ success: true, paneId, tabId });
});
Expand Down
128 changes: 67 additions & 61 deletions apps/desktop/src/main/windows/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { BrowserWindow } from "electron";
import { Notification, screen } from "electron";
import { createWindow } from "lib/electron-app/factories/windows/create";
import { createAppRouter } from "lib/trpc/routers";
import { PORTS } from "shared/constants";
import { NOTIFICATION_EVENTS, PORTS } from "shared/constants";
import { createIPCHandler } from "trpc-electron/main";
import { productName } from "~/package.json";
import { appState } from "../lib/app-state";
Expand Down Expand Up @@ -78,72 +78,78 @@ export async function MainWindow() {
);

// Handle agent completion notifications
notificationsEmitter.on("agent-complete", (event: AgentCompleteEvent) => {
if (Notification.isSupported()) {
const isPermissionRequest = event.eventType === "PermissionRequest";

// Derive workspace name from workspaceId with safe fallbacks
let workspaceName = "Workspace";
try {
const workspaces = db.data?.workspaces;
const worktrees = db.data?.worktrees;
if (Array.isArray(workspaces) && Array.isArray(worktrees)) {
const workspace = workspaces.find((w) => w.id === event.workspaceId);
const worktree = workspace
? worktrees.find((wt) => wt.id === workspace.worktreeId)
notificationsEmitter.on(
NOTIFICATION_EVENTS.AGENT_COMPLETE,
(event: AgentCompleteEvent) => {
if (Notification.isSupported()) {
const isPermissionRequest = event.eventType === "PermissionRequest";

// Derive workspace name from workspaceId with safe fallbacks
let workspaceName = "Workspace";
try {
const workspaces = db.data?.workspaces;
const worktrees = db.data?.worktrees;
if (Array.isArray(workspaces) && Array.isArray(worktrees)) {
const workspace = workspaces.find(
(w) => w.id === event.workspaceId,
);
const worktree = workspace
? worktrees.find((wt) => wt.id === workspace.worktreeId)
: undefined;
workspaceName = workspace?.name || worktree?.branch || "Workspace";
}
} catch (error) {
console.error(
"[notifications] Failed to access db for workspace name:",
error,
);
}

// Derive title from tab name, falling back to pane name
// Priority: tab.userTitle (user-set name) > tab.name (auto-generated) > pane.name > "Terminal"
let title = "Terminal";
try {
const { paneId, tabId } = event;
const tabsState = appState.data?.tabsState;
const pane = paneId ? tabsState?.panes?.[paneId] : undefined;
const tab = tabId
? tabsState?.tabs?.find((t) => t.id === tabId)
: undefined;
workspaceName = workspace?.name || worktree?.branch || "Workspace";
title =
tab?.userTitle?.trim() || tab?.name || pane?.name || "Terminal";
} catch (error) {
console.error(
"[notifications] Failed to access appState for tab title:",
error,
);
}
} catch (error) {
console.error(
"[notifications] Failed to access db for workspace name:",
error,
);
}

// Derive title from tab name, falling back to pane name
// Priority: tab.userTitle (user-set name) > tab.name (auto-generated) > pane.name > "Terminal"
let title = "Terminal";
try {
const { paneId, tabId } = event;
const tabsState = appState.data?.tabsState;
const pane = paneId ? tabsState?.panes?.[paneId] : undefined;
const tab = tabId
? tabsState?.tabs?.find((t) => t.id === tabId)
: undefined;
title = tab?.userTitle?.trim() || tab?.name || pane?.name || "Terminal";
} catch (error) {
console.error(
"[notifications] Failed to access appState for tab title:",
error,
);
}
const notification = new Notification({
title: isPermissionRequest
? `Input Needed — ${workspaceName}`
: `Agent Complete — ${workspaceName}`,
body: isPermissionRequest
? `"${title}" needs your attention`
: `"${title}" has finished its task`,
silent: true,
});

const notification = new Notification({
title: isPermissionRequest
? `Input Needed — ${workspaceName}`
: `Agent Complete — ${workspaceName}`,
body: isPermissionRequest
? `"${title}" needs your attention`
: `"${title}" has finished its task`,
silent: true,
});

playNotificationSound();

notification.on("click", () => {
window.show();
window.focus();
notificationsEmitter.emit("focus-tab", {
paneId: event.paneId,
tabId: event.tabId,
workspaceId: event.workspaceId,
playNotificationSound();

notification.on("click", () => {
window.show();
window.focus();
notificationsEmitter.emit(NOTIFICATION_EVENTS.FOCUS_TAB, {
paneId: event.paneId,
tabId: event.tabId,
workspaceId: event.workspaceId,
});
});
});

notification.show();
}
});
notification.show();
}
},
);

window.webContents.on("did-finish-load", async () => {
window.show();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,66 +122,68 @@ export function TabItem({ tab, index, isActive }: TabItemProps) {
onClose={handleRemoveTab}
onRename={startRename}
>
<Button
ref={attachRef}
variant="ghost"
onClick={handleTabClick}
onDoubleClick={startRename}
onKeyDown={(e) => {
if (!isRenaming && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleTabClick();
}
}}
tabIndex={0}
className={`
w-full text-start group px-3 py-2 rounded-md cursor-pointer flex items-center justify-between
${isActive ? "bg-tertiary-active" : ""}
${isDragging ? "opacity-50" : ""}
${isDragOver ? "bg-tertiary-active/50" : ""}
`}
>
<HiMiniCommandLine className="size-4" />
<div className="flex items-center gap-1 flex-1 min-w-0">
{isRenaming ? (
<Input
ref={inputRef}
variant="ghost"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={submitRename}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
className="flex-1"
/>
) : (
<>
<span className="truncate flex-1">{displayName}</span>
{needsAttention && (
<Tooltip>
<TooltipTrigger asChild>
<span className="relative flex size-2 shrink-0 ml-1">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
</TooltipTrigger>
<TooltipContent side="right">
Agent completed
</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
<div className="relative group">
<Button
ref={attachRef}
variant="ghost"
onClick={handleTabClick}
onDoubleClick={startRename}
onKeyDown={(e) => {
if (!isRenaming && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleTabClick();
}
}}
tabIndex={0}
className={`
w-full text-start px-3 py-2 rounded-md cursor-pointer flex items-center justify-between pr-8
${isActive ? "bg-tertiary-active" : ""}
${isDragging ? "opacity-50" : ""}
${isDragOver ? "bg-tertiary-active/50" : ""}
`}
>
<HiMiniCommandLine className="size-4" />
<div className="flex items-center gap-1 flex-1 min-w-0">
{isRenaming ? (
<Input
ref={inputRef}
variant="ghost"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={submitRename}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
className="flex-1"
/>
) : (
<>
<span className="truncate flex-1">{displayName}</span>
{needsAttention && (
<Tooltip>
<TooltipTrigger asChild>
<span className="relative flex size-2 shrink-0 ml-1">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
</TooltipTrigger>
<TooltipContent side="right">
Agent completed
</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
</Button>
<button
type="button"
tabIndex={-1}
onClick={handleRemoveTab}
className="cursor-pointer opacity-0 group-hover:opacity-100 ml-2 text-xs shrink-0"
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer opacity-0 group-hover:opacity-100 text-xs"
>
<HiMiniXMark className="size-4" />
</button>
</Button>
</div>
</TabContextMenu>
</div>
);
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { trpc } from "renderer/lib/trpc";
import { useSetActiveWorkspace } from "renderer/react-query/workspaces/useSetActiveWorkspace";
import { NOTIFICATION_EVENTS } from "shared/constants";
import { useAppStore } from "../app-state";
import { useTabsStore } from "./store";
import { resolveNotificationTarget } from "./utils/resolve-notification-target";
Expand All @@ -14,13 +15,15 @@ export function useAgentHookListener() {

trpc.notifications.subscribe.useSubscription(undefined, {
onData: (event) => {
if (!event.data) return;

const state = useTabsStore.getState();
const target = resolveNotificationTarget(event.data, state);
if (!target) return;

const { paneId, workspaceId } = target;

if (event.type === "agent-complete") {
if (event.type === NOTIFICATION_EVENTS.AGENT_COMPLETE) {
if (!paneId) return;

// Only show red dot if not already viewing this pane
Expand All @@ -32,7 +35,7 @@ export function useAgentHookListener() {
if (!isAlreadyActive) {
state.setNeedsAttention(paneId, true);
}
} else if (event.type === "focus-tab") {
} else if (event.type === NOTIFICATION_EVENTS.FOCUS_TAB) {
// Switch to workspace view if not already there
const appState = useAppStore.getState();
if (appState.currentView !== "workspace") {
Expand Down
Loading