diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/apps/code/src/renderer/components/HeaderRow.tsx index 2e36918e6..86872be20 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -4,6 +4,7 @@ import { CloudGitInteractionHeader } from "@features/git-interaction/components/ import { GitInteractionHeader } from "@features/git-interaction/components/GitInteractionHeader"; import { SidebarTrigger } from "@features/sidebar/components/SidebarTrigger"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; +import { SkillButtonsMenu } from "@features/skill-buttons/components/SkillButtonsMenu"; import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import { Box, Flex } from "@radix-ui/themes"; import { useHeaderStore } from "@stores/headerStore"; @@ -110,6 +111,9 @@ export function HeaderRow() { overflow: "hidden", }} > +
+ +
{activeWorkspace && (activeWorkspace.branchName || activeWorkspace.baseBranch) && (
diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 926d4bb89..bb8d63be7 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -7,6 +7,7 @@ import { useSessionForTask, } from "@features/sessions/stores/sessionStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { SkillButtonActionMessage } from "@features/skill-buttons/components/SkillButtonActionMessage"; import { ArrowDown, XCircle } from "@phosphor-icons/react"; import { Box, Button, Flex, Text } from "@radix-ui/themes"; import type { AcpMessage } from "@shared/types/session-events"; @@ -167,6 +168,8 @@ export function ConversationView({ ); case "git_action": return ; + case "skill_button_action": + return ; case "session_update": return ( (); const gitAction = parseGitActionMessage(userContent); + const skillButtonId = extractSkillButtonId(userPrompt.blocks); const childItems = new Map(); const context: TurnContext = { @@ -270,6 +276,12 @@ function handlePromptRequest( id: `${turnId}-git-action`, actionType: gitAction.actionType, }); + } else if (skillButtonId) { + b.items.push({ + type: "skill_button_action", + id: `${turnId}-skill-action`, + buttonId: skillButtonId, + }); } else { b.items.push({ type: "user_message", @@ -520,16 +532,17 @@ function ensureImplicitTurn(b: ItemBuilder, ts: number) { function extractUserPrompt(params: unknown): { content: string; attachments: UserMessageAttachment[]; + blocks: ContentBlock[]; } { const p = params as { prompt?: ContentBlock[] }; if (!p?.prompt?.length) { - return { content: "", attachments: [] }; + return { content: "", attachments: [], blocks: [] }; } const { text, attachments } = extractPromptDisplayContent(p.prompt, { filterHidden: true, }); - return { content: text, attachments }; + return { content: text, attachments, blocks: p.prompt }; } function getParentToolCallId(update: SessionUpdate): string | undefined { diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 41e1ba26a..c69dcc3e5 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -28,6 +28,7 @@ import { } from "@features/sessions/stores/sessionStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { taskViewedApi } from "@features/sidebar/hooks/useTaskViewed"; +import { extractSkillButtonId } from "@features/skill-buttons/prompts"; import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; import { getAvailableCodexModes, @@ -1331,11 +1332,19 @@ export class SessionService { pausedDurationMs: 0, }); - sessionStoreSetters.appendOptimisticItem(session.taskRunId, { - type: "user_message", - content: promptText, - timestamp: Date.now(), - }); + const skillButtonId = extractSkillButtonId(blocks); + if (skillButtonId) { + sessionStoreSetters.appendOptimisticItem(session.taskRunId, { + type: "skill_button_action", + buttonId: skillButtonId, + }); + } else { + sessionStoreSetters.appendOptimisticItem(session.taskRunId, { + type: "user_message", + content: promptText, + timestamp: Date.now(), + }); + } try { const result = await trpcClient.agent.prompt.mutate({ diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts index 23c9334a9..f177b927a 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts @@ -6,6 +6,7 @@ import type { SessionConfigSelectOptions, } from "@agentclientprotocol/sdk"; import type { ExecutionMode, TaskRunStatus } from "@shared/types"; +import type { SkillButtonId } from "@shared/types/analytics"; import type { AcpMessage } from "@shared/types/session-events"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; @@ -25,12 +26,18 @@ export interface QueuedMessage { export type { TaskRunStatus }; -export type OptimisticItem = { - type: "user_message"; - id: string; - content: string; - timestamp: number; -}; +export type OptimisticItem = + | { + type: "user_message"; + id: string; + content: string; + timestamp: number; + } + | { + type: "skill_button_action"; + id: string; + buttonId: SkillButtonId; + }; export interface AgentSession { taskRunId: string; @@ -379,13 +386,17 @@ export const sessionStoreSetters = { appendOptimisticItem: ( taskRunId: string, - item: Omit, + item: OptimisticItem extends infer T + ? T extends { id: string } + ? Omit + : never + : never, ): void => { const id = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; useSessionStore.setState((state) => { const session = state.sessions[taskRunId]; if (session) { - session.optimisticItems.push({ ...item, id }); + session.optimisticItems.push({ ...item, id } as OptimisticItem); } }); }, diff --git a/apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts b/apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts index b6765b0b9..a3cd2a3d5 100644 --- a/apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts +++ b/apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts @@ -1,3 +1,4 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; import { useReviewNavigationStore } from "@features/code-review/stores/reviewNavigationStore"; import { DEFAULT_TAB_IDS } from "@features/panels/constants/panelConstants"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; @@ -8,7 +9,10 @@ import { getSessionService } from "@features/sessions/service/service"; * Sends a prompt to the agent session for a task, collapses the review * panel to split mode if expanded, and switches to the logs/chat tab. */ -export function sendPromptToAgent(taskId: string, prompt: string): void { +export function sendPromptToAgent( + taskId: string, + prompt: string | ContentBlock[], +): void { getSessionService().sendPrompt(taskId, prompt); const { getReviewMode, setReviewMode } = useReviewNavigationStore.getState(); diff --git a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx b/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx new file mode 100644 index 000000000..23a23beec --- /dev/null +++ b/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SkillButtonActionMessage } from "./SkillButtonActionMessage"; + +const meta: Meta = { + title: "Skill Buttons/SkillButtonActionMessage", + component: SkillButtonActionMessage, + parameters: { + layout: "centered", + }, +}; + +export default meta; +type Story = StoryObj; + +export const AddAnalytics: Story = { + args: { + buttonId: "add-analytics", + }, +}; + +export const CreateFeatureFlag: Story = { + args: { + buttonId: "create-feature-flags", + }, +}; + +export const RunExperiment: Story = { + args: { + buttonId: "run-experiment", + }, +}; + +export const AddErrorTracking: Story = { + args: { + buttonId: "add-error-tracking", + }, +}; + +export const InstrumentLlmCalls: Story = { + args: { + buttonId: "instrument-llm-calls", + }, +}; + +export const AddLogging: Story = { + args: { + buttonId: "add-logging", + }, +}; diff --git a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.tsx b/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.tsx new file mode 100644 index 000000000..0b99db9fe --- /dev/null +++ b/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.tsx @@ -0,0 +1,30 @@ +import { + SKILL_BUTTONS, + type SkillButtonId, +} from "@features/skill-buttons/prompts"; + +interface SkillButtonActionMessageProps { + buttonId: SkillButtonId; +} + +export function SkillButtonActionMessage({ + buttonId, +}: SkillButtonActionMessageProps) { + const { Icon, color, actionTitle, actionDescription } = + SKILL_BUTTONS[buttonId]; + + return ( +
+ +

+ + {actionTitle} + + — {actionDescription} +

+
+ ); +} diff --git a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.stories.tsx b/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.stories.tsx new file mode 100644 index 000000000..8541d8f48 --- /dev/null +++ b/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SkillButtonsMenu } from "./SkillButtonsMenu"; + +const meta: Meta = { + title: "Skill Buttons/SkillButtonsMenu", + component: SkillButtonsMenu, + parameters: { + layout: "centered", + }, + args: { + taskId: "storybook-task", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.tsx b/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.tsx new file mode 100644 index 000000000..f52dcabc2 --- /dev/null +++ b/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.tsx @@ -0,0 +1,109 @@ +import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; +import { + buildSkillButtonPromptBlocks, + SKILL_BUTTON_ORDER, + SKILL_BUTTONS, + type SkillButton, + type SkillButtonId, +} from "@features/skill-buttons/prompts"; +import { useSkillButtonsStore } from "@features/skill-buttons/stores/skillButtonsStore"; +import { CaretDown } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { track } from "@utils/analytics"; + +interface SkillButtonsMenuProps { + taskId: string; +} + +function SkillButtonIcon({ button }: { button: SkillButton }) { + const { Icon, color } = button; + return ; +} + +export function SkillButtonsMenu({ taskId }: SkillButtonsMenuProps) { + const lastSelectedId = useSkillButtonsStore((s) => s.lastSelectedId); + const setLastSelectedId = useSkillButtonsStore((s) => s.setLastSelectedId); + + const primaryButton = SKILL_BUTTONS[lastSelectedId]; + const dropdownButtons = SKILL_BUTTON_ORDER.filter( + (id) => id !== lastSelectedId, + ).map((id) => SKILL_BUTTONS[id]); + + const handleTrigger = ( + buttonId: SkillButtonId, + source: "primary" | "dropdown", + ) => { + track(ANALYTICS_EVENTS.SKILL_BUTTON_TRIGGERED, { + task_id: taskId, + button_id: buttonId, + source, + }); + setLastSelectedId(buttonId); + sendPromptToAgent(taskId, buildSkillButtonPromptBlocks(buttonId)); + }; + + return ( + +
+ + handleTrigger(primaryButton.id, "primary")} + > + + {primaryButton.label} + + } + /> + {primaryButton.tooltip} + + + + + + } + /> + + {dropdownButtons.map((button) => ( + + handleTrigger(button.id, "dropdown")} + > + + {button.label} + + } + /> + {button.tooltip} + + ))} + + +
+
+ ); +} diff --git a/apps/code/src/renderer/features/skill-buttons/prompts.test.ts b/apps/code/src/renderer/features/skill-buttons/prompts.test.ts new file mode 100644 index 000000000..de570e360 --- /dev/null +++ b/apps/code/src/renderer/features/skill-buttons/prompts.test.ts @@ -0,0 +1,59 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { + buildSkillButtonPromptBlocks, + extractSkillButtonId, + SKILL_BUTTONS, +} from "./prompts"; + +describe("buildSkillButtonPromptBlocks", () => { + it("produces a text block carrying the button id under posthogCode meta", () => { + const [block] = buildSkillButtonPromptBlocks("add-analytics"); + expect(block.type).toBe("text"); + expect((block as { text: string }).text).toBe( + SKILL_BUTTONS["add-analytics"].prompt, + ); + expect((block as { _meta?: unknown })._meta).toEqual({ + posthogCode: { skillButtonId: "add-analytics" }, + }); + }); +}); + +describe("extractSkillButtonId", () => { + it("round-trips through buildSkillButtonPromptBlocks", () => { + for (const id of Object.keys(SKILL_BUTTONS)) { + const blocks = buildSkillButtonPromptBlocks( + id as keyof typeof SKILL_BUTTONS, + ); + expect(extractSkillButtonId(blocks)).toBe(id); + } + }); + + it("returns null for blocks with no meta", () => { + const blocks: ContentBlock[] = [{ type: "text", text: "hello" }]; + expect(extractSkillButtonId(blocks)).toBeNull(); + }); + + it("returns null when meta carries an unknown id", () => { + const blocks: ContentBlock[] = [ + { + type: "text", + text: "hi", + _meta: { posthogCode: { skillButtonId: "unknown" } }, + }, + ]; + expect(extractSkillButtonId(blocks)).toBeNull(); + }); + + it("ignores plain text that happens to match a prompt string", () => { + const blocks: ContentBlock[] = [ + { type: "text", text: SKILL_BUTTONS["add-analytics"].prompt }, + ]; + expect(extractSkillButtonId(blocks)).toBeNull(); + }); + + it("handles undefined blocks", () => { + expect(extractSkillButtonId(undefined)).toBeNull(); + expect(extractSkillButtonId([])).toBeNull(); + }); +}); diff --git a/apps/code/src/renderer/features/skill-buttons/prompts.ts b/apps/code/src/renderer/features/skill-buttons/prompts.ts new file mode 100644 index 000000000..4e68e5daa --- /dev/null +++ b/apps/code/src/renderer/features/skill-buttons/prompts.ts @@ -0,0 +1,144 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { + Broadcast, + ChartBar, + Flask, + type Icon, + Pulse, + ToggleRight, + Warning, +} from "@phosphor-icons/react"; +import type { SkillButtonId } from "@shared/types/analytics"; + +export type { SkillButtonId }; + +export interface SkillButton { + id: SkillButtonId; + label: string; + prompt: string; + color: string; + Icon: Icon; + actionTitle: string; + actionDescription: string; + tooltip: string; +} + +export const SKILL_BUTTONS: Record = { + "add-analytics": { + id: "add-analytics", + label: "Track events", + prompt: "/instrument-product-analytics", + color: "#2F80FA", + Icon: ChartBar, + actionTitle: "Adding analytics", + actionDescription: "to measure how this change performs in production.", + tooltip: + "Instrument PostHog events so you can measure this change in production", + }, + "create-feature-flags": { + id: "create-feature-flags", + label: "Add feature flag", + prompt: "/instrument-feature-flags", + color: "#30ABC6", + Icon: ToggleRight, + actionTitle: "Creating a feature flag", + actionDescription: + "to roll this out safely and toggle it without a redeploy.", + tooltip: + "Gate this change behind a PostHog feature flag for a safe rollout", + }, + "run-experiment": { + id: "run-experiment", + label: "Run experiment", + prompt: + "Set up a PostHog experiment for the feature in this task. Use the PostHog MCP to create the feature flag with control and test variants, then create the experiment in draft with a clear hypothesis and primary metric tied to the feature's success. Wire the variant into the code via posthog.getFeatureFlag. Only launch the experiment if the feature is already live in production — otherwise leave it in draft and tell me to launch it after this is merged and deployed.", + color: "#B62AD9", + Icon: Flask, + actionTitle: "Setting up an experiment", + actionDescription: + "with control and test variants tied to a primary metric, ready to launch once this ships.", + tooltip: + "Scaffold a PostHog A/B experiment with control and test variants tied to a primary metric", + }, + "add-error-tracking": { + id: "add-error-tracking", + label: "Track errors", + prompt: "/instrument-error-tracking", + color: "#BF8113", + Icon: Warning, + actionTitle: "Adding error tracking", + actionDescription: + "so exceptions surface in PostHog with stack traces and source maps.", + tooltip: + "Capture exceptions in PostHog with stack traces so issues surface quickly in production", + }, + "instrument-llm-calls": { + id: "instrument-llm-calls", + label: "Trace LLM calls", + prompt: "/instrument-llm-analytics", + color: "#B029D2", + Icon: Broadcast, + actionTitle: "Instrumenting LLM calls", + actionDescription: + "for visibility into prompts, tokens, latency, and costs.", + tooltip: + "Inspect traces, spans, latency, usage, and per-user costs for AI-powered features", + }, + "add-logging": { + id: "add-logging", + label: "Capture logs", + prompt: "/instrument-logs", + color: "#C92474", + Icon: Pulse, + actionTitle: "Adding logging", + actionDescription: + "so structured log events flow into PostHog for inspection and debugging.", + tooltip: + "Capture structured application logs in PostHog for inspection and debugging", + }, +}; + +export const SKILL_BUTTON_ORDER: SkillButtonId[] = [ + "add-analytics", + "add-logging", + "add-error-tracking", + "instrument-llm-calls", + "create-feature-flags", + "run-experiment", +]; + +const SKILL_BUTTON_META_NAMESPACE = "posthogCode"; +const SKILL_BUTTON_META_FIELD = "skillButtonId"; + +export function buildSkillButtonPromptBlocks( + buttonId: SkillButtonId, +): ContentBlock[] { + return [ + { + type: "text", + text: SKILL_BUTTONS[buttonId].prompt, + _meta: { + [SKILL_BUTTON_META_NAMESPACE]: { + [SKILL_BUTTON_META_FIELD]: buttonId, + }, + }, + }, + ]; +} + +export function extractSkillButtonId( + blocks: ContentBlock[] | undefined, +): SkillButtonId | null { + if (!blocks?.length) return null; + for (const block of blocks) { + const meta = (block as { _meta?: Record })._meta; + const namespace = meta?.[SKILL_BUTTON_META_NAMESPACE] as + | Record + | undefined; + const id = namespace?.[SKILL_BUTTON_META_FIELD]; + if (typeof id === "string" && id in SKILL_BUTTONS) { + return id as SkillButtonId; + } + } + return null; +} diff --git a/apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts b/apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts new file mode 100644 index 000000000..229854e73 --- /dev/null +++ b/apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts @@ -0,0 +1,45 @@ +import { + SKILL_BUTTON_ORDER, + SKILL_BUTTONS, +} from "@features/skill-buttons/prompts"; +import type { SkillButtonId } from "@shared/types/analytics"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface SkillButtonsStoreState { + lastSelectedId: SkillButtonId; +} + +interface SkillButtonsStoreActions { + setLastSelectedId: (id: SkillButtonId) => void; +} + +type SkillButtonsStore = SkillButtonsStoreState & SkillButtonsStoreActions; + +const DEFAULT_PRIMARY: SkillButtonId = SKILL_BUTTON_ORDER[0]; + +export const useSkillButtonsStore = create()( + persist( + (set) => ({ + lastSelectedId: DEFAULT_PRIMARY, + setLastSelectedId: (lastSelectedId) => set({ lastSelectedId }), + }), + { + name: "skill-buttons-storage", + merge: (persisted, current) => { + const persistedState = persisted as { + lastSelectedId?: string; + }; + const restored = + persistedState.lastSelectedId && + persistedState.lastSelectedId in SKILL_BUTTONS + ? (persistedState.lastSelectedId as SkillButtonId) + : DEFAULT_PRIMARY; + return { + ...current, + lastSelectedId: restored, + }; + }, + }, + ), +); diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 1c612c4aa..03740d8f8 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -19,6 +19,14 @@ export type FeedbackType = "good" | "bad" | "general"; type FileOpenSource = "sidebar" | "agent-suggestion" | "search" | "diff"; export type FileChangeType = "added" | "modified" | "deleted"; type StopReason = "user_cancelled" | "completed" | "error" | "timeout"; +export type SkillButtonId = + | "add-analytics" + | "create-feature-flags" + | "run-experiment" + | "add-error-tracking" + | "instrument-llm-calls" + | "add-logging"; +type SkillButtonSource = "primary" | "dropdown"; export type CommandMenuAction = | "home" | "new-task" @@ -158,6 +166,12 @@ export interface CommandMenuActionProperties { action_type: CommandMenuAction; } +export interface SkillButtonTriggeredProperties { + task_id: string; + button_id: SkillButtonId; + source: SkillButtonSource; +} + // Settings events export interface SettingChangedProperties { setting_name: string; @@ -280,6 +294,7 @@ export const ANALYTICS_EVENTS = { COMMAND_MENU_OPENED: "Command menu opened", COMMAND_MENU_ACTION: "Command menu action", COMMAND_CENTER_VIEWED: "Command center viewed", + SKILL_BUTTON_TRIGGERED: "Skill button triggered", // Permission events PERMISSION_RESPONDED: "Permission responded", @@ -343,6 +358,7 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.COMMAND_MENU_OPENED]: never; [ANALYTICS_EVENTS.COMMAND_MENU_ACTION]: CommandMenuActionProperties; [ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED]: never; + [ANALYTICS_EVENTS.SKILL_BUTTON_TRIGGERED]: SkillButtonTriggeredProperties; // Permission events [ANALYTICS_EVENTS.PERMISSION_RESPONDED]: PermissionRespondedProperties;