diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 046fadd003..a93758472f 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -273,6 +273,11 @@ export const osRouter = router({ */ getWorktreeLocation: publicProcedure.query(() => getWorktreeLocation()), + /** + * Get the user's home directory. + */ + getHomeDir: publicProcedure.output(z.string()).query(() => os.homedir()), + /** * 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/api/posthogClient.test.ts b/apps/code/src/renderer/api/posthogClient.test.ts index f684aa266c..7e5b01bacb 100644 --- a/apps/code/src/renderer/api/posthogClient.test.ts +++ b/apps/code/src/renderer/api/posthogClient.test.ts @@ -385,4 +385,108 @@ describe("PostHogAPIClient", () => { expect(result.length).toBe(50); }); }); + + describe("Task automations", () => { + function buildAutomationClient() { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + const api = { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }; + (client as unknown as { api: typeof api }).api = api; + return { client, api }; + } + + it("listTaskAutomations returns the paginated results array", async () => { + const { client, api } = buildAutomationClient(); + api.get.mockResolvedValue({ results: [{ id: "a" }, { id: "b" }] }); + const result = await client.listTaskAutomations(); + expect(api.get).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/", + expect.objectContaining({ + path: { project_id: "123" }, + query: {}, + }), + ); + expect(result).toEqual([{ id: "a" }, { id: "b" }]); + }); + + it("listTaskAutomations handles a missing results field", async () => { + const { client, api } = buildAutomationClient(); + api.get.mockResolvedValue({}); + expect(await client.listTaskAutomations()).toEqual([]); + }); + + it("createTaskAutomation POSTs the input body to the team-scoped path", async () => { + const { client, api } = buildAutomationClient(); + api.post.mockResolvedValue({ id: "new" }); + const result = await client.createTaskAutomation({ + name: "Daily audit", + prompt: "Audit my flags", + cron_expression: "0 9 * * *", + repository: "", + timezone: "America/New_York", + enabled: true, + }); + expect(api.post).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/", + expect.objectContaining({ + path: { project_id: "123" }, + body: expect.objectContaining({ + name: "Daily audit", + prompt: "Audit my flags", + cron_expression: "0 9 * * *", + repository: "", + timezone: "America/New_York", + enabled: true, + }), + }), + ); + expect(result).toEqual({ id: "new" }); + }); + + it("updateTaskAutomation PATCHes the team-and-id-scoped path", async () => { + const { client, api } = buildAutomationClient(); + api.patch.mockResolvedValue({ id: "abc" }); + await client.updateTaskAutomation("abc", { enabled: false }); + expect(api.patch).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/{id}/", + expect.objectContaining({ + path: { project_id: "123", id: "abc" }, + body: { enabled: false }, + }), + ); + }); + + it("deleteTaskAutomation DELETEs the team-and-id-scoped path", async () => { + const { client, api } = buildAutomationClient(); + api.delete.mockResolvedValue(undefined); + await client.deleteTaskAutomation("abc"); + expect(api.delete).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/{id}/", + expect.objectContaining({ + path: { project_id: "123", id: "abc" }, + }), + ); + }); + + it("runTaskAutomationNow POSTs to the /run/ endpoint", async () => { + const { client, api } = buildAutomationClient(); + api.post.mockResolvedValue({ id: "abc" }); + await client.runTaskAutomationNow("abc"); + expect(api.post).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/{id}/run/", + expect.objectContaining({ + path: { project_id: "123", id: "abc" }, + }), + ); + }); + }); }); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 0114942534..cfafe08b43 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1013,6 +1013,77 @@ export class PostHogAPIClient { }); } + async listTaskAutomations(): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.get( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + query: {}, + }, + ); + return data.results ?? []; + } + + async createTaskAutomation( + input: Pick< + Schemas.TaskAutomation, + "name" | "prompt" | "cron_expression" | "repository" + > & + Partial< + Pick< + Schemas.TaskAutomation, + "github_integration" | "timezone" | "template_id" | "enabled" + > + >, + ): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.post( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + body: input as unknown as Schemas.TaskAutomation, + }, + ); + return data; + } + + async updateTaskAutomation( + automationId: string, + updates: Schemas.PatchedTaskAutomation, + ): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.patch( + `/api/projects/{project_id}/task_automations/{id}/`, + { + path: { project_id: teamId.toString(), id: automationId }, + body: updates, + }, + ); + return data; + } + + async deleteTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + await this.api.delete(`/api/projects/{project_id}/task_automations/{id}/`, { + path: { project_id: teamId.toString(), id: automationId }, + }); + } + + async runTaskAutomationNow( + automationId: string, + ): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.post( + `/api/projects/{project_id}/task_automations/{id}/run/`, + { + path: { project_id: teamId.toString(), id: automationId }, + body: {} as unknown as Schemas.TaskAutomation, + }, + ); + return data; + } + async sendRunCommand( taskId: string, runId: string, 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..9167d98d44 --- /dev/null +++ b/apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts @@ -0,0 +1,141 @@ +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +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 { + 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(); + } + }, MAX_RECORDING_MS); + } catch (error) { + log.error("Failed to start recording", error); + cleanup(); + setIsRecording(false); + toast.error( + "Microphone access denied. Allow microphone access to record a custom sound.", + ); + } + }, [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 && ( + ); + })} + + + + + ); +} diff --git a/apps/code/src/renderer/features/work/components/ScheduleField.tsx b/apps/code/src/renderer/features/work/components/ScheduleField.tsx new file mode 100644 index 0000000000..0d743d71e3 --- /dev/null +++ b/apps/code/src/renderer/features/work/components/ScheduleField.tsx @@ -0,0 +1,83 @@ +import { Check, WarningCircle } from "@phosphor-icons/react"; +import { Flex, Text, TextField } from "@radix-ui/themes"; +import { parseSchedule } from "../utils/parseSchedule"; + +interface ScheduleFieldProps { + value: string; + onChange: (next: string) => void; +} + +const QUICK_FILLS = [ + "Daily at 9am", + "Weekdays at 9am", + "Mondays at 9am", + "Every hour", + "1st of month at 9am", +]; + +export function ScheduleField({ value, onChange }: ScheduleFieldProps) { + const trimmed = value.trim(); + const parsed = trimmed ? parseSchedule(trimmed) : null; + const hasValue = trimmed.length > 0; + const isValid = parsed !== null; + + return ( + + + Schedule + + + onChange(e.target.value)} + /> + + {hasValue && isValid && ( + + + → {parsed.description} + + )} + {hasValue && !isValid && ( + + + + Couldn't understand that — try "every Tuesday at 5pm", "daily at + 9am", "weekdays at 9am", or a cron expression. + + + )} + + + + Quick fills + + + {QUICK_FILLS.map((label) => { + const isActive = trimmed.toLowerCase() === label.toLowerCase(); + return ( + + ); + })} + + + + ); +} diff --git a/apps/code/src/renderer/features/work/components/ScheduledTaskEditor.tsx b/apps/code/src/renderer/features/work/components/ScheduledTaskEditor.tsx new file mode 100644 index 0000000000..7877f6037e --- /dev/null +++ b/apps/code/src/renderer/features/work/components/ScheduledTaskEditor.tsx @@ -0,0 +1,402 @@ +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { + ArrowLeft, + ArrowSquareOut, + ClockCounterClockwise, + FloppyDisk, + Play, + Trash, +} from "@phosphor-icons/react"; +import { + Box, + Button, + Callout, + Flex, + ScrollArea, + Switch, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; +import type { Schemas } from "@renderer/api/generated"; +import { useNavigationStore } from "@stores/navigationStore"; +import { formatRelativeTimeLong } from "@utils/time"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useOpenLastRun } from "../hooks/useOpenLastRun"; +import { + useCreateScheduledTask, + useDeleteScheduledTask, + useRunScheduledTaskNow, + useScheduledTasks, + useUpdateScheduledTask, +} from "../hooks/useScheduledTasks"; +import { useWorkStore } from "../stores/workStore"; +import { describeCron, parseSchedule } from "../utils/parseSchedule"; +import { decodePrompt, encodePrompt } from "../utils/sourcesPrompt"; +import { detectTimezone } from "../utils/timezone"; +import { ScheduledTaskStatusBadge } from "./ScheduledTaskStatusBadge"; +import { ScheduleField } from "./ScheduleField"; +import { SourcesPicker } from "./SourcesPicker"; + +interface ScheduledTaskEditorProps { + editingId: string | null; +} + +interface Draft { + name: string; + promptBody: string; + sources: string[]; + scheduleText: string; + enabled: boolean; +} + +function toDraft(automation: Schemas.TaskAutomation | null): Draft { + if (!automation) { + return { + name: "", + promptBody: "", + sources: [], + scheduleText: "Daily at 9am", + enabled: true, + }; + } + const { sources, body } = decodePrompt(automation.prompt); + return { + name: automation.name, + promptBody: body, + sources, + scheduleText: describeCron(automation.cron_expression), + enabled: automation.enabled ?? true, + }; +} + +export function ScheduledTaskEditor({ editingId }: ScheduledTaskEditorProps) { + const showList = useNavigationStore((s) => s.navigateToWorkScheduledList); + const consumePendingCreateDraft = useWorkStore( + (s) => s.consumePendingCreateDraft, + ); + const { data: automations } = useScheduledTasks(); + + const existing = useMemo( + () => automations?.find((a) => a.id === editingId) ?? null, + [automations, editingId], + ); + + const [draft, setDraft] = useState(() => { + const base = toDraft(existing); + if (!existing) { + const seeded = consumePendingCreateDraft(); + if (seeded) { + return { + ...base, + name: seeded.name ?? base.name, + promptBody: seeded.prompt ?? base.promptBody, + }; + } + } + return base; + }); + + // Sync the draft when the editor is pointed at a different existing automation. + useEffect(() => { + if (existing) setDraft(toDraft(existing)); + // The id is what should trigger a reset — other field changes from polling + // shouldn't clobber user edits. + }, [existing?.id, existing]); + + const createScheduledTask = useCreateScheduledTask(); + const updateScheduledTask = useUpdateScheduledTask(); + const deleteScheduledTask = useDeleteScheduledTask(); + const runScheduledTaskNow = useRunScheduledTaskNow(); + const { openLastRun, isOpening } = useOpenLastRun(); + + const isEditing = editingId !== null && existing !== null; + const isSaving = + createScheduledTask.isPending || updateScheduledTask.isPending; + + const nameTrimmed = draft.name.trim(); + const promptTrimmed = draft.promptBody.trim(); + const parsedSchedule = parseSchedule(draft.scheduleText); + const missingFields = !nameTrimmed || !promptTrimmed || !parsedSchedule; + const canSave = !missingFields && !isSaving; + + const headerContent = useMemo( + () => ( + + + + {isEditing ? existing.name || "Scheduled task" : "New scheduled task"} + + + ), + [isEditing, existing], + ); + useSetHeaderContent(headerContent); + + const handleSave = async () => { + if (!canSave || !parsedSchedule) return; + const promptToSend = encodePrompt(promptTrimmed, draft.sources); + + try { + if (isEditing) { + await updateScheduledTask.mutateAsync({ + id: existing.id, + updates: { + name: nameTrimmed, + prompt: promptToSend, + cron_expression: parsedSchedule.cron, + enabled: draft.enabled, + }, + }); + toast.success("Scheduled task updated"); + } else { + await createScheduledTask.mutateAsync({ + name: nameTrimmed, + prompt: promptToSend, + cron_expression: parsedSchedule.cron, + // Work-mode tasks aren't repo-scoped, but the backend currently + // requires a non-blank `repository`. Send a sentinel until the + // backend makes the field nullable; the runtime ignores it for + // PostHog-data skills. + repository: "posthog-work", + timezone: detectTimezone(), + enabled: draft.enabled, + }); + toast.success("Scheduled task created"); + } + showList(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to save scheduled task", + ); + } + }; + + const handleDelete = async () => { + if (!isEditing) return; + if ( + !window.confirm( + `Delete "${existing.name || "this scheduled task"}"? This cannot be undone.`, + ) + ) { + return; + } + try { + await deleteScheduledTask.mutateAsync(existing.id); + toast.success("Scheduled task deleted"); + showList(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to delete scheduled task", + ); + } + }; + + const handleRunNow = async () => { + if (!isEditing) return; + try { + await runScheduledTaskNow.mutateAsync(existing.id); + toast.success("Running now — opening the task to follow along"); + // After the run is triggered the automation row gets new last_task_id / + // last_task_run_id values on the next refetch. We don't deep-link + // immediately because the task may take a moment to appear in cache. + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to run scheduled task", + ); + } + }; + + return ( + + + + + + {isEditing ? "Edit scheduled task" : "New scheduled task"} + + + + {isEditing && ( + <> + + + + )} + + + + + + + + + + Name + + + setDraft((d) => ({ ...d, name: e.target.value })) + } + /> + + + + + What should it do? + +