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:
Fusion
2026-05-02 00:37:57 -07:00
committed by gsxdsm
parent d42dfb0ab3
commit d227c6416b
14 changed files with 495 additions and 53 deletions

View File

@@ -1759,6 +1759,7 @@ function SoulTab({
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false); const [justSaved, setJustSaved] = useState(false);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
setSoul(agent.soul ?? ""); setSoul(agent.soul ?? "");
@@ -1766,6 +1767,14 @@ function SoulTab({
setShowPreview(false); setShowPreview(false);
}, [agent.id, agent.soul]); }, [agent.id, agent.soul]);
useEffect(() => {
return () => {
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
};
}, []);
const hasChanges = soul !== (agent.soul ?? ""); const hasChanges = soul !== (agent.soul ?? "");
const handleSave = async () => { const handleSave = async () => {
@@ -1779,7 +1788,10 @@ function SoulTab({
await updateAgentSoul(agent.id, soul, projectId); await updateAgentSoul(agent.id, soul, projectId);
addToast("Soul saved", "success"); addToast("Soul saved", "success");
setJustSaved(true); setJustSaved(true);
setTimeout(() => setJustSaved(false), 3000); if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
await onSaved(); await onSaved();
} catch (err) { } catch (err) {
addToast(`Failed to save soul: ${getErrorMessage(err)}`, "error"); addToast(`Failed to save soul: ${getErrorMessage(err)}`, "error");
@@ -1910,6 +1922,8 @@ function MemoryTab({
const [savingSelectedFile, setSavingSelectedFile] = useState(false); const [savingSelectedFile, setSavingSelectedFile] = useState(false);
const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false); const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false);
const [fileSwitchHint, setFileSwitchHint] = useState(""); 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 isReadOnly = agent.state === "running";
const hasInlineChanges = memory !== (agent.memory ?? ""); const hasInlineChanges = memory !== (agent.memory ?? "");
@@ -1973,6 +1987,17 @@ function MemoryTab({
void loadMemoryFiles(); void loadMemoryFiles();
}, [agent.id, agent.memory, loadMemoryFiles]); }, [agent.id, agent.memory, loadMemoryFiles]);
useEffect(() => {
return () => {
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
if (selectedFileJustSavedTimeoutRef.current) {
clearTimeout(selectedFileJustSavedTimeoutRef.current);
}
};
}, []);
const handleSaveInlineMemory = async () => { const handleSaveInlineMemory = async () => {
if (memory.length > 50000) { if (memory.length > 50000) {
addToast("Memory must be at most 50,000 characters", "error"); addToast("Memory must be at most 50,000 characters", "error");
@@ -1984,7 +2009,10 @@ function MemoryTab({
await updateAgentMemory(agent.id, memory, projectId); await updateAgentMemory(agent.id, memory, projectId);
addToast("Memory saved", "success"); addToast("Memory saved", "success");
setJustSaved(true); setJustSaved(true);
setTimeout(() => setJustSaved(false), 3000); if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
await onSaved(); await onSaved();
} catch (err) { } catch (err) {
addToast(`Failed to save memory: ${getErrorMessage(err)}`, "error"); addToast(`Failed to save memory: ${getErrorMessage(err)}`, "error");
@@ -2016,7 +2044,10 @@ function MemoryTab({
await saveAgentMemoryFile(agent.id, selectedFilePath, selectedFileContent, projectId); await saveAgentMemoryFile(agent.id, selectedFilePath, selectedFileContent, projectId);
setSelectedFileDirty(false); setSelectedFileDirty(false);
setSelectedFileJustSaved(true); setSelectedFileJustSaved(true);
setTimeout(() => setSelectedFileJustSaved(false), 3000); if (selectedFileJustSavedTimeoutRef.current) {
clearTimeout(selectedFileJustSavedTimeoutRef.current);
}
selectedFileJustSavedTimeoutRef.current = setTimeout(() => setSelectedFileJustSaved(false), 3000);
setFileSwitchHint(""); setFileSwitchHint("");
await loadMemoryFiles(selectedFilePath); await loadMemoryFiles(selectedFilePath);
addToast("Agent memory file saved", "success"); addToast("Agent memory file saved", "success");
@@ -2255,6 +2286,8 @@ function InstructionsTab({
const [isSavingFile, setIsSavingFile] = useState(false); const [isSavingFile, setIsSavingFile] = useState(false);
const [justSaved, setJustSaved] = useState(false); const [justSaved, setJustSaved] = useState(false);
const [justSavedFile, setJustSavedFile] = 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 // Load file content when instructionsPath changes
useEffect(() => { useEffect(() => {
@@ -2296,6 +2329,17 @@ function InstructionsTab({
setShowPreview(false); setShowPreview(false);
}, [agent.id, agent.instructionsText, agent.instructionsPath]); }, [agent.id, agent.instructionsText, agent.instructionsPath]);
useEffect(() => {
return () => {
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
if (justSavedFileTimeoutRef.current) {
clearTimeout(justSavedFileTimeoutRef.current);
}
};
}, []);
const hasInstructionsChanges = (() => { const hasInstructionsChanges = (() => {
const currentText = instructionsText ?? ""; const currentText = instructionsText ?? "";
const persistedText = agent.instructionsText ?? ""; const persistedText = agent.instructionsText ?? "";
@@ -2317,7 +2361,10 @@ function InstructionsTab({
); );
addToast("Instructions saved", "success"); addToast("Instructions saved", "success");
setJustSaved(true); setJustSaved(true);
setTimeout(() => setJustSaved(false), 3000); if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
await onSaved(); await onSaved();
} catch (err) { } catch (err) {
addToast(`Failed to save instructions: ${getErrorMessage(err)}`, "error"); addToast(`Failed to save instructions: ${getErrorMessage(err)}`, "error");
@@ -2339,7 +2386,10 @@ function InstructionsTab({
addToast("Instructions file saved", "success"); addToast("Instructions file saved", "success");
setFileContentDirty(false); setFileContentDirty(false);
setJustSavedFile(true); setJustSavedFile(true);
setTimeout(() => setJustSavedFile(false), 3000); if (justSavedFileTimeoutRef.current) {
clearTimeout(justSavedFileTimeoutRef.current);
}
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
} catch (err) { } catch (err) {
addToast(`Failed to save instructions file: ${getErrorMessage(err)}`, "error"); addToast(`Failed to save instructions file: ${getErrorMessage(err)}`, "error");
} finally { } finally {
@@ -2602,6 +2652,7 @@ function HeartbeatProcedureSection({
const [fileContentDirty, setFileContentDirty] = useState(false); const [fileContentDirty, setFileContentDirty] = useState(false);
const [fileLoadError, setFileLoadError] = useState<string | null>(null); const [fileLoadError, setFileLoadError] = useState<string | null>(null);
const [justSavedFile, setJustSavedFile] = useState(false); const [justSavedFile, setJustSavedFile] = useState(false);
const justSavedFileTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentPath = agent.heartbeatProcedurePath?.trim(); const currentPath = agent.heartbeatProcedurePath?.trim();
const canonicalDefaultPath = `.fusion/agents/${agent.name const canonicalDefaultPath = `.fusion/agents/${agent.name
.toLowerCase() .toLowerCase()
@@ -2647,6 +2698,14 @@ function HeartbeatProcedureSection({
setJustSavedFile(false); setJustSavedFile(false);
}, [agent.id, currentPath]); }, [agent.id, currentPath]);
useEffect(() => {
return () => {
if (justSavedFileTimeoutRef.current) {
clearTimeout(justSavedFileTimeoutRef.current);
}
};
}, []);
const handleOpenViewer = async () => { const handleOpenViewer = async () => {
if (!currentPath) return; if (!currentPath) return;
setShowFileViewer(true); setShowFileViewer(true);
@@ -2661,7 +2720,10 @@ function HeartbeatProcedureSection({
setFileContentDirty(false); setFileContentDirty(false);
setJustSavedFile(true); setJustSavedFile(true);
addToast("Heartbeat procedure file saved", "success"); addToast("Heartbeat procedure file saved", "success");
setTimeout(() => setJustSavedFile(false), 3000); if (justSavedFileTimeoutRef.current) {
clearTimeout(justSavedFileTimeoutRef.current);
}
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
} catch (err) { } catch (err) {
addToast(`Failed to save heartbeat procedure file: ${getErrorMessage(err)}`, "error"); addToast(`Failed to save heartbeat procedure file: ${getErrorMessage(err)}`, "error");
} finally { } finally {

View File

@@ -54,6 +54,12 @@
line-height: 1.45; line-height: 1.45;
} }
.conversation-comment {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
}
.conversation-entry-thinking { .conversation-entry-thinking {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -102,6 +102,12 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
entry.question && responseValue !== undefined entry.question && responseValue !== undefined
? formatResponse(entry.question, responseValue) ? 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 ( return (
<div key={`${entry.question?.id ?? "thinking"}-${index}`} className="conversation-entry"> <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"> <div className="conversation-entry-response">
<strong>Your response</strong> <strong>Your response</strong>
<p>{formattedResponse || "—"}</p> <p>{formattedResponse || "—"}</p>
{comment && <p className="conversation-comment">💬 {comment}</p>}
</div> </div>
)} )}

View File

@@ -627,20 +627,31 @@ interface InterviewQuestionFormProps {
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) { function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
const [response, setResponse] = useState<QuestionResponse>({}); const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState(""); const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = useState("");
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
let nextResponse: QuestionResponse;
if (question.type === "text") { if (question.type === "text") {
onSubmit({ [question.id]: textValue }); nextResponse = { [question.id]: textValue };
} else if (question.type === "confirm") { } else if (question.type === "confirm") {
onSubmit({ [question.id]: response[question.id] === true }); nextResponse = { [question.id]: response[question.id] === true };
} else { } 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(() => { useEffect(() => {
setResponse({}); setResponse({});
setTextValue(""); setTextValue("");
setCommentValue("");
}, [question.id]); }, [question.id]);
const isValid = () => { const isValid = () => {
@@ -774,6 +785,22 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
</div> </div>
)} )}
</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> </div>
</div> </div>

View File

@@ -1028,20 +1028,31 @@ interface InterviewQuestionFormProps {
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) { function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
const [response, setResponse] = useState<QuestionResponse>({}); const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState(""); const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = useState("");
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
let nextResponse: QuestionResponse;
if (question.type === "text") { if (question.type === "text") {
onSubmit({ [question.id]: textValue }); nextResponse = { [question.id]: textValue };
} else if (question.type === "confirm") { } else if (question.type === "confirm") {
onSubmit({ [question.id]: response[question.id] === true }); nextResponse = { [question.id]: response[question.id] === true };
} else { } 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(() => { useEffect(() => {
setResponse({}); setResponse({});
setTextValue(""); setTextValue("");
setCommentValue("");
}, [question.id]); }, [question.id]);
const isValid = () => { const isValid = () => {
@@ -1175,6 +1186,22 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
</div> </div>
)} )}
</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> </div>
</div> </div>

View File

@@ -476,6 +476,21 @@
box-shadow: var(--focus-ring); 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 { .planning-char-count {
text-align: right; text-align: right;
font-size: 12px; font-size: 12px;
@@ -1248,6 +1263,10 @@
font-size: 16px; font-size: 16px;
} }
.planning-comment-section .planning-textarea {
min-height: calc(var(--space-md) * 5);
}
/* Planning options compact on mobile */ /* Planning options compact on mobile */
.planning-option { .planning-option {
padding: 12px; padding: 12px;

View File

@@ -1522,21 +1522,32 @@ interface QuestionFormProps {
function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) { function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) {
const [response, setResponse] = useState<QuestionResponse>({}); const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState(""); const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = useState("");
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
let nextResponse: QuestionResponse;
if (question.type === "text") { if (question.type === "text") {
onSubmit({ [question.id]: textValue }); nextResponse = { [question.id]: textValue };
} else if (question.type === "confirm") { } else if (question.type === "confirm") {
onSubmit({ [question.id]: response[question.id] === true }); nextResponse = { [question.id]: response[question.id] === true };
} else { } 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 // Reset state when question changes
useEffect(() => { useEffect(() => {
setResponse({}); setResponse({});
setTextValue(""); setTextValue("");
setCommentValue("");
}, [question.id]); }, [question.id]);
const isValid = () => { const isValid = () => {
@@ -1670,6 +1681,22 @@ function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }:
</div> </div>
)} )}
</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> </div>
</div> </div>

View File

@@ -72,4 +72,19 @@ describe("ConversationHistory", () => {
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i })); fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
expect(screen.getByText("Reasoning captured during subtask generation")).toBeDefined(); 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();
});
}); });

View File

@@ -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", () => { describe("resume session rehydration", () => {
const mockSessionAwaitingInput = { const mockSessionAwaitingInput = {
id: "session-resume-123", id: "session-resume-123",

View File

@@ -292,4 +292,36 @@ describe("MissionInterviewModal", () => {
expect(screen.getByText("Continuing...")).toBeInTheDocument(); expect(screen.getByText("Continuing...")).toBeInTheDocument();
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2); 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),
);
});
});
}); });

View File

@@ -1279,6 +1279,125 @@ describe("PlanningModeModal", () => {
expect(container.querySelector(".planning-question-form > .planning-actions")).not.toBeNull(); 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 () => { it("shows reconnecting indicator without clearing current question state", async () => {
let streamHandlers: any; let streamHandlers: any;

View File

@@ -645,30 +645,42 @@ function formatResponseForAgent(
responses: Record<string, unknown> responses: Record<string, unknown>
): string { ): string {
const responseValue = responses[question.id]; const responseValue = responses[question.id];
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
let formatted: string;
switch (question.type) { switch (question.type) {
case "text": case "text":
return `Question: ${question.question}\n\nAnswer: ${responseValue}`; formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
break;
case "single_select": case "single_select":
if (typeof responseValue === "string") { if (typeof responseValue === "string") {
const option = question.options?.find((o) => o.id === responseValue); 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": case "multi_select":
if (Array.isArray(responseValue)) { if (Array.isArray(responseValue)) {
const selected = responseValue.map((id) => { const selected = responseValue.map((id) => {
const option = question.options?.find((o) => o.id === id); const option = question.options?.find((o) => o.id === id);
return option?.label || 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": case "confirm":
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`; formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
break;
default: 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> { function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
@@ -735,15 +747,23 @@ function formatInterviewHistory(
return history return history
.map(({ question, response }) => { .map(({ question, response }) => {
const responseValue = const responseRecord =
response && typeof response === "object" && !Array.isArray(response) response && typeof response === "object" && !Array.isArray(response)
? (response as Record<string, unknown>)[question.id] ? (response as Record<string, unknown>)
: response; : undefined;
const responseValue = responseRecord ? responseRecord[question.id] : response;
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
return [ const lines = [
`Q: ${question.question}`, `Q: ${question.question}`,
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`, `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"); .join("\n\n");
} }

View File

@@ -703,30 +703,42 @@ function formatResponseForAgent(
responses: Record<string, unknown> responses: Record<string, unknown>
): string { ): string {
const responseValue = responses[question.id]; const responseValue = responses[question.id];
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
let formatted: string;
switch (question.type) { switch (question.type) {
case "text": case "text":
return `Question: ${question.question}\n\nAnswer: ${responseValue}`; formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
break;
case "single_select": case "single_select":
if (typeof responseValue === "string") { if (typeof responseValue === "string") {
const option = question.options?.find((o) => o.id === responseValue); 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": case "multi_select":
if (Array.isArray(responseValue)) { if (Array.isArray(responseValue)) {
const selected = responseValue.map((id) => { const selected = responseValue.map((id) => {
const option = question.options?.find((o) => o.id === id); const option = question.options?.find((o) => o.id === id);
return option?.label || 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": case "confirm":
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`; formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
break;
default: 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> { function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
@@ -832,15 +844,23 @@ function formatMissionInterviewHistory(
return history return history
.map(({ question, response }) => { .map(({ question, response }) => {
const responseValue = const responseRecord =
response && typeof response === "object" && !Array.isArray(response) response && typeof response === "object" && !Array.isArray(response)
? (response as Record<string, unknown>)[question.id] ? (response as Record<string, unknown>)
: response; : undefined;
const responseValue = responseRecord ? responseRecord[question.id] : response;
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
return [ const lines = [
`Q: ${question.question}`, `Q: ${question.question}`,
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`, `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"); .join("\n\n");
} }

View File

@@ -1593,17 +1593,23 @@ function formatResponseForAgent(
responses: Record<string, unknown> responses: Record<string, unknown>
): string { ): string {
const responseValue = responses[question.id]; const responseValue = responses[question.id];
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
let formatted: string;
switch (question.type) { switch (question.type) {
case "text": case "text":
return `Question: ${question.question}\n\nAnswer: ${responseValue}`; formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
break;
case "single_select": case "single_select":
if (typeof responseValue === "string") { if (typeof responseValue === "string") {
const option = question.options?.find((o) => o.id === responseValue); 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": case "multi_select":
if (Array.isArray(responseValue)) { if (Array.isArray(responseValue)) {
@@ -1611,16 +1617,22 @@ function formatResponseForAgent(
const option = question.options?.find((o) => o.id === id); const option = question.options?.find((o) => o.id === id);
return option?.label || 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": case "confirm":
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`; formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
break;
default: 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> { function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
@@ -1692,12 +1704,15 @@ export function formatInterviewQA(
} }
const entries = history.map(({ question, response }) => { const entries = history.map(({ question, response }) => {
const responseValue = const responseRecord =
response && typeof response === "object" && !Array.isArray(response) response && typeof response === "object" && !Array.isArray(response)
? (response as Record<string, unknown>)[question.id] ? (response as Record<string, unknown>)
: response; : 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")}`; return `## Planning Interview Context\n\n${entries.join("\n\n")}`;