feat(code): add Discord Rich Presence integration#2453
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Prefer parameterised tests over individual
it blocks
The project convention ("We always prefer parameterised tests") applies here. The seven it blocks all exercise the same buildActivity function with different intent/options combinations and check expected field values — a textbook candidate for it.each. As written, adding a new scenario means another copy-paste block; a it.each table makes the test matrix legible and reduces repetition.
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/main/services/discord-presence/presence-format.test.ts
Line: 20-84
Comment:
**Prefer parameterised tests over individual `it` blocks**
The project convention ("We always prefer parameterised tests") applies here. The seven `it` blocks all exercise the same `buildActivity` function with different intent/options combinations and check expected field values — a textbook candidate for `it.each`. As written, adding a new scenario means another copy-paste block; a `it.each` table makes the test matrix legible and reduces repetition.
**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| 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); | ||
| }); |
There was a problem hiding this comment.
Truncation test only bounds-checks length, not content
The test verifies length <= 128 but not that the truncation is correct — for example, it doesn't assert that the result ends with … or that the non-truncated prefix matches. A future change that naively slices to 128 characters (dropping the ellipsis, or inserting extra chars) would still pass this test.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/main/services/discord-presence/presence-format.test.ts
Line: 75-83
Comment:
**Truncation test only bounds-checks length, not content**
The test verifies `length <= 128` but not that the truncation is correct — for example, it doesn't assert that the result ends with `…` or that the non-truncated prefix matches. A future change that naively slices to 128 characters (dropping the ellipsis, or inserting extra chars) would still pass this test.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Pull request overview
Adds a Discord Rich Presence integration to the PostHog Code desktop app, with a main-process Discord IPC client/service, tRPC endpoints, renderer boot-time subscriptions to keep presence in sync, and a new Settings UI section to control privacy and enablement.
Changes:
- Implement main-process Discord IPC client + presence service (rate-limited updates, reconnect loop, settings persistence, schemas, tests).
- Add renderer-side boot subscription to derive “presence intent” from navigation/session state and push it to the main service.
- Add Discord settings section + preview card and wire into Settings dialog navigation.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/code/vite.main.config.mts | Exposes VITE_DISCORD_CLIENT_ID to the main build via define. |
| apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts | Adds a new "discord" settings category. |
| apps/code/src/renderer/features/settings/components/SettingsDialog.tsx | Adds Discord section entry/icon/title/component wiring. |
| apps/code/src/renderer/features/settings/components/sections/DiscordSettings.tsx | New Discord settings UI (enable + privacy toggles) driven by tRPC state/subscription. |
| apps/code/src/renderer/features/settings/components/sections/DiscordPresencePreview.tsx | New in-app preview card for Rich Presence appearance. |
| apps/code/src/renderer/features/discord-presence/subscriptions.ts | New renderer subscription registrar to push presence intent to main via tRPC. |
| apps/code/src/renderer/App.tsx | Registers Discord presence subscriptions once at app boot. |
| apps/code/src/main/trpc/routers/discord-presence.ts | New tRPC router exposing Discord presence service controls + status subscription. |
| apps/code/src/main/trpc/router.ts | Wires discordPresence into the root tRPC router. |
| apps/code/src/main/services/settingsStore.ts | Persists Discord presence enablement + privacy flags. |
| apps/code/src/main/services/discord-presence/service.ts | New main-process Discord presence service (connect/reconnect, rate-limit, formatting, state events). |
| apps/code/src/main/services/discord-presence/service.test.ts | Unit tests for connection gating behavior. |
| apps/code/src/main/services/discord-presence/schemas.ts | Zod schemas/types for presence state and intent. |
| apps/code/src/main/services/discord-presence/presence-format.ts | Pure formatter from intent+privacy options to Discord activity payload. |
| apps/code/src/main/services/discord-presence/presence-format.test.ts | Unit tests for formatting/privacy/truncation. |
| apps/code/src/main/services/discord-presence/discord-ipc.ts | New Node net-based Discord local IPC client implementation. |
| apps/code/src/main/services/discord-presence/constants.ts | New constants + getDiscordClientId() helper. |
| apps/code/src/main/index.ts | Eagerly instantiates the Discord presence service at startup. |
| apps/code/src/main/di/tokens.ts | Adds DI token for DiscordPresenceService. |
| apps/code/src/main/di/container.ts | Binds DiscordPresenceService into the DI container. |
| .env.example | Documents VITE_DISCORD_CLIENT_ID setup. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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<K extends keyof DiscordIpcClientEvents>( | ||
| event: K, | ||
| listener: DiscordIpcClientEvents[K], | ||
| ): this { | ||
| return super.on(event, listener); | ||
| } | ||
|
|
||
| override emit<K extends keyof DiscordIpcClientEvents>(event: K): boolean { | ||
| return super.emit(event); | ||
| } |
| new_value: checked, | ||
| old_value: enabled, | ||
| }); | ||
| setState((prev) => (prev ? { ...prev, enabled: checked } : prev)); |
| new_value: checked, | ||
| old_value: state?.showTaskTitle ?? false, | ||
| }); | ||
| setState((prev) => (prev ? { ...prev, showTaskTitle: checked } : prev)); |
| new_value: checked, | ||
| old_value: state?.showRepoName ?? false, | ||
| }); | ||
| setState((prev) => (prev ? { ...prev, showRepoName: checked } : prev)); |
Summary
Adds a Discord Rich Presence integration that surfaces the user's current activity in their Discord status.
discord-presence/service.ts) talking to Discord over its local IPC socket (discord-ipc.ts), with presence formatting helpers (presence-format.ts) and Zod schemas (schemas.ts).routers/discord-presence.ts) exposing the service, wired into the root router and DI container.settingsStoreplus a new Discord settings section in the renderer (DiscordSettings.tsx), gated by a settings dialog entry.renderer/features/discord-presence/subscriptions.ts) wired once at app boot..env.exampleentries and avite.main.config.mtstweak for the integration.Screenshots
Test plan
presence-format.test.tsunit testspnpm typecheck(ran via pre-commit hook)🤖 Generated with Claude Code