From 5a192ece168e5aa102ce5d11dcb45e0993dcebb0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 24 Jun 2026 23:03:12 -0700 Subject: [PATCH] FN-6946: add GitHub reference picker to task dialog Add a GitHub issue and pull request picker that seeds new task prompts from detected remotes. - Add remote-aware GitHub issue/PR selection to the New Task modal. - Generate focused task descriptions for selected issues and pull requests while confirming before replacing user text. - Style and document the compact picker and cover loading, errors, remote selection, and overwrite behavior in tests. - Add a changeset for the published Fusion CLI package. Files changed: .changeset/fn-6946-github-reference-picker.md | 7 + docs/dashboard-guide.md | 2 + packages/dashboard/app/components/NewTaskModal.css | 53 ++++ packages/dashboard/app/components/NewTaskModal.tsx | 297 ++++++++++++++++++++- .../app/components/__tests__/NewTaskModal.test.tsx | 199 +++++++++++++- 5 files changed, 555 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6946 Fusion-Task-Lineage: e871827c-dfe5-4fc3-a525-c1161639d306 --- .changeset/fn-6946-github-reference-picker.md | 7 + docs/dashboard-guide.md | 2 + .../dashboard/app/components/NewTaskModal.css | 53 ++++ .../dashboard/app/components/NewTaskModal.tsx | 297 +++++++++++++++++- .../__tests__/NewTaskModal.test.tsx | 199 +++++++++++- 5 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-6946-github-reference-picker.md diff --git a/.changeset/fn-6946-github-reference-picker.md b/.changeset/fn-6946-github-reference-picker.md new file mode 100644 index 0000000000..113e4d92b5 --- /dev/null +++ b/.changeset/fn-6946-github-reference-picker.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Add a New Task dialog picker that seeds prompts from current-remote GitHub issues and PRs. +category: feature +dev: Reuses existing GitHub remote, issue, and pull list endpoints; PR prompts direct agents to address review comments. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bc55e46222..3cae7719c3 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -319,6 +319,8 @@ Rules: The dialog also exposes the board quick-add AI handoffs: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior. **Execution mode** is available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution before creating a task from either surface. +The full **New Task** dialog includes a compact **GitHub issue or PR** picker near the description. It detects GitHub remotes for the current project, auto-selects a single remote or `origin`, and asks you to choose a remote when multiple non-`origin` remotes are available. Selecting an issue replaces the description with a prompt that tells the executor to fetch/read the issue and includes `Source: `; selecting a pull request creates a PR-focused prompt with `PR: ` and explicit instructions to inspect the PR conversation, review comments, checks, and changed files, then resolve or address actionable review comments. If you already typed a description, Fusion asks before replacing it. This picker only seeds the prompt; it does not import, close, or comment on GitHub items. + ## Chat View Chat view provides project-scoped conversations with agents. diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css index e767247666..5412fc77de 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -147,6 +147,59 @@ Edge + corner resize handles. touch-action:none keeps the drag from being hijack margin-bottom: 0; } +/* +FNXC:NewTaskGitHubReference 2026-06-24-00:00: +The GitHub reference picker is a compact prompt-seeding helper inside the primary create flow, not a competing create action. Keep it token-sized, vertically stacked, and allowed to shrink so desktop floating windows and mobile sheets avoid horizontal overflow while unavailable states render as text instead of empty selects. +*/ +.new-task-github-reference-picker { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; + padding: var(--space-sm); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} + +.new-task-github-reference-picker__header { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-sm); + min-width: 0; +} + +.new-task-github-reference-picker__header label, +.new-task-github-reference-picker__label { + margin-bottom: 0; + font-weight: 600; + color: var(--text); +} + +.new-task-github-reference-picker__remote { + min-width: 0; + color: var(--text-muted); + font-size: var(--font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.new-task-github-reference-picker__remote-select, +.new-task-github-reference-picker__select { + width: 100%; + min-width: 0; +} + +.new-task-github-reference-picker__status { + margin: 0; + color: var(--text-muted); + font-size: var(--font-size-xs); + line-height: 1.4; +} + .task-form-primary-section .description-with-refine { padding: var(--space-sm); border: 1px solid var(--border-subtle); diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 2e11caa81a..e85181671c 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -1,11 +1,22 @@ import "./NewTaskModal.css"; -import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; +import { useState, useCallback, useEffect, useRef, type CSSProperties, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; -import { checkDuplicateTasks, uploadAttachment, type CreateTaskInput, type DuplicateMatch } from "../api"; +import { + apiFetchGitHubIssues, + apiFetchGitHubPulls, + checkDuplicateTasks, + fetchGitRemotes, + uploadAttachment, + type CreateTaskInput, + type DuplicateMatch, + type GitHubIssue, + type GitHubPull, + type GitRemote, +} from "../api"; import { Bot } from "lucide-react"; import { useSetupReadiness } from "../hooks/useSetupReadiness"; import { SetupWarningBanner } from "./SetupWarningBanner"; @@ -131,6 +142,259 @@ function writeFloatPosition(position: FloatPosition, size: FloatSize): FloatPosi type FloatResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; const NEW_TASK_RESIZE_DIRECTIONS: FloatResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; +const NEW_TASK_GITHUB_REFERENCE_LIMIT = 30; + +type GitHubReferenceOption = + | { type: "issue"; number: number; title: string; url: string } + | { type: "pull"; number: number; title: string; url: string }; + +function buildGitHubReferenceValue(option: GitHubReferenceOption): string { + return `${option.type}:${option.number}`; +} + +function buildGitHubIssuePrompt(issue: GitHubReferenceOption): string { + return `Fetch and read this GitHub issue, then implement the requested fix or feature.\n\nSource: ${issue.url}\n\nUse the issue details, reproduction notes, linked discussion, and acceptance criteria to produce a complete implementation with tests and documentation updates as needed.`; +} + +function buildGitHubPullPrompt(pull: GitHubReferenceOption): string { + return `Fetch and read this GitHub pull request, inspect the conversation, review comments, check failures, and changed files as needed, then resolve or address all actionable PR review comments.\n\nPR: ${pull.url}\n\nKeep the PR intent intact while making the requested fixes, and verify the result with targeted tests.`; +} + +function defaultGitHubRemote(remotes: GitRemote[]): GitRemote | undefined { + if (remotes.length === 1) return remotes[0]; + return remotes.find((remote) => remote.name === "origin"); +} + +function gitHubReferenceLabel(option: GitHubReferenceOption): string { + return `${option.type === "issue" ? "Issue" : "PR"} #${option.number} — ${option.title}`; +} + +interface NewTaskGitHubReferencePickerProps { + isOpen: boolean; + projectId?: string; + disabled?: boolean; + onSelectReference: (option: GitHubReferenceOption) => Promise | boolean; +} + +/* +FNXC:NewTaskGitHubReference 2026-06-24-00:00: +The New Task dialog gets exactly one compact GitHub reference picker that seeds prompts from the current GitHub remote. It reuses existing remote/list helpers and never imports, closes, comments on, or otherwise mutates GitHub issues/PRs; selecting an item only writes task description text for the executor to fetch/read the selected URL. +*/ +function NewTaskGitHubReferencePicker({ isOpen, projectId, disabled = false, onSelectReference }: NewTaskGitHubReferencePickerProps) { + const { t } = useTranslation("app"); + const [remotes, setRemotes] = useState([]); + const [loadingRemotes, setLoadingRemotes] = useState(false); + const [remoteError, setRemoteError] = useState(null); + const [selectedRemoteName, setSelectedRemoteName] = useState(""); + const [issues, setIssues] = useState([]); + const [pulls, setPulls] = useState([]); + const [loadingReferences, setLoadingReferences] = useState(false); + const [referenceError, setReferenceError] = useState(null); + const [selectedValue, setSelectedValue] = useState(""); + const remoteRequestIdRef = useRef(0); + const referenceRequestIdRef = useRef(0); + + useEffect(() => { + if (!isOpen) { + remoteRequestIdRef.current += 1; + referenceRequestIdRef.current += 1; + setRemotes([]); + setLoadingRemotes(false); + setRemoteError(null); + setSelectedRemoteName(""); + setIssues([]); + setPulls([]); + setLoadingReferences(false); + setReferenceError(null); + setSelectedValue(""); + return; + } + + const requestId = remoteRequestIdRef.current + 1; + remoteRequestIdRef.current = requestId; + setRemotes([]); + setLoadingRemotes(true); + setRemoteError(null); + setSelectedRemoteName(""); + setIssues([]); + setPulls([]); + setReferenceError(null); + setSelectedValue(""); + + let cancelled = false; + fetchGitRemotes(projectId) + .then((fetchedRemotes) => { + if (cancelled || remoteRequestIdRef.current !== requestId) return; + setRemotes(fetchedRemotes); + const defaultRemote = defaultGitHubRemote(fetchedRemotes); + setSelectedRemoteName(defaultRemote?.name ?? ""); + }) + .catch((error) => { + if (cancelled || remoteRequestIdRef.current !== requestId) return; + setRemoteError(getErrorMessage(error) || t("newTaskModal.githubRemoteError", "Unable to load GitHub remotes.")); + }) + .finally(() => { + if (!cancelled && remoteRequestIdRef.current === requestId) { + setLoadingRemotes(false); + } + }); + + return () => { + cancelled = true; + }; + }, [isOpen, projectId, t]); + + const selectedRemote = remotes.find((remote) => remote.name === selectedRemoteName); + + useEffect(() => { + if (!isOpen || !selectedRemote) { + referenceRequestIdRef.current += 1; + setIssues([]); + setPulls([]); + setLoadingReferences(false); + setReferenceError(null); + setSelectedValue(""); + return; + } + + const requestId = referenceRequestIdRef.current + 1; + referenceRequestIdRef.current = requestId; + setIssues([]); + setPulls([]); + setLoadingReferences(true); + setReferenceError(null); + setSelectedValue(""); + + let cancelled = false; + Promise.all([ + apiFetchGitHubIssues(selectedRemote.owner, selectedRemote.repo, NEW_TASK_GITHUB_REFERENCE_LIMIT), + apiFetchGitHubPulls(selectedRemote.owner, selectedRemote.repo, NEW_TASK_GITHUB_REFERENCE_LIMIT), + ]) + .then(([fetchedIssues, fetchedPulls]) => { + if (cancelled || referenceRequestIdRef.current !== requestId) return; + setIssues(fetchedIssues); + setPulls(fetchedPulls); + }) + .catch((error) => { + if (cancelled || referenceRequestIdRef.current !== requestId) return; + setReferenceError(getErrorMessage(error) || t("newTaskModal.githubReferenceError", "Unable to load GitHub issues and pull requests.")); + }) + .finally(() => { + if (!cancelled && referenceRequestIdRef.current === requestId) { + setLoadingReferences(false); + } + }); + + return () => { + cancelled = true; + }; + }, [isOpen, selectedRemote, t]); + + const issueOptions: GitHubReferenceOption[] = issues.map((issue) => ({ type: "issue", number: issue.number, title: issue.title, url: issue.html_url })); + const pullOptions: GitHubReferenceOption[] = pulls.map((pull) => ({ type: "pull", number: pull.number, title: pull.title, url: pull.html_url })); + const allOptions = [...issueOptions, ...pullOptions]; + const multipleRemotesRequireChoice = remotes.length > 1 && !defaultGitHubRemote(remotes) && !selectedRemote; + const canSelectReference = allOptions.length > 0 && !referenceError; + + const handleReferenceChange = async (event: ChangeEvent) => { + const nextValue = event.target.value; + const option = allOptions.find((candidate) => buildGitHubReferenceValue(candidate) === nextValue); + if (!option) { + setSelectedValue(""); + return; + } + const accepted = await onSelectReference(option); + if (accepted) { + setSelectedValue(nextValue); + } + }; + + let statusText = ""; + if (loadingRemotes) { + statusText = t("newTaskModal.githubLoadingRemotes", "Loading GitHub remotes…"); + } else if (remoteError) { + statusText = remoteError; + } else if (remotes.length === 0) { + statusText = t("newTaskModal.githubNoRemotes", "No GitHub remotes were detected for this project."); + } else if (multipleRemotesRequireChoice) { + statusText = t("newTaskModal.githubChooseRemote", "Choose a GitHub remote before selecting an issue or pull request."); + } else if (loadingReferences) { + statusText = t("newTaskModal.githubLoadingReferences", "Loading open issues and pull requests…"); + } else if (referenceError) { + statusText = referenceError; + } else if (selectedRemote && allOptions.length === 0) { + statusText = t("newTaskModal.githubNoReferences", "No open issues or pull requests were found for the selected remote."); + } else if (selectedRemote) { + statusText = t("newTaskModal.githubReferenceHelp", "Select an open issue or pull request to seed the task prompt."); + } + + return ( +
+
+ {canSelectReference ? ( + + ) : ( + {t("newTaskModal.githubReferenceLabel", "GitHub issue or PR")} + )} + {remotes.length === 1 && ( + + {remotes[0].name}: {remotes[0].owner}/{remotes[0].repo} + + )} +
+ + {remotes.length > 1 && ( + + )} + + {canSelectReference ? ( + + ) : null} + + {statusText && ( +

+ {statusText} +

+ )} +
+ ); +} export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) { const { t } = useTranslation("app"); @@ -148,6 +412,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, } as React.CSSProperties) : {}; const [description, setDescription] = useState(""); + const githubGeneratedDescriptionRef = useRef(""); const wasOpenRef = useRef(false); /* @@ -473,6 +738,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, setGithubTrackingEnabled(false); setGithubRepoOverride(""); setDuplicateMatches(null); + githubGeneratedDescriptionRef.current = ""; }, [pendingImages]); const handleClose = useCallback(async () => { @@ -632,6 +898,26 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, setIsSubmitting(false); }, []); + const handleGitHubReferenceSelect = useCallback(async (option: GitHubReferenceOption) => { + const nextDescription = option.type === "issue" ? buildGitHubIssuePrompt(option) : buildGitHubPullPrompt(option); + const currentDescription = description.trim(); + const currentGenerated = githubGeneratedDescriptionRef.current; + // FNXC:NewTaskGitHubReference 2026-06-24-00:00: Protect user-authored prompt text from silent replacement; generated GitHub templates may be replaced without another confirm so issue↔PR switching stays lightweight. + const shouldConfirmOverwrite = currentDescription !== "" && description !== currentGenerated && description !== nextDescription; + + if (shouldConfirmOverwrite) { + const shouldOverwrite = await confirm({ + title: t("newTaskModal.githubOverwriteTitle", "Replace description?"), + message: t("newTaskModal.githubOverwriteMessage", "Selecting a GitHub issue or PR will replace the current task description. Continue?"), + }); + if (!shouldOverwrite) return false; + } + + githubGeneratedDescriptionRef.current = nextDescription; + setDescription(nextDescription); + return true; + }, [confirm, description, t]); + // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Escape") { @@ -647,6 +933,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, // Quick fields: promoted dependencies and agent assignment const quickFields = (
+ + {/* Dependencies field */}
diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 9de01c466d..7fc27fbdef 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -4,7 +4,7 @@ import type { ComponentProps } from "react"; import { readFileSync } from "node:fs"; import { NewTaskModal } from "../NewTaskModal"; import type { Task, Column } from "@fusion/core"; -import { checkDuplicateTasks, type BoardWorkflowsPayload } from "../../api"; +import { apiFetchGitHubIssues, apiFetchGitHubPulls, checkDuplicateTasks, fetchGitRemotes, type BoardWorkflowsPayload } from "../../api"; import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache"; import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow"; @@ -37,6 +37,9 @@ vi.mock("../ProviderIcon", () => ({ vi.mock("../../api", () => ({ uploadAttachment: vi.fn().mockResolvedValue({}), checkDuplicateTasks: vi.fn().mockResolvedValue([]), + fetchGitRemotes: vi.fn().mockResolvedValue([]), + apiFetchGitHubIssues: vi.fn().mockResolvedValue([]), + apiFetchGitHubPulls: vi.fn().mockResolvedValue([]), fetchModels: vi.fn().mockResolvedValue({ models: [ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, @@ -115,6 +118,9 @@ describe("NewTaskModal", () => { mockConfirm.mockReset(); mockConfirm.mockResolvedValue(true); vi.mocked(checkDuplicateTasks).mockResolvedValue([]); + vi.mocked(fetchGitRemotes).mockResolvedValue([]); + vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]); + vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]); mockUseMobileKeyboard.mockReturnValue({ keyboardOpen: false, keyboardOverlap: 0, @@ -188,6 +194,197 @@ describe("NewTaskModal", () => { expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy(); }); + describe("GitHub reference picker", () => { + const originRemote = { name: "origin", owner: "runfusion", repo: "fusion", url: "https://github.com/runfusion/fusion.git" }; + const upstreamRemote = { name: "upstream", owner: "octo", repo: "project", url: "https://github.com/octo/project.git" }; + const issue = { + number: 12, + title: "Crash on startup", + body: null, + html_url: "https://github.com/runfusion/fusion/issues/12", + labels: [], + }; + const pull = { + number: 34, + title: "Fix login", + body: null, + html_url: "https://github.com/runfusion/fusion/pull/34", + headBranch: "fix-login", + baseBranch: "main", + }; + + async function renderPickerWithData({ remotes = [originRemote], issues = [issue], pulls = [pull], viewport = "mobile" as "mobile" | "desktop" } = {}) { + mockViewportMode = viewport; + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(remotes); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls); + renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(fetchGitRemotes).toHaveBeenCalledWith("project-1")); + if (remotes.length === 1 || remotes.some((remote) => remote.name === "origin")) { + await waitFor(() => expect(apiFetchGitHubIssues).toHaveBeenCalled()); + } + } + + it("loads origin remote references and seeds the issue prompt", async () => { + await renderPickerWithData(); + + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument()); + expect(screen.getByText("Issue #12 — Crash on startup")).toBeInTheDocument(); + expect(screen.getByText("PR #34 — Fix login")).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "issue:12" } }); + + await waitFor(() => { + const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement; + expect(textarea.value).toContain("Fetch and read this GitHub issue"); + expect(textarea.value).toContain("Source: https://github.com/runfusion/fusion/issues/12"); + }); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it("seeds the PR prompt with review-comment resolution instructions", async () => { + await renderPickerWithData(); + + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument()); + fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "pull:34" } }); + + await waitFor(() => { + const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement; + expect(textarea.value).toContain("Fetch and read this GitHub pull request"); + expect(textarea.value).toContain("resolve or address all actionable PR review comments"); + expect(textarea.value).toContain("PR: https://github.com/runfusion/fusion/pull/34"); + }); + }); + + it("protects typed descriptions before replacing them with a GitHub prompt", async () => { + await renderPickerWithData(); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument()); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Keep my draft" } }); + + mockConfirm.mockResolvedValueOnce(false); + fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "issue:12" } }); + + await waitFor(() => expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Replace description?" }))); + expect(screen.getByPlaceholderText("What needs to be done?")).toHaveValue("Keep my draft"); + expect(screen.getByTestId("new-task-github-reference-select")).toHaveValue(""); + + mockConfirm.mockResolvedValueOnce(true); + fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "issue:12" } }); + + await waitFor(() => { + const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement; + expect(textarea.value).toContain("Source: https://github.com/runfusion/fusion/issues/12"); + }); + }); + + it.each([ + { label: "issues only", issues: [issue], pulls: [], expected: "Issue #12 — Crash on startup", absent: "PR #34 — Fix login" }, + { label: "PRs only", issues: [], pulls: [pull], expected: "PR #34 — Fix login", absent: "Issue #12 — Crash on startup" }, + ])("renders $label references without an empty dropdown shell", async ({ issues, pulls, expected, absent }) => { + await renderPickerWithData({ issues, pulls }); + + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument()); + expect(screen.getByText(expected)).toBeInTheDocument(); + expect(screen.queryByText(absent)).toBeNull(); + }); + + it("keeps duplicate issue and PR numbers distinct", async () => { + await renderPickerWithData({ + issues: [{ ...issue, number: 7, html_url: "https://github.com/runfusion/fusion/issues/7" }], + pulls: [{ ...pull, number: 7, html_url: "https://github.com/runfusion/fusion/pull/7" }], + }); + + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument()); + expect(screen.getByText("Issue #7 — Crash on startup")).toBeInTheDocument(); + expect(screen.getByText("PR #7 — Fix login")).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "pull:7" } }); + await waitFor(() => { + const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement; + expect(textarea.value).toContain("PR: https://github.com/runfusion/fusion/pull/7"); + }); + + fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "issue:7" } }); + await waitFor(() => { + const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement; + expect(textarea.value).toContain("Source: https://github.com/runfusion/fusion/issues/7"); + }); + }); + + it("shows unavailable states without an empty reference dropdown shell", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]); + const noRemoteRender = renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("No GitHub remotes were detected")); + expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull(); + noRemoteRender.unmount(); + + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([upstreamRemote, { ...originRemote, name: "fork" }]); + const { unmount } = renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByText("Choose a GitHub remote before selecting an issue or pull request.")).toBeInTheDocument()); + expect(screen.getByTestId("new-task-github-remote-select")).toBeInTheDocument(); + expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull(); + unmount(); + + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]); + const emptyRender = renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("No open issues or pull requests")); + expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull(); + emptyRender.unmount(); + + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]); + vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("GitHub auth required")); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]); + const authErrorRender = renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("GitHub auth required")); + expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull(); + authErrorRender.unmount(); + + vi.mocked(fetchGitRemotes).mockRejectedValueOnce(new Error("Remote network failure")); + renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("Remote network failure")); + expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull(); + }); + + it.each(["desktop", "mobile"] as const)("renders the picker in %s New Task mode", async (viewport) => { + await renderPickerWithData({ viewport }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-picker")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled()); + }); + + it("ignores stale remote responses after the project changes", async () => { + let resolveOldRemotes: (remotes: Array) => void = () => {}; + vi.mocked(fetchGitRemotes) + .mockReturnValueOnce(new Promise((resolve) => { resolveOldRemotes = resolve; })) + .mockResolvedValueOnce([originRemote]); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]); + + const { props, rerender } = renderNewTaskModal({ projectId: "old-project" }); + rerender(); + resolveOldRemotes([{ ...upstreamRemote, name: "stale" }]); + + await waitFor(() => expect(fetchGitRemotes).toHaveBeenCalledWith("new-project")); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-remote")).toHaveTextContent("origin: runfusion/fusion")); + expect(screen.queryByText(/stale:/)).toBeNull(); + }); + + it("renders the picker in the mobile keyboard-open layout", async () => { + mockUseMobileKeyboard.mockReturnValue({ + keyboardOpen: true, + keyboardOverlap: 250, + viewportHeight: 400, + viewportOffsetTop: 50, + }); + await renderPickerWithData({ viewport: "mobile" }); + + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-picker")).toBeInTheDocument()); + expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled(); + expect(document.querySelector(".new-task-modal")?.getAttribute("style")).toContain("--keyboard-overlap: 250px"); + }); + }); + it("exposes New Task dialog quick-add affordance parity when AI handoff callbacks are supplied", () => { renderNewTaskModal({ onPlanningMode: vi.fn(),