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
19 changes: 5 additions & 14 deletions apps/desktop/src/lib/trpc/routers/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,24 @@
import { observable } from "@trpc/server/observable";
import {
type AgentCompleteEvent,
type NotificationIds,
notificationsEmitter,
} from "main/lib/notifications/server";
import { publicProcedure, router } from "..";

type NotificationEvent =
| { type: "agent-complete"; data: AgentCompleteEvent }
| {
type: "focus-tab";
data: { paneId: string; tabId: string; workspaceId: string };
};
| { type: "focus-tab"; data: NotificationIds };

export const createNotificationsRouter = () => {
return router({
/**
* Subscribe to notification events (completions and focus requests).
*/
subscribe: publicProcedure.subscription(() => {
return observable<NotificationEvent>((emit) => {
const onComplete = (event: AgentCompleteEvent) => {
emit.next({ type: "agent-complete", data: event });
const onComplete = (data: AgentCompleteEvent) => {
emit.next({ type: "agent-complete", data });
};

const onFocusTab = (data: {
paneId: string;
tabId: string;
workspaceId: string;
}) => {
const onFocusTab = (data: NotificationIds) => {
emit.next({ type: "focus-tab", data });
};

Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/lib/notifications/server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { EventEmitter } from "node:events";
import express from "express";

export interface AgentCompleteEvent {
export interface NotificationIds {
paneId?: string;
tabId?: string;
workspaceId?: string;
}

export interface AgentCompleteEvent extends NotificationIds {
eventType: "Stop" | "PermissionRequest";
}

Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/main/windows/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ export async function MainWindow() {
notification.on("click", () => {
window.show();
window.focus();
// Request focus on the specific pane
notificationsEmitter.emit("focus-tab", {
paneId: event.paneId,
tabId: event.tabId,
Expand Down
48 changes: 24 additions & 24 deletions apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { trpc } from "renderer/lib/trpc";
import { useSetActiveWorkspace } from "renderer/react-query/workspaces/useSetActiveWorkspace";
import { useAppStore } from "../app-state";
import { useTabsStore } from "./store";
import { resolveNotificationTarget } from "./utils/resolve-notification-target";

/**
* Hook that listens for notification events via tRPC subscription.
Expand All @@ -13,15 +14,14 @@ export function useAgentHookListener() {

trpc.notifications.subscribe.useSubscription(undefined, {
onData: (event) => {
if (event.type === "agent-complete") {
const { paneId, workspaceId } = event.data;
if (!paneId || !workspaceId) return;
const state = useTabsStore.getState();
const target = resolveNotificationTarget(event.data, state);
if (!target) return;

const state = useTabsStore.getState();
const { paneId, workspaceId } = target;

// Find the tab containing this pane
const pane = state.panes[paneId];
if (!pane) return;
if (event.type === "agent-complete") {
if (!paneId) return;

// Only show red dot if not already viewing this pane
const activeTabId = state.activeTabIds[workspaceId];
Expand All @@ -33,36 +33,36 @@ export function useAgentHookListener() {
state.setNeedsAttention(paneId, true);
}
} else if (event.type === "focus-tab") {
const { paneId, workspaceId } = event.data;
if (!paneId || !workspaceId) return;

// Switch to workspace view if not already there
const appState = useAppStore.getState();
if (appState.currentView !== "workspace") {
appState.setView("workspace");
}

// Switch to the workspace first, then look up pane/tab from fresh state
setActiveWorkspace.mutate(
{ id: workspaceId },
{
onSuccess: () => {
// Get fresh state after workspace switch
const currentState = useTabsStore.getState();

// Look up pane from current state
const pane = currentState.panes[paneId];
if (!pane) return;
const freshState = useTabsStore.getState();
const freshTarget = resolveNotificationTarget(
event.data,
freshState,
);
if (!freshTarget?.tabId) return;

const tabId = pane.tabId;
const freshTab = freshState.tabs.find(
(t) => t.id === freshTarget.tabId,
);
if (!freshTab || freshTab.workspaceId !== workspaceId) return;

// Validate tab belongs to the target workspace
const tab = currentState.tabs.find((t) => t.id === tabId);
if (!tab || tab.workspaceId !== workspaceId) return;
freshState.setActiveTab(workspaceId, freshTarget.tabId);

// Set active tab and focused pane
currentState.setActiveTab(workspaceId, tabId);
currentState.setFocusedPane(tabId, paneId);
if (freshTarget.paneId && freshState.panes[freshTarget.paneId]) {
freshState.setFocusedPane(
freshTarget.tabId,
freshTarget.paneId,
);
}
},
},
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, expect, it } from "bun:test";
import { resolveNotificationTarget } from "./resolve-notification-target";

describe("resolveNotificationTarget", () => {
const createPane = (id: string, tabId: string) => ({
id,
tabId,
type: "terminal" as const,
name: "Terminal",
});

const createTab = (id: string, workspaceId: string) => ({
id,
name: "Tab",
workspaceId,
createdAt: Date.now(),
layout: id,
});

describe("with all IDs provided", () => {
it("returns the provided IDs", () => {
const state = {
panes: { "pane-1": createPane("pane-1", "tab-1") },
tabs: [createTab("tab-1", "ws-1")],
};

const result = resolveNotificationTarget(
{ paneId: "pane-1", tabId: "tab-1", workspaceId: "ws-1" },
state,
);

expect(result).toEqual({
paneId: "pane-1",
tabId: "tab-1",
workspaceId: "ws-1",
});
});
});

describe("with missing workspaceId", () => {
it("resolves workspaceId from tab", () => {
const state = {
panes: { "pane-1": createPane("pane-1", "tab-1") },
tabs: [createTab("tab-1", "ws-1")],
};

const result = resolveNotificationTarget(
{ paneId: "pane-1", tabId: "tab-1" },
state,
);

expect(result).toEqual({
paneId: "pane-1",
tabId: "tab-1",
workspaceId: "ws-1",
});
});
});

describe("with missing tabId", () => {
it("resolves tabId from pane", () => {
const state = {
panes: { "pane-1": createPane("pane-1", "tab-1") },
tabs: [createTab("tab-1", "ws-1")],
};

const result = resolveNotificationTarget(
{ paneId: "pane-1", workspaceId: "ws-1" },
state,
);

expect(result).toEqual({
paneId: "pane-1",
tabId: "tab-1",
workspaceId: "ws-1",
});
});
});

describe("with missing tabId and workspaceId", () => {
it("resolves both from pane and tab chain", () => {
const state = {
panes: { "pane-1": createPane("pane-1", "tab-1") },
tabs: [createTab("tab-1", "ws-1")],
};

const result = resolveNotificationTarget({ paneId: "pane-1" }, state);

expect(result).toEqual({
paneId: "pane-1",
tabId: "tab-1",
workspaceId: "ws-1",
});
});
});

describe("with only tabId", () => {
it("resolves workspaceId from tab", () => {
const state = {
panes: {},
tabs: [createTab("tab-1", "ws-1")],
};

const result = resolveNotificationTarget({ tabId: "tab-1" }, state);

expect(result).toEqual({
paneId: undefined,
tabId: "tab-1",
workspaceId: "ws-1",
});
});
});

describe("with only workspaceId", () => {
it("returns workspaceId with undefined pane and tab", () => {
const state = {
panes: {},
tabs: [],
};

const result = resolveNotificationTarget({ workspaceId: "ws-1" }, state);

expect(result).toEqual({
paneId: undefined,
tabId: undefined,
workspaceId: "ws-1",
});
});
});

describe("with no resolvable workspaceId", () => {
it("returns null when no IDs provided", () => {
const state = { panes: {}, tabs: [] };

const result = resolveNotificationTarget({}, state);

expect(result).toBeNull();
});

it("returns null when pane not found", () => {
const state = { panes: {}, tabs: [] };

const result = resolveNotificationTarget({ paneId: "missing" }, state);

expect(result).toBeNull();
});

it("returns null when tab not found", () => {
const state = { panes: {}, tabs: [] };

const result = resolveNotificationTarget({ tabId: "missing" }, state);

expect(result).toBeNull();
});
});

describe("with pane pointing to missing tab", () => {
it("returns null when tab not in state", () => {
const state = {
panes: { "pane-1": createPane("pane-1", "missing-tab") },
tabs: [],
};

const result = resolveNotificationTarget({ paneId: "pane-1" }, state);

expect(result).toBeNull();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { NotificationIds } from "main/lib/notifications/server";
import type { Pane, Tab } from "../types";

interface TabsState {
panes: Record<string, Pane>;
tabs: Tab[];
}

interface ResolvedTarget extends NotificationIds {
workspaceId: string; // Required in resolved target
}

/**
* Resolves notification target IDs by looking up missing values from state.
* Priority: event data > pane's tab > tab's workspace
*/
export function resolveNotificationTarget(
ids: NotificationIds,
state: TabsState,
): ResolvedTarget | null {
const { paneId, tabId, workspaceId } = ids;

const pane = paneId ? state.panes[paneId] : undefined;

// Resolve tabId: prefer pane's tabId, fallback to event tabId
const resolvedTabId = pane?.tabId ?? tabId;

const tab = resolvedTabId
? state.tabs.find((t) => t.id === resolvedTabId)
: undefined;

// Resolve workspaceId: prefer event, fallback to tab's workspace
const resolvedWorkspaceId = workspaceId || tab?.workspaceId;

if (!resolvedWorkspaceId) return null;

return {
paneId,
tabId: resolvedTabId,
workspaceId: resolvedWorkspaceId,
};
}