fix(dashboard): reset scroll on ChatView refocus to undo iOS drift
After dismissing and re-bringing up the mobile keyboard, iOS could
leave window.scrollY > 0 and visualViewport.offsetTop > 0. With
useMobileScrollLock then pinning body{position:fixed} relative to that
drifted scroll, the message thread anchored above the visible viewport
and a large blank area appeared below it.
handleInputFocus now resets window scroll to (0,0) on mobile in a
zero-delay timeout — late enough that iOS finishes its own
scroll-into-view first, but before useMobileScrollLock observes the
drifted state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1366,6 +1366,22 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||||
hideSkillMenuTimeoutRef.current = null;
|
||||
}
|
||||
// iOS quirk: after the keyboard has been dismissed once, re-focusing
|
||||
// an input leaves window.scrollY > 0 *and* visualViewport.offsetTop
|
||||
// > 0 — the layout viewport drifts up, and the position:fixed
|
||||
// useMobileScrollLock applies to a body that is no longer at the
|
||||
// top of the document. Result: the message thread anchors above
|
||||
// the visible viewport with a large blank area below it. Forcing
|
||||
// scroll back to (0,0) on the focus event neutralizes the drift
|
||||
// before lock applies. Done in a microtask so iOS finishes its
|
||||
// own scroll-into-view first.
|
||||
if (typeof window !== "undefined" && window.innerWidth <= 768) {
|
||||
window.setTimeout(() => {
|
||||
if (window.scrollY !== 0 || window.scrollX !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle archive
|
||||
|
||||
@@ -42,6 +42,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
: {};
|
||||
const [description, setDescription] = useState("");
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [branch, setBranch] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [executorModel, setExecutorModel] = useState("");
|
||||
@@ -170,9 +172,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
selectedAgentId !== null ||
|
||||
reviewLevel !== undefined ||
|
||||
priority !== DEFAULT_TASK_PRIORITY ||
|
||||
nodeId !== undefined;
|
||||
nodeId !== undefined ||
|
||||
branch !== "" ||
|
||||
baseBranch !== "";
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId, branch, baseBranch]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (hasDirtyState) {
|
||||
@@ -202,6 +206,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setReviewLevel(undefined);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
setNodeId(undefined);
|
||||
setBranch("");
|
||||
setBaseBranch("");
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages, confirm]);
|
||||
@@ -236,6 +242,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
reviewLevel,
|
||||
priority,
|
||||
nodeId,
|
||||
branch: branch.trim() === "" ? undefined : branch.trim(),
|
||||
baseBranch: baseBranch.trim() === "" ? undefined : baseBranch.trim(),
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
@@ -271,6 +279,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setReviewLevel(undefined);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
setNodeId(undefined);
|
||||
setBranch("");
|
||||
setBaseBranch("");
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
onClose();
|
||||
@@ -279,7 +289,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority, nodeId]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority, nodeId, branch, baseBranch]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -483,6 +493,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onReviewLevelChange={setReviewLevel}
|
||||
priority={priority}
|
||||
onPriorityChange={setPriority}
|
||||
branch={branch}
|
||||
onBranchChange={setBranch}
|
||||
baseBranch={baseBranch}
|
||||
onBaseBranchChange={setBaseBranch}
|
||||
nodeId={nodeId}
|
||||
onNodeIdChange={setNodeId}
|
||||
nodeOptions={nodes}
|
||||
|
||||
@@ -108,11 +108,31 @@
|
||||
.quick-entry-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.quick-entry-branch-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-entry-branch-fields label {
|
||||
display: flex;
|
||||
flex: 1 1 calc(50% - var(--space-sm));
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: min(100%, calc(var(--space-xl) * 12));
|
||||
}
|
||||
|
||||
.quick-entry-branch-fields span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-entry-model-wrap {
|
||||
position: relative;
|
||||
}
|
||||
@@ -434,8 +454,13 @@
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.quick-entry-branch-fields label {
|
||||
flex: 1 1 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.quick-entry-box .dep-dropdown {
|
||||
max-width: calc(100vw - 32px);
|
||||
max-width: calc(100vw - calc(var(--space-lg) * 2));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const [isFastMode, setIsFastMode] = useState(false);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
|
||||
const [branch, setBranch] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const { nodes } = useNodes();
|
||||
|
||||
// AI Refinement state
|
||||
@@ -437,6 +439,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setIsFastMode(false);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
setNodeId(undefined);
|
||||
setBranch("");
|
||||
setBaseBranch("");
|
||||
setShowDeps(false);
|
||||
setIsModelMenuOpen(false);
|
||||
setModelMenuPosition(null);
|
||||
@@ -510,6 +514,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
...(isFastMode ? { executionMode: "fast" } : {}),
|
||||
priority,
|
||||
nodeId,
|
||||
branch: branch.trim() === "" ? undefined : branch.trim(),
|
||||
baseBranch: baseBranch.trim() === "" ? undefined : baseBranch.trim(),
|
||||
});
|
||||
if (createdTask && pendingImages.length > 0) {
|
||||
const failures: string[] = [];
|
||||
@@ -557,6 +563,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
isFastMode,
|
||||
priority,
|
||||
nodeId,
|
||||
branch,
|
||||
baseBranch,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -1849,6 +1857,34 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showExpandedControls && (
|
||||
<div className="quick-entry-branch-fields" data-testid="quick-entry-branch-fields">
|
||||
<label>
|
||||
<span>Working branch</span>
|
||||
<input
|
||||
className="input"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="e.g. feature/my-task"
|
||||
data-testid="quick-entry-working-branch"
|
||||
disabled={isSubmitting || isDisabled}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Merge target / base branch</span>
|
||||
<input
|
||||
className="input"
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
placeholder="e.g. main"
|
||||
data-testid="quick-entry-base-branch"
|
||||
disabled={isSubmitting || isDisabled}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, index) => (
|
||||
|
||||
@@ -439,6 +439,8 @@ export function TaskDetailContent({
|
||||
const [editTitle, setEditTitle] = useState(task.title || "");
|
||||
const [editDescription, setEditDescription] = useState(task.description || "");
|
||||
const [editDependencies, setEditDependencies] = useState<string[]>(task.dependencies || []);
|
||||
const [editBranch, setEditBranch] = useState(task.branch ?? "");
|
||||
const [editBaseBranch, setEditBaseBranch] = useState(task.baseBranch ?? "");
|
||||
const [editExecutorModel, setEditExecutorModel] = useState("");
|
||||
const [editValidatorModel, setEditValidatorModel] = useState("");
|
||||
const [editPlanningModel, setEditPlanningModel] = useState("");
|
||||
@@ -501,6 +503,8 @@ export function TaskDetailContent({
|
||||
useEffect(() => {
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
setEditBranch(task.branch ?? "");
|
||||
setEditBaseBranch(task.baseBranch ?? "");
|
||||
setEditSourceIssueProvider(task.sourceIssue?.provider ?? "");
|
||||
setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
|
||||
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
|
||||
@@ -508,7 +512,7 @@ export function TaskDetailContent({
|
||||
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
||||
setSourceIssueExpanded(false);
|
||||
setIsEditing(false);
|
||||
}, [task.id, task.title, task.description, task.sourceIssue, task.executionMode]);
|
||||
}, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
|
||||
@@ -694,6 +698,8 @@ export function TaskDetailContent({
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
setEditDependencies(task.dependencies || []);
|
||||
setEditBranch(task.branch ?? "");
|
||||
setEditBaseBranch(task.baseBranch ?? "");
|
||||
// Populate model overrides from task
|
||||
const execModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
|
||||
const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
|
||||
@@ -721,6 +727,8 @@ export function TaskDetailContent({
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
setEditDependencies(task.dependencies || []);
|
||||
setEditBranch(task.branch ?? "");
|
||||
setEditBaseBranch(task.baseBranch ?? "");
|
||||
setEditNodeId(task.nodeId);
|
||||
setEditSourceIssueProvider(task.sourceIssue?.provider ?? "");
|
||||
setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
|
||||
@@ -746,6 +754,14 @@ export function TaskDetailContent({
|
||||
if (!sameStringArray(editDependencies, task.dependencies ?? [])) updates.dependencies = editDependencies;
|
||||
if (!sameStringArray(editSelectedWorkflowSteps, task.enabledWorkflowSteps ?? [])) updates.enabledWorkflowSteps = editSelectedWorkflowSteps;
|
||||
|
||||
const normalizedBranch = editBranch.trim() || null;
|
||||
const currentBranch = task.branch ?? null;
|
||||
if (normalizedBranch !== currentBranch) updates.branch = normalizedBranch;
|
||||
|
||||
const normalizedBaseBranch = editBaseBranch.trim() || null;
|
||||
const currentBaseBranch = task.baseBranch ?? null;
|
||||
if (normalizedBaseBranch !== currentBaseBranch) updates.baseBranch = normalizedBaseBranch;
|
||||
|
||||
const executorSelection = splitModelSelection(editExecutorModel);
|
||||
const currentExecutorModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
|
||||
if (editExecutorModel !== currentExecutorModel) {
|
||||
@@ -809,7 +825,7 @@ export function TaskDetailContent({
|
||||
}
|
||||
|
||||
return { updates, error: null as string | null };
|
||||
}, [editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editTitle, editValidatorModel, task]);
|
||||
}, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editTitle, editValidatorModel, task]);
|
||||
|
||||
const persistEditChanges = useCallback(async (includeDescription: boolean) => {
|
||||
const { updates, error } = buildEditUpdates(includeDescription);
|
||||
@@ -879,6 +895,8 @@ export function TaskDetailContent({
|
||||
isEditing,
|
||||
editTitle,
|
||||
editDependencies,
|
||||
editBranch,
|
||||
editBaseBranch,
|
||||
editExecutorModel,
|
||||
editValidatorModel,
|
||||
editPlanningModel,
|
||||
@@ -1628,6 +1646,10 @@ export function TaskDetailContent({
|
||||
onDescriptionChange={setEditDescription}
|
||||
dependencies={editDependencies}
|
||||
onDependenciesChange={setEditDependencies}
|
||||
branch={editBranch}
|
||||
onBranchChange={setEditBranch}
|
||||
baseBranch={editBaseBranch}
|
||||
onBaseBranchChange={setEditBaseBranch}
|
||||
executorModel={editExecutorModel}
|
||||
onExecutorModelChange={setEditExecutorModel}
|
||||
validatorModel={editValidatorModel}
|
||||
|
||||
@@ -48,6 +48,10 @@ export interface TaskFormProps {
|
||||
// Dependencies
|
||||
dependencies: string[];
|
||||
onDependenciesChange: (deps: string[]) => void;
|
||||
branch?: string;
|
||||
onBranchChange?: (value: string) => void;
|
||||
baseBranch?: string;
|
||||
onBaseBranchChange?: (value: string) => void;
|
||||
nodeId?: string;
|
||||
onNodeIdChange?: (nodeId: string | undefined) => void;
|
||||
nodeOptions?: NodeInfo[];
|
||||
@@ -119,6 +123,10 @@ export function TaskForm({
|
||||
onTitleChange,
|
||||
dependencies,
|
||||
onDependenciesChange,
|
||||
branch,
|
||||
onBranchChange,
|
||||
baseBranch,
|
||||
onBaseBranchChange,
|
||||
nodeId,
|
||||
onNodeIdChange,
|
||||
nodeOptions,
|
||||
@@ -173,6 +181,8 @@ export function TaskForm({
|
||||
(thinkingLevel || "") !== "" ||
|
||||
reviewLevel !== undefined ||
|
||||
executionMode === "fast" ||
|
||||
(branch || "") !== "" ||
|
||||
(baseBranch || "") !== "" ||
|
||||
(nodeId || "") !== "";
|
||||
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
@@ -239,6 +249,8 @@ export function TaskForm({
|
||||
(thinkingLevel || "") !== "" ||
|
||||
reviewLevel !== undefined ||
|
||||
executionMode === "fast" ||
|
||||
(branch || "") !== "" ||
|
||||
(baseBranch || "") !== "" ||
|
||||
(nodeId || "") !== "";
|
||||
|
||||
// Auto-select preset by size (create mode only)
|
||||
@@ -918,6 +930,38 @@ export function TaskForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(onBranchChange || onBaseBranchChange) && (
|
||||
<div className="form-group">
|
||||
<label>Branch Settings</label>
|
||||
{onBranchChange && (
|
||||
<>
|
||||
<label htmlFor="task-working-branch" className="model-select-label">Working branch</label>
|
||||
<input
|
||||
id="task-working-branch"
|
||||
className="input"
|
||||
value={branch || ""}
|
||||
onChange={(e) => onBranchChange(e.target.value)}
|
||||
placeholder="e.g. feature/my-task"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{onBaseBranchChange && (
|
||||
<>
|
||||
<label htmlFor="task-base-branch" className="model-select-label">Merge target / base branch</label>
|
||||
<input
|
||||
id="task-base-branch"
|
||||
className="input"
|
||||
value={baseBranch || ""}
|
||||
onChange={(e) => onBaseBranchChange(e.target.value)}
|
||||
placeholder="e.g. main"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="form-group">
|
||||
<label>Model Configuration</label>
|
||||
|
||||
@@ -215,6 +215,42 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes branch and baseBranch when provided", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByLabelText("Working branch"), { target: { value: " feature/fn-3422 " } });
|
||||
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: "feature/fn-3422",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits branch and baseBranch when left blank", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task without branches" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: undefined,
|
||||
baseBranch: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("still submits when setup warnings are shown", async () => {
|
||||
const { fetchAuthStatus } = await import("../../api");
|
||||
vi.mocked(fetchAuthStatus).mockResolvedValueOnce({
|
||||
@@ -742,7 +778,7 @@ describe("NewTaskModal", () => {
|
||||
const select = document.getElementById("review-level") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "2" } });
|
||||
|
||||
const descTextarea = screen.getByRole('textbox');
|
||||
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(descTextarea, { target: { value: "Task with review level" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
@@ -770,7 +806,7 @@ describe("NewTaskModal", () => {
|
||||
const select = document.getElementById("review-level") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "3" } });
|
||||
|
||||
const descTextarea = screen.getByRole('textbox');
|
||||
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(descTextarea, { target: { value: "Task with full review" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
@@ -806,7 +842,7 @@ describe("NewTaskModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "urgent" } });
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with urgent priority" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with urgent priority" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -724,6 +724,44 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes branch and baseBranch when provided", async () => {
|
||||
const { props } = renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with quick-entry branches" } });
|
||||
fireEvent.change(screen.getByTestId("quick-entry-working-branch"), { target: { value: " feature/quick " } });
|
||||
fireEvent.change(screen.getByTestId("quick-entry-base-branch"), { target: { value: " main " } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: "feature/quick",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits branch and baseBranch when branch fields are blank", async () => {
|
||||
const { props } = renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task without quick-entry branches" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: undefined,
|
||||
baseBranch: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles Fast pressed state", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
@@ -963,6 +963,69 @@ describe("TaskDetailModal", () => {
|
||||
expect(descTextarea.value).toBe("My Description");
|
||||
});
|
||||
|
||||
it("pre-populates working/base branch inputs and saves changed branch only", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "todo", branch: "feature/fn-3422", baseBranch: "develop" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
const workingBranchInput = container.querySelector("#task-working-branch") as HTMLInputElement;
|
||||
const baseBranchInput = container.querySelector("#task-base-branch") as HTMLInputElement;
|
||||
expect(workingBranchInput.value).toBe("feature/fn-3422");
|
||||
expect(baseBranchInput.value).toBe("develop");
|
||||
|
||||
fireEvent.change(workingBranchInput, { target: { value: "feature/fn-3422-updated" } });
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { branch: "feature/fn-3422-updated" }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends null branch fields when working/base branches are cleared", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "todo", branch: "feature/fn-3422", baseBranch: "main" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
fireEvent.change(container.querySelector("#task-working-branch") as HTMLInputElement, { target: { value: "" } });
|
||||
fireEvent.change(container.querySelector("#task-base-branch") as HTMLInputElement, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({ branch: null, baseBranch: null }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates auto-saved description updates via onTaskUpdated", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdateTask = vi.mocked(updateTask);
|
||||
|
||||
@@ -221,6 +221,51 @@ describe("TaskForm", () => {
|
||||
expect(onPriorityChange).toHaveBeenCalledWith("urgent");
|
||||
});
|
||||
|
||||
it("renders working and base branch inputs when branch callbacks are provided", () => {
|
||||
renderTaskForm({
|
||||
branch: "feature/fn-3422",
|
||||
baseBranch: "main",
|
||||
onBranchChange: vi.fn(),
|
||||
onBaseBranchChange: vi.fn(),
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
expect(screen.getByLabelText("Working branch")).toHaveValue("feature/fn-3422");
|
||||
expect(screen.getByLabelText("Merge target / base branch")).toHaveValue("main");
|
||||
});
|
||||
|
||||
it("calls branch change handlers and supports explicit clearing", () => {
|
||||
const onBranchChange = vi.fn();
|
||||
const onBaseBranchChange = vi.fn();
|
||||
|
||||
renderTaskForm({
|
||||
branch: "feature/fn-3422",
|
||||
baseBranch: "develop",
|
||||
onBranchChange,
|
||||
onBaseBranchChange,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Working branch"), { target: { value: "feature/new" } });
|
||||
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: "" } });
|
||||
|
||||
expect(onBranchChange).toHaveBeenCalledWith("feature/new");
|
||||
expect(onBaseBranchChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("auto-expands more options when branch fields are prefilled", () => {
|
||||
renderTaskForm({
|
||||
branch: "feature/fn-3422",
|
||||
baseBranch: "main",
|
||||
onBranchChange: vi.fn(),
|
||||
onBaseBranchChange: vi.fn(),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("auto-expands more options when priority is non-default", () => {
|
||||
renderTaskForm({ priority: "high", onPriorityChange: vi.fn() });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user