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" && ( - )} diff --git a/apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts b/apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts new file mode 100644 index 0000000000..14492160fe --- /dev/null +++ b/apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts @@ -0,0 +1,168 @@ +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { trpcClient } from "@renderer/trpc"; +import { logger } from "@utils/logger"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +const log = logger.scope("useSoundRecorder"); + +const MAX_RECORDING_MS = 10_000; + +function pickMimeType(): string | undefined { + if (typeof MediaRecorder === "undefined") return undefined; + const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]; + for (const mime of candidates) { + if (MediaRecorder.isTypeSupported(mime)) return mime; + } + return undefined; +} + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} + +export interface SoundRecorder { + isRecording: boolean; + hasRecording: boolean; + start: () => Promise; + stop: () => void; + clear: () => void; +} + +export function useSoundRecorder(): SoundRecorder { + const customCompletionSound = useSettingsStore( + (s) => s.customCompletionSound, + ); + const setCustomCompletionSound = useSettingsStore( + (s) => s.setCustomCompletionSound, + ); + + const [isRecording, setIsRecording] = useState(false); + const recorderRef = useRef(null); + const streamRef = useRef(null); + const chunksRef = useRef([]); + const timeoutRef = useRef | null>(null); + + const cleanup = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + for (const track of streamRef.current?.getTracks() ?? []) { + track.stop(); + } + streamRef.current = null; + recorderRef.current = null; + chunksRef.current = []; + }, []); + + useEffect(() => () => cleanup(), [cleanup]); + + const start = useCallback(async () => { + if (recorderRef.current) return; + try { + // Trigger macOS prompt up front when status is unknown. In dev mode this + // can be flaky (adhoc-signed Electron); the getUserMedia call below will + // re-trigger the prompt if needed and the catch handles denial cleanly. + const status = await trpcClient.os.getMicrophoneAccessStatus.query(); + log.info("Microphone status", { status }); + if (status === "not-determined") { + await trpcClient.os.requestMicrophoneAccess.mutate(); + } + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mimeType = pickMimeType(); + const recorder = new MediaRecorder( + stream, + mimeType ? { mimeType } : undefined, + ); + streamRef.current = stream; + recorderRef.current = recorder; + chunksRef.current = []; + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) chunksRef.current.push(event.data); + }; + + recorder.onstop = async () => { + const blob = new Blob(chunksRef.current, { + type: recorder.mimeType || "audio/webm", + }); + log.info("Recording stopped", { + blobSize: blob.size, + blobType: blob.type, + chunkCount: chunksRef.current.length, + }); + cleanup(); + setIsRecording(false); + if (blob.size === 0) { + toast.error("Recording was empty"); + return; + } + try { + const dataUrl = await blobToDataUrl(blob); + log.info("Recording saved", { dataUrlLength: dataUrl.length }); + setCustomCompletionSound(dataUrl); + } catch (error) { + log.error("Failed to encode recording", error); + toast.error("Could not save recording"); + } + }; + + recorder.start(); + setIsRecording(true); + timeoutRef.current = setTimeout(() => { + if (recorderRef.current?.state === "recording") { + recorderRef.current.stop(); + toast.error(`Recording capped at ${MAX_RECORDING_MS / 1000} seconds`); + } + }, MAX_RECORDING_MS); + } catch (error) { + log.error("Failed to start recording", error); + cleanup(); + setIsRecording(false); + const isPermissionError = + error instanceof Error && + (error.name === "NotAllowedError" || + error.name === "SecurityError" || + error.message.toLowerCase().includes("permission")); + if (isPermissionError) { + toast.error("Microphone access denied", { + description: + "Enable microphone access for PostHog Code in System Settings.", + action: { + label: "Open Settings", + onClick: () => + trpcClient.os.openExternal.mutate({ + url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + }), + }, + }); + } else { + toast.error("Could not start recording"); + } + } + }, [cleanup, setCustomCompletionSound]); + + const stop = useCallback(() => { + if (recorderRef.current?.state === "recording") { + recorderRef.current.stop(); + } + }, []); + + const clear = useCallback(() => { + setCustomCompletionSound(null); + }, [setCustomCompletionSound]); + + return { + isRecording, + hasRecording: customCompletionSound !== null, + start, + stop, + clear, + }; +} diff --git a/apps/code/src/renderer/features/settings/stores/settingsStore.ts b/apps/code/src/renderer/features/settings/stores/settingsStore.ts index 1ea969bf23..49e72ba577 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsStore.ts +++ b/apps/code/src/renderer/features/settings/stores/settingsStore.ts @@ -20,7 +20,8 @@ export type CompletionSound = | "shoot" | "slide" | "switch" - | "wilhelm"; + | "wilhelm" + | "custom"; export type AgentAdapter = "claude" | "codex"; export type AutoConvertLongText = "off" | "1000" | "2500" | "5000" | "10000"; export type DefaultInitialTaskMode = "plan" | "last_used"; @@ -47,6 +48,7 @@ interface SettingsStore { autoConvertLongText: AutoConvertLongText; completionSound: CompletionSound; + customCompletionSound: string | null; completionVolume: number; sendMessagesWith: SendMessagesWith; allowBypassPermissions: boolean; @@ -65,6 +67,7 @@ interface SettingsStore { markHintLearned: (key: string) => void; setCompletionSound: (sound: CompletionSound) => void; + setCustomCompletionSound: (dataUrl: string | null) => void; setCompletionVolume: (volume: number) => void; setDefaultRunMode: (mode: DefaultRunMode) => void; setLastUsedRunMode: (mode: "local" | "cloud") => void; @@ -112,6 +115,7 @@ export const useSettingsStore = create()( dockBadgeNotifications: true, dockBounceNotifications: false, completionSound: "none", + customCompletionSound: null, completionVolume: 80, autoConvertLongText: "2500", @@ -154,6 +158,8 @@ export const useSettingsStore = create()( }), setCompletionSound: (sound) => set({ completionSound: sound }), + setCustomCompletionSound: (dataUrl) => + set({ customCompletionSound: dataUrl }), setCompletionVolume: (volume) => set({ completionVolume: volume }), setDefaultRunMode: (mode) => set({ defaultRunMode: mode }), setLastUsedRunMode: (mode) => set({ lastUsedRunMode: mode }), @@ -222,6 +228,7 @@ export const useSettingsStore = create()( autoConvertLongText: state.autoConvertLongText, completionSound: state.completionSound, + customCompletionSound: state.customCompletionSound, completionVolume: state.completionVolume, sendMessagesWith: state.sendMessagesWith, allowBypassPermissions: state.allowBypassPermissions, diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx index 81dc03740c..1580ea7ee4 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx @@ -1,5 +1,7 @@ import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; import { SidebarUsageBar } from "@features/billing/components/SidebarUsageBar"; +import { ModeSwitcher } from "@features/mode-switcher/components/ModeSwitcher"; +import { WorkSidebarMenu } from "@features/work/components/WorkSidebarMenu"; import { ArchiveIcon } from "@phosphor-icons/react"; import { Box, Flex } from "@radix-ui/themes"; import { useNavigationStore } from "@stores/navigationStore"; @@ -13,14 +15,18 @@ export const SidebarContent: React.FC = () => { const navigateToArchived = useNavigationStore( (state) => state.navigateToArchived, ); + const mode = useNavigationStore((state) => state.mode); + const isCodeMode = mode === "code"; + return ( + - + {isCodeMode ? : } - {archivedTaskIds.size > 0 && ( + {isCodeMode && archivedTaskIds.size > 0 && (