diff --git a/apps/code/build/entitlements.mac.plist b/apps/code/build/entitlements.mac.plist
index 0d3832ce4d..2d5eac9541 100644
--- a/apps/code/build/entitlements.mac.plist
+++ b/apps/code/build/entitlements.mac.plist
@@ -13,5 +13,9 @@
com.apple.security.cs.allow-jit
+
+
+ com.apple.security.device.audio-input
+
diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts
index f52736bf2e..9045cbdc1c 100644
--- a/apps/code/forge.config.ts
+++ b/apps/code/forge.config.ts
@@ -152,11 +152,11 @@ const config: ForgeConfig = {
appBundleId: "com.posthog.array",
appCategoryType: "public.app-category.productivity",
extraResource: existsSync("build/Assets.car") ? ["build/Assets.car"] : [],
- extendInfo: existsSync("build/Assets.car")
- ? {
- CFBundleIconName: "Icon",
- }
- : {},
+ extendInfo: {
+ NSMicrophoneUsageDescription:
+ "PostHog Code uses the microphone to let you record a custom completion sound.",
+ ...(existsSync("build/Assets.car") ? { CFBundleIconName: "Icon" } : {}),
+ },
...(osxSignConfig
? {
osxSign: osxSignConfig,
diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts
index 959ea1431c..e7d286078b 100644
--- a/apps/code/src/main/di/container.ts
+++ b/apps/code/src/main/di/container.ts
@@ -18,6 +18,7 @@ import { ElectronDialog } from "../platform-adapters/electron-dialog";
import { ElectronFileIcon } from "../platform-adapters/electron-file-icon";
import { ElectronImageProcessor } from "../platform-adapters/electron-image-processor";
import { ElectronMainWindow } from "../platform-adapters/electron-main-window";
+import { ElectronMediaAccess } from "../platform-adapters/electron-media-access";
import { ElectronNotifier } from "../platform-adapters/electron-notifier";
import { ElectronPowerManager } from "../platform-adapters/electron-power-manager";
import { ElectronSecureStorage } from "../platform-adapters/electron-secure-storage";
@@ -86,6 +87,7 @@ container.bind(MAIN_TOKENS.Notifier).to(ElectronNotifier);
container.bind(MAIN_TOKENS.ContextMenu).to(ElectronContextMenu);
container.bind(MAIN_TOKENS.BundledResources).to(ElectronBundledResources);
container.bind(MAIN_TOKENS.ImageProcessor).to(ElectronImageProcessor);
+container.bind(MAIN_TOKENS.MediaAccess).to(ElectronMediaAccess);
container.bind(MAIN_TOKENS.DatabaseService).to(DatabaseService);
container
diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts
index c8225b2b1d..b811efe180 100644
--- a/apps/code/src/main/di/tokens.ts
+++ b/apps/code/src/main/di/tokens.ts
@@ -21,6 +21,7 @@ export const MAIN_TOKENS = Object.freeze({
ContextMenu: Symbol.for("Platform.ContextMenu"),
BundledResources: Symbol.for("Platform.BundledResources"),
ImageProcessor: Symbol.for("Platform.ImageProcessor"),
+ MediaAccess: Symbol.for("Platform.MediaAccess"),
// Stores
SettingsStore: Symbol.for("Main.SettingsStore"),
diff --git a/apps/code/src/main/platform-adapters/electron-media-access.ts b/apps/code/src/main/platform-adapters/electron-media-access.ts
new file mode 100644
index 0000000000..d7153648ad
--- /dev/null
+++ b/apps/code/src/main/platform-adapters/electron-media-access.ts
@@ -0,0 +1,21 @@
+import type {
+ IMediaAccess,
+ MediaAccessStatus,
+} from "@posthog/platform/media-access";
+import { systemPreferences } from "electron";
+import { injectable } from "inversify";
+
+@injectable()
+export class ElectronMediaAccess implements IMediaAccess {
+ public getMicrophoneStatus(): MediaAccessStatus {
+ if (process.platform !== "darwin") return "granted";
+ return systemPreferences.getMediaAccessStatus(
+ "microphone",
+ ) as MediaAccessStatus;
+ }
+
+ public async requestMicrophoneAccess(): Promise {
+ if (process.platform !== "darwin") return true;
+ return systemPreferences.askForMediaAccess("microphone");
+ }
+}
diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts
index 046fadd003..7cb4fab311 100644
--- a/apps/code/src/main/trpc/routers/os.ts
+++ b/apps/code/src/main/trpc/routers/os.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import type { IAppMeta } from "@posthog/platform/app-meta";
import type { DialogSeverity, IDialog } from "@posthog/platform/dialog";
import type { IImageProcessor } from "@posthog/platform/image-processor";
+import type { IMediaAccess } from "@posthog/platform/media-access";
import type { IUrlLauncher } from "@posthog/platform/url-launcher";
import { IMAGE_MIME_TYPES } from "@shared/constants/image";
import { z } from "zod";
@@ -20,6 +21,8 @@ const getDialog = () => container.get(MAIN_TOKENS.Dialog);
const getAppMeta = () => container.get(MAIN_TOKENS.AppMeta);
const getImageProcessor = () =>
container.get(MAIN_TOKENS.ImageProcessor);
+const getMediaAccess = () =>
+ container.get(MAIN_TOKENS.MediaAccess);
const messageBoxOptionsSchema = z.object({
type: z.enum(["none", "info", "error", "question", "warning"]).optional(),
@@ -273,6 +276,29 @@ export const osRouter = router({
*/
getWorktreeLocation: publicProcedure.query(() => getWorktreeLocation()),
+ /**
+ * Get the user's home directory.
+ */
+ getHomeDir: publicProcedure.output(z.string()).query(() => os.homedir()),
+
+ /**
+ * Get the macOS microphone permission status.
+ * Returns "not-determined" | "granted" | "denied" | "restricted" | "unknown".
+ * On non-macOS platforms returns "granted".
+ */
+ getMicrophoneAccessStatus: publicProcedure
+ .output(z.string())
+ .query(() => getMediaAccess().getMicrophoneStatus()),
+
+ /**
+ * Trigger the macOS microphone permission prompt (or return existing decision).
+ * Resolves to true if access is granted, false otherwise.
+ * On non-macOS platforms always returns true.
+ */
+ requestMicrophoneAccess: publicProcedure
+ .output(z.boolean())
+ .mutation(() => getMediaAccess().requestMicrophoneAccess()),
+
/**
* Read a file and return it as a base64 data URL
* Used for image thumbnails in the editor
diff --git a/apps/code/src/renderer/assets/images/hedgehogs/beach-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/beach-hog.png
new file mode 100644
index 0000000000..0f1d23f143
Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/beach-hog.png differ
diff --git a/apps/code/src/renderer/assets/images/hedgehogs/cursor-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/cursor-hog.png
new file mode 100644
index 0000000000..af9ac1e436
Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/cursor-hog.png differ
diff --git a/apps/code/src/renderer/assets/images/hedgehogs/experiments-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/experiments-hog.png
new file mode 100644
index 0000000000..0f0bd4a7c0
Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/experiments-hog.png differ
diff --git a/apps/code/src/renderer/assets/images/hedgehogs/file-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/file-hog.png
new file mode 100644
index 0000000000..f1563cfb87
Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/file-hog.png differ
diff --git a/apps/code/src/renderer/assets/images/hedgehogs/hacker-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/hacker-hog.png
new file mode 100644
index 0000000000..245e1b69bf
Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/hacker-hog.png differ
diff --git a/apps/code/src/renderer/assets/images/hedgehogs/magic-book-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/magic-book-hog.png
new file mode 100644
index 0000000000..1fee8980f0
Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/magic-book-hog.png differ
diff --git a/apps/code/src/renderer/assets/images/hedgehogs/party-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/party-hog.png
new file mode 100644
index 0000000000..547ae29a07
Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/party-hog.png differ
diff --git a/apps/code/src/renderer/assets/images/personalities/blank.png b/apps/code/src/renderer/assets/images/personalities/blank.png
new file mode 100644
index 0000000000..5e39c0c702
Binary files /dev/null and b/apps/code/src/renderer/assets/images/personalities/blank.png differ
diff --git a/apps/code/src/renderer/assets/images/personalities/data.png b/apps/code/src/renderer/assets/images/personalities/data.png
new file mode 100644
index 0000000000..a36c089c21
Binary files /dev/null and b/apps/code/src/renderer/assets/images/personalities/data.png differ
diff --git a/apps/code/src/renderer/assets/images/personalities/operator.png b/apps/code/src/renderer/assets/images/personalities/operator.png
new file mode 100644
index 0000000000..4e57b304cb
Binary files /dev/null and b/apps/code/src/renderer/assets/images/personalities/operator.png differ
diff --git a/apps/code/src/renderer/assets/images/personalities/sales.png b/apps/code/src/renderer/assets/images/personalities/sales.png
new file mode 100644
index 0000000000..a321906868
Binary files /dev/null and b/apps/code/src/renderer/assets/images/personalities/sales.png differ
diff --git a/apps/code/src/renderer/assets/images/personalities/support.png b/apps/code/src/renderer/assets/images/personalities/support.png
new file mode 100644
index 0000000000..adcbea6256
Binary files /dev/null and b/apps/code/src/renderer/assets/images/personalities/support.png differ
diff --git a/apps/code/src/renderer/assets/services/asana.svg b/apps/code/src/renderer/assets/services/asana.svg
new file mode 100644
index 0000000000..ed20e640c6
--- /dev/null
+++ b/apps/code/src/renderer/assets/services/asana.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/code/src/renderer/assets/services/gmail.svg b/apps/code/src/renderer/assets/services/gmail.svg
new file mode 100644
index 0000000000..40b7175c11
--- /dev/null
+++ b/apps/code/src/renderer/assets/services/gmail.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/apps/code/src/renderer/assets/services/google_calendar.svg b/apps/code/src/renderer/assets/services/google_calendar.svg
new file mode 100644
index 0000000000..c32c0c772b
--- /dev/null
+++ b/apps/code/src/renderer/assets/services/google_calendar.svg
@@ -0,0 +1,28 @@
+
+
+
diff --git a/apps/code/src/renderer/assets/services/google_drive.svg b/apps/code/src/renderer/assets/services/google_drive.svg
new file mode 100644
index 0000000000..a8cefd5b28
--- /dev/null
+++ b/apps/code/src/renderer/assets/services/google_drive.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/apps/code/src/renderer/assets/services/granola.svg b/apps/code/src/renderer/assets/services/granola.svg
new file mode 100644
index 0000000000..014da736e6
--- /dev/null
+++ b/apps/code/src/renderer/assets/services/granola.svg
@@ -0,0 +1,89 @@
+
+
+
diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx
index d25f279673..1e6b76afba 100644
--- a/apps/code/src/renderer/components/MainLayout.tsx
+++ b/apps/code/src/renderer/components/MainLayout.tsx
@@ -25,6 +25,7 @@ import { useTasks } from "@features/tasks/hooks/useTasks";
import { TourOverlay } from "@features/tour/components/TourOverlay";
import { useTourStore } from "@features/tour/stores/tourStore";
import { createFirstTaskTour } from "@features/tour/tours/createFirstTaskTour";
+import { WorkView } from "@features/work/components/WorkView";
import { useFeatureFlag } from "@hooks/useFeatureFlag";
import { useIntegrations } from "@hooks/useIntegrations";
import { Box, Flex } from "@radix-ui/themes";
@@ -45,6 +46,8 @@ export function MainLayout() {
taskInputReportAssociation,
taskInputCloudRepository,
} = useNavigationStore();
+ const mode = useNavigationStore((s) => s.mode);
+ const isCodeMode = mode === "code";
const {
isOpen: commandMenuOpen,
setOpen: setCommandMenuOpen,
@@ -105,46 +108,55 @@ export function MainLayout() {
- {view.type === "task-input" && (
-
- )}
+ {isCodeMode ? (
+ <>
+ {view.type === "task-input" && (
+
+ )}
- {view.type === "task-detail" && view.data && (
-
- )}
+ {view.type === "task-detail" && view.data && (
+
+ )}
+
+ {view.type === "folder-settings" && }
- {view.type === "folder-settings" && }
+ {view.type === "inbox" && }
- {view.type === "inbox" && }
+ {view.type === "archived" && }
- {view.type === "archived" && }
+ {view.type === "command-center" && }
- {view.type === "command-center" && }
+ {view.type === "skills" && }
- {view.type === "skills" && }
+ {view.type === "mcp-servers" && }
- {view.type === "mcp-servers" && }
- {view.type === "setup" && }
+ {view.type === "setup" && }
+ >
+ ) : (
+
+ )}
-
+ {isCodeMode && (
+
+ )}
= {
airops: IconAirOps,
+ asana: IconAsana,
atlassian: IconAtlassian,
attio: IconAttio,
box: IconBox,
@@ -55,6 +61,10 @@ const BRAND_ICONS: Record = {
firetiger: IconFiretiger,
github: IconGitHub,
gitlab: IconGitLab,
+ gmail: IconGmail,
+ google_calendar: IconGoogleCalendar,
+ google_drive: IconGoogleDrive,
+ granola: IconGranola,
hex: IconHex,
hubspot: IconHubSpot,
launchdarkly: IconLaunchDarkly,
diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts b/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts
index 50074a794d..7d54dba840 100644
--- a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts
+++ b/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts
@@ -1,3 +1,7 @@
+import {
+ isLocalTemplateId,
+ LOCAL_MCP_TEMPLATES,
+} from "@features/mcp-servers/localTemplates";
import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation";
import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery";
import type {
@@ -92,6 +96,19 @@ export function useMcpServers() {
(client) => client.getMcpServers(),
);
+ const mergedServers = useMemo(() => {
+ if (!servers) return servers;
+ const remoteIconKeys = new Set(
+ servers.map((s) => s.icon_key).filter((k): k is string => !!k),
+ );
+ const locals = LOCAL_MCP_TEMPLATES.filter(
+ (t) => !t.icon_key || !remoteIconKeys.has(t.icon_key),
+ );
+ return [...servers, ...locals].sort((a, b) =>
+ a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
+ );
+ }, [servers]);
+
const installedTemplateIds = useMemo(
() =>
new Set(
@@ -170,17 +187,6 @@ export function useMcpServers() {
},
);
- const installTemplate = useCallback(
- (template: McpRecommendedServer, opts?: { api_key?: string }) => {
- setInstallingId(template.id);
- installTemplateMutation.mutate({
- template_id: template.id,
- api_key: opts?.api_key,
- });
- },
- [installTemplateMutation],
- );
-
const installCustomMutation = useAuthenticatedMutation(
(
client,
@@ -202,13 +208,36 @@ export function useMcpServers() {
toast.error(data.error);
}
invalidateInstallations();
+ setInstallingId(null);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to add server");
+ setInstallingId(null);
},
},
);
+ const installTemplate = useCallback(
+ (template: McpRecommendedServer, opts?: { api_key?: string }) => {
+ setInstallingId(template.id);
+ if (isLocalTemplateId(template.id)) {
+ installCustomMutation.mutate({
+ name: template.name,
+ url: template.url,
+ description: template.description ?? "",
+ auth_type: template.auth_type ?? "oauth",
+ ...(opts?.api_key ? { api_key: opts.api_key } : {}),
+ });
+ return;
+ }
+ installTemplateMutation.mutate({
+ template_id: template.id,
+ api_key: opts?.api_key,
+ });
+ },
+ [installTemplateMutation, installCustomMutation],
+ );
+
const reauthorizeMutation = useAuthenticatedMutation(
async (client, installationId: string) => {
const callbackUrl = await getCallbackUrl();
@@ -247,7 +276,7 @@ export function useMcpServers() {
return {
installations: installations as McpServerInstallation[] | undefined,
installationsLoading,
- servers: servers as McpRecommendedServer[] | undefined,
+ servers: mergedServers,
serversLoading,
installedTemplateIds,
installedUrls,
diff --git a/apps/code/src/renderer/features/mcp-servers/localTemplates.ts b/apps/code/src/renderer/features/mcp-servers/localTemplates.ts
new file mode 100644
index 0000000000..e4720158c2
--- /dev/null
+++ b/apps/code/src/renderer/features/mcp-servers/localTemplates.ts
@@ -0,0 +1,72 @@
+import type { McpRecommendedServer } from "@renderer/api/posthogClient";
+
+export type LocalMcpTemplate = McpRecommendedServer & {
+ id: `local:${string}`;
+};
+
+export const LOCAL_TEMPLATE_PREFIX = "local:" as const;
+
+export function isLocalTemplateId(id: string): id is `local:${string}` {
+ return id.startsWith(LOCAL_TEMPLATE_PREFIX);
+}
+
+export const LOCAL_MCP_TEMPLATES: LocalMcpTemplate[] = [
+ {
+ id: "local:asana",
+ name: "Asana",
+ url: "https://mcp.asana.com/v2/mcp",
+ auth_type: "oauth",
+ category: "productivity",
+ icon_key: "asana",
+ description:
+ "Manage Asana projects, tasks, and teams. Requires a pre-registered OAuth app — add your client ID and secret under the optional authentication fields.",
+ docs_url: "https://developers.asana.com/docs/using-asanas-mcp-server",
+ },
+ {
+ id: "local:gmail",
+ name: "Gmail",
+ url: "https://gmailmcp.googleapis.com/mcp/v1",
+ auth_type: "oauth",
+ category: "productivity",
+ icon_key: "gmail",
+ description:
+ "Search, read, and compose Gmail messages. Requires an OAuth client created in Google Cloud — add your client ID and secret under the optional authentication fields.",
+ docs_url:
+ "https://developers.google.com/workspace/gmail/api/guides/configure-mcp-server",
+ },
+ {
+ id: "local:google_calendar",
+ name: "Google Calendar",
+ url: "https://calendarmcp.googleapis.com/mcp/v1",
+ auth_type: "oauth",
+ category: "productivity",
+ icon_key: "google_calendar",
+ description:
+ "Read and manage Google Calendar events. Requires an OAuth client created in Google Cloud — add your client ID and secret under the optional authentication fields.",
+ docs_url:
+ "https://developers.google.com/workspace/guides/configure-mcp-servers",
+ },
+ {
+ id: "local:google_drive",
+ name: "Google Drive",
+ url: "https://drivemcp.googleapis.com/mcp/v1",
+ auth_type: "oauth",
+ category: "productivity",
+ icon_key: "google_drive",
+ description:
+ "Browse and read files in Google Drive (including Docs, Sheets, and Slides). Requires an OAuth client created in Google Cloud — add your client ID and secret under the optional authentication fields.",
+ docs_url:
+ "https://developers.google.com/workspace/guides/configure-mcp-servers",
+ },
+ {
+ id: "local:granola",
+ name: "Granola",
+ url: "https://mcp.granola.ai/mcp",
+ auth_type: "oauth",
+ category: "productivity",
+ icon_key: "granola",
+ description:
+ "Access Granola meeting notes and transcripts. Uses Dynamic Client Registration — no additional credentials required.",
+ docs_url: "https://docs.granola.ai/help-center/sharing/integrations/mcp",
+ },
+];
diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx
index 9ebd58675d..b6d9c842c3 100644
--- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx
+++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx
@@ -52,6 +52,7 @@ export interface PromptInputProps {
onCancel?: () => void;
onAttachFiles?: (files: File[]) => void;
onEmptyChange?: (isEmpty: boolean) => void;
+ onTextChange?: (text: string) => void;
onFocus?: () => void;
onBlur?: () => void;
// manual submit override (for flows like new-task that submit outside the editor hook)
@@ -90,6 +91,7 @@ export const PromptInput = forwardRef(
onCancel,
onAttachFiles,
onEmptyChange,
+ onTextChange,
onFocus,
onBlur,
onSubmitClick,
@@ -138,6 +140,7 @@ export const PromptInput = forwardRef(
onBashCommand,
onBashModeChange,
onEmptyChange,
+ onTextChange,
onFocus,
onBlur,
});
diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts
index 9dbe099ac9..895cad8ce2 100644
--- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts
+++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts
@@ -48,6 +48,7 @@ export interface UseTiptapEditorOptions {
onBashCommand?: (command: string) => void;
onBashModeChange?: (isBashMode: boolean) => void;
onEmptyChange?: (isEmpty: boolean) => void;
+ onTextChange?: (text: string) => void;
onFocus?: () => void;
onBlur?: () => void;
}
@@ -198,6 +199,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
onBashCommand,
onBashModeChange,
onEmptyChange,
+ onTextChange,
onFocus,
onBlur,
} = options;
@@ -214,6 +216,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
onBashCommand,
onBashModeChange,
onEmptyChange,
+ onTextChange,
onFocus,
onBlur,
});
@@ -223,6 +226,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
onBashCommand,
onBashModeChange,
onEmptyChange,
+ onTextChange,
onFocus,
onBlur,
};
@@ -496,6 +500,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
},
onUpdate: ({ editor: e }) => {
const text = e.getText();
+ callbackRefs.current.onTextChange?.(text);
const newBashMode = enableBashMode && text.trimStart().startsWith("!");
if (newBashMode !== prevBashModeRef.current) {
diff --git a/apps/code/src/renderer/features/mode-switcher/components/ModeSwitcher.tsx b/apps/code/src/renderer/features/mode-switcher/components/ModeSwitcher.tsx
new file mode 100644
index 0000000000..d3de906b03
--- /dev/null
+++ b/apps/code/src/renderer/features/mode-switcher/components/ModeSwitcher.tsx
@@ -0,0 +1,40 @@
+import { Box, Flex } from "@radix-ui/themes";
+import { type AppMode, useNavigationStore } from "@stores/navigationStore";
+
+const MODES: { value: AppMode; label: string }[] = [
+ { value: "code", label: "Code" },
+ { value: "work", label: "Work" },
+];
+
+export function ModeSwitcher() {
+ const mode = useNavigationStore((s) => s.mode);
+ const setMode = useNavigationStore((s) => s.setMode);
+
+ return (
+
+
+ {MODES.map((m) => {
+ const isActive = mode === m.value;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx
index b64c73d5b3..afcd50f3e7 100644
--- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx
+++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx
@@ -42,6 +42,7 @@ import { ModelSelector } from "./ModelSelector";
import { PlanStatusBar } from "./PlanStatusBar";
import { ReasoningLevelSelector } from "./ReasoningLevelSelector";
import { RawLogsView } from "./raw-logs/RawLogsView";
+import { TryInPostHogWorkBanner } from "./TryInPostHogWorkBanner";
interface SessionViewProps {
events: AcpMessage[];
@@ -250,9 +251,27 @@ export function SessionView({
);
const [isDraggingFile, setIsDraggingFile] = useState(false);
+ const [showPostHogWorkBanner, setShowPostHogWorkBanner] = useState(false);
+ const [postHogWorkBannerDismissed, setPostHogWorkBannerDismissed] =
+ useState(false);
const editorRef = useRef(null);
const dragCounterRef = useRef(0);
+ const handlePromptTextChange = useCallback(
+ (text: string) => {
+ if (postHogWorkBannerDismissed) return;
+ if (/pineapple/i.test(text)) {
+ setShowPostHogWorkBanner(true);
+ }
+ },
+ [postHogWorkBannerDismissed],
+ );
+
+ const handleDismissPostHogWorkBanner = useCallback(() => {
+ setShowPostHogWorkBanner(false);
+ setPostHogWorkBannerDismissed(true);
+ }, []);
+
const firstPendingPermission = useMemo(() => {
const entries = Array.from(pendingPermissions.entries());
if (entries.length === 0) return null;
@@ -615,8 +634,14 @@ export function SessionView({
: { maxWidth: CHAT_CONTENT_MAX_WIDTH }
}
>
+ {showPostHogWorkBanner && (
+
+ )}
void;
+}
+
+export function TryInPostHogWorkBanner({
+ onDismiss,
+}: TryInPostHogWorkBannerProps) {
+ const setMode = useNavigationStore((s) => s.setMode);
+
+ return (
+
+
+
+
+
+
+
+ Try this in PostHog Work
+
+
+ Looks like you're trying to generate shareholder value. Try
+ continuing this task in PostHog Work.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx
index c9d35b912b..21416b729e 100644
--- a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx
+++ b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx
@@ -1,5 +1,6 @@
import { useAuthStateValue } from "@features/auth/hooks/authQueries";
import { SettingRow } from "@features/settings/components/SettingRow";
+import { useSoundRecorder } from "@features/settings/hooks/useSoundRecorder";
import {
type AutoConvertLongText,
type CompletionSound,
@@ -8,7 +9,7 @@ import {
type SendMessagesWith,
useSettingsStore,
} from "@features/settings/stores/settingsStore";
-import { ArrowSquareOut } from "@phosphor-icons/react";
+import { ArrowSquareOut, Trash } from "@phosphor-icons/react";
import {
Button,
Flex,
@@ -154,6 +155,8 @@ export function GeneralSettings() {
playCompletionSound(completionSound, completionVolume);
}, [completionSound, completionVolume]);
+ const recorder = useSoundRecorder();
+
const handleAutoConvertLongTextChange = useCallback(
(value: AutoConvertLongText) => {
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
@@ -310,7 +313,7 @@ export function GeneralSettings() {
description="Play a sound when the agent finishes a task or needs your input"
noBorder={completionSound === "none"}
>
-
+
@@ -333,10 +336,59 @@ export function GeneralSettings() {
Slide
Switch
Wilhelm scream
+ Custom recording
+ {completionSound === "custom" &&
+ (recorder.isRecording ? (
+
+
+
+ Recording…
+
+
+
+ ) : (
+ <>
+
+ {recorder.hasRecording && (
+
+ )}
+ >
+ ))}
{completionSound !== "none" && (
-
+ }
+ />
+
+
+
+ Looking for coding skills? Manage them in PostHog Code → Skills.
+
+
+ openPersonality("you")}
+ className="text-(--gray-10) text-[12px] italic underline-offset-2 transition-colors hover:text-(--gray-11) hover:underline"
+ >
+ What does your skill mix say about you?
+
+
+
+
+
+
+ );
+}
diff --git a/apps/code/src/renderer/features/work/components/WorkView.tsx b/apps/code/src/renderer/features/work/components/WorkView.tsx
new file mode 100644
index 0000000000..03a3835d55
--- /dev/null
+++ b/apps/code/src/renderer/features/work/components/WorkView.tsx
@@ -0,0 +1,72 @@
+import { Box, Flex, Text } from "@radix-ui/themes";
+import beachHog from "@renderer/assets/images/hedgehogs/beach-hog.png";
+import { useNavigationStore } from "@stores/navigationStore";
+import { WorkGenerateView } from "./WorkGenerateView";
+import { WorkHomePrompt } from "./WorkHomePrompt";
+import { WorkSampleProjects } from "./WorkSampleProjects";
+import { WorkSkillDetailView } from "./WorkSkillDetailView";
+import { WorkSkillsView } from "./WorkSkillsView";
+
+export function WorkView() {
+ const workView = useNavigationStore((s) => s.workView);
+
+ if (workView === "generate") {
+ return ;
+ }
+
+ if (workView === "skill-detail") {
+ return ;
+ }
+
+ if (workView === "library") {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+ Hello normie, what can I do for you today?
+
+
+
+
+
+
+
+
+
+
+ Or if you're used to outsourcing your brain to Claude...
+
+
+
+
+
+ );
+}
diff --git a/apps/code/src/renderer/features/work/utils/buildSkillGeneratorPrompt.ts b/apps/code/src/renderer/features/work/utils/buildSkillGeneratorPrompt.ts
new file mode 100644
index 0000000000..4a783ac8ae
--- /dev/null
+++ b/apps/code/src/renderer/features/work/utils/buildSkillGeneratorPrompt.ts
@@ -0,0 +1,14 @@
+export function buildSkillGeneratorPrompt(userPrompt: string): string {
+ return `You are a PostHog skill author. Your job is to design and create a reusable PostHog skill that fulfills the user's request below.
+
+Guidelines:
+- Use only the PostHog \`llma-skill-*\` MCP tools (\`llma-skill-duplicate\`, \`llma-skill-archive\`, \`llma-skill-file-rename\`, etc.) plus any other PostHog MCP tools needed to inspect existing skills, prompts, or templates.
+- Do NOT use Bash, Edit, Write, or other local-filesystem tools. Skills live in PostHog, not on this disk.
+- Before creating, briefly explain in plain language what the skill will do and why.
+- When you create or update the skill, narrate each step so the user can follow along.
+- After creating it, summarize what was made and propose 2-3 follow-up tweaks the user could ask for.
+
+User's request:
+${userPrompt.trim()}
+`;
+}
diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts
index 93a9338c2c..b6d66a6aae 100644
--- a/apps/code/src/renderer/stores/navigationStore.ts
+++ b/apps/code/src/renderer/stores/navigationStore.ts
@@ -35,6 +35,10 @@ interface TaskInputNavigationOptions {
reportAssociation?: TaskInputReportAssociation;
}
+export type AppMode = "code" | "work";
+
+export type WorkView = "home" | "generate" | "skill-detail" | "library";
+
interface ViewState {
type: ViewType;
data?: Task;
@@ -47,6 +51,16 @@ interface ViewState {
}
interface NavigationStore {
+ mode: AppMode;
+ setMode: (mode: AppMode) => void;
+ workOnboardingSkipped: boolean;
+ skipWorkOnboarding: () => void;
+ workView: WorkView;
+ workSelectedSkillId?: string;
+ navigateToWorkHome: () => void;
+ navigateToWorkGenerate: () => void;
+ navigateToWorkSkill: (skillId: string) => void;
+ navigateToWorkLibrary: () => void;
view: ViewState;
history: ViewState[];
historyIndex: number;
@@ -123,6 +137,20 @@ export const useNavigationStore = create()(
};
return {
+ mode: "code",
+ setMode: (mode: AppMode) => set({ mode }),
+ workOnboardingSkipped: false,
+ skipWorkOnboarding: () => set({ workOnboardingSkipped: true }),
+ workView: "home",
+ workSelectedSkillId: undefined,
+ navigateToWorkHome: () =>
+ set({ workView: "home", workSelectedSkillId: undefined }),
+ navigateToWorkGenerate: () =>
+ set({ workView: "generate", workSelectedSkillId: undefined }),
+ navigateToWorkSkill: (skillId: string) =>
+ set({ workView: "skill-detail", workSelectedSkillId: skillId }),
+ navigateToWorkLibrary: () =>
+ set({ workView: "library", workSelectedSkillId: undefined }),
view: { type: "task-input" },
history: [{ type: "task-input" }],
historyIndex: 0,
@@ -338,6 +366,10 @@ export const useNavigationStore = create()(
name: "navigation-storage",
storage: electronStorage,
partialize: (state) => ({
+ mode: state.mode,
+ workOnboardingSkipped: state.workOnboardingSkipped,
+ workView: state.workView,
+ workSelectedSkillId: state.workSelectedSkillId,
view: {
type: state.view.type,
taskId: state.view.taskId,
diff --git a/apps/code/src/renderer/stores/workSkillsStore.ts b/apps/code/src/renderer/stores/workSkillsStore.ts
new file mode 100644
index 0000000000..9b761a0c31
--- /dev/null
+++ b/apps/code/src/renderer/stores/workSkillsStore.ts
@@ -0,0 +1,66 @@
+import { electronStorage } from "@utils/electronStorage";
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export interface WorkSkill {
+ id: string;
+ name: string;
+ prompt: string;
+ taskId?: string;
+ isSeed?: true;
+}
+
+interface WorkSkillsStoreState {
+ skills: WorkSkill[];
+}
+
+interface WorkSkillsStoreActions {
+ addSkill: (skill: WorkSkill) => void;
+ updateSkill: (id: string, updates: Partial) => void;
+ getSkill: (id: string) => WorkSkill | undefined;
+}
+
+type WorkSkillsStore = WorkSkillsStoreState & WorkSkillsStoreActions;
+
+const SEED_SKILLS: WorkSkill[] = [
+ {
+ id: "seed-slack-overnight",
+ name: "Check important Slack messages from overnight",
+ prompt:
+ "Each morning, surface high-signal Slack messages from overnight: mentions, replies in threads I'm in, and any messages in priority channels that look urgent.",
+ isSeed: true,
+ },
+];
+
+export const useWorkSkillsStore = create()(
+ persist(
+ (set, get) => ({
+ skills: SEED_SKILLS,
+ addSkill: (skill) =>
+ set((state) => ({ skills: [...state.skills, skill] })),
+ updateSkill: (id, updates) =>
+ set((state) => ({
+ skills: state.skills.map((s) =>
+ s.id === id ? { ...s, ...updates } : s,
+ ),
+ })),
+ getSkill: (id) => get().skills.find((s) => s.id === id),
+ }),
+ {
+ name: "work-skills-storage",
+ storage: electronStorage,
+ partialize: (state) => ({ skills: state.skills }),
+ merge: (persisted, current) => {
+ const persistedState = persisted as Partial;
+ const persistedSkills = persistedState?.skills ?? [];
+ const merged = [...SEED_SKILLS];
+ for (const skill of persistedSkills) {
+ if (!merged.some((s) => s.id === skill.id)) {
+ merged.push(skill);
+ }
+ }
+ return { ...current, skills: merged };
+ },
+ },
+ ),
+);
diff --git a/apps/code/src/renderer/styles/globals.css b/apps/code/src/renderer/styles/globals.css
index 16aba59ab2..db47997080 100644
--- a/apps/code/src/renderer/styles/globals.css
+++ b/apps/code/src/renderer/styles/globals.css
@@ -306,6 +306,35 @@
animation: zen-float 3.5s ease-in-out infinite;
}
+/* Work-mode home entrance — staggered fade + lift */
+@keyframes work-enter {
+ 0% {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.work-enter {
+ animation: work-enter 360ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
+}
+
+.work-enter-1 {
+ animation-delay: 0ms;
+}
+.work-enter-2 {
+ animation-delay: 80ms;
+}
+.work-enter-3 {
+ animation-delay: 160ms;
+}
+.work-enter-4 {
+ animation-delay: 240ms;
+}
+
/* Pulse animation for generating indicator */
@keyframes ph-pulse {
0%,
diff --git a/apps/code/src/renderer/utils/sounds.ts b/apps/code/src/renderer/utils/sounds.ts
index bbfdc17f62..c7c3271359 100644
--- a/apps/code/src/renderer/utils/sounds.ts
+++ b/apps/code/src/renderer/utils/sounds.ts
@@ -1,4 +1,7 @@
-import type { CompletionSound } from "@features/settings/stores/settingsStore";
+import {
+ type CompletionSound,
+ useSettingsStore,
+} from "@features/settings/stores/settingsStore";
import bubblesUrl from "@renderer/assets/sounds/bubbles.mp3";
import daniloUrl from "@renderer/assets/sounds/danilo.mp3";
import dropUrl from "@renderer/assets/sounds/drop.mp3";
@@ -11,8 +14,12 @@ import shootUrl from "@renderer/assets/sounds/shoot.mp3";
import slideUrl from "@renderer/assets/sounds/slide.mp3";
import switchUrl from "@renderer/assets/sounds/switch.mp3";
import wilhelmUrl from "@renderer/assets/sounds/wilhelm.mp3";
+import { logger } from "@utils/logger";
-const SOUND_URLS: Record, string> = {
+const SOUND_URLS: Record<
+ Exclude,
+ string
+> = {
guitar: guitarUrl,
danilo: daniloUrl,
revi: reviUrl,
@@ -27,13 +34,21 @@ const SOUND_URLS: Record, string> = {
wilhelm: wilhelmUrl,
};
+const log = logger.scope("sounds");
+
let currentAudio: HTMLAudioElement | null = null;
export function playCompletionSound(sound: CompletionSound, volume = 80): void {
if (sound === "none") return;
- const url = SOUND_URLS[sound];
- if (!url) return;
+ const url =
+ sound === "custom"
+ ? useSettingsStore.getState().customCompletionSound
+ : SOUND_URLS[sound];
+ if (!url) {
+ log.warn("No URL for completion sound", { sound });
+ return;
+ }
if (currentAudio) {
currentAudio.pause();
@@ -42,9 +57,16 @@ export function playCompletionSound(sound: CompletionSound, volume = 80): void {
const audio = new Audio(url);
audio.volume = Math.max(0, Math.min(100, volume)) / 100;
+ audio.addEventListener("error", () => {
+ log.error("Audio element error", {
+ sound,
+ code: audio.error?.code,
+ message: audio.error?.message,
+ });
+ });
currentAudio = audio;
- audio.play().catch(() => {
- // Audio play can fail if user hasn't interacted with the page yet
+ audio.play().catch((error) => {
+ log.error("audio.play() rejected", { sound, error: String(error) });
});
audio.addEventListener("ended", () => {
if (currentAudio === audio) {
diff --git a/packages/platform/package.json b/packages/platform/package.json
index 68916d1e20..08f40f63d6 100644
--- a/packages/platform/package.json
+++ b/packages/platform/package.json
@@ -63,6 +63,10 @@
"./image-processor": {
"types": "./dist/image-processor.d.ts",
"import": "./dist/image-processor.js"
+ },
+ "./media-access": {
+ "types": "./dist/media-access.d.ts",
+ "import": "./dist/media-access.js"
}
},
"scripts": {
diff --git a/packages/platform/src/media-access.ts b/packages/platform/src/media-access.ts
new file mode 100644
index 0000000000..084d04fbc2
--- /dev/null
+++ b/packages/platform/src/media-access.ts
@@ -0,0 +1,11 @@
+export type MediaAccessStatus =
+ | "not-determined"
+ | "granted"
+ | "denied"
+ | "restricted"
+ | "unknown";
+
+export interface IMediaAccess {
+ getMicrophoneStatus(): MediaAccessStatus;
+ requestMicrophoneAccess(): Promise;
+}
diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts
index 20fd8b4461..4cbc1c527c 100644
--- a/packages/platform/tsup.config.ts
+++ b/packages/platform/tsup.config.ts
@@ -17,6 +17,7 @@ export default defineConfig({
"src/context-menu.ts",
"src/bundled-resources.ts",
"src/image-processor.ts",
+ "src/media-access.ts",
],
format: ["esm"],
dts: true,