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 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
|
||||||
|
|
||||||
Chat view provides project-scoped conversations with agents.
|
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;
|
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 {
|
.task-form-primary-section .description-with-refine {
|
||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import "./NewTaskModal.css";
|
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 { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority } from "@fusion/core";
|
import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority } from "@fusion/core";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
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 { Bot } from "lucide-react";
|
||||||
import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
||||||
import { SetupWarningBanner } from "./SetupWarningBanner";
|
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";
|
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_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) {
|
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
@@ -148,6 +412,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
|||||||
} as React.CSSProperties)
|
} as React.CSSProperties)
|
||||||
: {};
|
: {};
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
const githubGeneratedDescriptionRef = useRef("");
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -473,6 +738,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
|||||||
setGithubTrackingEnabled(false);
|
setGithubTrackingEnabled(false);
|
||||||
setGithubRepoOverride("");
|
setGithubRepoOverride("");
|
||||||
setDuplicateMatches(null);
|
setDuplicateMatches(null);
|
||||||
|
githubGeneratedDescriptionRef.current = "";
|
||||||
}, [pendingImages]);
|
}, [pendingImages]);
|
||||||
|
|
||||||
const handleClose = useCallback(async () => {
|
const handleClose = useCallback(async () => {
|
||||||
@@ -632,6 +898,26 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
|||||||
setIsSubmitting(false);
|
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
|
// Handle keyboard shortcuts
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -647,6 +933,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
|||||||
// Quick fields: promoted dependencies and agent assignment
|
// Quick fields: promoted dependencies and agent assignment
|
||||||
const quickFields = (
|
const quickFields = (
|
||||||
<div className="new-task-quick-fields">
|
<div className="new-task-quick-fields">
|
||||||
|
<NewTaskGitHubReferencePicker
|
||||||
|
isOpen={isOpen}
|
||||||
|
projectId={projectId}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onSelectReference={handleGitHubReferenceSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Dependencies field */}
|
{/* Dependencies field */}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t("newTaskModal.dependencies", "Dependencies")}</label>
|
<label>{t("newTaskModal.dependencies", "Dependencies")}</label>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { NewTaskModal } from "../NewTaskModal";
|
import { NewTaskModal } from "../NewTaskModal";
|
||||||
import type { Task, Column } from "@fusion/core";
|
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 { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
|
||||||
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
|
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
|
||||||
|
|
||||||
@@ -37,6 +37,9 @@ vi.mock("../ProviderIcon", () => ({
|
|||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
uploadAttachment: vi.fn().mockResolvedValue({}),
|
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||||
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
|
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchGitRemotes: vi.fn().mockResolvedValue([]),
|
||||||
|
apiFetchGitHubIssues: vi.fn().mockResolvedValue([]),
|
||||||
|
apiFetchGitHubPulls: vi.fn().mockResolvedValue([]),
|
||||||
fetchModels: vi.fn().mockResolvedValue({ models: [
|
fetchModels: vi.fn().mockResolvedValue({ models: [
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
{ 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 },
|
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||||
@@ -115,6 +118,9 @@ describe("NewTaskModal", () => {
|
|||||||
mockConfirm.mockReset();
|
mockConfirm.mockReset();
|
||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
|
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
|
||||||
|
vi.mocked(fetchGitRemotes).mockResolvedValue([]);
|
||||||
|
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]);
|
||||||
|
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]);
|
||||||
mockUseMobileKeyboard.mockReturnValue({
|
mockUseMobileKeyboard.mockReturnValue({
|
||||||
keyboardOpen: false,
|
keyboardOpen: false,
|
||||||
keyboardOverlap: 0,
|
keyboardOverlap: 0,
|
||||||
@@ -188,6 +194,197 @@ describe("NewTaskModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
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", () => {
|
it("exposes New Task dialog quick-add affordance parity when AI handoff callbacks are supplied", () => {
|
||||||
renderNewTaskModal({
|
renderNewTaskModal({
|
||||||
onPlanningMode: vi.fn(),
|
onPlanningMode: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user