fix: keep caret in task title fields clamped at the limit (#2529)
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
EMPTY_STAGES,
|
||||
type StagesDraft,
|
||||
} from "./new-task-stages";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-restore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function buildMetadata(draft: IssueDraft): Record<string, unknown> | undefined {
|
||||
@@ -131,6 +131,7 @@ function NewIssueDialogBody({
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore<HTMLTextAreaElement>(draft.title);
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -150,9 +151,10 @@ function NewIssueDialogBody({
|
||||
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
placeholder={t("office:taskTitle")}
|
||||
value={draft.title}
|
||||
onChange={(e) => updateDraft({ title: clampTaskTitleInput(e.target.value) })}
|
||||
onChange={(e) => updateDraft({ title: clampChange(e) })}
|
||||
className="text-lg font-medium border-0 resize-none focus-visible:ring-0 min-h-[40px]"
|
||||
rows={1}
|
||||
autoFocus
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-restore";
|
||||
import {
|
||||
DEFAULT_ONBOARDING_TASK_DESCRIPTION,
|
||||
DEFAULT_ONBOARDING_TASK_TITLE,
|
||||
@@ -19,6 +19,7 @@ type StepTaskProps = {
|
||||
|
||||
export function StepTask({ agentName, taskTitle, taskDescription, onChange }: StepTaskProps) {
|
||||
const { t } = useTranslation();
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore(taskTitle);
|
||||
// The coordinator step requires a non-empty agentName before advancing,
|
||||
// so by the time this step renders we always have a real value.
|
||||
const name = agentName.trim() || "coordinator";
|
||||
@@ -35,9 +36,10 @@ export function StepTask({ agentName, taskTitle, taskDescription, onChange }: St
|
||||
<div>
|
||||
<Label htmlFor="task-title">{t("office:taskTitle")}</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="task-title"
|
||||
value={taskTitle}
|
||||
onChange={(e) => onChange({ taskTitle: clampTaskTitleInput(e.target.value) })}
|
||||
onChange={(e) => onChange({ taskTitle: clampChange(e) })}
|
||||
placeholder={DEFAULT_ONBOARDING_TASK_TITLE}
|
||||
className="mt-1"
|
||||
autoFocus
|
||||
|
||||
@@ -18,7 +18,7 @@ import { PromptSection } from "./prompt-section";
|
||||
import { RequiredFieldLabel } from "./required-field-label";
|
||||
import { TriggersSection } from "./triggers-section";
|
||||
import { WebhookCreatedDialog } from "./webhook-created-dialog";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-restore";
|
||||
|
||||
type UpdateField = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
|
||||
|
||||
@@ -141,6 +141,7 @@ export function ThenSection({
|
||||
updateField: UpdateField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore(form.taskTitleTemplate);
|
||||
const dirtyFields: Array<keyof FormState> = [
|
||||
"taskTitleTemplate",
|
||||
"prompt",
|
||||
@@ -165,11 +166,10 @@ export function ThenSection({
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("automations:taskTitleLabel")}</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={form.taskTitleTemplate}
|
||||
data-settings-dirty={isAutomationFieldDirty(form, savedForm, "taskTitleTemplate")}
|
||||
onChange={(event) =>
|
||||
updateField("taskTitleTemplate", clampTaskTitleInput(event.target.value))
|
||||
}
|
||||
onChange={(event) => updateField("taskTitleTemplate", clampChange(event))}
|
||||
// defaultTaskTitle is the backend trigger type's own template — a
|
||||
// persisted value, not copy. The fallback is the example hint.
|
||||
placeholder={defaultTaskTitle || t("automations:taskTitlePlaceholder")}
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { JiraTicket } from "@/lib/types/jira";
|
||||
import type { LinearIssue } from "@/lib/types/linear";
|
||||
import { useTaskCreatePromptMention } from "@/hooks/use-task-create-prompt-mention";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-restore";
|
||||
import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -303,7 +303,7 @@ export const InlineTaskName = memo(function InlineTaskName({
|
||||
autoFocus,
|
||||
}: InlineTaskNameProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore(value);
|
||||
const hasFocusedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -319,7 +319,7 @@ export const InlineTaskName = memo(function InlineTaskName({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(clampTaskTitleInput(e.target.value))}
|
||||
onChange={(e) => onChange(clampChange(e))}
|
||||
placeholder={t("task:taskName")}
|
||||
data-testid="task-title-input"
|
||||
className="w-full min-w-0 max-w-full border border-input bg-input/20 dark:bg-input/30 text-sm font-medium rounded-md px-3 py-2 placeholder:text-muted-foreground/70 outline-none focus-visible:border-ring transition-colors"
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useDialogAttachments,
|
||||
} from "./session-dialog-shared";
|
||||
import { ContextZone } from "./chat/context-items/context-zone";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-restore";
|
||||
import { TaskAutopilotToggle } from "@/components/task-autopilot-toggle";
|
||||
|
||||
export function WorktreeBadge({ show, branch }: { show: boolean; branch: string | null }) {
|
||||
@@ -428,6 +428,7 @@ export function SubtaskFormBody({
|
||||
onSubmit,
|
||||
}: SubtaskFormBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore(title);
|
||||
const showWorktreeBadge = shouldShowWorktreeBadge(fs, worktreeBranch, parentRepositoryId);
|
||||
const inheritParent = workspaceMode === "inherit_parent";
|
||||
return (
|
||||
@@ -441,9 +442,10 @@ export function SubtaskFormBody({
|
||||
{t("common:title")}
|
||||
</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="subtask-title-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(clampTaskTitleInput(e.target.value))}
|
||||
onChange={(e) => setTitle(clampChange(e))}
|
||||
placeholder={t("common:subtaskTitle")}
|
||||
className="min-w-0 max-w-full text-sm"
|
||||
data-testid="subtask-title-input"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-restore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TaskRenameDialogProps = {
|
||||
@@ -26,7 +26,7 @@ function TaskRenameForm({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(currentTitle);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore(value);
|
||||
|
||||
const trimmed = value.trim();
|
||||
const canSubmit = trimmed.length > 0 && trimmed !== currentTitle;
|
||||
@@ -46,7 +46,7 @@ function TaskRenameForm({
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(clampTaskTitleInput(e.target.value))}
|
||||
onChange={(e) => setValue(clampChange(e))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder={t("task:taskTitle")}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@kan
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-restore";
|
||||
|
||||
type TaskTopBarTitleProps = {
|
||||
taskId?: string | null;
|
||||
@@ -19,6 +19,7 @@ export function TaskTopBarTitle({ taskId, taskTitle, isArchived }: TaskTopBarTit
|
||||
const { t } = useTranslation();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore(draft);
|
||||
const titleRef = useRef<HTMLSpanElement>(null);
|
||||
const restoreFocusRef = useRef(false);
|
||||
const canRename = Boolean(taskId) && !isArchived;
|
||||
@@ -80,11 +81,12 @@ export function TaskTopBarTitle({ taskId, taskTitle, isArchived }: TaskTopBarTit
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid="task-title-rename-input"
|
||||
aria-label={t("task:taskTitle")}
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(clampTaskTitleInput(e.target.value))}
|
||||
onChange={(e) => setDraft(clampChange(e))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onBlur={handleCancel}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const LONG_TITLE = "T".repeat(60);
|
||||
|
||||
/**
|
||||
* Mobile regression: the phone drawer renders the same TaskRenameDialog and
|
||||
* task-edit dialog as desktop. Typing mid-title at the 60-character cap must
|
||||
* keep the caret immediately after the inserted text on a phone viewport too.
|
||||
*/
|
||||
test.describe("Mobile task title fields keep the caret at the 60-char cap", () => {
|
||||
async function openSheet(
|
||||
testPage: import("@playwright/test").Page,
|
||||
taskId: string,
|
||||
title: string,
|
||||
) {
|
||||
await testPage.goto(`/t/${taskId}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await testPage.evaluate(
|
||||
"window.__KANDEV_E2E_STORE__?.getState().setMobileSessionTaskSwitcherOpen(true)",
|
||||
);
|
||||
const surface = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const taskRow = surface.getByTestId("sidebar-task-item").filter({ hasText: title });
|
||||
await expect(taskRow).toBeVisible();
|
||||
return taskRow;
|
||||
}
|
||||
|
||||
test("phone drawer rename keeps the caret when typing mid-title at the cap", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, LONG_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const taskRow = await openSheet(testPage, task.id, LONG_TITLE);
|
||||
|
||||
await taskRow.click({ button: "right" });
|
||||
await testPage.getByRole("menuitem", { name: "Rename", exact: true }).click();
|
||||
const dialog = testPage.getByRole("dialog", { name: "Rename task" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByRole("textbox");
|
||||
await expect(input).toHaveValue(LONG_TITLE);
|
||||
|
||||
await input.click();
|
||||
await input.evaluate((el) => (el as HTMLInputElement).setSelectionRange(6, 6));
|
||||
await testPage.keyboard.type("XY");
|
||||
|
||||
await expect(input).toHaveValue(`${LONG_TITLE.slice(0, 6)}XY${LONG_TITLE.slice(6, 58)}`);
|
||||
expect(await input.evaluate((el) => (el as HTMLInputElement).selectionStart)).toBe(8);
|
||||
});
|
||||
|
||||
test("phone drawer rename keeps the caret when a same-char keystroke at the cap leaves the value unchanged", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, LONG_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const taskRow = await openSheet(testPage, task.id, LONG_TITLE);
|
||||
|
||||
await taskRow.click({ button: "right" });
|
||||
await testPage.getByRole("menuitem", { name: "Rename", exact: true }).click();
|
||||
const dialog = testPage.getByRole("dialog", { name: "Rename task" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByRole("textbox");
|
||||
await expect(input).toHaveValue(LONG_TITLE);
|
||||
|
||||
await input.click();
|
||||
await input.evaluate((el) => (el as HTMLInputElement).setSelectionRange(6, 6));
|
||||
await testPage.keyboard.type("T");
|
||||
|
||||
await expect(input).toHaveValue(LONG_TITLE);
|
||||
expect(await input.evaluate((el) => (el as HTMLInputElement).selectionStart)).toBe(7);
|
||||
});
|
||||
|
||||
test("phone drawer task-edit keeps the caret when typing mid-title at the cap", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, LONG_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const taskRow = await openSheet(testPage, task.id, LONG_TITLE);
|
||||
|
||||
await taskRow.click({ button: "right" });
|
||||
await testPage.getByRole("menuitem", { name: "Edit", exact: true }).click();
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByTestId("task-title-input");
|
||||
await expect(input).toHaveValue(LONG_TITLE);
|
||||
|
||||
await input.click();
|
||||
await input.evaluate((el) => (el as HTMLInputElement).setSelectionRange(6, 6));
|
||||
await testPage.keyboard.type("XY");
|
||||
|
||||
await expect(input).toHaveValue(`${LONG_TITLE.slice(0, 6)}XY${LONG_TITLE.slice(6, 58)}`);
|
||||
expect(await input.evaluate((el) => (el as HTMLInputElement).selectionStart)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const TASK_VISIBLE_TIMEOUT = 10_000;
|
||||
const LONG_TITLE = "T".repeat(60);
|
||||
|
||||
/**
|
||||
* Regression: typing mid-title in a task title field at the 60-character cap
|
||||
* used to rewrite the DOM value and reset the caret to the end of the field on
|
||||
* every keystroke. The caret must stay immediately after the inserted text.
|
||||
*/
|
||||
test.describe("Task title fields keep the caret at the 60-char cap", () => {
|
||||
test("task-edit dialog keeps the caret when typing mid-title at the cap", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, LONG_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCardByTitle(LONG_TITLE)).toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
});
|
||||
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
await testPage.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByTestId("task-title-input");
|
||||
await expect(input).toHaveValue(LONG_TITLE);
|
||||
|
||||
await input.click();
|
||||
await input.evaluate((el) => (el as HTMLInputElement).setSelectionRange(6, 6));
|
||||
await testPage.keyboard.type("XY");
|
||||
|
||||
// "XY" stays at position 6, the last two characters of the original title
|
||||
// are dropped by the cap, and the caret sits right after the insert.
|
||||
await expect(input).toHaveValue(`${LONG_TITLE.slice(0, 6)}XY${LONG_TITLE.slice(6, 58)}`);
|
||||
expect(await input.evaluate((el) => (el as HTMLInputElement).selectionStart)).toBe(8);
|
||||
});
|
||||
|
||||
test("task-edit dialog keeps the caret when a same-char keystroke at the cap leaves the value unchanged", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, LONG_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCardByTitle(LONG_TITLE)).toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
});
|
||||
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
await testPage.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByTestId("task-title-input");
|
||||
await expect(input).toHaveValue(LONG_TITLE);
|
||||
|
||||
await input.click();
|
||||
await input.evaluate((el) => (el as HTMLInputElement).setSelectionRange(6, 6));
|
||||
await testPage.keyboard.type("T");
|
||||
|
||||
// The value cannot change (still 60 T's), but the caret must stay right
|
||||
// after the insertion point instead of jumping to the end.
|
||||
await expect(input).toHaveValue(LONG_TITLE);
|
||||
expect(await input.evaluate((el) => (el as HTMLInputElement).selectionStart)).toBe(7);
|
||||
});
|
||||
|
||||
test("task rename dialog keeps the caret when typing mid-title at the cap", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, LONG_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const row = session.sidebarTaskItem(LONG_TITLE);
|
||||
await expect(row).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await row.click({ button: "right" });
|
||||
await testPage.getByRole("menuitem", { name: "Rename", exact: true }).click();
|
||||
|
||||
const dialog = testPage.getByRole("dialog", { name: "Rename task" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByRole("textbox");
|
||||
await expect(input).toHaveValue(LONG_TITLE);
|
||||
|
||||
await input.click();
|
||||
await input.evaluate((el) => (el as HTMLInputElement).setSelectionRange(6, 6));
|
||||
await testPage.keyboard.type("XY");
|
||||
|
||||
await expect(input).toHaveValue(`${LONG_TITLE.slice(0, 6)}XY${LONG_TITLE.slice(6, 58)}`);
|
||||
expect(await input.evaluate((el) => (el as HTMLInputElement).selectionStart)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MockInstance } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useTaskTitleSelectionRestore } from "./use-task-title-selection-restore";
|
||||
|
||||
const LONG = "T".repeat(60);
|
||||
|
||||
type TitleInput = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function Harness({ initial }: { initial: string }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore(value);
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="title"
|
||||
value={value}
|
||||
onChange={(e) => setValue(clampChange(e))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HarnessTextarea({ initial }: { initial: string }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore<HTMLTextAreaElement>(value);
|
||||
return (
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
data-testid="title"
|
||||
value={value}
|
||||
onChange={(e) => setValue(clampChange(e))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlledHarness({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { inputRef, clampChange } = useTaskTitleSelectionRestore(value);
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="title"
|
||||
value={value}
|
||||
onChange={(e) => onChange(clampChange(e))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate typing: write the DOM value through the prototype setter (React's
|
||||
* instance value tracker would otherwise swallow the change), place the caret,
|
||||
* then dispatch the change event React maps to onChange.
|
||||
*/
|
||||
function simulateInsert(
|
||||
input: TitleInput,
|
||||
value: string,
|
||||
caret: number,
|
||||
setSelectionRange: MockInstance,
|
||||
) {
|
||||
const setNativeValue = Object.getOwnPropertyDescriptor(
|
||||
input.constructor.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
setNativeValue.call(input, value);
|
||||
input.setSelectionRange(caret, caret);
|
||||
setSelectionRange.mockClear();
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useTaskTitleSelectionRestore - commit-path caret restore", () => {
|
||||
it("clamps the value to 60 code points on change", () => {
|
||||
render(<Harness initial={LONG.slice(0, 59)} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
input.focus();
|
||||
simulateInsert(input, LONG, 60, vi.spyOn(HTMLInputElement.prototype, "setSelectionRange"));
|
||||
expect(input.value).toHaveLength(60);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 2],
|
||||
[6, 8],
|
||||
[58, 60],
|
||||
])("pins the caret after inserting mid-title at the cap (insert at %i)", (caret, expected) => {
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
||||
render(<Harness initial={LONG} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
input.focus();
|
||||
// Simulate the DOM right after typing "XY" at `caret`: 62 code points,
|
||||
// caret after the insert.
|
||||
simulateInsert(
|
||||
input,
|
||||
`${LONG.slice(0, caret)}XY${LONG.slice(caret)}`,
|
||||
caret + 2,
|
||||
setSelectionRange,
|
||||
);
|
||||
|
||||
expect(input.value).toHaveLength(60);
|
||||
expect(input.value.slice(caret, caret + 2)).toBe("XY");
|
||||
// The caret must be re-pinned after React rewrites the truncated value.
|
||||
expect(setSelectionRange).toHaveBeenCalledWith(expected, expected);
|
||||
});
|
||||
|
||||
it("leaves the caret alone when the clamp does not truncate", () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
||||
render(<Harness initial={LONG.slice(0, 30)} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
input.focus();
|
||||
simulateInsert(input, `${"T".repeat(30)}XY`, 32, setSelectionRange);
|
||||
expect(input.value).toHaveLength(32);
|
||||
expect(setSelectionRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the restore when the input is not focused", () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
||||
render(<Harness initial={LONG} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
simulateInsert(input, `${LONG.slice(0, 6)}XY${LONG.slice(6)}`, 8, setSelectionRange);
|
||||
expect(setSelectionRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replay a stale selection from an unfocused truncating change", () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
||||
render(<Harness initial={LONG} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
// Truncating change while the input is not focused: records then discards.
|
||||
simulateInsert(input, `${LONG.slice(0, 6)}XY${LONG.slice(6)}`, 8, setSelectionRange);
|
||||
// A later focused, non-truncating change must not restore the old caret.
|
||||
input.focus();
|
||||
simulateInsert(input, `${LONG.slice(0, 6)}XY${LONG.slice(6, 58)}`, 8, setSelectionRange);
|
||||
expect(setSelectionRange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskTitleSelectionRestore - bail-out caret restore", () => {
|
||||
it("restores the caret when a truncating keystroke leaves the clamped value unchanged", async () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
||||
render(<Harness initial={LONG} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
input.focus();
|
||||
// Typing the same character into an all-same-char title at the cap: the
|
||||
// clamped value equals the committed value, so React bails out of the
|
||||
// render (no layout effect) but still restores the controlled DOM value
|
||||
// after the event, resetting the caret to the end. The hook must re-pin
|
||||
// it after that write.
|
||||
simulateInsert(input, `${LONG}T`, 6, setSelectionRange);
|
||||
await act(async () => {});
|
||||
expect(input.value).toHaveLength(60);
|
||||
expect(setSelectionRange).toHaveBeenCalledWith(6, 6);
|
||||
});
|
||||
|
||||
it("does not replay the caret on a later commit after a same-result bail-out", async () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(<ControlledHarness value={LONG} onChange={onChange} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
input.focus();
|
||||
// Typing the same character at the cap: the clamped value equals the
|
||||
// committed value, so the render bails out.
|
||||
simulateInsert(input, `${LONG}T`, 6, setSelectionRange);
|
||||
expect(onChange).toHaveBeenCalledWith(LONG);
|
||||
await act(async () => {});
|
||||
// The same-key restore happens exactly once, via the immediate path.
|
||||
expect(setSelectionRange).toHaveBeenCalledTimes(1);
|
||||
setSelectionRange.mockClear();
|
||||
// An unrelated value update must not replay the caret.
|
||||
rerender(<ControlledHarness value={"U".repeat(60)} onChange={onChange} />);
|
||||
expect(setSelectionRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not apply a pending commit-path caret to an unrelated value commit", async () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(<ControlledHarness value={LONG} onChange={onChange} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
input.focus();
|
||||
// A truncating change records the caret for the commit path...
|
||||
const next = `${LONG.slice(0, 6)}XY${LONG.slice(6, 58)}`;
|
||||
simulateInsert(input, `${LONG.slice(0, 6)}XY${LONG.slice(6)}`, 8, setSelectionRange);
|
||||
expect(onChange).toHaveBeenCalledWith(next);
|
||||
// ...but the parent rejects it (no state update), and an unrelated value
|
||||
// commit arrives later: the stale caret must not be applied to it.
|
||||
rerender(<ControlledHarness value={"U".repeat(60)} onChange={onChange} />);
|
||||
await act(async () => {});
|
||||
expect(setSelectionRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restore a stale caret when the value changes in the same turn as a bail-out", async () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(<ControlledHarness value={LONG} onChange={onChange} />);
|
||||
const input = screen.getByTestId("title") as HTMLInputElement;
|
||||
input.focus();
|
||||
// Schedules the bail-out restore for the old value...
|
||||
simulateInsert(input, `${LONG}T`, 6, setSelectionRange);
|
||||
// ...but the controlled value changes synchronously before the microtask
|
||||
// runs, so the restore must be superseded.
|
||||
rerender(<ControlledHarness value={"U".repeat(60)} onChange={onChange} />);
|
||||
await act(async () => {});
|
||||
expect(setSelectionRange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskTitleSelectionRestore - textarea path", () => {
|
||||
it("pins the caret after a truncating change in a textarea", () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLTextAreaElement.prototype, "setSelectionRange");
|
||||
render(<HarnessTextarea initial={LONG} />);
|
||||
const textarea = screen.getByTestId("title") as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
simulateInsert(textarea, `${LONG.slice(0, 6)}XY${LONG.slice(6)}`, 8, setSelectionRange);
|
||||
|
||||
expect(textarea.value).toHaveLength(60);
|
||||
expect(textarea.value.slice(6, 8)).toBe("XY");
|
||||
expect(setSelectionRange).toHaveBeenCalledWith(8, 8);
|
||||
});
|
||||
|
||||
it("restores the caret on a same-result bail-out in a textarea", async () => {
|
||||
const setSelectionRange = vi.spyOn(HTMLTextAreaElement.prototype, "setSelectionRange");
|
||||
render(<HarnessTextarea initial={LONG} />);
|
||||
const textarea = screen.getByTestId("title") as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
simulateInsert(textarea, `${LONG}T`, 6, setSelectionRange);
|
||||
await act(async () => {});
|
||||
expect(textarea.value).toHaveLength(60);
|
||||
expect(setSelectionRange).toHaveBeenCalledWith(6, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { clampTaskTitleInput } from "@/lib/task-title";
|
||||
|
||||
type TitleInputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
type PendingSelection = { start: number; end: number; value: string };
|
||||
|
||||
/**
|
||||
* Re-pin the caret after React restores a controlled value whose render
|
||||
* bailed out. The epoch, ownership, and value checks keep a stale restore
|
||||
* from touching a newer value or a replaced element; the connection and focus
|
||||
* checks cover unmount and blur between scheduling and execution.
|
||||
*/
|
||||
function scheduleSameResultCaretRestore(options: {
|
||||
el: TitleInputElement;
|
||||
next: string;
|
||||
start: number;
|
||||
end: number;
|
||||
epoch: number;
|
||||
refs: {
|
||||
restoreEpochRef: { current: number };
|
||||
inputRef: { current: TitleInputElement | null };
|
||||
};
|
||||
}) {
|
||||
const { el, next, start, end, epoch, refs } = options;
|
||||
queueMicrotask(() => {
|
||||
if (epoch !== refs.restoreEpochRef.current) return;
|
||||
if (refs.inputRef.current !== el) return;
|
||||
if (el.value !== next) return;
|
||||
if (!el.isConnected || document.activeElement !== el) return;
|
||||
const max = next.length;
|
||||
el.setSelectionRange(Math.min(start, max), Math.min(end, max));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the caret in place while a task-title field clamps its value at the
|
||||
* 60-character cap.
|
||||
*
|
||||
* `clampChange` truncates the typed value (dropping the tail beyond the cap)
|
||||
* and records where the caret was. Because a truncated value differs from the
|
||||
* DOM's current value, React rewrites the DOM and the browser resets the caret
|
||||
* to the end of the field; the layout effect re-pins the caret to the recorded
|
||||
* position (bounded by the clamped length) after that commit.
|
||||
*
|
||||
* The record is cleared on every non-truncating change so a stale caret from
|
||||
* a keystroke that never committed (typing at the very end while at the cap)
|
||||
* cannot be replayed by a later commit.
|
||||
*
|
||||
* When a truncating keystroke leaves the clamped value equal to the last
|
||||
* committed value (e.g. typing the same character into an all-same-char title
|
||||
* at the cap), `setValue` bails out of the render, so no layout effect runs —
|
||||
* but React still restores the controlled DOM value after the event, which
|
||||
* resets the caret to the end. That case is handled with an immediate
|
||||
* microtask restore instead of the commit-driven path.
|
||||
*/
|
||||
export function useTaskTitleSelectionRestore<T extends TitleInputElement = HTMLInputElement>(
|
||||
value: string,
|
||||
) {
|
||||
const inputRef = useRef<T | null>(null);
|
||||
const pendingSelectionRef = useRef<PendingSelection | null>(null);
|
||||
const lastCommittedRef = useRef(value);
|
||||
const restoreEpochRef = useRef(0);
|
||||
|
||||
const clampChange = useCallback((e: ChangeEvent<TitleInputElement>) => {
|
||||
const el = e.target;
|
||||
const next = clampTaskTitleInput(el.value);
|
||||
// Any new change supersedes bail-out restores scheduled by an earlier
|
||||
// change in the same turn.
|
||||
restoreEpochRef.current += 1;
|
||||
if (next !== el.value) {
|
||||
if (next !== lastCommittedRef.current) {
|
||||
// The commit will change the value: record the caret (bound to the
|
||||
// clamped value it belongs to) for the layout effect, which runs
|
||||
// after React rewrites the DOM.
|
||||
pendingSelectionRef.current = {
|
||||
start: el.selectionStart ?? el.value.length,
|
||||
end: el.selectionEnd ?? el.value.length,
|
||||
value: next,
|
||||
};
|
||||
} else {
|
||||
// A same-result keystroke supersedes any earlier commit-path record
|
||||
// that the parent never applied.
|
||||
pendingSelectionRef.current = null;
|
||||
// The clamped value equals the committed value: the render bails out,
|
||||
// but React still restores the controlled DOM value after the event
|
||||
// and the browser resets the caret to the end. Re-pin it after that
|
||||
// write via a microtask; there is no commit to hook into.
|
||||
scheduleSameResultCaretRestore({
|
||||
el,
|
||||
next,
|
||||
start: el.selectionStart ?? el.value.length,
|
||||
end: el.selectionEnd ?? el.value.length,
|
||||
epoch: restoreEpochRef.current,
|
||||
refs: { restoreEpochRef, inputRef },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
pendingSelectionRef.current = null;
|
||||
}
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
lastCommittedRef.current = value;
|
||||
// A committed value change supersedes any pending bail-out restore.
|
||||
restoreEpochRef.current += 1;
|
||||
const selection = pendingSelectionRef.current;
|
||||
pendingSelectionRef.current = null;
|
||||
if (!selection) return;
|
||||
// The committed value must be the one the caret was computed for; a
|
||||
// parent that delayed, rejected, or superseded the clamped update must
|
||||
// not receive a stale caret.
|
||||
if (selection.value !== value) return;
|
||||
const el = inputRef.current;
|
||||
if (!el || document.activeElement !== el) return;
|
||||
const max = value.length;
|
||||
el.setSelectionRange(Math.min(selection.start, max), Math.min(selection.end, max));
|
||||
}, [value]);
|
||||
|
||||
return { inputRef, clampChange };
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
---
|
||||
spec: docs/specs/tasks/title-length-limit.md
|
||||
created: 2026-08-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Keep the Caret When Task Title Fields Clamp at the 60-Character Limit
|
||||
|
||||
## Overview
|
||||
|
||||
Every editable task-title field runs `clampTaskTitleInput` inside its `onChange`
|
||||
handler. When the typed text exceeds 60 code points (that is, the field's value
|
||||
is already at the cap), the clamp truncates the value, so the value React
|
||||
commits differs from the DOM's current value. React writes the DOM value and the
|
||||
browser resets the caret to the end of the field. This repeats on every
|
||||
keystroke, so editing a title that is at the 60-character limit becomes
|
||||
unusable: the caret jumps to the end after each keypress in the task "Rename"
|
||||
dialog and the task-edit dialog.
|
||||
|
||||
The fix preserves the caret after a clamping commit without changing the clamp
|
||||
semantics: a small hook captures the caret when truncation occurs and restores
|
||||
it (pinned to the clamped length) after React commits the truncated value.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
- `clampTaskTitleInput` (identity below 60 code points, tail-dropping at the
|
||||
cap) is applied inside `onChange` in every task-title input.
|
||||
- At the cap, typing inserts into the DOM (61+ code points), the clamp drops
|
||||
the tail, and the committed value differs from the DOM value. React writes
|
||||
the DOM, and the browser places the caret at the end. Every keystroke
|
||||
repeats the cycle.
|
||||
- Any title at exactly 60 code points triggers this, including every
|
||||
remote-imported title shortened with `…` by `truncateRemoteTaskTitle`
|
||||
(GitHub review/issue, Jira, Linear, GitLab, Azure DevOps watchers) and any
|
||||
title that reached the cap while typing.
|
||||
- The value never differs below the cap, which is why short titles behave
|
||||
correctly.
|
||||
|
||||
### Reproduction evidence (uncommitted scratch specs, removed after diagnosis)
|
||||
|
||||
- `apps/web/e2e/tests/kanban/scratch-cursor.spec.ts` — short titles: typing
|
||||
mid-title in the rename and edit dialogs keeps the caret (passed).
|
||||
- `apps/web/e2e/tests/kanban/scratch-cursor-60.spec.ts` — 60-character titles:
|
||||
typing mid-title in both dialogs ends with `caret=60` after every keystroke
|
||||
(failed, reproduced the report).
|
||||
- `apps/web/e2e/tests/task/mobile-scratch-cursor.spec.ts` — phone drawer
|
||||
rename and edit, short titles (passed; same components on mobile).
|
||||
|
||||
## Fix
|
||||
|
||||
### New hook
|
||||
|
||||
- `apps/web/hooks/use-task-title-selection-restore.ts`: returns `{ inputRef,
|
||||
clampChange }`.
|
||||
- `clampChange(e)` returns `clampTaskTitleInput(e.target.value)`; it records
|
||||
the DOM caret (`selectionStart`/`selectionEnd`) in a ref when the clamp
|
||||
actually truncated (`next !== e.target.value`) and clears the ref on any
|
||||
non-truncating change, so a stale record from a keystroke that never
|
||||
committed (typing at the very end at the cap) cannot be replayed by a later
|
||||
commit.
|
||||
- A `useLayoutEffect` on `value` restores the recorded selection with
|
||||
`setSelectionRange(min(start, value.length), min(end, value.length))` after
|
||||
React commits, skipping when the input is not focused.
|
||||
- Works for `<input>` and `<textarea>` (both expose
|
||||
`selectionStart`/`selectionEnd`/`setSelectionRange`).
|
||||
- The hook lives in `apps/web/hooks/` next to the other behavior hooks; the
|
||||
pure `clampTaskTitleInput` stays in `apps/web/lib/task-title.ts` unchanged.
|
||||
|
||||
### Reported surfaces (required)
|
||||
|
||||
- `apps/web/components/task/task-rename-dialog.tsx` — the "Rename task" dialog
|
||||
title input.
|
||||
- `apps/web/components/task-create-dialog-selectors.tsx` (`InlineTaskName`) —
|
||||
the task-create/task-edit dialog title input (`task-title-input`).
|
||||
|
||||
### Sibling surfaces with the same latent bug (consistency wave)
|
||||
|
||||
- `apps/web/components/task/task-top-bar-title.tsx` — inline breadcrumb title
|
||||
editor (`task-title-rename-input`).
|
||||
- `apps/web/components/task/new-subtask-form-parts.tsx` — subtask title input
|
||||
(`subtask-title-input`).
|
||||
- `apps/web/app/office/components/new-task-dialog.tsx` — Office new-task title
|
||||
textarea.
|
||||
- `apps/web/app/office/setup/step-task.tsx` — onboarding task title input.
|
||||
- `apps/web/components/automations/automation-editor-sections.tsx` — automation
|
||||
`taskTitleTemplate` input.
|
||||
|
||||
These use the identical clamp-in-`onChange` pattern and exhibit the same caret
|
||||
jump at the cap. Each is a one-line swap to `clampChange(e)` plus the hook call.
|
||||
They are a separate wave so the reviewer can drop them without touching the
|
||||
reported fix.
|
||||
|
||||
## Behavior after the fix
|
||||
|
||||
- Typing mid-title at the cap inserts the characters and drops the tail (as
|
||||
today), but the caret stays immediately after the inserted text.
|
||||
- Typing below the cap is unchanged (no truncation, no hook activity).
|
||||
- The cap is still enforced: the field value never exceeds 60 code points and
|
||||
submission still clamps/validates.
|
||||
- No `maxLength` attribute is added (the codebase deliberately avoids it
|
||||
because it counts UTF-16 units, not code points; the create dialog's
|
||||
`pr-action-create-task-dialog.spec.ts` asserts its absence).
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** the hook returns the clamped value and restores the caret after a
|
||||
truncating change; it does not touch the caret when no truncation occurs and
|
||||
skips restore when the input is not focused.
|
||||
**File:** `apps/web/hooks/use-task-title-selection-restore.test.tsx`.
|
||||
**How:** render a tiny controlled input through the hook with
|
||||
`@testing-library/react` + happy-dom; set the selection, dispatch a change
|
||||
with a 61-code-point value inside `act`, and assert `selectionStart` is
|
||||
pinned to the recorded position rather than the end.
|
||||
- **What:** the rename and edit dialogs keep the caret when typing mid-title at
|
||||
the 60-character cap.
|
||||
**File:** `apps/web/e2e/tests/task/task-title-caret.spec.ts` (desktop
|
||||
`chromium` project).
|
||||
**How:** seed a task with a 60-character title; open the edit dialog and the
|
||||
rename dialog; click the title input, set the selection to position 6, type
|
||||
`XY`, and assert the value length stays 60 and `selectionStart` is 8 (the
|
||||
characters after the insert), not 60.
|
||||
- **What:** the same user value on a phone.
|
||||
**File:** `apps/web/e2e/tests/task/mobile-task-title-caret.spec.ts`
|
||||
(`mobile-chrome` project).
|
||||
**How:** open the phone task drawer, right-click the task row, and repeat the
|
||||
rename + edit assertions. The mobile sheet renders the same dialogs, so this
|
||||
proves viewport parity of the fix.
|
||||
|
||||
## E2E scenarios
|
||||
|
||||
- **GIVEN** the task-edit dialog with a title at the 60-character limit, **WHEN**
|
||||
the user places the caret mid-title and types `XY`, **THEN** the value stays
|
||||
60 characters, `XY` appears in place, and the caret is immediately after `XY`.
|
||||
- **GIVEN** the task "Rename" dialog with a title at the 60-character limit,
|
||||
**WHEN** the user places the caret mid-title and types `XY`, **THEN** the value
|
||||
stays 60 characters, `XY` appears in place, and the caret is immediately
|
||||
after `XY`.
|
||||
- **GIVEN** a title below the limit, **WHEN** the user types mid-title, **THEN**
|
||||
the caret behaves as before (no regression).
|
||||
|
||||
## Mobile parity note
|
||||
|
||||
The change is caret preservation inside existing inputs; it does not alter
|
||||
layout, touch behavior, scrolling, or viewport-dependent interaction. The
|
||||
phone drawer renders the same `TaskRenameDialog` and `SidebarTaskEditDialog`
|
||||
components, so the desktop E2E assertion covers the mechanism and the mobile
|
||||
spec proves the same user value on the phone viewport.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/hooks/use-task-title-selection-restore.ts` (new)
|
||||
- `apps/web/hooks/use-task-title-selection-restore.test.tsx` (new)
|
||||
- `apps/web/components/task/task-rename-dialog.tsx`
|
||||
- `apps/web/components/task-create-dialog-selectors.tsx`
|
||||
- `apps/web/components/task/task-top-bar-title.tsx`
|
||||
- `apps/web/components/task/new-subtask-form-parts.tsx`
|
||||
- `apps/web/app/office/components/new-task-dialog.tsx`
|
||||
- `apps/web/app/office/setup/step-task.tsx`
|
||||
- `apps/web/components/automations/automation-editor-sections.tsx`
|
||||
- `apps/web/e2e/tests/task/task-title-caret.spec.ts` (new)
|
||||
- `apps/web/e2e/tests/task/mobile-task-title-caret.spec.ts` (new)
|
||||
- `docs/specs/tasks/title-length-limit.md` (amended: caret scenario)
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit: `cd apps && pnpm --filter @kandev/web test -- --run apps/web/hooks/use-task-title-selection-restore.test.tsx`
|
||||
- E2E desktop: `cd apps/web && pnpm e2e:raw tests/task/task-title-caret.spec.ts`
|
||||
- E2E mobile: `cd apps/web && pnpm e2e:raw --project=mobile-chrome tests/task/mobile-task-title-caret.spec.ts`
|
||||
- Related suite: `cd apps && pnpm --filter @kandev/web test -- --run apps/web/lib/task-title.test.ts` and
|
||||
`cd apps/web && pnpm e2e:raw tests/github/pr-action-create-task-dialog.spec.ts` (asserts no `maxlength`)
|
||||
- Full gate (after the final task): `make fmt`, then `make typecheck test lint`
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1 (sequential):
|
||||
|
||||
- [x] [task-01-regression-e2e](task-01-regression-e2e.md) — write the failing
|
||||
E2E regression specs and the failing hook unit test (RED).
|
||||
|
||||
Wave 2 (depends on Wave 1):
|
||||
|
||||
- [x] [task-02-caret-preserving-clamp](task-02-caret-preserving-clamp.md) —
|
||||
implement the hook; unit test green.
|
||||
|
||||
Wave 3 (depends on Wave 2):
|
||||
|
||||
- [x] [task-03-rename-edit-dialogs](task-03-rename-edit-dialogs.md) — apply the
|
||||
hook to the reported rename and edit dialogs; E2E green.
|
||||
|
||||
Wave 4 (depends on Wave 3):
|
||||
|
||||
- [x] [task-04-sibling-title-inputs](task-04-sibling-title-inputs.md) —
|
||||
consistency across the sibling title inputs; full verification gate.
|
||||
|
||||
Parallel delegation is not authorized by this plan. The work stays in the
|
||||
primary session and follows the dependency order above.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run hooks/use-task-title-selection-restore.test.tsx`
|
||||
— passed (12 tests).
|
||||
- `cd apps/web && pnpm e2e:raw tests/task/task-title-caret.spec.ts` — passed
|
||||
(3 tests) before the fix (RED: caret at 60) and after (GREEN: caret at 8;
|
||||
the third case covers the same-char bail-out).
|
||||
- `cd apps/web && pnpm e2e:raw --project=mobile-chrome tests/task/mobile-task-title-caret.spec.ts`
|
||||
— passed (3 tests) RED then GREEN.
|
||||
- `cd apps/web && pnpm e2e:raw tests/github/pr-action-create-task-dialog.spec.ts`
|
||||
— passed (guards the `maxlength`-absence contract).
|
||||
- `make fmt` — passed.
|
||||
- `make typecheck` — passed.
|
||||
- `make lint` — passed (0 issues).
|
||||
- `make test-web` — 10410+ passed; only `lib/http-git-server.test.ts` fails,
|
||||
reproduced identically on the clean tree (pre-existing, unrelated).
|
||||
- `make test-backend` — pre-existing failures in
|
||||
`internal/agentctl/server/{process,api,config}` reproduced identically on the
|
||||
clean tree (unrelated to this web-only change).
|
||||
|
||||
## Adversarial Review Loop
|
||||
|
||||
Round 1 (DeepSeek V4 Pro sub-task, read-only): no blockers, no majors. Two
|
||||
minors and one nit, all fixed in `30e6c647`:
|
||||
|
||||
- **Minor** — stale `pendingSelectionRef` when a same-value clamp makes React
|
||||
bail out of the render: the recorded caret could be replayed by an unrelated
|
||||
later commit. Fixed by tracking `lastCommittedRef` and only recording when
|
||||
the clamp result differs from it.
|
||||
- **Minor** — unit tests covered only `<input>`, not the `<textarea>` path
|
||||
used by the Office new-task dialog. Added `HarnessTextarea` coverage.
|
||||
- **Nit** — plan referenced `tests/kanban/` instead of `tests/task/` for the
|
||||
E2E file. Corrected.
|
||||
|
||||
Round 2 (same reviewer): **NO FINDINGS** — the `lastCommittedRef` fix was
|
||||
verified complete (no desync path; the guard test genuinely exercises the
|
||||
bail-out), the new coverage is sound, and the earlier-passing areas remain
|
||||
intact.
|
||||
|
||||
Rounds 3-4 (OMP GPT Luna, fresh reviewer): one major + two minors + two nits,
|
||||
all fixed in `f8574cf6` and `6740e3e9`:
|
||||
|
||||
- **Major (round 3)** — same-result truncations (e.g. typing the identical
|
||||
character into an all-same-char 60-char title) still reset the caret: the
|
||||
render bails out, but React's controlled-state restoration writes the value
|
||||
back after the event with no commit to hook into. Fixed with an immediate
|
||||
microtask restore in the bail-out path, guarded by element connection and
|
||||
focus. Unit + desktop/mobile E2E coverage added.
|
||||
- **Minor (round 4)** — the bail-out microtask could restore a stale caret
|
||||
after a same-turn value or identity change. Fixed with an epoch token
|
||||
invalidated by newer changes and commits, plus ownership (`inputRef.current
|
||||
=== el`) and value (`el.value === next`) guards. Regression test added.
|
||||
- **Minor (round 4)** — bail-out path untested for textarea. Added.
|
||||
- **Nits** — verification counts corrected (unit 12, E2E 3 per project).
|
||||
|
||||
Round 5 (OMP GPT Luna): **no correctness findings** — the bail-out microtask
|
||||
guards (epoch, ownership, value, connection, focus) verified correctly ordered,
|
||||
all seven call sites sound, input and textarea paths covered. One nit (stale
|
||||
verification count) fixed here.
|
||||
|
||||
Round 6 (OMP GPT Luna, final round): one minor, fixed in `e071d1a5` — the
|
||||
commit-path record stored only the caret, so a parent that delayed, rejected,
|
||||
or superseded the clamped update could commit an unrelated value and apply the
|
||||
stale caret to it (reachable in the automation editor's async form load).
|
||||
The pending selection now stores the clamped value it belongs to, the
|
||||
same-result bail-out clears any lingering record, and the layout effect
|
||||
discards the record when the committed value does not match. Regression test
|
||||
added (controlled harness that rejects the clamped update, then commits an
|
||||
unrelated value). This was the 6-round cap; the final finding is addressed and
|
||||
the full gate is green (13 unit tests, 3 E2E per project).
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The root cause is reproduced with deterministic E2E evidence and the fix
|
||||
is behavior-preserving apart from the caret position.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
id: "01-regression-e2e"
|
||||
title: "Write failing regression tests for the caret jump"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/title-length-limit.md"
|
||||
---
|
||||
|
||||
# Task 01: Write failing regression tests for the caret jump
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `apps/web/e2e/tests/kanban/task-title-caret.spec.ts` exists with two desktop
|
||||
tests (edit dialog, rename dialog) that seed a 60-character task title, place
|
||||
the caret at position 6 of the title input, type `XY`, and assert the value
|
||||
length stays 60 and `selectionStart` is 8.
|
||||
- `apps/web/e2e/tests/task/mobile-task-title-caret.spec.ts` exists with the
|
||||
phone drawer flow (right-click task row in the `Tasks` dialog) covering the
|
||||
same rename + edit assertions.
|
||||
- `apps/web/hooks/use-task-title-selection-restore.test.tsx` exists with a
|
||||
focused test that renders a controlled input through the (not yet existing)
|
||||
hook and asserts the caret is pinned after a truncating change.
|
||||
- All three files FAIL on the current code for the expected reason: the caret
|
||||
lands at the end (60) instead of staying after the inserted text, and the
|
||||
hook module does not exist.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/web && pnpm e2e:raw tests/kanban/task-title-caret.spec.ts` — both
|
||||
tests fail with `selectionStart` 60, not 8.
|
||||
- `cd apps/web && pnpm e2e:raw --project=mobile-chrome tests/task/mobile-task-title-caret.spec.ts`
|
||||
— both tests fail the same way.
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run apps/web/hooks/use-task-title-selection-restore.test.tsx`
|
||||
— fails (module not found).
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/kanban/task-title-caret.spec.ts` (new)
|
||||
- `apps/web/e2e/tests/task/mobile-task-title-caret.spec.ts` (new)
|
||||
- `apps/web/hooks/use-task-title-selection-restore.test.tsx` (new)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This is the RED wave for the whole fix.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The reproduction technique from `plan.md` ("Reproduction evidence"): seed a
|
||||
task with `"T".repeat(60)`, open the dialog, `click()` the input,
|
||||
`setSelectionRange(6, 6)`, `testPage.keyboard.type("XY")`, then read
|
||||
`selectionStart` and the value.
|
||||
- Existing flows to model: `tests/kanban/dialog-enter-confirms.spec.ts`
|
||||
(kanban actions menu), `tests/task/mobile-sidebar-task-actions.spec.ts`
|
||||
(phone drawer rename/edit), `pages/kanban-page.ts`, `pages/session-page.ts`.
|
||||
- The amended spec scenario: caret stays immediately after inserted text at the
|
||||
60-character cap.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the changed files, the exact failing assertions (observed caret values),
|
||||
and the task status in the same conversation. Do not implement the fix in this
|
||||
task. Do not delegate to a subagent without explicit user authorization.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "02-caret-preserving-clamp"
|
||||
title: "Implement the caret-preserving clamp hook"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-regression-e2e"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/title-length-limit.md"
|
||||
---
|
||||
|
||||
# Task 02: Implement the caret-preserving clamp hook
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `apps/web/hooks/use-task-title-selection-restore.ts` exports
|
||||
`useTaskTitleSelectionRestore(value: string)` returning
|
||||
`{ inputRef, clampChange }`:
|
||||
- `clampChange(e)` returns `clampTaskTitleInput(e.target.value)`; it records
|
||||
the DOM `selectionStart`/`selectionEnd` in a ref when the clamp truncates
|
||||
(`next !== e.target.value`) and clears the ref on a non-truncating change,
|
||||
so a stale record (e.g. from typing at the very end at the cap, which never
|
||||
commits) cannot be replayed by a later commit.
|
||||
- A `useLayoutEffect` keyed on `value` restores the recorded selection with
|
||||
`setSelectionRange(min(start, value.length), min(end, value.length))` and
|
||||
clears the record; it skips when the input is not focused or no selection
|
||||
was recorded.
|
||||
- The ref type supports both `HTMLInputElement` and `HTMLTextAreaElement`.
|
||||
- `use-task-title-selection-restore.test.tsx` passes:
|
||||
- a truncating change (61 code points) pins the caret to the recorded
|
||||
position, not the end;
|
||||
- a non-truncating change leaves the caret untouched and clears any stale
|
||||
recorded selection (a later non-truncating change must not replay it);
|
||||
- the restore is skipped when the input is not focused.
|
||||
- `clampTaskTitleInput` and `apps/web/lib/task-title.ts` are unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run apps/web/hooks/use-task-title-selection-restore.test.tsx`
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run apps/web/lib/task-title.test.ts`
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/use-task-title-selection-restore.ts` (new)
|
||||
- `apps/web/hooks/use-task-title-selection-restore.test.tsx` (new)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Wave 1 (failing tests) must exist so this task's unit test can go green.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. No other task edits these files.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The `clampChange`/ref/layout-effect design in `plan.md` ("New hook").
|
||||
- Test-drive pattern: `apps/web/components/task-create-dialog-selectors.test.tsx`
|
||||
sets `setSelectionRange` then asserts `selectionStart` under `act`, which
|
||||
works with happy-dom.
|
||||
- The hook must not add user-facing copy (i18n ratchet applies to the files it
|
||||
touches).
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the hook implementation, the exact unit test command and result, and the
|
||||
task status in the same conversation. Do not modify the dialog components in
|
||||
this task.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: "03-rename-edit-dialogs"
|
||||
title: "Apply the hook to the rename and edit dialogs"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-caret-preserving-clamp"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/title-length-limit.md"
|
||||
---
|
||||
|
||||
# Task 03: Apply the hook to the rename and edit dialogs
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `apps/web/components/task/task-rename-dialog.tsx` (`TaskRenameForm`) wires
|
||||
the title `Input` through `useTaskTitleSelectionRestore`: `ref={inputRef}`,
|
||||
`onChange={(e) => setValue(clampChange(e))}`. `onFocus` select, `autoFocus`,
|
||||
submit, and cancel behavior are unchanged.
|
||||
- `apps/web/components/task-create-dialog-selectors.tsx` (`InlineTaskName`)
|
||||
wires its controlled `<input>` through the hook: `ref={inputRef}`,
|
||||
`onChange={(e) => onChange(clampChange(e))}`. The `memo` boundary and the
|
||||
autoFocus/select effect are unchanged.
|
||||
- The desktop and mobile E2E regression tests from Wave 1 now PASS: caret stays
|
||||
at 8 (not 60) after typing `XY` mid-title in both dialogs at the 60-character
|
||||
cap.
|
||||
- Existing short-title behavior is unchanged (the Wave 1 specs' assertion also
|
||||
covers this via the value-length check).
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/web && pnpm e2e:raw tests/kanban/task-title-caret.spec.ts`
|
||||
- `cd apps/web && pnpm e2e:raw --project=mobile-chrome tests/task/mobile-task-title-caret.spec.ts`
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run apps/web/hooks/use-task-title-selection-restore.test.tsx`
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/task-rename-dialog.tsx`
|
||||
- `apps/web/components/task-create-dialog-selectors.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Wave 2 (the hook) must exist.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Two dialog files, one shared hook contract; no other task edits
|
||||
these files.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The hook API from Task 02.
|
||||
- The `task-title-input` testid and `create-task-dialog` testid used by the E2E
|
||||
specs (unchanged).
|
||||
- The amended spec scenario this task makes true.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the changed files, the exact E2E and unit commands with results (green
|
||||
after this task), and the task status in the same conversation.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: "04-sibling-title-inputs"
|
||||
title: "Extend the hook to sibling task-title inputs"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["03-rename-edit-dialogs"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/title-length-limit.md"
|
||||
---
|
||||
|
||||
# Task 04: Extend the hook to sibling task-title inputs
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every remaining task-title input that clamps inside `onChange` now uses
|
||||
`useTaskTitleSelectionRestore` and keeps the caret at the 60-character cap:
|
||||
- `apps/web/components/task/task-top-bar-title.tsx` (inline breadcrumb title
|
||||
editor, `task-title-rename-input`);
|
||||
- `apps/web/components/task/new-subtask-form-parts.tsx` (`subtask-title-input`);
|
||||
- `apps/web/app/office/components/new-task-dialog.tsx` (title textarea —
|
||||
the hook must be wired to the `Textarea` ref, which supports the same
|
||||
selection API);
|
||||
- `apps/web/app/office/setup/step-task.tsx` (onboarding title input);
|
||||
- `apps/web/components/automations/automation-editor-sections.tsx`
|
||||
(`taskTitleTemplate` input).
|
||||
- Behavior is otherwise unchanged: no `maxLength` attributes added, no new
|
||||
user-facing copy, clamp semantics unchanged.
|
||||
- The full verification gate passes: `make fmt`, then `make typecheck test
|
||||
lint`.
|
||||
- The amended spec `docs/specs/tasks/title-length-limit.md` status flips back
|
||||
to `complete` and the plan/task statuses are updated.
|
||||
|
||||
## Verification
|
||||
|
||||
- `make fmt`
|
||||
- `make typecheck`
|
||||
- `make test` (web suite, including `task-title.test.ts` and the new hook
|
||||
test)
|
||||
- `make lint`
|
||||
- `cd apps/web && pnpm e2e:raw tests/kanban/task-title-caret.spec.ts`
|
||||
- `cd apps/web && pnpm e2e:raw --project=mobile-chrome tests/task/mobile-task-title-caret.spec.ts`
|
||||
- `cd apps/web && pnpm e2e:raw tests/github/pr-action-create-task-dialog.spec.ts`
|
||||
(guards the `maxlength`-absence contract)
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/task-top-bar-title.tsx`
|
||||
- `apps/web/components/task/new-subtask-form-parts.tsx`
|
||||
- `apps/web/app/office/components/new-task-dialog.tsx`
|
||||
- `apps/web/app/office/setup/step-task.tsx`
|
||||
- `apps/web/components/automations/automation-editor-sections.tsx`
|
||||
- `docs/specs/tasks/title-length-limit.md` (status back to `complete`)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Waves 1–3 must be complete.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. These files share the hook contract and are verified together.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The hook API from Task 02.
|
||||
- `new-subtask-form-parts.tsx` and the Office files may control their state
|
||||
locally; wire the same `ref`/`clampChange` swap as Task 03. If a textarea
|
||||
does not forward a ref the same way the `Input` does, adjust the ref wiring
|
||||
only, never the hook behavior.
|
||||
- The Office surfaces run under the office routes in the SPA; the desktop
|
||||
`chromium` E2E suite covers the reported dialogs, and the office inputs are
|
||||
covered by typecheck/lint plus the shared unit test for the hook.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the changed files, the exact verification commands and results
|
||||
(`make fmt typecheck test lint` plus the targeted E2E runs), and the updated
|
||||
task/plan/spec statuses in the same conversation.
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
status: complete
|
||||
created: 2026-08-01
|
||||
updated: 2026-08-11
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
@@ -14,6 +15,7 @@ Long task titles crowd navigation and task chrome, especially when a remote pull
|
||||
|
||||
- New and renamed task titles must contain no more than 60 characters.
|
||||
- Every editable task-title field enforces the same limit, including the shared create/edit dialog, task rename dialog, subtask form, and Office new-task dialog on desktop and mobile.
|
||||
- Enforcing the limit never disturbs the user's caret: while a field's value is at the limit, typing mid-title keeps the caret immediately after the inserted characters instead of resetting it to the end of the field.
|
||||
- When a remote pull request, merge request, or issue supplies a title longer than 60 characters, the task dialog uses the first 59 characters followed by `…`. The full remote title remains available in the generated task description or linked remote item; only the task title is shortened.
|
||||
- The `create_task_kandev` MCP tool advertises and enforces the 60-character limit and asks agents to use a concise, few-word title.
|
||||
- Backend task creation and title updates reject titles longer than 60 characters regardless of whether the caller uses HTTP, WebSocket, MCP, Office, automation, or another service adapter.
|
||||
@@ -38,6 +40,7 @@ Long task titles crowd navigation and task chrome, especially when a remote pull
|
||||
|
||||
- **GIVEN** the create-task dialog on desktop or mobile, **WHEN** a user types or pastes more than 60 characters into the title field, **THEN** the field contains at most 60 characters and a valid title can be submitted.
|
||||
- **GIVEN** any other task-title editor, **WHEN** a user types or pastes more than 60 characters, **THEN** the field contains at most 60 characters.
|
||||
- **GIVEN** a task-title field whose current value is at the 60-character limit (for example a remote-imported title shortened with `…`), **WHEN** the user places the caret mid-title and types, **THEN** the inserted characters appear in place, the field stays at 60 characters, and the caret remains immediately after the inserted text instead of jumping to the end of the field.
|
||||
- **GIVEN** a remote pull request, merge request, or issue whose proposed task title exceeds 60 characters, **WHEN** the create-task dialog opens, **THEN** its title field contains the first 59 characters plus `…` and the complete remote context remains in the description or remote link.
|
||||
- **GIVEN** an MCP caller supplies a title of 60 characters, **WHEN** it calls `create_task_kandev`, **THEN** task creation proceeds subject to the existing validation rules.
|
||||
- **GIVEN** an MCP caller supplies a title longer than 60 characters, **WHEN** it calls `create_task_kandev`, **THEN** the call returns a validation error and creates no task.
|
||||
@@ -55,4 +58,4 @@ Long task titles crowd navigation and task chrome, especially when a remote pull
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [the implementation plan](../../plans/task-title-length-limit/plan.md).
|
||||
See [the implementation plan](../../plans/task-title-length-limit/plan.md) and the [caret-preservation fix plan](../../plans/task-title-caret/plan.md).
|
||||
|
||||
Reference in New Issue
Block a user