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
This commit is contained in:
gsxdsm
2026-06-24 23:03:12 -07:00
parent 3ae053e744
commit 5a192ece16
5 changed files with 555 additions and 3 deletions

View File

@@ -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.

View File

@@ -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: <issue-url>`; selecting a pull request creates a PR-focused prompt with `PR: <pr-url>` 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.

View File

@@ -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);

View File

@@ -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> | 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<GitRemote[]>([]);
const [loadingRemotes, setLoadingRemotes] = useState(false);
const [remoteError, setRemoteError] = useState<string | null>(null);
const [selectedRemoteName, setSelectedRemoteName] = useState("");
const [issues, setIssues] = useState<GitHubIssue[]>([]);
const [pulls, setPulls] = useState<GitHubPull[]>([]);
const [loadingReferences, setLoadingReferences] = useState(false);
const [referenceError, setReferenceError] = useState<string | null>(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<HTMLSelectElement>) => {
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 (
<div className="new-task-github-reference-picker" data-testid="new-task-github-reference-picker">
<div className="new-task-github-reference-picker__header">
{canSelectReference ? (
<label htmlFor="new-task-github-reference-select">{t("newTaskModal.githubReferenceLabel", "GitHub issue or PR")}</label>
) : (
<span className="new-task-github-reference-picker__label">{t("newTaskModal.githubReferenceLabel", "GitHub issue or PR")}</span>
)}
{remotes.length === 1 && (
<span className="new-task-github-reference-picker__remote" data-testid="new-task-github-reference-remote">
{remotes[0].name}: {remotes[0].owner}/{remotes[0].repo}
</span>
)}
</div>
{remotes.length > 1 && (
<select
className="input new-task-github-reference-picker__remote-select"
aria-label={t("newTaskModal.githubRemoteLabel", "GitHub remote")}
data-testid="new-task-github-remote-select"
value={selectedRemoteName}
onChange={(event) => setSelectedRemoteName(event.target.value)}
disabled={disabled || loadingRemotes}
>
<option value="">{t("newTaskModal.githubSelectRemote", "Select remote…")}</option>
{remotes.map((remote) => (
<option key={remote.name} value={remote.name}>{remote.name}: {remote.owner}/{remote.repo}</option>
))}
</select>
)}
{canSelectReference ? (
<select
id="new-task-github-reference-select"
className="input new-task-github-reference-picker__select"
data-testid="new-task-github-reference-select"
value={selectedValue}
onChange={handleReferenceChange}
disabled={disabled || loadingReferences}
aria-describedby="new-task-github-reference-status"
>
<option value="">{t("newTaskModal.githubSelectReference", "Select issue or PR…")}</option>
{issueOptions.length > 0 && (
<optgroup label={t("newTaskModal.githubIssueGroup", "Issues")}>
{issueOptions.map((option) => (
<option key={buildGitHubReferenceValue(option)} value={buildGitHubReferenceValue(option)}>{gitHubReferenceLabel(option)}</option>
))}
</optgroup>
)}
{pullOptions.length > 0 && (
<optgroup label={t("newTaskModal.githubPullGroup", "Pull requests")}>
{pullOptions.map((option) => (
<option key={buildGitHubReferenceValue(option)} value={buildGitHubReferenceValue(option)}>{gitHubReferenceLabel(option)}</option>
))}
</optgroup>
)}
</select>
) : null}
{statusText && (
<p id="new-task-github-reference-status" className="new-task-github-reference-picker__status" role="status" aria-live="polite" data-testid="new-task-github-reference-status">
{statusText}
</p>
)}
</div>
);
}
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 = (
<div className="new-task-quick-fields">
<NewTaskGitHubReferencePicker
isOpen={isOpen}
projectId={projectId}
disabled={isSubmitting}
onSelectReference={handleGitHubReferenceSelect}
/>
{/* Dependencies field */}
<div className="form-group">
<label>{t("newTaskModal.dependencies", "Dependencies")}</label>

View File

@@ -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<typeof originRemote>) => 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(<NewTaskModal {...props} projectId="new-project" />);
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(),