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
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Effect from "effect/Effect";

import * as DesktopIpc from "./DesktopIpc.ts";
import { installNotificationBadge } from "./methods/notificationBadge.ts";
import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
import {
clearConnectionCatalog,
Expand Down Expand Up @@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m

export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
const ipc = yield* DesktopIpc.DesktopIpc;
yield* installNotificationBadge();
yield* PreviewIpc.installPreviewEventForwarding();

yield* ipc.handle(AppActivationIpc.setReady);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge";
export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
Expand Down
144 changes: 144 additions & 0 deletions apps/desktop/src/ipc/methods/notificationBadge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import * as Effect from "effect/Effect";
import { beforeEach, expect, vi } from "vite-plus/test";
import { it } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

const native = vi.hoisted(() => ({
setBadgeCount: vi.fn(),
setOverlayIcon: vi.fn(),
isDestroyed: vi.fn(() => false),
getFocusedWindow: vi.fn(() => null as object | null),
image: { isEmpty: vi.fn(() => false) },
createFromDataURL: vi.fn(),
webContents: { send: vi.fn() },
listeners: new Map<string, () => void>(),
}));
vi.mock("electron", () => ({
app: {
setBadgeCount: native.setBadgeCount,
on: (event: string, listener: () => void) => native.listeners.set(event, listener),
removeListener: (event: string) => native.listeners.delete(event),
},
BrowserWindow: {
getFocusedWindow: native.getFocusedWindow,
getAllWindows: () => [native],
},
nativeImage: { createFromDataURL: native.createFromDataURL },
}));

import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts";

const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" };

beforeEach(() => {
vi.clearAllMocks();
native.getFocusedWindow.mockReturnValue(null);
native.isDestroyed.mockReturnValue(false);
native.image.isEmpty.mockReturnValue(false);
native.createFromDataURL.mockReturnValue(native.image);
native.setBadgeCount.mockImplementation(() => true);
native.listeners.clear();
});

it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => {
applyNotificationBadge(platform, badge);
applyNotificationBadge(platform, { count: 0, image: null });
expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]);
expect(native.createFromDataURL).not.toHaveBeenCalled();
});

it("sets and clears the Windows taskbar overlay", () => {
applyNotificationBadge("win32", badge);
expect(native.setOverlayIcon).toHaveBeenLastCalledWith(
native.image,
"2 threads with new notifications",
);
applyNotificationBadge("win32", { count: 0, image: null });
expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, "");
});

it.each(["win32", "darwin", "linux"] as const)(
"rejects a late positive count while %s is focused",
(platform) => {
native.getFocusedWindow.mockReturnValue({});
applyNotificationBadge(platform, badge);
if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, "");
else expect(native.setBadgeCount).toHaveBeenCalledWith(0);
expect(native.createFromDataURL).not.toHaveBeenCalled();
},
);

it("ignores destroyed windows and clears invalid images", () => {
native.isDestroyed.mockReturnValue(true);
applyNotificationBadge("win32", badge);
expect(native.setOverlayIcon).not.toHaveBeenCalled();
native.isDestroyed.mockReturnValue(false);
native.image.isEmpty.mockReturnValue(true);
applyNotificationBadge("win32", badge);
expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull();
});

it("keeps notifications working when the native badge API fails", () => {
native.setBadgeCount.mockImplementation(() => {
throw new Error("Unavailable");
});
expect(() => applyNotificationBadge("linux", badge)).not.toThrow();
});

it.effect("validates IPC and clears on native focus, quit, and disposal", () =>
Effect.gen(function* () {
const handlers = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
yield* Effect.scoped(
Effect.gen(function* () {
yield* installNotificationBadge();
const handler = handlers.get("desktop:set-notification-badge")!;
const event = { sender: { id: 1 } };
for (const invalid of [
{ ...badge, count: -1 },
{ ...badge, count: 0.5 },
{ ...badge, count: Infinity },
{ ...badge, image: "https://example.com/icon.png" },
{ ...badge, image: `data:image/png;base64,${"a".repeat(16_384)}` },
]) {
yield* Effect.promise(() => expect(handler(event, invalid)).rejects.toBeDefined());
}
expect(native.setBadgeCount).not.toHaveBeenCalled();
yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
expect(native.setBadgeCount).toHaveBeenLastCalledWith(2);
native.listeners.get("browser-window-focus")!();
expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
expect(native.webContents.send).toHaveBeenCalledWith("desktop:set-notification-badge");
native.getFocusedWindow.mockReturnValue({});
yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
expect(native.webContents.send).toHaveBeenCalledTimes(2);
yield* Effect.promise(() => Promise.resolve(handler(event, { count: 0, image: null })));
expect(native.webContents.send).toHaveBeenCalledTimes(2);
native.getFocusedWindow.mockReturnValue(null);
yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
native.listeners.get("before-quit")!();
expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
}),
).pipe(
Effect.provideService(HostProcessPlatform, "linux"),
Effect.provide([
ElectronApp.layer,
DesktopIpc.layer({
handle: (channel, handler) => {
handlers.set(channel, handler);
},
removeHandler: (channel) => {
handlers.delete(channel);
},
on: vi.fn(),
removeAllListeners: vi.fn(),
}),
]),
);
expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
expect(native.listeners.size).toBe(0);
expect(handlers.size).toBe(0);
}),
);
71 changes: 71 additions & 0 deletions apps/desktop/src/ipc/methods/notificationBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as Electron from "electron";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import { SET_NOTIFICATION_BADGE_CHANNEL } from "../channels.ts";

