From 4aa1c2c7440b9c28a4353ac73ce976c63a9c6002 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Tue, 9 Jun 2026 10:40:25 -0700 Subject: [PATCH] feat(code): suggest PR work items on new task page --- apps/code/src/main/services/git/schemas.ts | 20 ++ .../src/main/services/git/service.test.ts | 175 +++++++++++++++++- apps/code/src/main/services/git/service.ts | 105 +++++++++++ apps/code/src/main/trpc/routers/git.ts | 7 + .../components/SuggestedTaskCard.tsx | 2 +- .../components/SuggestedTasksPanel.tsx | 129 +++++++++++-- .../task-detail/components/TaskInput.tsx | 9 +- .../features/work-items/WorkItemCard.tsx | 81 ++++++++ .../work-items/buildWorkItemPrompt.test.ts | 34 ++++ .../work-items/buildWorkItemPrompt.ts | 35 ++++ .../dismissedWorkItemsStore.test.ts | 56 ++++++ .../work-items/dismissedWorkItemsStore.ts | Bin 0 -> 1401 bytes .../work-items/useWorkItemSuggestions.ts | 28 +++ apps/code/src/shared/constants.ts | 1 + apps/code/src/shared/types/analytics.ts | 20 ++ 15 files changed, 682 insertions(+), 20 deletions(-) create mode 100644 apps/code/src/renderer/features/work-items/WorkItemCard.tsx create mode 100644 apps/code/src/renderer/features/work-items/buildWorkItemPrompt.test.ts create mode 100644 apps/code/src/renderer/features/work-items/buildWorkItemPrompt.ts create mode 100644 apps/code/src/renderer/features/work-items/dismissedWorkItemsStore.test.ts create mode 100644 apps/code/src/renderer/features/work-items/dismissedWorkItemsStore.ts create mode 100644 apps/code/src/renderer/features/work-items/useWorkItemSuggestions.ts diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index f25a73f69c..d35a995514 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -623,3 +623,23 @@ export const createPrProgressPayload = z.object({ }); export type CreatePrProgressPayload = z.infer; + +// PR work items: the current user's open PRs that need action. Each kind is a +// distinct reason a single PR is "waiting on you" (a PR can yield several). +export const prWorkItemKindSchema = z.enum(["review", "ci", "conflict"]); +export type PrWorkItemKind = z.infer; + +export const prWorkItemSchema = z.object({ + kind: prWorkItemKindSchema, + prNumber: z.number(), + title: z.string(), + url: z.string(), + headRefName: z.string(), + // Head commit SHA — lets dismissals be commit-scoped (a new push re-surfaces). + headSha: z.string(), +}); + +export type PrWorkItem = z.infer; + +export const getPrWorkItemsInput = directoryPathInput; +export const getPrWorkItemsOutput = z.array(prWorkItemSchema); diff --git a/apps/code/src/main/services/git/service.test.ts b/apps/code/src/main/services/git/service.test.ts index 5c80e000c3..5d9a38dfa6 100644 --- a/apps/code/src/main/services/git/service.test.ts +++ b/apps/code/src/main/services/git/service.test.ts @@ -27,7 +27,7 @@ import type { IWorkspaceRepository } from "../../db/repositories/workspace-repos import type { AgentService } from "../agent/service"; import type { LlmGatewayService } from "../llm-gateway/service"; import type { WorkspaceService } from "../workspace/service"; -import { GitService, mapPrState } from "./service"; +import { derivePrWorkItems, GitService, mapPrState } from "./service"; const stubWorkspaceRepo = {} as IWorkspaceRepository; @@ -545,3 +545,176 @@ describe("GitService.resolveReviewThread", () => { expect(result).toEqual({ success: false, isResolved: false }); }); }); + +describe("derivePrWorkItems", () => { + // `ghPr` mirrors a `gh pr list --json` row (uses `number`); `expected` is the + // derived work-item shape (uses `prNumber`). + const ghPr = { + number: 12, + title: "Add the thing", + url: "https://github.com/posthog/code/pull/12", + headRefName: "feat/thing", + headRefOid: "abc123", + }; + const expected = { + prNumber: 12, + title: "Add the thing", + url: "https://github.com/posthog/code/pull/12", + headRefName: "feat/thing", + headSha: "abc123", + }; + + it("surfaces a review item for changes-requested", () => { + const items = derivePrWorkItems([ + { ...ghPr, reviewDecision: "CHANGES_REQUESTED" }, + ]); + expect(items).toEqual([{ ...expected, kind: "review" }]); + }); + + it("surfaces a ci item when a check fails (conclusion or state)", () => { + expect( + derivePrWorkItems([ + { ...ghPr, statusCheckRollup: [{ conclusion: "FAILURE" }] }, + ]), + ).toEqual([{ ...expected, kind: "ci" }]); + expect( + derivePrWorkItems([{ ...ghPr, statusCheckRollup: [{ state: "ERROR" }] }]), + ).toEqual([{ ...expected, kind: "ci" }]); + }); + + it("surfaces a conflict item when mergeable is CONFLICTING", () => { + const items = derivePrWorkItems([{ ...ghPr, mergeable: "CONFLICTING" }]); + expect(items).toEqual([{ ...expected, kind: "conflict" }]); + }); + + it("surfaces multiple items for one PR with multiple problems", () => { + const items = derivePrWorkItems([ + { + ...ghPr, + reviewDecision: "CHANGES_REQUESTED", + mergeable: "CONFLICTING", + statusCheckRollup: [{ conclusion: "FAILURE" }], + }, + ]); + expect(items.map((i) => i.kind)).toEqual(["review", "ci", "conflict"]); + }); + + it("yields nothing for a clean PR", () => { + expect( + derivePrWorkItems([ + { + ...ghPr, + reviewDecision: "APPROVED", + mergeable: "MERGEABLE", + statusCheckRollup: [{ conclusion: "SUCCESS" }, { state: "PENDING" }], + }, + ]), + ).toEqual([]); + }); + + it("for a draft, surfaces only conflicts (skips review/ci)", () => { + expect( + derivePrWorkItems([ + { + ...ghPr, + isDraft: true, + reviewDecision: "CHANGES_REQUESTED", + mergeable: "CONFLICTING", + statusCheckRollup: [{ conclusion: "FAILURE" }], + }, + ]), + ).toEqual([{ ...expected, kind: "conflict" }]); + }); + + it("yields nothing for a draft with no conflict", () => { + expect( + derivePrWorkItems([ + { + ...ghPr, + isDraft: true, + reviewDecision: "CHANGES_REQUESTED", + mergeable: "MERGEABLE", + statusCheckRollup: [{ conclusion: "FAILURE" }], + }, + ]), + ).toEqual([]); + }); +}); + +describe("GitService.getPrWorkItems", () => { + let service: GitService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new GitService( + {} as LlmGatewayService, + {} as WorkspaceService, + { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, + ); + }); + + it("lists the user's open PRs and derives work items", async () => { + mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git"); + mockExecGh.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([ + { + number: 7, + title: "Fix login", + url: "https://github.com/posthog/code/pull/7", + headRefName: "fix/login", + headRefOid: "deadbeef", + reviewDecision: "CHANGES_REQUESTED", + mergeable: "MERGEABLE", + statusCheckRollup: [{ conclusion: "SUCCESS" }], + }, + ]), + }); + + const result = await service.getPrWorkItems("/repo"); + + const args = mockExecGh.mock.calls[0][0] as string[]; + expect(args.slice(0, 6)).toEqual([ + "pr", + "list", + "--author", + "@me", + "--state", + "open", + ]); + expect(args).toContain("posthog/code"); + expect(result).toEqual([ + { + kind: "review", + prNumber: 7, + title: "Fix login", + url: "https://github.com/posthog/code/pull/7", + headRefName: "fix/login", + headSha: "deadbeef", + }, + ]); + }); + + it("returns [] for a non-GitHub remote without calling gh", async () => { + mockGetRemoteUrl.mockResolvedValue("https://gitlab.com/foo/bar.git"); + + const result = await service.getPrWorkItems("/repo"); + + expect(result).toEqual([]); + expect(mockExecGh).not.toHaveBeenCalled(); + }); + + it("returns [] when gh exits non-zero (missing/unauthenticated)", async () => { + mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git"); + mockExecGh.mockResolvedValue({ + exitCode: 1, + stdout: "", + stderr: "auth required", + }); + + const result = await service.getPrWorkItems("/repo"); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index dab3bda0aa..b18d7eacb3 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -75,6 +75,7 @@ import type { PrReviewComment, PrReviewThread, PrStatusOutput, + PrWorkItem, PublishOutput, PullOutput, PushOutput, @@ -131,6 +132,68 @@ function toUnifiedDiffPatch( return `diff --git a/${oldPath} b/${filename}\n--- ${fromPath}\n+++ ${toPath}\n${rawPatch}`; } +/** Shape of a `gh pr list --json …` row consumed by `derivePrWorkItems`. */ +interface GhPrListItem { + number: number; + title: string; + url: string; + headRefName: string; + headRefOid?: string; + mergeable?: string; + reviewDecision?: string; + isDraft?: boolean; + statusCheckRollup?: Array<{ state?: string; conclusion?: string }>; +} + +// Check-run `conclusion` / status-context `state` values that count as a real +// failure worth a fix-it task. Ambiguous outcomes (cancelled, action_required, +// pending) are intentionally excluded so we don't nag about non-failures. +const FAILED_CHECK_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT"]); +const FAILED_CHECK_STATES = new Set(["FAILURE", "ERROR"]); + +function hasFailingCheck(rollup: GhPrListItem["statusCheckRollup"]): boolean { + if (!rollup?.length) return false; + return rollup.some((check) => { + const conclusion = check.conclusion?.toUpperCase(); + const state = check.state?.toUpperCase(); + return ( + (!!conclusion && FAILED_CHECK_CONCLUSIONS.has(conclusion)) || + (!!state && FAILED_CHECK_STATES.has(state)) + ); + }); +} + +/** + * Derives 0..N work items from the current user's open PRs. A single PR can + * surface several (e.g. changes-requested *and* failing CI). + * + * Drafts surface only `conflict`: a merge conflict is the author's to resolve + * regardless of ready state and only rots, whereas changes-requested / failing + * CI on a draft is expected work-in-progress noise. + */ +export function derivePrWorkItems(prs: GhPrListItem[]): PrWorkItem[] { + const items: PrWorkItem[] = []; + for (const pr of prs) { + const base = { + prNumber: pr.number, + title: pr.title, + url: pr.url, + headRefName: pr.headRefName, + headSha: pr.headRefOid ?? "", + }; + if (!pr.isDraft && pr.reviewDecision === "CHANGES_REQUESTED") { + items.push({ ...base, kind: "review" }); + } + if (!pr.isDraft && hasFailingCheck(pr.statusCheckRollup)) { + items.push({ ...base, kind: "ci" }); + } + if (pr.mergeable === "CONFLICTING") { + items.push({ ...base, kind: "conflict" }); + } + } + return items; +} + @injectable() export class GitService extends TypedEventEmitter { private lastFetchTime = new Map(); @@ -1023,6 +1086,48 @@ export class GitService extends TypedEventEmitter { } } + /** + * Surfaces the current user's open PRs in this repo that need action: + * - `review`: changes requested + * - `ci`: a failing check + * - `conflict`: merge conflicts + * Returns `[]` (no noise) when this isn't a GitHub repo, gh is missing/ + * unauthenticated, or the call fails. + */ + public async getPrWorkItems(directoryPath: string): Promise { + try { + const remoteUrl = await getRemoteUrl(directoryPath); + const parsed = remoteUrl ? parseGithubUrl(remoteUrl) : null; + if (!parsed) return []; + + const result = await execGh( + [ + "pr", + "list", + "--author", + "@me", + "--state", + "open", + "--limit", + "20", + "--repo", + `${parsed.owner}/${parsed.repo}`, + "--json", + "number,title,url,headRefName,headRefOid,mergeable,reviewDecision,statusCheckRollup,isDraft", + ], + { cwd: directoryPath }, + ); + + if (result.exitCode !== 0) return []; + + const prs = JSON.parse(result.stdout) as GhPrListItem[]; + return derivePrWorkItems(prs); + } catch (error) { + log.warn("Failed to fetch PR work items", { directoryPath, error }); + return []; + } + } + private async createPrViaGh( directoryPath: string, title?: string, diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 21b7e65099..d447909ad1 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -58,6 +58,8 @@ import { getPrTemplateOutput, getPrUrlForBranchInput, getPrUrlForBranchOutput, + getPrWorkItemsInput, + getPrWorkItemsOutput, ghAuthTokenOutput, ghStatusOutput, gitStateSnapshotSchema, @@ -339,6 +341,11 @@ export const gitRouter = router({ getService().getPrUrlForBranch(input.directoryPath, input.branchName), ), + getPrWorkItems: publicProcedure + .input(getPrWorkItemsInput) + .output(getPrWorkItemsOutput) + .query(({ input }) => getService().getPrWorkItems(input.directoryPath)), + createPr: publicProcedure .input(createPrInput) .output(createPrOutput) diff --git a/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx b/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx index dc770e3032..7e854d3089 100644 --- a/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx +++ b/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx @@ -85,7 +85,7 @@ export function SuggestedTaskCard({ e.stopPropagation(); onDismiss(task); }} - className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-(--gray-9) hover:bg-(--gray-a3) hover:text-(--gray-12)" + className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md bg-(--gray-3) text-(--gray-11) shadow-sm transition-colors hover:bg-(--gray-4) hover:text-(--gray-12)" > diff --git a/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx b/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx index 89a19a8f2e..2fa903b5bb 100644 --- a/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx @@ -1,3 +1,4 @@ +import { useFolders } from "@features/folders/hooks/useFolders"; import { DiscoveredTaskDetailDialog } from "@features/setup/components/DiscoveredTaskDetailDialog"; import { SetupScanFeed } from "@features/setup/components/SetupScanFeed"; import { @@ -7,6 +8,16 @@ import { useSetupStore, } from "@features/setup/stores/setupStore"; import type { DiscoveredTask } from "@features/setup/types"; +import { buildWorkItemPrompt } from "@features/work-items/buildWorkItemPrompt"; +import { + dismissedWorkItemKey, + useDismissedWorkItemsStore, +} from "@features/work-items/dismissedWorkItemsStore"; +import { useWorkItemSuggestions } from "@features/work-items/useWorkItemSuggestions"; +import { WorkItemCard } from "@features/work-items/WorkItemCard"; +import { useDetectedCloudRepository } from "@hooks/useDetectedCloudRepository"; +import { openTaskInput } from "@hooks/useOpenTask"; +import type { PrWorkItem } from "@main/services/git/schemas"; import { CaretLeft, CaretRight, @@ -14,7 +25,9 @@ import { MagnifyingGlass, } from "@phosphor-icons/react"; import { Flex, Text } from "@radix-ui/themes"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useActiveRepoStore } from "@stores/activeRepoStore"; +import { track } from "@utils/analytics"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, @@ -25,7 +38,14 @@ import { } from "react"; import { SuggestedTaskCard } from "./SuggestedTaskCard"; -const VISIBLE_LIMIT = 3; +const workItemKey = (item: PrWorkItem) => `${item.prNumber}:${item.kind}`; + +type PanelEntry = + | { kind: "work"; id: string; item: PrWorkItem } + | { kind: "discovered"; id: string; task: DiscoveredTask }; + +const VISIBLE_LIMIT = 4; +const GRID_COLUMNS = 2; const DEFAULT_LOG_LINES = 4; const TOP_MARGIN = 12; @@ -65,6 +85,14 @@ export function SuggestedTasksPanel() { ); const removeDiscoveredTask = useSetupStore((s) => s.removeDiscoveredTask); + const workItemsRaw = useWorkItemSuggestions(selectedDirectory); + const dismissedWorkItemKeys = useDismissedWorkItemsStore( + (s) => s.dismissedKeys, + ); + const dismissWorkItem = useDismissedWorkItemsStore((s) => s.dismiss); + const { folders } = useFolders(); + const detectedCloudRepository = useDetectedCloudRepository(selectedDirectory); + const [detailTask, setDetailTask] = useState(null); const [pageStart, setPageStart] = useState(0); const [pageDirection, setPageDirection] = useState<1 | -1>(1); @@ -119,13 +147,62 @@ export function SuggestedTasksPanel() { setDetailTask(null); }, []); - const hasTasks = discoveredTasks.length > 0; + const handleSelectWorkItem = useCallback( + (item: PrWorkItem, position: number, total: number) => { + track(ANALYTICS_EVENTS.SETUP_WORK_ITEM_SELECTED, { + kind: item.kind, + pr_number: item.prNumber, + position, + total, + }); + const folderId = folders.find((f) => f.path === selectedDirectory)?.id; + openTaskInput({ + initialPrompt: buildWorkItemPrompt(item), + folderId, + initialCloudRepository: detectedCloudRepository ?? undefined, + }); + }, + [folders, selectedDirectory, detectedCloudRepository], + ); + + const handleDismissWorkItem = useCallback( + (item: PrWorkItem, position: number, total: number) => { + track(ANALYTICS_EVENTS.SETUP_WORK_ITEM_DISMISSED, { + kind: item.kind, + pr_number: item.prNumber, + position, + total, + }); + dismissWorkItem(dismissedWorkItemKey(selectedDirectory, item)); + }, + [dismissWorkItem, selectedDirectory], + ); + + const dismissed = new Set(dismissedWorkItemKeys); + const workItems = workItemsRaw.filter( + (item) => !dismissed.has(dismissedWorkItemKey(selectedDirectory, item)), + ); + + const entries: PanelEntry[] = [ + ...discoveredTasks.map((task) => ({ + kind: "discovered" as const, + id: task.id, + task, + })), + ...workItems.map((item) => ({ + kind: "work" as const, + id: `work:${workItemKey(item)}`, + item, + })), + ]; + + const hasTasks = entries.length > 0; const showEnricherFeed = !hasTasks && enricherStatus === "running"; const showDiscoveryFeed = discoveryStatus === "running"; if (!hasTasks && !showEnricherFeed && !showDiscoveryFeed) return null; - const totalTasks = discoveredTasks.length; + const totalTasks = entries.length; const desiredVisible = Math.min(totalTasks, VISIBLE_LIMIT); const discoveryFeedHasEntries = discoveryFeed.recentEntries.length > 0; @@ -133,7 +210,8 @@ export function SuggestedTasksPanel() { const sections: number[] = []; if (hasTasks) sections.push(HEADER_HEIGHT); if (cardCount > 0) { - sections.push(cardCount * CARD_HEIGHT + Math.max(0, cardCount - 1) * GAP); + const rows = Math.ceil(cardCount / GRID_COLUMNS); + sections.push(rows * CARD_HEIGHT + Math.max(0, rows - 1) * GAP); } if (showEnricherFeed) sections.push(SCAN_PILL_HEIGHT); if (showDiscoveryFeed) { @@ -161,13 +239,13 @@ export function SuggestedTasksPanel() { const effectivePageStart = visibleCount > 0 && pageStart < totalTasks ? pageStart : 0; - const visibleTasks = discoveredTasks.slice( + const visibleEntries = entries.slice( effectivePageStart, effectivePageStart + visibleCount, ); const canGoPrev = effectivePageStart > 0; - const canGoNext = effectivePageStart + visibleTasks.length < totalTasks; + const canGoNext = effectivePageStart + visibleEntries.length < totalTasks; const showPager = visibleCount > 0 && totalTasks > visibleCount; const currentPage = visibleCount > 0 ? Math.floor(effectivePageStart / visibleCount) + 1 : 1; @@ -245,17 +323,38 @@ export function SuggestedTasksPanel() { transition: { duration: 0.13, ease: "easeIn" }, }), }} - className="flex flex-col gap-2" + className="grid grid-cols-2 gap-2" > - {visibleTasks.map((task) => ( - - ))} + {visibleEntries.map((entry) => + entry.kind === "work" ? ( + + handleSelectWorkItem( + item, + workItems.indexOf(item), + workItems.length, + ) + } + onDismiss={(item) => + handleDismissWorkItem( + item, + workItems.indexOf(item), + workItems.length, + ) + } + /> + ) : ( + + ), + )} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 7f005bcad8..3a787b1331 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -17,6 +17,7 @@ import { PromptHistoryDialog } from "@features/message-editor/components/PromptH import { PromptInput } from "@features/message-editor/components/PromptInput"; import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; import type { EditorHandle } from "@features/message-editor/types"; +import { xmlToContent } from "@features/message-editor/utils/content"; import { resolveAndAttachDroppedFiles } from "@features/message-editor/utils/persistFile"; import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; @@ -133,9 +134,11 @@ export function TaskInput({ useEffect(() => { if (!initialPrompt || !prefillRequestKey) return; - useDraftStore.getState().actions.setPendingContent(sessionId, { - segments: [{ type: "text", text: initialPrompt }], - }); + // Hydrate chip tags (e.g. ) into real pills; plain prompts + // round-trip unchanged to a single text segment. + useDraftStore + .getState() + .actions.setPendingContent(sessionId, xmlToContent(initialPrompt)); }, [initialPrompt, prefillRequestKey, sessionId]); useEffect(() => { diff --git a/apps/code/src/renderer/features/work-items/WorkItemCard.tsx b/apps/code/src/renderer/features/work-items/WorkItemCard.tsx new file mode 100644 index 0000000000..8fef664ec8 --- /dev/null +++ b/apps/code/src/renderer/features/work-items/WorkItemCard.tsx @@ -0,0 +1,81 @@ +import type { PrWorkItem } from "@main/services/git/schemas"; +import { GitPullRequest, X } from "@phosphor-icons/react"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { motion } from "framer-motion"; + +const KIND_TITLE: Record string> = { + review: (n) => `Address review on PR #${n}`, + ci: (n) => `Fix failing CI on PR #${n}`, + conflict: (n) => `Resolve merge conflicts on PR #${n}`, +}; + +export interface WorkItemCardProps { + item: PrWorkItem; + onSelect: (item: PrWorkItem) => void; + onDismiss: (item: PrWorkItem) => void; +} + +export function WorkItemCard({ item, onSelect, onDismiss }: WorkItemCardProps) { + return ( + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/work-items/buildWorkItemPrompt.test.ts b/apps/code/src/renderer/features/work-items/buildWorkItemPrompt.test.ts new file mode 100644 index 0000000000..59f2ec1c46 --- /dev/null +++ b/apps/code/src/renderer/features/work-items/buildWorkItemPrompt.test.ts @@ -0,0 +1,34 @@ +import { xmlToContent } from "@features/message-editor/utils/content"; +import type { PrWorkItem } from "@main/services/git/schemas"; +import { describe, expect, it } from "vitest"; +import { buildWorkItemPrompt } from "./buildWorkItemPrompt"; + +const item: PrWorkItem = { + kind: "review", + prNumber: 2480, + title: "Configurable base branch", + url: "https://github.com/PostHog/code/pull/2480", + headRefName: "feat/base-branch", + headSha: "abc123", +}; + +describe("buildWorkItemPrompt", () => { + it("embeds the PR as a github_pr chip that hydrates into a pill", () => { + const content = xmlToContent(buildWorkItemPrompt(item)); + const chipSegment = content.segments.find((s) => s.type === "chip"); + expect(chipSegment).toEqual({ + type: "chip", + chip: { + type: "github_pr", + id: item.url, + label: "#2480 - Configurable base branch", + }, + }); + }); + + it("includes the kind instruction and branch hint", () => { + const xml = buildWorkItemPrompt(item); + expect(xml).toContain("Address the requested review changes"); + expect(xml).toContain("Branch: feat/base-branch"); + }); +}); diff --git a/apps/code/src/renderer/features/work-items/buildWorkItemPrompt.ts b/apps/code/src/renderer/features/work-items/buildWorkItemPrompt.ts new file mode 100644 index 0000000000..0564d9cc9b --- /dev/null +++ b/apps/code/src/renderer/features/work-items/buildWorkItemPrompt.ts @@ -0,0 +1,35 @@ +import { + contentToXml, + type EditorContent, +} from "@features/message-editor/utils/content"; +import { githubPullRequestToMentionChip } from "@features/message-editor/utils/githubIssueChip"; +import type { PrWorkItem } from "@main/services/git/schemas"; + +const KIND_INSTRUCTION: Record = { + review: + "Address the requested review changes on this pull request. Read the unresolved review comments, make the changes, reply where useful, and push.", + ci: "Investigate and fix the failing CI checks on this pull request. Reproduce the failure locally, fix the root cause, and push.", + conflict: + "Resolve the merge conflicts on this pull request. Rebase the branch on the default branch, resolve each conflict, and push.", +}; + +/** + * Builds the pre-filled prompt for a PR work item as editor XML: the PR is a + * real `github_pr` mention chip (so it renders as a pill and the agent gets a + * structured reference), with the head branch as a hint to check out. + */ +export function buildWorkItemPrompt(item: PrWorkItem): string { + const chip = githubPullRequestToMentionChip({ + number: item.prNumber, + title: item.title, + url: item.url, + }); + const content: EditorContent = { + segments: [ + { type: "text", text: `${KIND_INSTRUCTION[item.kind]}\n\nPR: ` }, + { type: "chip", chip }, + { type: "text", text: `\nBranch: ${item.headRefName}` }, + ], + }; + return contentToXml(content); +} diff --git a/apps/code/src/renderer/features/work-items/dismissedWorkItemsStore.test.ts b/apps/code/src/renderer/features/work-items/dismissedWorkItemsStore.test.ts new file mode 100644 index 0000000000..9f201ec735 --- /dev/null +++ b/apps/code/src/renderer/features/work-items/dismissedWorkItemsStore.test.ts @@ -0,0 +1,56 @@ +import type { PrWorkItem } from "@main/services/git/schemas"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + dismissedWorkItemKey, + useDismissedWorkItemsStore, +} from "./dismissedWorkItemsStore"; + +const item: PrWorkItem = { + kind: "ci", + prNumber: 7, + title: "Fix login", + url: "https://github.com/posthog/code/pull/7", + headRefName: "fix/login", + headSha: "sha-a", +}; + +describe("dismissedWorkItemKey", () => { + it("is commit-scoped — a new head SHA yields a different key", () => { + const a = dismissedWorkItemKey("/repo", item); + const b = dismissedWorkItemKey("/repo", { ...item, headSha: "sha-b" }); + expect(a).not.toBe(b); + }); + + it("distinguishes repo, pr number, and kind", () => { + const base = dismissedWorkItemKey("/repo", item); + expect(dismissedWorkItemKey("/other", item)).not.toBe(base); + expect(dismissedWorkItemKey("/repo", { ...item, prNumber: 8 })).not.toBe( + base, + ); + expect( + dismissedWorkItemKey("/repo", { ...item, kind: "conflict" }), + ).not.toBe(base); + }); +}); + +describe("useDismissedWorkItemsStore", () => { + beforeEach(() => { + useDismissedWorkItemsStore.setState({ dismissedKeys: [] }); + }); + + it("records a dismissal once (idempotent)", () => { + const { dismiss } = useDismissedWorkItemsStore.getState(); + dismiss("k1"); + dismiss("k1"); + expect(useDismissedWorkItemsStore.getState().dismissedKeys).toEqual(["k1"]); + }); + + it("caps the list so commit-scoped keys can't grow unbounded", () => { + const { dismiss } = useDismissedWorkItemsStore.getState(); + for (let i = 0; i < 550; i++) dismiss(`k${i}`); + const keys = useDismissedWorkItemsStore.getState().dismissedKeys; + expect(keys).toHaveLength(500); + expect(keys).not.toContain("k0"); // oldest dropped + expect(keys).toContain("k549"); // newest kept + }); +}); diff --git a/apps/code/src/renderer/features/work-items/dismissedWorkItemsStore.ts b/apps/code/src/renderer/features/work-items/dismissedWorkItemsStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..729f6de57b8c80772f4a79375270718337beaa72 GIT binary patch literal 1401 zcmZux+iuf95arolapV{5C~^A2OF~>B*6z9?q>=atzK}0r z){dJCwW8R`oO5R8%&cWuY2)D56=8!r^HrPW#8HVY=0=wY-<3kDp(XPuQ?kQ_bVHlw zR0DDr)}2CzrOdK|R>BaYgW(X@qJq^(by-R`u&J&n z!;;n((JGf|jx-l)LC^~HNM?KHToeWLe2%}r|3EmbwBe{w{7EH)kV#ugYefM=t~SahL8=vFZm+(5znM&L zC)4Ts8=T|K*_m&l*9*|Y^-=wM4elhtM5RTYNwt8>2|0-j(+^iDNhk{eMXRPOPB^f& znG1H_YkF`hOg4r*5?D&*EuzXc5SJSw<_gPYGBl%#_)EDHo|iOKX;i}H5RY1B7I$9; zsxBYM@C1rNTIn@JYP7Wtn7c=B7|_pVZ|1d19YZ>kDC-vJBdwzV4dssIN3d)+H~a)u zv*AzRSULp+p4KJz8r>5fFPw&R=(K|cM z#eMQaOBIE$!gw5PgFq@rjjOn6XS*-kPMu$5pK~`e%D5HVRWrLE??WR*JkBl@8hInn-v-9>P0GDzR#oou=@$rv9WXUzzB44M!PHLUqds7brZKa;G z`yXJ9DB@%4sFCWFJW0+ug^wghx=FirWPR6h%(HEV(jdJn-PQK2=%?fEW=WE~pw;(B zy797O?bHtIIPL2ohiCZ=NkMAC`*3l58c&eGWs9~2zr~_M;^3GV|Hm9P%+61rce`(P g8@GY(zJU@YjSxN?H{kcla^UydhS+*EQ+N&k0r11Zh5!Hn literal 0 HcmV?d00001 diff --git a/apps/code/src/renderer/features/work-items/useWorkItemSuggestions.ts b/apps/code/src/renderer/features/work-items/useWorkItemSuggestions.ts new file mode 100644 index 0000000000..548e46ea76 --- /dev/null +++ b/apps/code/src/renderer/features/work-items/useWorkItemSuggestions.ts @@ -0,0 +1,28 @@ +import { useFeatureFlag } from "@hooks/useFeatureFlag"; +import type { PrWorkItem } from "@main/services/git/schemas"; +import { useTRPC } from "@renderer/trpc"; +import { WORK_ITEM_SUGGESTIONS_FLAG } from "@shared/constants"; +import { useQuery } from "@tanstack/react-query"; + +export function useWorkItemSuggestions( + selectedDirectory: string | null | undefined, +): PrWorkItem[] { + const trpcReact = useTRPC(); + const flagEnabled = useFeatureFlag( + WORK_ITEM_SUGGESTIONS_FLAG, + import.meta.env.DEV, + ); + + const { data } = useQuery( + trpcReact.git.getPrWorkItems.queryOptions( + { directoryPath: selectedDirectory ?? "" }, + { + enabled: flagEnabled && !!selectedDirectory, + staleTime: 5 * 60_000, + refetchOnWindowFocus: true, + }, + ), + ); + + return data ?? []; +} diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts index 1cdb2c9f38..6e0be7dd36 100644 --- a/apps/code/src/shared/constants.ts +++ b/apps/code/src/shared/constants.ts @@ -4,6 +4,7 @@ export const EXPERIMENT_SUGGESTIONS_FLAG = "posthog-code-experiment-suggestions"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; +export const WORK_ITEM_SUGGESTIONS_FLAG = "posthog-code-work-item-suggestions"; export const BRANCH_PREFIX = "posthog-code/"; export const DATA_DIR = ".posthog-code"; export const WORKTREES_DIR = ".posthog-code/worktrees"; diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index b2bd3554cf..1d9ec0104e 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -474,6 +474,22 @@ export interface SetupTaskDismissedProperties { total_discovered: number; } +export type WorkItemKind = "review" | "ci" | "conflict"; + +export interface SetupWorkItemSelectedProperties { + kind: WorkItemKind; + pr_number: number; + position: number; + total: number; +} + +export interface SetupWorkItemDismissedProperties { + kind: WorkItemKind; + pr_number: number; + position: number; + total: number; +} + // Inbox events export type InboxReportOpenMethod = | "click" @@ -763,6 +779,8 @@ export const ANALYTICS_EVENTS = { SETUP_DISCOVERY_FAILED: "Setup discovery failed", SETUP_TASK_SELECTED: "Setup task selected", SETUP_TASK_DISMISSED: "Setup task dismissed", + SETUP_WORK_ITEM_SELECTED: "Setup work item selected", + SETUP_WORK_ITEM_DISMISSED: "Setup work item dismissed", // Deep link events DEEP_LINK_NEW_TASK: "Deep link new task", @@ -886,6 +904,8 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED]: SetupDiscoveryFailedProperties; [ANALYTICS_EVENTS.SETUP_TASK_SELECTED]: SetupTaskSelectedProperties; [ANALYTICS_EVENTS.SETUP_TASK_DISMISSED]: SetupTaskDismissedProperties; + [ANALYTICS_EVENTS.SETUP_WORK_ITEM_SELECTED]: SetupWorkItemSelectedProperties; + [ANALYTICS_EVENTS.SETUP_WORK_ITEM_DISMISSED]: SetupWorkItemDismissedProperties; // Deep link events [ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK]: DeepLinkNewTaskProperties;