From 0cf8b61926e0ca8a6e23acbeb0589992cf162653 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Mon, 1 Jun 2026 16:06:16 -0400 Subject: [PATCH 1/4] feat(code): add Discord Rich Presence integration Add a Discord Rich Presence service that surfaces current activity in the user's Discord status. Includes a main-process service over Discord IPC, tRPC router, settings persistence, and a Discord settings section in the renderer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 4 + apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + apps/code/src/main/index.ts | 3 + .../services/discord-presence/constants.ts | 30 +++ .../services/discord-presence/discord-ipc.ts | 216 ++++++++++++++++++ .../discord-presence/presence-format.test.ts | 84 +++++++ .../discord-presence/presence-format.ts | 68 ++++++ .../main/services/discord-presence/schemas.ts | 43 ++++ .../main/services/discord-presence/service.ts | 203 ++++++++++++++++ apps/code/src/main/services/settingsStore.ts | 18 ++ apps/code/src/main/trpc/router.ts | 2 + .../src/main/trpc/routers/discord-presence.ts | 53 +++++ apps/code/src/renderer/App.tsx | 6 + .../discord-presence/subscriptions.ts | 64 ++++++ .../settings/components/SettingsDialog.tsx | 5 + .../components/sections/DiscordSettings.tsx | 134 +++++++++++ .../settings/stores/settingsDialogStore.ts | 1 + apps/code/vite.main.config.mts | 3 + 19 files changed, 940 insertions(+) create mode 100644 apps/code/src/main/services/discord-presence/constants.ts create mode 100644 apps/code/src/main/services/discord-presence/discord-ipc.ts create mode 100644 apps/code/src/main/services/discord-presence/presence-format.test.ts create mode 100644 apps/code/src/main/services/discord-presence/presence-format.ts create mode 100644 apps/code/src/main/services/discord-presence/schemas.ts create mode 100644 apps/code/src/main/services/discord-presence/service.ts create mode 100644 apps/code/src/main/trpc/routers/discord-presence.ts create mode 100644 apps/code/src/renderer/features/discord-presence/subscriptions.ts create mode 100644 apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx diff --git a/.env.example b/.env.example index b2fa2b571e..ee6c6a4dc5 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,7 @@ APPLE_CODESIGN_KEYCHAIN_PASSWORD="xxx" VITE_POSTHOG_API_KEY=xxx VITE_POSTHOG_API_HOST=xxx VITE_POSTHOG_UI_HOST=xxx + +# Discord Rich Presence. Upload Rich Presence art assets named +# "posthog_logo", "agent_running", and "posthog_idle" in the Discord app. +VITE_DISCORD_CLIENT_ID= diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b2e2379419..a86583ed75 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -35,6 +35,7 @@ import { CloudTaskService } from "../services/cloud-task/service"; import { ConnectivityService } from "../services/connectivity/service"; import { ContextMenuService } from "../services/context-menu/service"; import { DeepLinkService } from "../services/deep-link/service"; +import { DiscordPresenceService } from "../services/discord-presence/service"; import { EnrichmentService } from "../services/enrichment/service"; import { EnvironmentService } from "../services/environment/service"; import { ExternalAppsService } from "../services/external-apps/service"; @@ -117,6 +118,7 @@ container.bind(MAIN_TOKENS.CloudTaskService).to(CloudTaskService); container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService); container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService); container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService); +container.bind(MAIN_TOKENS.DiscordPresenceService).to(DiscordPresenceService); container.bind(MAIN_TOKENS.EnrichmentService).to(EnrichmentService); container.bind(MAIN_TOKENS.EnvironmentService).to(EnvironmentService); container.bind(MAIN_TOKENS.ProvisioningService).to(ProvisioningService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 69ea894b37..8b9f86c0f9 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -50,6 +50,7 @@ export const MAIN_TOKENS = Object.freeze({ CloudTaskService: Symbol.for("Main.CloudTaskService"), ConnectivityService: Symbol.for("Main.ConnectivityService"), ContextMenuService: Symbol.for("Main.ContextMenuService"), + DiscordPresenceService: Symbol.for("Main.DiscordPresenceService"), ExternalAppsService: Symbol.for("Main.ExternalAppsService"), LlmGatewayService: Symbol.for("Main.LlmGatewayService"), diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 6a005d365e..a85e57c9fd 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -12,6 +12,7 @@ import { MAIN_TOKENS } from "./di/tokens"; import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox"; import type { AppLifecycleService } from "./services/app-lifecycle/service"; import type { AuthService } from "./services/auth/service"; +import type { DiscordPresenceService } from "./services/discord-presence/service"; import type { ExternalAppsService } from "./services/external-apps/service"; import type { GitHubIntegrationService } from "./services/github-integration/service"; import type { InboxLinkService } from "./services/inbox-link/service"; @@ -156,6 +157,8 @@ async function initializeServices(): Promise { container.get(MAIN_TOKENS.SlackIntegrationService); container.get(MAIN_TOKENS.ExternalAppsService); container.get(MAIN_TOKENS.PosthogPluginService); + // Eagerly start the Discord presence service so it connects when enabled. + container.get(MAIN_TOKENS.DiscordPresenceService); await authService.initialize(); diff --git a/apps/code/src/main/services/discord-presence/constants.ts b/apps/code/src/main/services/discord-presence/constants.ts new file mode 100644 index 0000000000..6f539f850f --- /dev/null +++ b/apps/code/src/main/services/discord-presence/constants.ts @@ -0,0 +1,30 @@ +/** + * Discord Rich Presence configuration. + * + * The client id identifies the Discord Application whose name and uploaded + * Rich Presence art (the `*_IMAGE_KEY` assets below) show up on a user's + * profile. Register an application at https://discord.com/developers and wire + * its id through `VITE_DISCORD_CLIENT_ID` (see `.env.example`). Until a real id + * is configured the service stays dormant and never opens a socket. + */ +export function getDiscordClientId(): string { + return process.env.VITE_DISCORD_CLIENT_ID ?? ""; +} + +/** Asset keys uploaded under the Discord app's Rich Presence → Art Assets. */ +export const LARGE_IMAGE_KEY = "posthog_logo"; +export const SMALL_IMAGE_RUNNING = "agent_running"; +export const SMALL_IMAGE_IDLE = "posthog_idle"; + +/** How long to wait before retrying a dropped/absent Discord connection. */ +export const RECONNECT_INTERVAL_MS = 15_000; + +/** + * Minimum spacing between SET_ACTIVITY frames. Discord rate-limits presence + * updates (~5 per 20s); we coalesce to one update per this window with a + * trailing flush so the final state always lands. + */ +export const MIN_UPDATE_INTERVAL_MS = 15_000; + +/** Discord rejects activity strings shorter than 2 or longer than 128 chars. */ +export const MAX_FIELD_LENGTH = 128; diff --git a/apps/code/src/main/services/discord-presence/discord-ipc.ts b/apps/code/src/main/services/discord-presence/discord-ipc.ts new file mode 100644 index 0000000000..82171b82cd --- /dev/null +++ b/apps/code/src/main/services/discord-presence/discord-ipc.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; +import net from "node:net"; +import path from "node:path"; +import { logger } from "../../utils/logger"; + +const log = logger.scope("discord-ipc"); + +/** Discord local-IPC opcodes (see Discord RPC transport docs). */ +const OPCODE = { + HANDSHAKE: 0, + FRAME: 1, + CLOSE: 2, + PING: 3, + PONG: 4, +} as const; + +/** The Rich Presence activity payload sent in a SET_ACTIVITY frame. */ +export interface DiscordActivity { + details?: string; + state?: string; + timestamps?: { start?: number; end?: number }; + assets?: { + large_image?: string; + large_text?: string; + small_image?: string; + small_text?: string; + }; + instance?: boolean; +} + +interface DiscordIpcClientEvents { + ready: () => void; + disconnect: () => void; +} + +/** + * Minimal Discord local-IPC client — just enough of the protocol to perform + * the handshake and push SET_ACTIVITY frames, modelled the same way VS Code's + * Discord integrations talk to the desktop client. It connects to the first + * reachable `discord-ipc-{0..9}` socket and emits `ready` once the client + * acknowledges the handshake, `disconnect` when the socket drops. + * + * It performs no reconnection of its own; the owning service decides when to + * retry so the policy lives in one place. + */ +export class DiscordIpcClient extends EventEmitter { + private socket: net.Socket | null = null; + private readBuffer = Buffer.alloc(0); + private ready = false; + + constructor(private readonly clientId: string) { + super(); + } + + override on( + event: K, + listener: DiscordIpcClientEvents[K], + ): this { + return super.on(event, listener); + } + + override emit(event: K): boolean { + return super.emit(event); + } + + isReady(): boolean { + return this.ready; + } + + /** Attempt to connect, trying each candidate socket path in turn. */ + connect(): void { + if (this.socket) return; + this.tryConnect(this.candidatePaths(), 0); + } + + /** Tear down without emitting — used when the owner intentionally stops. */ + destroy(): void { + if (this.socket) { + try { + this.socket.destroy(); + } catch { + // best effort + } + this.socket = null; + } + this.ready = false; + this.readBuffer = Buffer.alloc(0); + this.removeAllListeners(); + } + + setActivity(activity: DiscordActivity | null): void { + if (!this.socket || !this.ready) return; + this.write(OPCODE.FRAME, { + cmd: "SET_ACTIVITY", + args: { pid: process.pid, activity: activity ?? undefined }, + nonce: randomUUID(), + }); + } + + private tryConnect(paths: string[], index: number): void { + if (index >= paths.length) { + log.debug("No reachable Discord IPC socket"); + super.emit("disconnect"); + return; + } + + const sock = net.createConnection(paths[index]); + + const onError = () => { + sock.removeAllListeners(); + sock.destroy(); + this.tryConnect(paths, index + 1); + }; + + sock.once("error", onError); + sock.once("connect", () => { + sock.removeListener("error", onError); + this.socket = sock; + sock.on("data", (chunk) => this.onData(chunk)); + sock.on("error", () => { + // Surfaced via the subsequent "close" event. + }); + sock.on("close", () => this.handleClose()); + this.write(OPCODE.HANDSHAKE, { v: 1, client_id: this.clientId }); + }); + } + + private candidatePaths(): string[] { + const ids = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + + if (process.platform === "win32") { + return ids.map((id) => `\\\\?\\pipe\\discord-ipc-${id}`); + } + + const base = + process.env.XDG_RUNTIME_DIR || + process.env.TMPDIR || + process.env.TMP || + process.env.TEMP || + "/tmp"; + const root = base.replace(/\/$/, ""); + // Discord may live at the temp root or under a sandbox subdir (Snap/Flatpak). + const dirs = [ + root, + path.join(root, "snap.discord"), + path.join(root, "app", "com.discordapp.Discord"), + path.join(root, "app", "com.discordapp.DiscordCanary"), + ]; + return dirs.flatMap((dir) => + ids.map((id) => path.join(dir, `discord-ipc-${id}`)), + ); + } + + private onData(chunk: Buffer): void { + this.readBuffer = Buffer.concat([this.readBuffer, chunk]); + // Frames are [Int32LE opcode][Int32LE length][JSON body]. + while (this.readBuffer.length >= 8) { + const op = this.readBuffer.readInt32LE(0); + const len = this.readBuffer.readInt32LE(4); + if (this.readBuffer.length < 8 + len) break; + const body = this.readBuffer.subarray(8, 8 + len); + this.readBuffer = this.readBuffer.subarray(8 + len); + this.handleFrame(op, body); + } + } + + private handleFrame(op: number, body: Buffer): void { + if (op === OPCODE.PING) { + this.write(OPCODE.PONG, this.parse(body)); + return; + } + if (op === OPCODE.CLOSE) { + this.handleClose(); + return; + } + if (op === OPCODE.FRAME) { + const msg = this.parse(body) as { cmd?: string; evt?: string } | null; + if (msg?.cmd === "DISPATCH" && msg.evt === "READY") { + this.ready = true; + log.info("Discord IPC handshake complete"); + super.emit("ready"); + } + } + } + + private handleClose(): void { + if (!this.socket) return; + this.ready = false; + this.readBuffer = Buffer.alloc(0); + try { + this.socket.destroy(); + } catch { + // best effort + } + this.socket = null; + super.emit("disconnect"); + } + + private write(op: number, payload: unknown): void { + if (!this.socket) return; + const json = Buffer.from(JSON.stringify(payload), "utf8"); + const header = Buffer.alloc(8); + header.writeInt32LE(op, 0); + header.writeInt32LE(json.length, 4); + this.socket.write(Buffer.concat([header, json])); + } + + private parse(body: Buffer): unknown { + try { + return JSON.parse(body.toString("utf8")); + } catch { + return null; + } + } +} diff --git a/apps/code/src/main/services/discord-presence/presence-format.test.ts b/apps/code/src/main/services/discord-presence/presence-format.test.ts new file mode 100644 index 0000000000..00354ba32e --- /dev/null +++ b/apps/code/src/main/services/discord-presence/presence-format.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { buildActivity } from "./presence-format"; +import type { PresenceIntent } from "./schemas"; + +const STARTED_AT = 1_700_000_000_000; + +const baseOptions = { + showTaskTitle: false, + showRepoName: false, + startedAt: STARTED_AT, +}; + +const activeIntent: PresenceIntent = { + hasActiveTask: true, + taskTitle: "Add Discord presence", + repoName: "posthog/code", + agentRunning: true, +}; + +describe("buildActivity", () => { + it("hides the task title and repo name by default (privacy-first)", () => { + const activity = buildActivity(activeIntent, baseOptions); + expect(activity.details).toBe("Working on a task"); + expect(activity.state).toBe("agent running"); + }); + + it("includes the task title only when opted in", () => { + const activity = buildActivity(activeIntent, { + ...baseOptions, + showTaskTitle: true, + }); + expect(activity.details).toBe('Working on "Add Discord presence"'); + }); + + it("includes the repo name only when opted in", () => { + const activity = buildActivity(activeIntent, { + ...baseOptions, + showRepoName: true, + }); + expect(activity.state).toBe("posthog/code · agent running"); + }); + + it("reflects review status with the idle badge when the agent is idle on a task", () => { + const activity = buildActivity( + { ...activeIntent, agentRunning: false }, + { ...baseOptions, showRepoName: true }, + ); + expect(activity.state).toBe("posthog/code · reviewing"); + expect(activity.assets?.small_image).toBe("posthog_idle"); + expect(activity.assets?.small_text).toBe("Reviewing"); + }); + + it("falls back to an idle/browsing presence with the idle badge when no task is focused", () => { + const activity = buildActivity( + { + hasActiveTask: false, + taskTitle: null, + repoName: null, + agentRunning: false, + }, + { ...baseOptions, showTaskTitle: true, showRepoName: true }, + ); + expect(activity.details).toBe("Idle"); + expect(activity.state).toBe("browsing"); + expect(activity.assets?.small_image).toBe("posthog_idle"); + expect(activity.assets?.small_text).toBe("Idle"); + }); + + it("surfaces the running indicator asset while the agent works", () => { + const activity = buildActivity(activeIntent, baseOptions); + expect(activity.assets?.small_image).toBe("agent_running"); + expect(activity.timestamps?.start).toBe(STARTED_AT); + }); + + it("truncates over-long titles to Discord's field limit", () => { + const longTitle = "x".repeat(200); + const activity = buildActivity( + { ...activeIntent, taskTitle: longTitle }, + { ...baseOptions, showTaskTitle: true }, + ); + expect(activity.details).toBeDefined(); + expect((activity.details as string).length).toBeLessThanOrEqual(128); + }); +}); diff --git a/apps/code/src/main/services/discord-presence/presence-format.ts b/apps/code/src/main/services/discord-presence/presence-format.ts new file mode 100644 index 0000000000..8f13167719 --- /dev/null +++ b/apps/code/src/main/services/discord-presence/presence-format.ts @@ -0,0 +1,68 @@ +import { + LARGE_IMAGE_KEY, + MAX_FIELD_LENGTH, + SMALL_IMAGE_IDLE, + SMALL_IMAGE_RUNNING, +} from "./constants"; +import type { DiscordActivity } from "./discord-ipc"; +import type { PresenceIntent } from "./schemas"; + +export interface PresenceFormatOptions { + showTaskTitle: boolean; + showRepoName: boolean; + /** Epoch ms used for the "elapsed" timer shown on the profile. */ + startedAt: number; +} + +/** + * Turn a high-level {@link PresenceIntent} into a Discord activity payload, + * honouring the privacy toggles. Pure so it can be unit-tested in isolation + * from the socket lifecycle. + */ +export function buildActivity( + intent: PresenceIntent, + options: PresenceFormatOptions, +): DiscordActivity { + const { hasActiveTask, taskTitle, repoName, agentRunning } = intent; + const { showTaskTitle, showRepoName, startedAt } = options; + + const details = truncate( + hasActiveTask + ? showTaskTitle && taskTitle + ? `Working on "${taskTitle}"` + : "Working on a task" + : "Idle", + ); + + const statusPart = agentRunning + ? "agent running" + : hasActiveTask + ? "reviewing" + : "browsing"; + const repoPart = showRepoName && repoName ? repoName : null; + const state = truncate(repoPart ? `${repoPart} · ${statusPart}` : statusPart); + + const smallText = agentRunning + ? "Agent running" + : hasActiveTask + ? "Reviewing" + : "Idle"; + + return { + details, + state, + timestamps: { start: startedAt }, + assets: { + large_image: LARGE_IMAGE_KEY, + large_text: "PostHog Code", + small_image: agentRunning ? SMALL_IMAGE_RUNNING : SMALL_IMAGE_IDLE, + small_text: smallText, + }, + instance: false, + }; +} + +function truncate(value: string): string { + if (value.length <= MAX_FIELD_LENGTH) return value; + return `${value.slice(0, MAX_FIELD_LENGTH - 1)}…`; +} diff --git a/apps/code/src/main/services/discord-presence/schemas.ts b/apps/code/src/main/services/discord-presence/schemas.ts new file mode 100644 index 0000000000..c85217e155 --- /dev/null +++ b/apps/code/src/main/services/discord-presence/schemas.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +/** Snapshot of the presence integration, surfaced to the settings UI. */ +export const discordPresenceStateSchema = z.object({ + /** Whether the user has turned the integration on. */ + enabled: z.boolean(), + /** Whether a live socket to the Discord client is currently established. */ + connected: z.boolean(), + /** Whether a Discord application id is configured (false = dev placeholder). */ + configured: z.boolean(), + /** Privacy toggle: include the focused task's title in the presence. */ + showTaskTitle: z.boolean(), + /** Privacy toggle: include the repository name in the presence. */ + showRepoName: z.boolean(), +}); + +export type DiscordPresenceState = z.infer; + +/** + * High-level description of what the user is doing, pushed from the renderer + * (which owns navigation/session UI state). The service decides how — and + * whether, given the privacy toggles — to render it onto Discord. + */ +export const presenceIntentSchema = z.object({ + /** True when a task is open in the foreground. */ + hasActiveTask: z.boolean(), + /** Title of the focused task, or null when none/hidden upstream. */ + taskTitle: z.string().nullable(), + /** "org/repo" of the focused task, or null. */ + repoName: z.string().nullable(), + /** True while the agent is actively working on the focused task. */ + agentRunning: z.boolean(), +}); + +export type PresenceIntent = z.infer; + +export const DiscordPresenceServiceEvent = { + StatusChanged: "status-changed", +} as const; + +export interface DiscordPresenceServiceEvents { + [DiscordPresenceServiceEvent.StatusChanged]: DiscordPresenceState; +} diff --git a/apps/code/src/main/services/discord-presence/service.ts b/apps/code/src/main/services/discord-presence/service.ts new file mode 100644 index 0000000000..95b1dab323 --- /dev/null +++ b/apps/code/src/main/services/discord-presence/service.ts @@ -0,0 +1,203 @@ +import { injectable, preDestroy } from "inversify"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { settingsStore } from "../settingsStore"; +import { + getDiscordClientId, + MIN_UPDATE_INTERVAL_MS, + RECONNECT_INTERVAL_MS, +} from "./constants"; +import { DiscordIpcClient } from "./discord-ipc"; +import { buildActivity } from "./presence-format"; +import { + DiscordPresenceServiceEvent, + type DiscordPresenceServiceEvents, + type DiscordPresenceState, + type PresenceIntent, +} from "./schemas"; + +const log = logger.scope("discord-presence"); + +const IDLE_INTENT: PresenceIntent = { + hasActiveTask: false, + taskTitle: null, + repoName: null, + agentRunning: false, +}; + +/** + * Owns the Discord Rich Presence integration: the socket lifecycle, + * reconnection, rate-limited activity updates, and the privacy-aware + * formatting of what shows on the user's profile. The renderer only feeds it a + * high-level {@link PresenceIntent}; all decisions live here so the same + * behaviour ports to any future host. + */ +@injectable() +export class DiscordPresenceService extends TypedEventEmitter { + private client: DiscordIpcClient | null = null; + private enabled: boolean; + private showTaskTitle: boolean; + private showRepoName: boolean; + private connected = false; + private intent: PresenceIntent = IDLE_INTENT; + private reconnectTimer: NodeJS.Timeout | null = null; + private throttleTimer: NodeJS.Timeout | null = null; + private lastUpdateAt = 0; + private readonly startedAt = Date.now(); + private readonly clientId = getDiscordClientId(); + + constructor() { + super(); + this.enabled = settingsStore.get("discordPresenceEnabled", false); + this.showTaskTitle = settingsStore.get( + "discordPresenceShowTaskTitle", + false, + ); + this.showRepoName = settingsStore.get("discordPresenceShowRepoName", false); + if (this.enabled) this.connect(); + } + + getState(): DiscordPresenceState { + return { + enabled: this.enabled, + connected: this.connected, + configured: this.clientId.length > 0, + showTaskTitle: this.showTaskTitle, + showRepoName: this.showRepoName, + }; + } + + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + log.info("setEnabled", { enabled }); + this.enabled = enabled; + settingsStore.set("discordPresenceEnabled", enabled); + if (enabled) { + this.connect(); + } else { + this.disconnect(); + } + this.emitStatus(); + } + + setShowTaskTitle(value: boolean): void { + if (this.showTaskTitle === value) return; + this.showTaskTitle = value; + settingsStore.set("discordPresenceShowTaskTitle", value); + this.render(); + this.emitStatus(); + } + + setShowRepoName(value: boolean): void { + if (this.showRepoName === value) return; + this.showRepoName = value; + settingsStore.set("discordPresenceShowRepoName", value); + this.render(); + this.emitStatus(); + } + + /** Update what the user is doing; rendered (rate-limited) onto Discord. */ + setActivity(intent: PresenceIntent): void { + this.intent = intent; + this.render(); + } + + @preDestroy() + cleanup(): void { + this.disconnect(); + } + + private connect(): void { + if (!this.clientId) { + log.warn( + "VITE_DISCORD_CLIENT_ID is not configured; Discord Rich Presence will stay dormant", + ); + return; + } + if (this.client) return; + + const client = new DiscordIpcClient(this.clientId); + this.client = client; + client.on("ready", () => { + this.connected = true; + log.info("Connected to Discord"); + this.render(true); + this.emitStatus(); + }); + client.on("disconnect", () => { + this.connected = false; + this.emitStatus(); + this.scheduleReconnect(); + }); + client.connect(); + } + + private disconnect(): void { + this.clearTimers(); + if (this.client) { + this.client.destroy(); + this.client = null; + } + this.connected = false; + } + + private scheduleReconnect(): void { + if (!this.enabled || this.reconnectTimer) return; + if (this.client) { + this.client.destroy(); + this.client = null; + } + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.enabled && !this.connected) this.connect(); + }, RECONNECT_INTERVAL_MS); + } + + /** + * Push the current intent to Discord, coalescing bursts to one update per + * {@link MIN_UPDATE_INTERVAL_MS} with a trailing flush so the latest state + * always lands without tripping Discord's rate limiter. + */ + private render(immediate = false): void { + if (!this.client || !this.connected) return; + + const elapsed = Date.now() - this.lastUpdateAt; + if (!immediate && elapsed < MIN_UPDATE_INTERVAL_MS) { + if (!this.throttleTimer) { + this.throttleTimer = setTimeout(() => { + this.throttleTimer = null; + this.render(true); + }, MIN_UPDATE_INTERVAL_MS - elapsed); + } + return; + } + + if (this.throttleTimer) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + this.lastUpdateAt = Date.now(); + this.client.setActivity( + buildActivity(this.intent, { + showTaskTitle: this.showTaskTitle, + showRepoName: this.showRepoName, + startedAt: this.startedAt, + }), + ); + } + + private clearTimers(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.throttleTimer) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + } + + private emitStatus(): void { + this.emit(DiscordPresenceServiceEvent.StatusChanged, this.getState()); + } +} diff --git a/apps/code/src/main/services/settingsStore.ts b/apps/code/src/main/services/settingsStore.ts index 2acf4a02f2..547be3a215 100644 --- a/apps/code/src/main/services/settingsStore.ts +++ b/apps/code/src/main/services/settingsStore.ts @@ -11,6 +11,9 @@ interface SettingsSchema { autoSuspendEnabled: boolean; maxActiveWorktrees: number; autoSuspendAfterDays: number; + discordPresenceEnabled: boolean; + discordPresenceShowTaskTitle: boolean; + discordPresenceShowRepoName: boolean; } function getDefaultWorktreeLocation(): string { @@ -84,6 +87,18 @@ const schema = { minimum: 1, maximum: 365, }, + discordPresenceEnabled: { + type: "boolean" as const, + default: false, + }, + discordPresenceShowTaskTitle: { + type: "boolean" as const, + default: false, + }, + discordPresenceShowRepoName: { + type: "boolean" as const, + default: false, + }, }; export const settingsStore = new Store({ @@ -96,6 +111,9 @@ export const settingsStore = new Store({ autoSuspendEnabled: true, maxActiveWorktrees: 5, autoSuspendAfterDays: 7, + discordPresenceEnabled: false, + discordPresenceShowTaskTitle: false, + discordPresenceShowRepoName: false, }, }); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f0f8dd9eb5..36adce7faf 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -7,6 +7,7 @@ import { cloudTaskRouter } from "./routers/cloud-task"; import { connectivityRouter } from "./routers/connectivity"; import { contextMenuRouter } from "./routers/context-menu"; import { deepLinkRouter } from "./routers/deep-link"; +import { discordPresenceRouter } from "./routers/discord-presence"; import { encryptionRouter } from "./routers/encryption"; import { enrichmentRouter } from "./routers/enrichment"; import { environmentRouter } from "./routers/environment"; @@ -49,6 +50,7 @@ export const trpcRouter = router({ cloudTask: cloudTaskRouter, connectivity: connectivityRouter, contextMenu: contextMenuRouter, + discordPresence: discordPresenceRouter, enrichment: enrichmentRouter, environment: environmentRouter, diff --git a/apps/code/src/main/trpc/routers/discord-presence.ts b/apps/code/src/main/trpc/routers/discord-presence.ts new file mode 100644 index 0000000000..bd120fb98e --- /dev/null +++ b/apps/code/src/main/trpc/routers/discord-presence.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + DiscordPresenceServiceEvent, + discordPresenceStateSchema, + presenceIntentSchema, +} from "../../services/discord-presence/schemas"; +import type { DiscordPresenceService } from "../../services/discord-presence/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.DiscordPresenceService); + +export const discordPresenceRouter = router({ + getState: publicProcedure + .output(discordPresenceStateSchema) + .query(() => getService().getState()), + + setEnabled: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => { + getService().setEnabled(input.enabled); + }), + + setShowTaskTitle: publicProcedure + .input(z.object({ value: z.boolean() })) + .mutation(({ input }) => { + getService().setShowTaskTitle(input.value); + }), + + setShowRepoName: publicProcedure + .input(z.object({ value: z.boolean() })) + .mutation(({ input }) => { + getService().setShowRepoName(input.value); + }), + + setActivity: publicProcedure + .input(presenceIntentSchema) + .mutation(({ input }) => { + getService().setActivity(input); + }), + + onStatusChanged: publicProcedure.subscription(async function* (opts) { + const service = getService(); + for await (const data of service.toIterable( + DiscordPresenceServiceEvent.StatusChanged, + { signal: opts.signal }, + )) { + yield data; + } + }), +}); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index a5748db25b..bfa0fc36cb 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -13,6 +13,7 @@ import { import { useAuthSession } from "@features/auth/hooks/useAuthSession"; import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; import { registerBillingSubscriptions } from "@features/billing/subscriptions"; +import { registerDiscordPresenceSubscriptions } from "@features/discord-presence/subscriptions"; import { AddDirectoryDialog } from "@features/folder-picker/components/AddDirectoryDialog"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; @@ -80,6 +81,11 @@ function App() { return initializeUpdateStore(); }, []); + // Keep Discord Rich Presence in sync with what the user is doing + useEffect(() => { + return registerDiscordPresenceSubscriptions(); + }, []); + // Dev-only inbox demo command for local QA from the renderer console. useEffect(() => { if (import.meta.env.PROD) { diff --git a/apps/code/src/renderer/features/discord-presence/subscriptions.ts b/apps/code/src/renderer/features/discord-presence/subscriptions.ts new file mode 100644 index 0000000000..d53c45ec74 --- /dev/null +++ b/apps/code/src/renderer/features/discord-presence/subscriptions.ts @@ -0,0 +1,64 @@ +import { + sessionStoreSetters, + useSessionStore, +} from "@features/sessions/stores/sessionStore"; +import type { PresenceIntent } from "@main/services/discord-presence/schemas"; +import { trpcClient } from "@renderer/trpc/client"; +import { useNavigationStore } from "@stores/navigationStore"; +import { logger } from "@utils/logger"; + +const log = logger.scope("discord-presence"); + +/** + * Derive the high-level presence intent from renderer UI state. "What the user + * is looking at" is genuinely renderer-owned (navigation + session), so we + * compute it here and hand a small validated payload to the main service, which + * owns the connection and the privacy-aware formatting. + */ +function computeIntent(): PresenceIntent { + const { view } = useNavigationStore.getState(); + const task = view.type === "task-detail" ? view.data : undefined; + + let agentRunning = false; + if (view.taskId) { + const session = sessionStoreSetters.getSessionByTaskId(view.taskId); + agentRunning = session?.isPromptPending ?? false; + } + + return { + hasActiveTask: Boolean(task), + taskTitle: task?.title ?? null, + repoName: task?.repository ?? null, + agentRunning, + }; +} + +// Last payload we sent, to avoid spamming the service with no-op updates as the +// stores churn. The main service additionally rate-limits before it reaches +// Discord. +let lastSent = ""; + +function push(): void { + const intent = computeIntent(); + const key = JSON.stringify(intent); + if (key === lastSent) return; + lastSent = key; + trpcClient.discordPresence.setActivity.mutate(intent).catch((error) => { + log.warn("Failed to update Discord presence", { error }); + }); +} + +/** + * Wire presence updates to navigation and session changes. Started once at app + * boot; returns a cleanup that detaches the store subscriptions. + */ +export function registerDiscordPresenceSubscriptions(): () => void { + push(); + const unsubscribeNavigation = useNavigationStore.subscribe(push); + const unsubscribeSession = useSessionStore.subscribe(push); + return () => { + unsubscribeNavigation(); + unsubscribeSession(); + lastSent = ""; + }; +} diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 606229da76..4ccb23263a 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -18,6 +18,7 @@ import { Code, CreditCard, Cube, + DiscordLogo, Folder, GearSix, GithubLogo, @@ -36,6 +37,7 @@ import { type ReactNode, useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { AdvancedSettings } from "./sections/AdvancedSettings"; import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings"; +import { DiscordSettings } from "./sections/DiscordSettings"; import { EnvironmentsSettings } from "./sections/environments/EnvironmentsSettings"; import { GeneralSettings } from "./sections/GeneralSettings"; import { GitHubSettings } from "./sections/GitHubSettings"; @@ -76,6 +78,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "shortcuts", label: "Shortcuts", icon: }, { id: "github", label: "GitHub", icon: }, { id: "slack", label: "Slack", icon: }, + { id: "discord", label: "Discord", icon: }, { id: "signals", @@ -99,6 +102,7 @@ const CATEGORY_TITLES: Record = { shortcuts: "Shortcuts", github: "GitHub", slack: "Slack integration", + discord: "Discord", signals: "Signals", updates: "Updates", @@ -118,6 +122,7 @@ const CATEGORY_COMPONENTS: Record = { shortcuts: ShortcutsSettings, github: GitHubSettings, slack: SlackSettings, + discord: DiscordSettings, signals: SignalSourcesSettings, updates: UpdatesSettings, diff --git a/apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx new file mode 100644 index 0000000000..a2b7866948 --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx @@ -0,0 +1,134 @@ +import { SettingRow } from "@features/settings/components/SettingRow"; +import type { DiscordPresenceState } from "@main/services/discord-presence/schemas"; +import { Flex, Switch, Text } from "@radix-ui/themes"; +import { useTRPC } from "@renderer/trpc"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { track } from "@utils/analytics"; +import { useEffect, useState } from "react"; + +export function DiscordSettings() { + const trpcReact = useTRPC(); + const { data } = useQuery(trpcReact.discordPresence.getState.queryOptions()); + const [state, setState] = useState(null); + + useEffect(() => { + if (data) setState(data); + }, [data]); + + // The service emits status changes (connect/disconnect, toggle writes) so the + // panel reflects the live connection without polling. + useSubscription( + trpcReact.discordPresence.onStatusChanged.subscriptionOptions(undefined, { + onData: setState, + }), + ); + + const setEnabled = useMutation( + trpcReact.discordPresence.setEnabled.mutationOptions(), + ); + const setShowTaskTitle = useMutation( + trpcReact.discordPresence.setShowTaskTitle.mutationOptions(), + ); + const setShowRepoName = useMutation( + trpcReact.discordPresence.setShowRepoName.mutationOptions(), + ); + + const enabled = state?.enabled ?? false; + const configured = state?.configured ?? false; + const connected = state?.connected ?? false; + + const handleEnabledChange = (checked: boolean) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "discord_presence_enabled", + new_value: checked, + old_value: enabled, + }); + setState((prev) => (prev ? { ...prev, enabled: checked } : prev)); + setEnabled.mutate({ enabled: checked }); + }; + + const handleShowTaskTitleChange = (checked: boolean) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "discord_presence_show_task_title", + new_value: checked, + old_value: state?.showTaskTitle ?? false, + }); + setState((prev) => (prev ? { ...prev, showTaskTitle: checked } : prev)); + setShowTaskTitle.mutate({ value: checked }); + }; + + const handleShowRepoNameChange = (checked: boolean) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "discord_presence_show_repo_name", + new_value: checked, + old_value: state?.showRepoName ?? false, + }); + setState((prev) => (prev ? { ...prev, showRepoName: checked } : prev)); + setShowRepoName.mutate({ value: checked }); + }; + + return ( + + + + + + {enabled && ( + <> + {!configured ? ( + + No Discord application is configured for this build, so nothing + will appear yet. Set VITE_DISCORD_CLIENT_ID to connect. + + ) : ( + + {connected + ? "Connected to Discord." + : "Waiting for Discord — make sure the desktop app is running."} + + )} + + + Privacy + + + + + + + + + + + )} + + ); +} diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts index b3c1557cf0..2fa5d21d2c 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts +++ b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts @@ -13,6 +13,7 @@ export type SettingsCategory = | "shortcuts" | "github" | "slack" + | "discord" | "signals" | "updates" | "advanced"; diff --git a/apps/code/vite.main.config.mts b/apps/code/vite.main.config.mts index 4e0f4b0368..0fba4abcc4 100644 --- a/apps/code/vite.main.config.mts +++ b/apps/code/vite.main.config.mts @@ -626,6 +626,9 @@ export default defineConfig(({ mode }) => { "process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE": JSON.stringify( env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE || "", ), + "process.env.VITE_DISCORD_CLIENT_ID": JSON.stringify( + env.VITE_DISCORD_CLIENT_ID || "", + ), "process.env.SKILLS_ZIP_URL": JSON.stringify(SKILLS_ZIP_URL), "process.env.CONTEXT_MILL_ZIP_URL": JSON.stringify(CONTEXT_MILL_ZIP_URL), ...createForceDevModeDefine(), From 3bb964c389e3cbcb8c0b747e691057529bacf80a Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 2 Jun 2026 16:13:47 -0400 Subject: [PATCH 2/4] feat(discord-presence): add live Rich Presence preview to settings Add a DiscordPresencePreview component that mocks the Discord activity card from app primitives so it tracks the theme. It reacts to the privacy toggles, offers a Running/Idle switch inline with the Preview header, shows an elapsing green timer, and dims when the feature is off. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sections/DiscordPresencePreview.tsx | 119 ++++++++++++++++++ .../components/sections/DiscordSettings.tsx | 27 ++-- 2 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx diff --git a/apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx b/apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx new file mode 100644 index 0000000000..6548bffa1d --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx @@ -0,0 +1,119 @@ +import { DotsThree, GameController, Pause, Play } from "@phosphor-icons/react"; +import { Flex, SegmentedControl, Text } from "@radix-ui/themes"; +import posthogIcon from "@renderer/assets/images/posthog-icon.svg"; +import { useEffect, useState } from "react"; + +interface DiscordPresencePreviewProps { + /** Mirrors the "Show task title" toggle. */ + showTaskTitle: boolean; + /** Mirrors the "Show repository name" toggle. */ + showRepoName: boolean; + /** When false the card is dimmed to read as an inactive teaser. */ + enabled: boolean; +} + +// Illustrative data — what a session looks like on a Discord profile. +const SAMPLE_TASK_TITLE = "Repository overview"; +const SAMPLE_REPO = "posthog/posthog"; + +function formatElapsed(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +/** + * A faithful mock of the Discord Rich Presence card, built from app primitives + * so it tracks the theme. It reacts to the privacy toggles (so users see what + * each reveals) and lets them flip between the running and idle states. + */ +export function DiscordPresencePreview({ + showTaskTitle, + showRepoName, + enabled, +}: DiscordPresencePreviewProps) { + const [running, setRunning] = useState(true); + const [elapsed, setElapsed] = useState(197); // 3:17, like a session in progress + + // Tick the elapsed timer so the card feels live, the way Discord shows it. + useEffect(() => { + const interval = setInterval(() => setElapsed((s) => s + 1), 1000); + return () => clearInterval(interval); + }, []); + + const details = showTaskTitle + ? `Working on "${SAMPLE_TASK_TITLE}"` + : "Working on a task"; + const statusPart = running ? "agent running" : "reviewing"; + const state = showRepoName ? `${SAMPLE_REPO} · ${statusPart}` : statusPart; + + return ( + + + Preview + setRunning(value === "running")} + > + Running + Idle + + + +
+ + + Playing + + + + + +
+
+ + + PostHog + +
+
+ {running ? ( + + ) : ( + + )} +
+
+ + + PostHog + + {details} + + + {state} + + + + + {formatElapsed(elapsed)} elapsed + + + +
+
+
+ ); +} diff --git a/apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx index a2b7866948..04f1e3e427 100644 --- a/apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx @@ -1,4 +1,5 @@ import { SettingRow } from "@features/settings/components/SettingRow"; +import { DiscordPresencePreview } from "@features/settings/components/sections/DiscordPresencePreview"; import type { DiscordPresenceState } from "@main/services/discord-presence/schemas"; import { Flex, Switch, Text } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; @@ -72,9 +73,9 @@ export function DiscordSettings() { return ( {!configured ? ( - + No Discord application is configured for this build, so nothing will appear yet. Set VITE_DISCORD_CLIENT_ID to connect. ) : ( {connected - ? "Connected to Discord." - : "Waiting for Discord — make sure the desktop app is running."} + ? "Connected to Discord" + : "Waiting for Discord (desktop app needs to be running)..."} )} - + Privacy )} + + ); } From df4563fb4fb38bd317d83f0f73ad262b9fed0655 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 2 Jun 2026 16:31:57 -0400 Subject: [PATCH 3/4] test(discord-presence): assert no Discord connection while disabled Lock in that a DiscordIpcClient is only ever constructed/connected when the toggle is enabled: not on boot when disabled, and not when activity or privacy updates arrive while disabled. Includes an enabled-path sanity check so the assertions are meaningful. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/discord-presence/service.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 apps/code/src/main/services/discord-presence/service.test.ts diff --git a/apps/code/src/main/services/discord-presence/service.test.ts b/apps/code/src/main/services/discord-presence/service.test.ts new file mode 100644 index 0000000000..005bdb2712 --- /dev/null +++ b/apps/code/src/main/services/discord-presence/service.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PresenceIntent } from "./schemas"; + +// Mock the scoped logger (the real one pulls in electron-log). +vi.mock("../../utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +// Controllable settings instead of the electron-store singleton. +const settings = vi.hoisted(() => ({ + values: {} as Record, +})); +vi.mock("../settingsStore", () => ({ + settingsStore: { + get: (key: string, def: boolean) => settings.values[key] ?? def, + set: (key: string, value: boolean) => { + settings.values[key] = value; + }, + }, +})); + +// Stub the IPC client so we can assert whether a connection was ever opened — +// and so node:net never runs in the test environment. A regular function (not +// an arrow) is used so the mock is constructable via `new`. +vi.mock("./discord-ipc", () => ({ + DiscordIpcClient: vi.fn(function (this: Record) { + this.on = vi.fn(); + this.connect = vi.fn(); + this.destroy = vi.fn(); + this.setActivity = vi.fn(); + }), +})); + +// A real client id so the "not configured" guard is never the reason a +// connection is skipped — we want to prove the *enabled* gate does the work. +vi.mock("./constants", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getDiscordClientId: () => "test-client-id" }; +}); + +import { DiscordIpcClient } from "./discord-ipc"; +import { DiscordPresenceService } from "./service"; + +const SAMPLE_INTENT: PresenceIntent = { + hasActiveTask: true, + taskTitle: "Repository overview", + repoName: "posthog/posthog", + agentRunning: true, +}; + +// The `this` captured for each `new DiscordIpcClient()` call. +const clientInstance = (index: number) => + vi.mocked(DiscordIpcClient).mock.instances[index] as unknown as { + connect: ReturnType; + destroy: ReturnType; + }; + +describe("DiscordPresenceService connection gating", () => { + beforeEach(() => { + vi.clearAllMocks(); + settings.values = { + discordPresenceEnabled: false, + discordPresenceShowTaskTitle: false, + discordPresenceShowRepoName: false, + }; + }); + + it("does not connect to Discord on construction when disabled", () => { + const service = new DiscordPresenceService(); + expect(DiscordIpcClient).not.toHaveBeenCalled(); + expect(service.getState().connected).toBe(false); + }); + + it("does not connect when activity or privacy updates arrive while disabled", () => { + const service = new DiscordPresenceService(); + service.setActivity(SAMPLE_INTENT); + service.setShowTaskTitle(true); + service.setShowRepoName(true); + expect(DiscordIpcClient).not.toHaveBeenCalled(); + }); + + it("connects only once enabled, and tears down when turned back off", () => { + const service = new DiscordPresenceService(); + expect(DiscordIpcClient).not.toHaveBeenCalled(); + + service.setEnabled(true); + expect(DiscordIpcClient).toHaveBeenCalledTimes(1); + expect(clientInstance(0).connect).toHaveBeenCalledTimes(1); + + service.setEnabled(false); + expect(clientInstance(0).destroy).toHaveBeenCalledTimes(1); + + // Activity pushed after disabling must not spin up a new connection. + service.setActivity(SAMPLE_INTENT); + expect(DiscordIpcClient).toHaveBeenCalledTimes(1); + }); + + it("connects on construction when already enabled (guard sanity check)", () => { + settings.values.discordPresenceEnabled = true; + new DiscordPresenceService(); + expect(DiscordIpcClient).toHaveBeenCalledTimes(1); + expect(clientInstance(0).connect).toHaveBeenCalledTimes(1); + }); +}); From d0f60aa061451c516da0c095e2efc409ebaef00f Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 2 Jun 2026 16:32:19 -0400 Subject: [PATCH 4/4] feat(discord-presence): pause preview timer and show idle when disabled When Rich Presence is off, the in-settings preview now stops its elapsed timer, falls back to the idle state (amber pause badge), and locks the Running/Idle toggle so the dormant integration reads clearly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sections/DiscordPresencePreview.tsx | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx b/apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx index 6548bffa1d..7c3a07edc3 100644 --- a/apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx @@ -35,11 +35,17 @@ export function DiscordPresencePreview({ const [running, setRunning] = useState(true); const [elapsed, setElapsed] = useState(197); // 3:17, like a session in progress - // Tick the elapsed timer so the card feels live, the way Discord shows it. + // While enabled, tick the elapsed timer so the card feels live, the way + // Discord shows it. When disabled, the integration is dormant: stop the timer + // and fall back to the idle state. useEffect(() => { + if (!enabled) { + setRunning(false); + return; + } const interval = setInterval(() => setElapsed((s) => s + 1), 1000); return () => clearInterval(interval); - }, []); + }, [enabled]); const details = showTaskTitle ? `Working on "${SAMPLE_TASK_TITLE}"` @@ -55,14 +61,18 @@ export function DiscordPresencePreview({ className="border-gray-6 border-t pt-4" > Preview - setRunning(value === "running")} - > - Running - Idle - +
+ setRunning(value === "running")} + > + + Running + + Idle + +