feat(FN-3139): add planning comment input to interview modals and show in c
Merges FN-3139 planning comment infrastructure (comment inputs in mission/milestone-slice/planning modals, comment display in conversation history), FN-3119 Quick Chat FAB with slash-triggered skill menu and plugin integration, FN-3117 plugin dashboard views and chat session icons, and FN-3066 plugi Fusion-Task-Id: FN-3139
This commit is contained in:
@@ -1759,6 +1759,7 @@ function SoulTab({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSoul(agent.soul ?? "");
|
||||
@@ -1766,6 +1767,14 @@ function SoulTab({
|
||||
setShowPreview(false);
|
||||
}, [agent.id, agent.soul]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (justSavedTimeoutRef.current) {
|
||||
clearTimeout(justSavedTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasChanges = soul !== (agent.soul ?? "");
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -1779,7 +1788,10 @@ function SoulTab({
|
||||
await updateAgentSoul(agent.id, soul, projectId);
|
||||
addToast("Soul saved", "success");
|
||||
setJustSaved(true);
|
||||
setTimeout(() => setJustSaved(false), 3000);
|
||||
if (justSavedTimeoutRef.current) {
|
||||
clearTimeout(justSavedTimeoutRef.current);
|
||||
}
|
||||
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
addToast(`Failed to save soul: ${getErrorMessage(err)}`, "error");
|
||||
@@ -1910,6 +1922,8 @@ function MemoryTab({
|
||||
const [savingSelectedFile, setSavingSelectedFile] = useState(false);
|
||||
const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false);
|
||||
const [fileSwitchHint, setFileSwitchHint] = useState("");
|
||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedFileJustSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isReadOnly = agent.state === "running";
|
||||
const hasInlineChanges = memory !== (agent.memory ?? "");
|
||||
@@ -1973,6 +1987,17 @@ function MemoryTab({
|
||||
void loadMemoryFiles();
|
||||
}, [agent.id, agent.memory, loadMemoryFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (justSavedTimeoutRef.current) {
|
||||
clearTimeout(justSavedTimeoutRef.current);
|
||||
}
|
||||
if (selectedFileJustSavedTimeoutRef.current) {
|
||||
clearTimeout(selectedFileJustSavedTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSaveInlineMemory = async () => {
|
||||
if (memory.length > 50000) {
|
||||
addToast("Memory must be at most 50,000 characters", "error");
|
||||
@@ -1984,7 +2009,10 @@ function MemoryTab({
|
||||
await updateAgentMemory(agent.id, memory, projectId);
|
||||
addToast("Memory saved", "success");
|
||||
setJustSaved(true);
|
||||
setTimeout(() => setJustSaved(false), 3000);
|
||||
if (justSavedTimeoutRef.current) {
|
||||
clearTimeout(justSavedTimeoutRef.current);
|
||||
}
|
||||
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
addToast(`Failed to save memory: ${getErrorMessage(err)}`, "error");
|
||||
@@ -2016,7 +2044,10 @@ function MemoryTab({
|
||||
await saveAgentMemoryFile(agent.id, selectedFilePath, selectedFileContent, projectId);
|
||||
setSelectedFileDirty(false);
|
||||
setSelectedFileJustSaved(true);
|
||||
setTimeout(() => setSelectedFileJustSaved(false), 3000);
|
||||
if (selectedFileJustSavedTimeoutRef.current) {
|
||||
clearTimeout(selectedFileJustSavedTimeoutRef.current);
|
||||
}
|
||||
selectedFileJustSavedTimeoutRef.current = setTimeout(() => setSelectedFileJustSaved(false), 3000);
|
||||
setFileSwitchHint("");
|
||||
await loadMemoryFiles(selectedFilePath);
|
||||
addToast("Agent memory file saved", "success");
|
||||
@@ -2255,6 +2286,8 @@ function InstructionsTab({
|
||||
const [isSavingFile, setIsSavingFile] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [justSavedFile, setJustSavedFile] = useState(false);
|
||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const justSavedFileTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Load file content when instructionsPath changes
|
||||
useEffect(() => {
|
||||
@@ -2296,6 +2329,17 @@ function InstructionsTab({
|
||||
setShowPreview(false);
|
||||
}, [agent.id, agent.instructionsText, agent.instructionsPath]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (justSavedTimeoutRef.current) {
|
||||
clearTimeout(justSavedTimeoutRef.current);
|
||||
}
|
||||
if (justSavedFileTimeoutRef.current) {
|
||||
clearTimeout(justSavedFileTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasInstructionsChanges = (() => {
|
||||
const currentText = instructionsText ?? "";
|
||||
const persistedText = agent.instructionsText ?? "";
|
||||
@@ -2317,7 +2361,10 @@ function InstructionsTab({
|
||||
);
|
||||
addToast("Instructions saved", "success");
|
||||
setJustSaved(true);
|
||||
setTimeout(() => setJustSaved(false), 3000);
|
||||
if (justSavedTimeoutRef.current) {
|
||||
clearTimeout(justSavedTimeoutRef.current);
|
||||
}
|
||||
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
addToast(`Failed to save instructions: ${getErrorMessage(err)}`, "error");
|
||||
@@ -2339,7 +2386,10 @@ function InstructionsTab({
|
||||
addToast("Instructions file saved", "success");
|
||||
setFileContentDirty(false);
|
||||
setJustSavedFile(true);
|
||||
setTimeout(() => setJustSavedFile(false), 3000);
|
||||
if (justSavedFileTimeoutRef.current) {
|
||||
clearTimeout(justSavedFileTimeoutRef.current);
|
||||
}
|
||||
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
|
||||
} catch (err) {
|
||||
addToast(`Failed to save instructions file: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
@@ -2602,6 +2652,7 @@ function HeartbeatProcedureSection({
|
||||
const [fileContentDirty, setFileContentDirty] = useState(false);
|
||||
const [fileLoadError, setFileLoadError] = useState<string | null>(null);
|
||||
const [justSavedFile, setJustSavedFile] = useState(false);
|
||||
const justSavedFileTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const currentPath = agent.heartbeatProcedurePath?.trim();
|
||||
const canonicalDefaultPath = `.fusion/agents/${agent.name
|
||||
.toLowerCase()
|
||||
@@ -2647,6 +2698,14 @@ function HeartbeatProcedureSection({
|
||||
setJustSavedFile(false);
|
||||
}, [agent.id, currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (justSavedFileTimeoutRef.current) {
|
||||
clearTimeout(justSavedFileTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenViewer = async () => {
|
||||
if (!currentPath) return;
|
||||
setShowFileViewer(true);
|
||||
@@ -2661,7 +2720,10 @@ function HeartbeatProcedureSection({
|
||||
setFileContentDirty(false);
|
||||
setJustSavedFile(true);
|
||||
addToast("Heartbeat procedure file saved", "success");
|
||||
setTimeout(() => setJustSavedFile(false), 3000);
|
||||
if (justSavedFileTimeoutRef.current) {
|
||||
clearTimeout(justSavedFileTimeoutRef.current);
|
||||
}
|
||||
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
|
||||
} catch (err) {
|
||||
addToast(`Failed to save heartbeat procedure file: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.conversation-comment {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.conversation-entry-thinking {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -102,6 +102,12 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
|
||||
entry.question && responseValue !== undefined
|
||||
? formatResponse(entry.question, responseValue)
|
||||
: "";
|
||||
const responseRecord =
|
||||
entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)
|
||||
? (entry.response as Record<string, unknown>)
|
||||
: undefined;
|
||||
const comment =
|
||||
typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
||||
|
||||
return (
|
||||
<div key={`${entry.question?.id ?? "thinking"}-${index}`} className="conversation-entry">
|
||||
@@ -120,6 +126,7 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
|
||||
<div className="conversation-entry-response">
|
||||
<strong>Your response</strong>
|
||||
<p>{formattedResponse || "—"}</p>
|
||||
{comment && <p className="conversation-comment">💬 {comment}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -627,20 +627,31 @@ interface InterviewQuestionFormProps {
|
||||
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
|
||||
const [response, setResponse] = useState<QuestionResponse>({});
|
||||
const [textValue, setTextValue] = useState("");
|
||||
const [commentValue, setCommentValue] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
let nextResponse: QuestionResponse;
|
||||
|
||||
if (question.type === "text") {
|
||||
onSubmit({ [question.id]: textValue });
|
||||
nextResponse = { [question.id]: textValue };
|
||||
} else if (question.type === "confirm") {
|
||||
onSubmit({ [question.id]: response[question.id] === true });
|
||||
nextResponse = { [question.id]: response[question.id] === true };
|
||||
} else {
|
||||
onSubmit(response);
|
||||
nextResponse = response;
|
||||
}
|
||||
}, [question, response, textValue, onSubmit]);
|
||||
|
||||
const trimmedComment = commentValue.trim();
|
||||
if (trimmedComment.length > 0) {
|
||||
nextResponse = { ...nextResponse, _comment: trimmedComment };
|
||||
}
|
||||
|
||||
onSubmit(nextResponse);
|
||||
}, [commentValue, question, response, textValue, onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
setResponse({});
|
||||
setTextValue("");
|
||||
setCommentValue("");
|
||||
}, [question.id]);
|
||||
|
||||
const isValid = () => {
|
||||
@@ -774,6 +785,22 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{question.type !== "text" && (
|
||||
<div className="planning-comment-section">
|
||||
<label className="planning-comment-label" htmlFor={`planning-comment-${question.id}`}>
|
||||
Additional comments (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id={`planning-comment-${question.id}`}
|
||||
className="planning-textarea"
|
||||
rows={2}
|
||||
placeholder="Add any extra context or direction..."
|
||||
value={commentValue}
|
||||
onChange={(e) => setCommentValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1028,20 +1028,31 @@ interface InterviewQuestionFormProps {
|
||||
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
|
||||
const [response, setResponse] = useState<QuestionResponse>({});
|
||||
const [textValue, setTextValue] = useState("");
|
||||
const [commentValue, setCommentValue] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
let nextResponse: QuestionResponse;
|
||||
|
||||
if (question.type === "text") {
|
||||
onSubmit({ [question.id]: textValue });
|
||||
nextResponse = { [question.id]: textValue };
|
||||
} else if (question.type === "confirm") {
|
||||
onSubmit({ [question.id]: response[question.id] === true });
|
||||
nextResponse = { [question.id]: response[question.id] === true };
|
||||
} else {
|
||||
onSubmit(response);
|
||||
nextResponse = response;
|
||||
}
|
||||
}, [question, response, textValue, onSubmit]);
|
||||
|
||||
const trimmedComment = commentValue.trim();
|
||||
if (trimmedComment.length > 0) {
|
||||
nextResponse = { ...nextResponse, _comment: trimmedComment };
|
||||
}
|
||||
|
||||
onSubmit(nextResponse);
|
||||
}, [commentValue, question, response, textValue, onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
setResponse({});
|
||||
setTextValue("");
|
||||
setCommentValue("");
|
||||
}, [question.id]);
|
||||
|
||||
const isValid = () => {
|
||||
@@ -1175,6 +1186,22 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{question.type !== "text" && (
|
||||
<div className="planning-comment-section">
|
||||
<label className="planning-comment-label" htmlFor={`planning-comment-${question.id}`}>
|
||||
Additional comments (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id={`planning-comment-${question.id}`}
|
||||
className="planning-textarea"
|
||||
rows={2}
|
||||
placeholder="Add any extra context or direction..."
|
||||
value={commentValue}
|
||||
onChange={(e) => setCommentValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -476,6 +476,21 @@
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.planning-comment-section {
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.planning-comment-label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
}
|
||||
|
||||
.planning-comment-section .planning-textarea {
|
||||
min-height: calc(var(--space-md) * 5);
|
||||
}
|
||||
|
||||
.planning-char-count {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
@@ -1248,6 +1263,10 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.planning-comment-section .planning-textarea {
|
||||
min-height: calc(var(--space-md) * 5);
|
||||
}
|
||||
|
||||
/* Planning options compact on mobile */
|
||||
.planning-option {
|
||||
padding: 12px;
|
||||
|
||||
@@ -1522,21 +1522,32 @@ interface QuestionFormProps {
|
||||
function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) {
|
||||
const [response, setResponse] = useState<QuestionResponse>({});
|
||||
const [textValue, setTextValue] = useState("");
|
||||
const [commentValue, setCommentValue] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
let nextResponse: QuestionResponse;
|
||||
|
||||
if (question.type === "text") {
|
||||
onSubmit({ [question.id]: textValue });
|
||||
nextResponse = { [question.id]: textValue };
|
||||
} else if (question.type === "confirm") {
|
||||
onSubmit({ [question.id]: response[question.id] === true });
|
||||
nextResponse = { [question.id]: response[question.id] === true };
|
||||
} else {
|
||||
onSubmit(response);
|
||||
nextResponse = response;
|
||||
}
|
||||
}, [question, response, textValue, onSubmit]);
|
||||
|
||||
const trimmedComment = commentValue.trim();
|
||||
if (trimmedComment.length > 0) {
|
||||
nextResponse = { ...nextResponse, _comment: trimmedComment };
|
||||
}
|
||||
|
||||
onSubmit(nextResponse);
|
||||
}, [commentValue, question, response, textValue, onSubmit]);
|
||||
|
||||
// Reset state when question changes
|
||||
useEffect(() => {
|
||||
setResponse({});
|
||||
setTextValue("");
|
||||
setCommentValue("");
|
||||
}, [question.id]);
|
||||
|
||||
const isValid = () => {
|
||||
@@ -1670,6 +1681,22 @@ function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{question.type !== "text" && (
|
||||
<div className="planning-comment-section">
|
||||
<label className="planning-comment-label" htmlFor={`planning-comment-${question.id}`}>
|
||||
Additional comments (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id={`planning-comment-${question.id}`}
|
||||
className="planning-textarea"
|
||||
rows={2}
|
||||
placeholder="Add any extra context or direction..."
|
||||
value={commentValue}
|
||||
onChange={(e) => setCommentValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,4 +72,19 @@ describe("ConversationHistory", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
|
||||
expect(screen.getByText("Reasoning captured during subtask generation")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders comment when response includes _comment", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: baseQuestion,
|
||||
response: { "q-scope": "small", _comment: "Need this done by next sprint" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("💬 Need this done by next sprint")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -411,6 +411,52 @@ describe("MilestoneSliceInterviewModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment input", () => {
|
||||
it("shows comment textarea and submits _comment in milestone interview", async () => {
|
||||
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
|
||||
|
||||
render(
|
||||
<MilestoneSliceInterviewModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onApplied={vi.fn()}
|
||||
targetType="milestone"
|
||||
targetId="MS-001"
|
||||
targetTitle="Test Milestone"
|
||||
projectId="test-project"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Preparing next question/)).toBeDefined();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onQuestion(SAMPLE_QUESTION);
|
||||
});
|
||||
|
||||
await screen.findByText("What is the target scope?");
|
||||
expect(screen.getByPlaceholderText("Add any extra context or direction...")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByText("MVP"));
|
||||
fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), {
|
||||
target: { value: "Keep this aligned with mission MVP" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Continue/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
expect.objectContaining({ scope: "mvp", _comment: "Keep this aligned with mission MVP" }),
|
||||
"test-project",
|
||||
"test-tab-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resume session rehydration", () => {
|
||||
const mockSessionAwaitingInput = {
|
||||
id: "session-resume-123",
|
||||
|
||||
@@ -292,4 +292,36 @@ describe("MissionInterviewModal", () => {
|
||||
expect(screen.getByText("Continuing...")).toBeInTheDocument();
|
||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows comment textarea and submits _comment for non-text questions", async () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByText("MVP"));
|
||||
fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), {
|
||||
target: { value: "Optimize for launch speed" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
||||
"mission-session-1",
|
||||
expect.objectContaining({ scope: "mvp", _comment: "Optimize for launch speed" }),
|
||||
undefined,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1279,6 +1279,125 @@ describe("PlanningModeModal", () => {
|
||||
expect(container.querySelector(".planning-question-form > .planning-actions")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows comment textarea for single_select questions", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
expect(await screen.findByPlaceholderText("Add any extra context or direction...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show comment textarea for text questions", async () => {
|
||||
const textQuestion: PlanningQuestion = {
|
||||
id: "q-text",
|
||||
type: "text",
|
||||
question: "Describe your requirements",
|
||||
};
|
||||
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onQuestion?.(textQuestion);
|
||||
}, 10);
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await screen.findByText("Describe your requirements");
|
||||
expect(screen.queryByPlaceholderText("Add any extra context or direction...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes _comment in response when comment is filled", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await screen.findByText("What is the scope?");
|
||||
fireEvent.click(screen.getByText("Medium"));
|
||||
fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), {
|
||||
target: { value: "Prioritize API first" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
expect.objectContaining({ "q-scope": "medium", _comment: "Prioritize API first" }),
|
||||
undefined,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits _comment when comment is empty", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await screen.findByText("What is the scope?");
|
||||
fireEvent.click(screen.getByText("Medium"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
expect.not.objectContaining({ _comment: expect.anything() }),
|
||||
undefined,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows reconnecting indicator without clearing current question state", async () => {
|
||||
let streamHandlers: any;
|
||||
|
||||
|
||||
@@ -645,30 +645,42 @@ function formatResponseForAgent(
|
||||
responses: Record<string, unknown>
|
||||
): string {
|
||||
const responseValue = responses[question.id];
|
||||
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
||||
|
||||
let formatted: string;
|
||||
|
||||
switch (question.type) {
|
||||
case "text":
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
case "single_select":
|
||||
if (typeof responseValue === "string") {
|
||||
const option = question.options?.find((o) => o.id === responseValue);
|
||||
return `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||
break;
|
||||
}
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
case "multi_select":
|
||||
if (Array.isArray(responseValue)) {
|
||||
const selected = responseValue.map((id) => {
|
||||
const option = question.options?.find((o) => o.id === id);
|
||||
return option?.label || id;
|
||||
});
|
||||
return `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||
break;
|
||||
}
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
case "confirm":
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
break;
|
||||
default:
|
||||
return `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return comment.length > 0 ? `${formatted}\n\nAdditional context: ${comment}` : formatted;
|
||||
}
|
||||
|
||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||
@@ -735,15 +747,23 @@ function formatInterviewHistory(
|
||||
|
||||
return history
|
||||
.map(({ question, response }) => {
|
||||
const responseValue =
|
||||
const responseRecord =
|
||||
response && typeof response === "object" && !Array.isArray(response)
|
||||
? (response as Record<string, unknown>)[question.id]
|
||||
: response;
|
||||
? (response as Record<string, unknown>)
|
||||
: undefined;
|
||||
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
||||
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
||||
|
||||
return [
|
||||
const lines = [
|
||||
`Q: ${question.question}`,
|
||||
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`,
|
||||
].join("\n");
|
||||
];
|
||||
|
||||
if (comment.length > 0) {
|
||||
lines.push(`Comment: ${comment}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -703,30 +703,42 @@ function formatResponseForAgent(
|
||||
responses: Record<string, unknown>
|
||||
): string {
|
||||
const responseValue = responses[question.id];
|
||||
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
||||
|
||||
let formatted: string;
|
||||
|
||||
switch (question.type) {
|
||||
case "text":
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
case "single_select":
|
||||
if (typeof responseValue === "string") {
|
||||
const option = question.options?.find((o) => o.id === responseValue);
|
||||
return `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||
break;
|
||||
}
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
case "multi_select":
|
||||
if (Array.isArray(responseValue)) {
|
||||
const selected = responseValue.map((id) => {
|
||||
const option = question.options?.find((o) => o.id === id);
|
||||
return option?.label || id;
|
||||
});
|
||||
return `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||
break;
|
||||
}
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
case "confirm":
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
break;
|
||||
default:
|
||||
return `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return comment.length > 0 ? `${formatted}\n\nAdditional context: ${comment}` : formatted;
|
||||
}
|
||||
|
||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||
@@ -832,15 +844,23 @@ function formatMissionInterviewHistory(
|
||||
|
||||
return history
|
||||
.map(({ question, response }) => {
|
||||
const responseValue =
|
||||
const responseRecord =
|
||||
response && typeof response === "object" && !Array.isArray(response)
|
||||
? (response as Record<string, unknown>)[question.id]
|
||||
: response;
|
||||
? (response as Record<string, unknown>)
|
||||
: undefined;
|
||||
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
||||
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
||||
|
||||
return [
|
||||
const lines = [
|
||||
`Q: ${question.question}`,
|
||||
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`,
|
||||
].join("\n");
|
||||
];
|
||||
|
||||
if (comment.length > 0) {
|
||||
lines.push(`Comment: ${comment}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -1593,17 +1593,23 @@ function formatResponseForAgent(
|
||||
responses: Record<string, unknown>
|
||||
): string {
|
||||
const responseValue = responses[question.id];
|
||||
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
||||
|
||||
let formatted: string;
|
||||
|
||||
switch (question.type) {
|
||||
case "text":
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
|
||||
case "single_select":
|
||||
if (typeof responseValue === "string") {
|
||||
const option = question.options?.find((o) => o.id === responseValue);
|
||||
return `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||
break;
|
||||
}
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
|
||||
case "multi_select":
|
||||
if (Array.isArray(responseValue)) {
|
||||
@@ -1611,16 +1617,22 @@ function formatResponseForAgent(
|
||||
const option = question.options?.find((o) => o.id === id);
|
||||
return option?.label || id;
|
||||
});
|
||||
return `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||
break;
|
||||
}
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||
break;
|
||||
|
||||
case "confirm":
|
||||
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
return `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return comment.length > 0 ? `${formatted}\n\nAdditional context: ${comment}` : formatted;
|
||||
}
|
||||
|
||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||
@@ -1692,12 +1704,15 @@ export function formatInterviewQA(
|
||||
}
|
||||
|
||||
const entries = history.map(({ question, response }) => {
|
||||
const responseValue =
|
||||
const responseRecord =
|
||||
response && typeof response === "object" && !Array.isArray(response)
|
||||
? (response as Record<string, unknown>)[question.id]
|
||||
: response;
|
||||
? (response as Record<string, unknown>)
|
||||
: undefined;
|
||||
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
||||
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
||||
|
||||
return `**Q: ${question.question}**\nA: ${formatInterviewAnswer(question, responseValue)}`;
|
||||
const answerLine = `**Q: ${question.question}**\nA: ${formatInterviewAnswer(question, responseValue)}`;
|
||||
return comment.length > 0 ? `${answerLine}\nComment: ${comment}` : answerLine;
|
||||
});
|
||||
|
||||
return `## Planning Interview Context\n\n${entries.join("\n\n")}`;
|
||||
|
||||
Reference in New Issue
Block a user