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:
7
.changeset/fn-6946-github-reference-picker.md
Normal file
7
.changeset/fn-6946-github-reference-picker.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user