const NotificationBadge = Schema.Struct({
count: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 2_147_483_647 })),
image: Schema.NullOr(
Schema.String.check(
Schema.isMaxLength(16_384),
Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i),
),
),
});

export function applyNotificationBadge(
platform: NodeJS.Platform,
{ count, image }: typeof NotificationBadge.Type,
): void {
try {
if (Electron.BrowserWindow.getFocusedWindow()) count = 0;
if (platform === "win32") {
const overlay = count > 0 && image ? Electron.nativeImage.createFromDataURL(image) : null;
for (const window of Electron.BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.setOverlayIcon(
overlay?.isEmpty() ? null : overlay,
count > 0 ? `${count} threads with new notifications` : "",
);
}
}
} else if (platform === "darwin" || platform === "linux") {
Electron.app.setBadgeCount(count);
}
} catch (error) {
Effect.runSync(Effect.logWarning("Could not update notification badge", error));
}
}

export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificationBadge")(
function* () {
const ipc = yield* DesktopIpc.DesktopIpc;
const app = yield* ElectronApp.ElectronApp;
const platform = yield* HostProcessPlatform;
const clear = () => {
applyNotificationBadge(platform, { count: 0, image: null });
for (const window of Electron.BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL);
}
};
yield* ipc.handle(
DesktopIpc.makeIpcMethod({
channel: SET_NOTIFICATION_BADGE_CHANNEL,
payload: NotificationBadge,
result: Schema.Void,
handler: (badge) =>
Effect.sync(() => {
if (badge.count > 0 && Electron.BrowserWindow.getFocusedWindow()) clear();
else applyNotificationBadge(platform, badge);
}),
}),
);
yield* app.on("browser-window-focus", clear);
yield* app.on("before-quit", clear);
yield* Effect.addFinalizer(() => Effect.sync(clear));
},
);
7 changes: 7 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
getClientPlatform: () => clientPlatform,
setNotificationBadge: (badge) =>
ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge),
onNotificationBadgeClear: (listener) => {
const handler = () => listener();
ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
},
getSystemLocale: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
return typeof result === "string" ? result : null;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";
const clientSettings: ClientSettings = {
...DEFAULT_CLIENT_SETTINGS,
notificationMode: "notifications-and-sound",
inAppNotificationsEnabled: true,
appearanceContrast: 100,
browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
browserDefaultZoomFactor: 1.25,
Expand Down Expand Up @@ -56,6 +57,7 @@ const clientSettings: ClientSettings = {
proactivePanelsEnabled: true,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
sidebarCompactThreadRows: false,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { assert, describe, expect, it, vi } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import { VcsRepositoryDetectionError } from "@t3tools/contracts";

Expand All @@ -24,6 +26,35 @@ function makeLayer(input: {
}

describe("GitWorkflowService", () => {
it.effect("reports a non-Git VCS repository as not a Git repository", () =>
Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const isRepository = yield* workflow.isRepository("/jj-repo");

assert.equal(isRepository, false);
}).pipe(
Effect.provide(
makeLayer({
detect: () =>
Effect.succeed({
kind: "jj",
repository: {
kind: "jj",
rootPath: "/jj-repo",
metadataPath: "/jj-repo/.jj",
freshness: {
source: "live-local",
observedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"),
expiresAt: Option.none(),
},
},
driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"],
}),
}),
),
),
);

it.effect("returns an empty local status when no VCS repository is detected", () =>
Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
export class GitWorkflowService extends Context.Service<
GitWorkflowService,
{
readonly isRepository: (cwd: string) => Effect.Effect<boolean, GitManagerServiceError>;
readonly hasCommit: (input: {
readonly cwd: string;
readonly refName: string;
}) => Effect.Effect<boolean, GitCommandError>;
readonly status: (
input: VcsStatusInput,
) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
Expand Down Expand Up @@ -263,6 +268,31 @@ export const make = Effect.gen(function* () {
ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));

return GitWorkflowService.of({
isRepository: (cwd) =>
registry.detect({ cwd }).pipe(
Effect.map((handle) => handle?.kind === "git"),
Effect.mapError(
(cause) =>
new GitManagerError({
operation: "GitWorkflowService.isRepository",
cwd,
detail: "Failed to detect a VCS repository for this Git workflow.",
cause,
}),
),
),
hasCommit: (input) =>
ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe(
Effect.andThen(
git.execute({
operation: "GitWorkflowService.hasCommit",
cwd: input.cwd,
args: ["rev-parse", "--verify", `${input.refName}^{commit}`],
allowNonZeroExit: true,
}),
),
Effect.map((result) => result.exitCode === 0),
),
status: (input) =>
detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
Effect.flatMap((isGitRepository) =>
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/acp/CursorTransportFailure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ describe("CursorTransportFailure", () => {
"Error: ConnectError: [unauthenticated] sign in",
"Error: ConnectError: [permission_denied] subscription required",
"Error: HTTP 500 from the application being debugged",
"Error: RetriableError: [internal] Failed to run step, exceeded max retries",
"Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)",
])("preserves prose, code and non-transport errors: %s", (message) => {
expect(failureFor([...message])).toBeUndefined();
});
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/acp/CursorTransportFailure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const maxLineLength = 4096;
// Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics.
const transportError =
/^Error: (?:RetriableError: .+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/;
/^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/;
const serverError = "Something went wrong communicating with the server. Please try again.";

interface ReplyState {
Expand Down
Loading
Loading