feat(FN-4712): complete Step 4 — autosize planning chat textareas
Fusion-Task-Id: FN-4712 Fusion-Task-Lineage: 92ef2161-7ce2-4277-9921-7fe1dd204e98
This commit is contained in:
committed by
gsxdsm
parent
a7859ccef3
commit
ef5f7bbdfb
@@ -595,7 +595,8 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
resize: vertical;
|
max-height: calc(var(--space-2xl) * 12);
|
||||||
|
overflow-y: auto;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { useViewportMode } from "../hooks/useViewportMode";
|
|||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
|
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
|
||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||||
|
|
||||||
interface PlanningModeModalProps {
|
interface PlanningModeModalProps {
|
||||||
@@ -170,7 +171,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const [isRetrying, setIsRetrying] = useState(false);
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
|
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const { ref: initialPlanAutosizeRef } = useAutosizeTextarea({
|
||||||
|
value: initialPlan,
|
||||||
|
minHeight: 120,
|
||||||
|
maxHeight: 320,
|
||||||
|
});
|
||||||
|
const setInitialPlanTextareaRef = useCallback((node: HTMLTextAreaElement | null) => {
|
||||||
|
textareaRef.current = node;
|
||||||
|
initialPlanAutosizeRef(node);
|
||||||
|
}, [initialPlanAutosizeRef]);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||||
const currentSessionIdRef = useRef<string | null>(null);
|
const currentSessionIdRef = useRef<string | null>(null);
|
||||||
@@ -1781,9 +1791,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="initial-plan">What do you want to build?</label>
|
<label htmlFor="initial-plan">What do you want to build?</label>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={setInitialPlanTextareaRef}
|
||||||
id="initial-plan"
|
id="initial-plan"
|
||||||
rows={4}
|
|
||||||
className="planning-textarea"
|
className="planning-textarea"
|
||||||
placeholder="e.g., Build a user authentication system with login, signup, and password reset..."
|
placeholder="e.g., Build a user authentication system with login, signup, and password reset..."
|
||||||
value={initialPlan}
|
value={initialPlan}
|
||||||
@@ -2141,6 +2150,18 @@ function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }:
|
|||||||
const [response, setResponse] = useState<QuestionResponse>({});
|
const [response, setResponse] = useState<QuestionResponse>({});
|
||||||
const [textValue, setTextValue] = useState("");
|
const [textValue, setTextValue] = useState("");
|
||||||
const [commentValue, setCommentValue] = useState("");
|
const [commentValue, setCommentValue] = useState("");
|
||||||
|
const { ref: textAnswerAutosizeRef } = useAutosizeTextarea({
|
||||||
|
value: textValue,
|
||||||
|
minHeight: 120,
|
||||||
|
maxHeight: 320,
|
||||||
|
deps: [question.id],
|
||||||
|
});
|
||||||
|
const { ref: commentAutosizeRef } = useAutosizeTextarea({
|
||||||
|
value: commentValue,
|
||||||
|
minHeight: 80,
|
||||||
|
maxHeight: 320,
|
||||||
|
deps: [question.id],
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
let nextResponse: QuestionResponse;
|
let nextResponse: QuestionResponse;
|
||||||
@@ -2215,8 +2236,8 @@ function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }:
|
|||||||
<div className="planning-options">
|
<div className="planning-options">
|
||||||
{question.type === "text" && (
|
{question.type === "text" && (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textAnswerAutosizeRef}
|
||||||
className="planning-textarea"
|
className="planning-textarea"
|
||||||
rows={4}
|
|
||||||
placeholder="Type your answer here..."
|
placeholder="Type your answer here..."
|
||||||
value={textValue}
|
value={textValue}
|
||||||
onChange={(e) => setTextValue(e.target.value)}
|
onChange={(e) => setTextValue(e.target.value)}
|
||||||
@@ -2306,9 +2327,9 @@ function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }:
|
|||||||
Additional comments (optional)
|
Additional comments (optional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={commentAutosizeRef}
|
||||||
id={`planning-comment-${question.id}`}
|
id={`planning-comment-${question.id}`}
|
||||||
className="planning-textarea"
|
className="planning-textarea"
|
||||||
rows={2}
|
|
||||||
placeholder="Add any extra context or direction..."
|
placeholder="Add any extra context or direction..."
|
||||||
value={commentValue}
|
value={commentValue}
|
||||||
onChange={(e) => setCommentValue(e.target.value)}
|
onChange={(e) => setCommentValue(e.target.value)}
|
||||||
@@ -2364,6 +2385,12 @@ function SummaryView({
|
|||||||
const [selectedDependencies, setSelectedDependencies] = useState<string[]>(
|
const [selectedDependencies, setSelectedDependencies] = useState<string[]>(
|
||||||
summary.suggestedDependencies
|
summary.suggestedDependencies
|
||||||
);
|
);
|
||||||
|
const { ref: descriptionAutosizeRef } = useAutosizeTextarea({
|
||||||
|
value: summary.description,
|
||||||
|
minHeight: isExpanded ? 200 : 120,
|
||||||
|
maxHeight: isExpanded ? 480 : 320,
|
||||||
|
deps: [isExpanded],
|
||||||
|
});
|
||||||
const selectedPriority = normalizeTaskPriority(summary.priority);
|
const selectedPriority = normalizeTaskPriority(summary.priority);
|
||||||
|
|
||||||
const handleDependencyToggle = (taskId: string) => {
|
const handleDependencyToggle = (taskId: string) => {
|
||||||
@@ -2403,8 +2430,8 @@ function SummaryView({
|
|||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={descriptionAutosizeRef}
|
||||||
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
|
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
|
||||||
rows={isExpanded ? 10 : 4}
|
|
||||||
value={summary.description}
|
value={summary.description}
|
||||||
onChange={(e) => onSummaryChange({ ...summary, description: e.target.value })}
|
onChange={(e) => onSummaryChange({ ...summary, description: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
|
import {
|
||||||
|
mockStartPlanningStreaming,
|
||||||
|
mockCreatePlanningDraft,
|
||||||
|
mockConnectPlanningStream,
|
||||||
|
mockRespondToPlanning,
|
||||||
|
mockRetryPlanningSession,
|
||||||
|
mockCancelPlanning,
|
||||||
|
mockStopPlanningGeneration,
|
||||||
|
mockUpdatePlanningSessionDraft,
|
||||||
|
mockCreateTaskFromPlanning,
|
||||||
|
mockStartPlanningBreakdown,
|
||||||
|
mockCreateTasksFromPlanning,
|
||||||
|
mockFetchAiSession,
|
||||||
|
mockParseConversationHistory,
|
||||||
|
mockFetchModels,
|
||||||
|
mockAcquireSessionLock,
|
||||||
|
mockReleaseSessionLock,
|
||||||
|
mockForceAcquireSessionLock,
|
||||||
|
mockFetchAiSessions,
|
||||||
|
mockConfirm,
|
||||||
|
mockUseViewportMode,
|
||||||
|
mockUseMobileKeyboard,
|
||||||
|
mockTasks,
|
||||||
|
mockModels,
|
||||||
|
} from "./PlanningModeModal.test-helpers";
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
||||||
|
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
|
||||||
|
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||||
|
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||||
|
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||||
|
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||||
|
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||||
|
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||||
|
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||||
|
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||||
|
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||||
|
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||||
|
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
||||||
|
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||||
|
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
||||||
|
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||||
|
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||||
|
fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
|
||||||
|
archiveAiSession: vi.fn(),
|
||||||
|
unarchiveAiSession: vi.fn(),
|
||||||
|
deleteAiSession: vi.fn(),
|
||||||
|
summarizePlanningDraftTitle: vi.fn().mockResolvedValue({ title: "Draft" }),
|
||||||
|
fetchModelsWithFallback: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useConfirm", () => ({
|
||||||
|
useConfirm: () => ({ confirm: mockConfirm }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useViewportMode", () => ({
|
||||||
|
useViewportMode: () => mockUseViewportMode(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||||
|
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSessionLock", () => ({
|
||||||
|
useSessionLock: () => ({ isLockedByOther: false, takeControl: vi.fn(), isLoading: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("PlanningModeModal autosize", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockConfirm.mockResolvedValue(true);
|
||||||
|
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||||
|
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||||
|
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||||
|
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||||
|
mockFetchAiSession.mockResolvedValue(null);
|
||||||
|
mockFetchAiSessions.mockResolvedValue([]);
|
||||||
|
mockParseConversationHistory.mockReturnValue([]);
|
||||||
|
mockFetchModels.mockResolvedValue({
|
||||||
|
models: mockModels,
|
||||||
|
favoriteProviders: [],
|
||||||
|
favoriteModels: [],
|
||||||
|
resolvedPlanningProvider: "openai",
|
||||||
|
resolvedPlanningModelId: "gpt-4o",
|
||||||
|
});
|
||||||
|
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||||
|
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||||
|
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||||
|
mockCancelPlanning.mockResolvedValue(undefined);
|
||||||
|
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
||||||
|
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||||
|
mockConnectPlanningStream.mockReturnValue({ close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grows initial planning textarea and caps at max", async () => {
|
||||||
|
render(<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement;
|
||||||
|
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
const value = (this as HTMLTextAreaElement).value;
|
||||||
|
if (!value) return 24;
|
||||||
|
if (value.split("\n").length > 5) return 800;
|
||||||
|
return 180;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(textarea, "line 1\nline 2");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(Number.parseInt(textarea.style.height, 10)).toBeGreaterThanOrEqual(120);
|
||||||
|
expect(Number.parseInt(textarea.style.height, 10)).toBeLessThanOrEqual(320);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(textarea, "\nline 3\nline 4\nline 5\nline 6");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(textarea.style.height).toBe("320px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user