feat(FN-671): preserve quick entry disclosure and normalize comment migration

- Keep QuickEntryBox disclosure state persisted and covered by updated UI tests
- Add normalized legacy comment migration logic to unify steering comments without duplication
- Update database migration tests to verify schema v5 comment handling behavior
- Carry merged dashboard, engine, and routing changes into the squash commit
This commit is contained in:
gsxdsm
2026-04-01 13:07:26 -07:00
parent 7f47013d13
commit d80a5765cf
14 changed files with 511 additions and 230 deletions

View File

@@ -537,6 +537,24 @@ export function Header({
<Clock size={16} />
<span>Scheduled Tasks</span>
</button>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onToggleTerminal)}
role="menuitem"
data-testid="overflow-terminal-btn"
>
<Terminal size={16} />
<span>Open Terminal</span>
</button>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSchedules)}
role="menuitem"
data-testid="overflow-schedules-btn"
>
<Clock size={16} />
<span>Scheduled Tasks</span>
</button>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSettings)}

View File

@@ -151,6 +151,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, [description]);
// Persist disclosure state to localStorage whenever it changes
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem(DISCLOSURE_STORAGE_KEY, isDisclosureExpanded.toString());
}
}, [isDisclosureExpanded]);
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -218,7 +225,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsModelModalOpen(false);
setIsRefineMenuOpen(false);
setIsRefining(false);
setIsExpanded(false); // Collapse on reset
setIsExpanded(false); // Collapse textarea height on reset
// Note: isDisclosureExpanded is NOT reset - user preference persists
justResetRef.current = true;
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
@@ -304,8 +312,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
localStorage.removeItem(STORAGE_KEY);
}
}
// Collapse on escape
// Collapse textarea and disclosure on escape
setIsExpanded(false);
setIsDisclosureExpanded(false);
textareaRef.current?.blur();
}
},
@@ -316,6 +325,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
showDeps,
isModelModalOpen,
isRefineMenuOpen,
setIsDisclosureExpanded,
],
);
@@ -442,15 +452,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, [availableModels]);
// Show expanded controls only when manually expanded (isExpanded)
const showExpandedControls = isExpanded;
// Show expanded controls based on disclosure state (user preference), not textarea focus
const showExpandedControls = isDisclosureExpanded;
const toggleExpanded = useCallback(() => {
setIsDisclosureExpanded((prev) => !prev);
setIsExpanded((prev) => !prev);
}, []);
return (
<div className={`quick-entry-box ${isExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
<div className="quick-entry-main-row">
<textarea
ref={textareaRef}
@@ -470,12 +481,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
type="button"
className="btn btn-sm quick-entry-toggle"
onClick={toggleExpanded}
aria-expanded={isExpanded}
aria-expanded={isDisclosureExpanded}
aria-controls="quick-entry-controls"
data-testid="quick-entry-toggle"
title={isExpanded ? "Collapse" : "Expand"}
title={isDisclosureExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
{isDisclosureExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
{showExpandedControls && (

View File

@@ -183,9 +183,7 @@ describe("QuickEntryBox", () => {
});
it("does NOT expand on focus", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
@@ -196,8 +194,7 @@ describe("QuickEntryBox", () => {
});
it("toggle button expands the view", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
const textarea = screen.getByTestId("quick-entry-input");
// Initially not expanded
@@ -221,7 +218,8 @@ describe("QuickEntryBox", () => {
});
it("toggle button collapses the view when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const textarea = screen.getByTestId("quick-entry-input");
// Expand first
@@ -236,7 +234,8 @@ describe("QuickEntryBox", () => {
});
it("does NOT collapse on blur when empty", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const textarea = screen.getByTestId("quick-entry-input");
// Expand manually
@@ -255,9 +254,7 @@ describe("QuickEntryBox", () => {
});
it("does NOT collapse on blur when has content", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
const textarea = screen.getByTestId("quick-entry-input");
// Expand manually and add content
@@ -294,9 +291,7 @@ describe("QuickEntryBox", () => {
});
it("allows Shift+Enter to insert newline when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -405,9 +400,7 @@ describe("QuickEntryBox", () => {
});
it("collapses and blurs on Escape key", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -498,7 +491,7 @@ describe("QuickEntryBox", () => {
describe("Rich creation features", () => {
it("shows dependency button when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, no controls are visible
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
@@ -515,7 +508,7 @@ describe("QuickEntryBox", () => {
it("shows model selector button when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, no controls are visible
@@ -534,7 +527,7 @@ describe("QuickEntryBox", () => {
it("shows Plan and Subtask buttons when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, no controls are visible
@@ -554,9 +547,7 @@ describe("QuickEntryBox", () => {
});
it("opens dependency dropdown when clicking deps button", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -569,9 +560,7 @@ describe("QuickEntryBox", () => {
});
it("opens model modal when clicking models button", () => {
const { container } = renderQuickEntryBox();
const { container } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -591,9 +580,7 @@ describe("QuickEntryBox", () => {
});
it("modal receives correct props (models, loading state, etc.)", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -612,9 +599,7 @@ describe("QuickEntryBox", () => {
});
it("selects dependencies and includes them in submit payload", async () => {
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -642,7 +627,6 @@ describe("QuickEntryBox", () => {
it("calls onPlanningMode and clears input when Plan clicked", async () => {
const onPlanningMode = vi.fn();
const { props } = renderQuickEntryBox({ onPlanningMode });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Plan this task" } });
@@ -659,7 +643,6 @@ describe("QuickEntryBox", () => {
it("calls onSubtaskBreakdown and clears input when Subtask clicked", async () => {
const onSubtaskBreakdown = vi.fn();
const { props } = renderQuickEntryBox({ onSubtaskBreakdown });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Break this down" } });
@@ -674,9 +657,7 @@ describe("QuickEntryBox", () => {
});
it("disables Plan and Subtask buttons when description is empty", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -702,9 +683,7 @@ describe("QuickEntryBox", () => {
});
it("Plan button prevents textarea blur on mousedown", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -722,9 +701,7 @@ describe("QuickEntryBox", () => {
});
it("Subtask button prevents textarea blur on mousedown", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -762,9 +739,7 @@ describe("QuickEntryBox", () => {
});
it("includes selected models in submit payload", async () => {
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -795,9 +770,7 @@ describe("QuickEntryBox", () => {
});
it("closes modal on Escape when open", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -818,9 +791,7 @@ describe("QuickEntryBox", () => {
});
it("clears all state on second Escape after dropdowns are closed", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -837,10 +808,8 @@ describe("QuickEntryBox", () => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
it("resets all state after successful creation", async () => {
const { props } = renderQuickEntryBox();
it("resets all state after successful creation (preserves disclosure preference)", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -991,9 +960,7 @@ describe("QuickEntryBox", () => {
});
it("clears localStorage when Escape clears non-empty input", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1012,9 +979,7 @@ describe("QuickEntryBox", () => {
});
it("does not clear localStorage on first Escape when closing dropdowns", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1036,8 +1001,7 @@ describe("QuickEntryBox", () => {
describe("AI Refine feature", () => {
it("shows refine button when expanded and text is entered", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, refine button is not visible
@@ -1053,9 +1017,7 @@ describe("QuickEntryBox", () => {
});
it("refine button is hidden when textarea is empty", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1074,9 +1036,7 @@ describe("QuickEntryBox", () => {
});
it("opens refine menu on button click", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1091,9 +1051,7 @@ describe("QuickEntryBox", () => {
});
it("closes refine menu on Escape key", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1115,9 +1073,7 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1140,9 +1096,7 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1171,9 +1125,7 @@ describe("QuickEntryBox", () => {
const { getRefineErrorMessage } = await import("../../api");
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1194,9 +1146,7 @@ describe("QuickEntryBox", () => {
// Slow down the promise to see loading state
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1218,9 +1168,7 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description with much more content here");
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1237,9 +1185,7 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined text");
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1263,8 +1209,7 @@ describe("QuickEntryBox", () => {
describe("Save button", () => {
it("shows save button when expanded and text is entered", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, save button is not visible
@@ -1280,9 +1225,7 @@ describe("QuickEntryBox", () => {
});
it("save button is disabled when textarea is empty", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1324,9 +1267,7 @@ describe("QuickEntryBox", () => {
});
it("clicking save button persists to localStorage", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1342,9 +1283,7 @@ describe("QuickEntryBox", () => {
});
it("clicking save button creates the task", async () => {
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1365,9 +1304,7 @@ describe("QuickEntryBox", () => {
});
it("save button has correct test id", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1379,9 +1316,7 @@ describe("QuickEntryBox", () => {
});
it("save button has correct title attribute", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1392,9 +1327,7 @@ describe("QuickEntryBox", () => {
});
it("save button prevents textarea blur on mousedown", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");

View File

@@ -10309,8 +10309,32 @@ html .column.drag-over * {
height: 32px;
width: 32px;
flex-shrink: 0;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.quick-entry-toggle:hover {
background: var(--card-hover);
border-color: var(--border);
color: var(--text);
}
.quick-entry-toggle:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* Expanded state - normal padding */
.quick-entry-box--expanded {
padding: 8px 10px;
}
.quick-entry-box--expanded .quick-entry-input {
min-height: 36px;
}
/* Collapsed state - minimal padding */
@@ -10327,7 +10351,30 @@ html .column.drag-over * {
border-bottom-color: var(--triage);
}
/* Responsive adjustments for quick entry */
@media (max-width: 640px) {
.quick-entry-controls {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.quick-entry-controls-left {
width: 100%;
}
.quick-entry-hint {
margin-left: 0;
width: 100%;
text-align: center;
}
.quick-entry-toggle {
height: 28px;
width: 28px;
padding: 4px;
}
}
/* === New Task Modal === */
.new-task-modal {

View File

@@ -1993,43 +1993,45 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
let files: TaskFileDiff[] = [];
const parseNameStatus = (output: string): TaskFileDiff[] => {
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split("\t");
const rawStatus = parts[0] ?? "M";
const statusCode = rawStatus[0];
const entries: TaskFileDiff[] = [];
if (statusCode === "R") {
const oldPath = parts[1];
const path = parts[2];
return path
? {
path,
oldPath,
status: "renamed" as const,
diff: "",
}
: null;
}
for (const rawLine of output.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const path = parts[1];
return path
? {
path,
status:
statusCode === "A"
? ("added" as const)
: statusCode === "D"
? ("deleted" as const)
: ("modified" as const),
diff: "",
}
: null;
})
.filter((entry): entry is TaskFileDiff => entry !== null);
const parts = line.split("\t");
const rawStatus = parts[0] ?? "M";
const statusCode = rawStatus[0];
if (statusCode === "R") {
const oldPath = parts[1];
const path = parts[2];
if (!path) continue;
entries.push({
path,
oldPath,
status: "renamed",
diff: "",
});
continue;
}
const path = parts[1];
if (!path) continue;
entries.push({
path,
status:
statusCode === "A"
? "added"
: statusCode === "D"
? "deleted"
: "modified",
diff: "",
});
}
return entries;
};
try {