fix: keep caret in task title fields clamped at the limit (#2529)

This commit is contained in:
Clément Fiere
2026-08-11 20:23:42 +02:00
committed by GitHub
parent b0d63d9b3c
commit e21fb8349b
17 changed files with 1152 additions and 20 deletions
@@ -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
+4 -2
View File
@@ -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 };
}
+272
View File
@@ -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.
+4 -1
View File
@@ -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).