fix(chat): update question abort state

This commit is contained in:
2026-06-08 18:39:45 +08:00
parent b23cb6acdd
commit 3a36c693cd
3 changed files with 255 additions and 27 deletions
+147 -12
View File
@@ -51,6 +51,7 @@ import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded"; import BlockRounded from "@mui/icons-material/BlockRounded";
import PushPinRounded from "@mui/icons-material/PushPinRounded"; import PushPinRounded from "@mui/icons-material/PushPinRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded"; import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
@@ -713,26 +714,30 @@ const QuestionRequestCard = ({
const isSubmitting = questionRequest.status === "submitting"; const isSubmitting = questionRequest.status === "submitting";
const statusColor = getQuestionStatusColor(questionRequest.status, theme); const statusColor = getQuestionStatusColor(questionRequest.status, theme);
const [selected, setSelected] = React.useState<Record<number, string[]>>({}); const [selected, setSelected] = React.useState<Record<number, string[]>>({});
const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({});
const [custom, setCustom] = React.useState<Record<number, string>>({}); const [custom, setCustom] = React.useState<Record<number, string>>({});
const answers = React.useMemo( const answers = React.useMemo(
() => () =>
questionRequest.questions.map((question, index) => { questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? []; const selectedAnswers = selected[index] ?? [];
const isCustomSelected =
customSelected[index] === true ||
(question.custom !== false && question.options.length === 0);
const customAnswer = custom[index]?.trim(); const customAnswer = custom[index]?.trim();
return customAnswer ? [...selectedAnswers, customAnswer] : selectedAnswers; return isCustomSelected && customAnswer
? [...selectedAnswers, customAnswer]
: selectedAnswers;
}), }),
[custom, questionRequest.questions, selected], [custom, customSelected, questionRequest.questions, selected],
); );
const canSubmit = const canSubmit =
isEditable && isEditable &&
questionRequest.questions.length > 0 && questionRequest.questions.length > 0 &&
questionRequest.questions.every((question, index) => { questionRequest.questions.every((_, index) => {
const answer = answers[index] ?? []; const answer = answers[index] ?? [];
const hasInput = answer.some((item) => item.trim().length > 0); return answer.some((item) => item.trim().length > 0);
const canAnswer = question.options.length > 0 || question.custom === true;
return canAnswer && hasInput;
}); });
const answerSummary = (questionRequest.answers ?? []) const answerSummary = (questionRequest.answers ?? [])
@@ -809,12 +814,22 @@ const QuestionRequestCard = ({
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}> <Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
{questionRequest.questions.map((question, index) => { {questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? []; const selectedAnswers = selected[index] ?? [];
const isCustomEnabled = question.custom !== false;
const isCustomSelected =
customSelected[index] === true ||
(isCustomEnabled && question.options.length === 0);
const setQuestionAnswers = (nextAnswers: string[]) => { const setQuestionAnswers = (nextAnswers: string[]) => {
setSelected((current) => ({ setSelected((current) => ({
...current, ...current,
[index]: nextAnswers, [index]: nextAnswers,
})); }));
}; };
const setQuestionCustomSelected = (checked: boolean) => {
setCustomSelected((current) => ({
...current,
[index]: checked,
}));
};
return ( return (
<Box <Box
@@ -884,7 +899,10 @@ const QuestionRequestCard = ({
size="small" size="small"
variant={checked ? "contained" : "outlined"} variant={checked ? "contained" : "outlined"}
disabled={!isEditable || isSubmitting} disabled={!isEditable || isSubmitting}
onClick={() => setQuestionAnswers([option.label])} onClick={() => {
setQuestionAnswers([option.label]);
setQuestionCustomSelected(false);
}}
startIcon={ startIcon={
checked ? ( checked ? (
<CheckCircleRounded fontSize="small" /> <CheckCircleRounded fontSize="small" />
@@ -921,16 +939,119 @@ const QuestionRequestCard = ({
</Button> </Button>
); );
})} })}
{isCustomEnabled ? (
question.multiple ? (
<FormControlLabel
disabled={!isEditable || isSubmitting}
control={
<Checkbox
size="small"
checked={isCustomSelected}
onChange={(event) =>
setQuestionCustomSelected(event.target.checked)
}
sx={{
p: 0.5,
color: alpha("#0288d1", 0.55),
"&.Mui-checked": { color: "#0288d1" },
}}
/>
}
label={
<Stack direction="row" spacing={0.75} alignItems="center">
<EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} />
<Typography variant="body2" fontWeight={800}>
</Typography>
</Stack>
}
sx={{
alignItems: "center",
minHeight: 38,
m: 0,
px: 0.75,
py: 0.25,
borderRadius: 2,
border: `1px solid ${
isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18)
}`,
bgcolor: isCustomSelected
? alpha("#0288d1", 0.1)
: alpha("#fff", 0.45),
transition: "background-color 0.18s ease, border-color 0.18s ease",
"&:hover": {
bgcolor: isCustomSelected
? alpha("#0288d1", 0.13)
: alpha("#0288d1", 0.07),
},
"& .MuiFormControlLabel-label": {
color: isCustomSelected ? "#0277bd" : "text.primary",
},
}}
/>
) : (
<Button
size="small"
variant={isCustomSelected ? "contained" : "outlined"}
disabled={!isEditable || isSubmitting}
onClick={() => {
setQuestionAnswers([]);
setQuestionCustomSelected(true);
}}
startIcon={
isCustomSelected ? (
<CheckCircleRounded fontSize="small" />
) : (
<EditNoteRounded fontSize="small" />
)
}
sx={{
justifyContent: "flex-start",
minHeight: 38,
borderRadius: 2,
textTransform: "none",
fontWeight: 800,
bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45),
borderColor: isCustomSelected
? "#0288d1"
: alpha("#0288d1", 0.22),
"&:hover": {
bgcolor: isCustomSelected
? "#0277bd"
: alpha("#0288d1", 0.08),
},
}}
>
<Box sx={{ textAlign: "left", minWidth: 0 }}>
<Typography variant="body2" fontWeight={800}>
</Typography>
</Box>
</Button>
)
) : null}
</Stack> </Stack>
) : null} ) : null}
{question.custom ? ( <Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
<Box
sx={{
mt: 0.85,
px: 1.15,
py: 0.85,
borderRadius: 2.5,
bgcolor: alpha("#fff", 0.62),
border: `1px solid ${alpha("#fff", 0.82)}`,
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
backdropFilter: "blur(18px)",
}}
>
<TextField <TextField
multiline multiline
minRows={2} minRows={2}
maxRows={5} maxRows={5}
fullWidth fullWidth
size="small" variant="standard"
disabled={!isEditable || isSubmitting} disabled={!isEditable || isSubmitting}
value={custom[index] ?? ""} value={custom[index] ?? ""}
onChange={(event) => onChange={(event) =>
@@ -939,10 +1060,24 @@ const QuestionRequestCard = ({
[index]: event.target.value, [index]: event.target.value,
})) }))
} }
placeholder="补充说明" placeholder="输入自定义回答"
sx={{ mt: 1 }} InputProps={{
disableUnderline: true,
sx: {
alignItems: "flex-start",
fontSize: "0.88rem",
lineHeight: 1.55,
fontWeight: 500,
color: "text.primary",
"& textarea::placeholder": {
color: alpha(theme.palette.text.primary, 0.38),
opacity: 1,
},
},
}}
/> />
) : null} </Box>
</Collapse>
</Box> </Box>
); );
})} })}
@@ -785,6 +785,29 @@ describe("useAgentChatSession", () => {
], ],
createdAt: 1001, createdAt: 1001,
} satisfies StreamEvent); } satisfies StreamEvent);
onEvent({
type: "permission_request",
sessionId: "session-1",
requestId: "perm-abort",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
createdAt: 1002,
} satisfies StreamEvent);
onEvent({
type: "question_request",
sessionId: "session-1",
requestId: "question-abort",
questions: [
{
header: "范围",
question: "请选择范围",
options: [{ label: "城区", description: "中心城区" }],
},
],
createdAt: 1003,
} satisfies StreamEvent);
signal?.addEventListener("abort", () => { signal?.addEventListener("abort", () => {
reject(new Error("aborted")); reject(new Error("aborted"));
@@ -842,6 +865,22 @@ describe("useAgentChatSession", () => {
], ],
}), }),
], ],
permissions: [
expect.objectContaining({
requestId: "perm-abort",
status: "rejected",
repliedAt: expect.any(Number),
error: undefined,
}),
],
questions: [
expect.objectContaining({
requestId: "question-abort",
status: "rejected",
repliedAt: expect.any(Number),
error: undefined,
}),
],
}), }),
); );
expect(abortAgentChat).toHaveBeenCalledWith("session-1"); expect(abortAgentChat).toHaveBeenCalledWith("session-1");
@@ -217,7 +217,7 @@ const getQuestionContentSignature = (
description: option.description, description: option.description,
})), })),
multiple: question.multiple ?? false, multiple: question.multiple ?? false,
custom: question.custom ?? false, custom: question.custom !== false,
})), })),
); );
@@ -366,12 +366,64 @@ const upsertTodoUpdate = (
}, },
]; ];
const rejectOpenPermissionsAfterAbort = (
permissions: AgentPermissionRequest[] | undefined,
) => {
if (!permissions?.length) return permissions;
let changed = false;
const nextPermissions = permissions.map((permission) => {
if (
permission.status !== "pending" &&
permission.status !== "submitting" &&
permission.status !== "error"
) {
return permission;
}
changed = true;
return {
...permission,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined,
};
});
return changed ? nextPermissions : permissions;
};
const rejectOpenQuestionsAfterAbort = (
questions: AgentQuestionRequest[] | undefined,
) => {
if (!questions?.length) return questions;
let changed = false;
const nextQuestions = questions.map((question) => {
if (
question.status !== "pending" &&
question.status !== "submitting" &&
question.status !== "error"
) {
return question;
}
changed = true;
return {
...question,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined,
};
});
return changed ? nextQuestions : questions;
};
const finalizeAssistantMessageAfterAbort = (message: Message): Message => { const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress); const completedProgress = completeRunningProgress(message.progress);
const cancelledTodos = cancelRunningTodos(message.todos); const cancelledTodos = cancelRunningTodos(message.todos);
const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions);
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
const hasVisibleOutput = const hasVisibleOutput =
message.content.trim().length > 0 || message.content.trim().length > 0 ||
Boolean(message.artifacts?.length) || Boolean(message.artifacts?.length) ||
Boolean(rejectedPermissions?.length) ||
Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) || Boolean(completedProgress?.length) ||
Boolean(cancelledTodos?.length); Boolean(cancelledTodos?.length);
@@ -384,6 +436,8 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
content: message.content || "⚠️ **请求已中断**", content: message.content || "⚠️ **请求已中断**",
isError: true, isError: true,
progress: completedProgress, progress: completedProgress,
permissions: rejectedPermissions,
questions: rejectedQuestions,
todos: cancelledTodos, todos: cancelledTodos,
}; };
}; };