diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 7990d0f..b9bd534 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -51,6 +51,7 @@ import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import BlockRounded from "@mui/icons-material/BlockRounded"; import PushPinRounded from "@mui/icons-material/PushPinRounded"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded"; @@ -713,26 +714,30 @@ const QuestionRequestCard = ({ const isSubmitting = questionRequest.status === "submitting"; const statusColor = getQuestionStatusColor(questionRequest.status, theme); const [selected, setSelected] = React.useState>({}); + const [customSelected, setCustomSelected] = React.useState>({}); const [custom, setCustom] = React.useState>({}); const answers = React.useMemo( () => questionRequest.questions.map((question, index) => { const selectedAnswers = selected[index] ?? []; + const isCustomSelected = + customSelected[index] === true || + (question.custom !== false && question.options.length === 0); 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 = isEditable && questionRequest.questions.length > 0 && - questionRequest.questions.every((question, index) => { + questionRequest.questions.every((_, index) => { const answer = answers[index] ?? []; - const hasInput = answer.some((item) => item.trim().length > 0); - const canAnswer = question.options.length > 0 || question.custom === true; - return canAnswer && hasInput; + return answer.some((item) => item.trim().length > 0); }); const answerSummary = (questionRequest.answers ?? []) @@ -809,12 +814,22 @@ const QuestionRequestCard = ({ {questionRequest.questions.map((question, index) => { const selectedAnswers = selected[index] ?? []; + const isCustomEnabled = question.custom !== false; + const isCustomSelected = + customSelected[index] === true || + (isCustomEnabled && question.options.length === 0); const setQuestionAnswers = (nextAnswers: string[]) => { setSelected((current) => ({ ...current, [index]: nextAnswers, })); }; + const setQuestionCustomSelected = (checked: boolean) => { + setCustomSelected((current) => ({ + ...current, + [index]: checked, + })); + }; return ( setQuestionAnswers([option.label])} + onClick={() => { + setQuestionAnswers([option.label]); + setQuestionCustomSelected(false); + }} startIcon={ checked ? ( @@ -921,28 +939,145 @@ const QuestionRequestCard = ({ ); })} + {isCustomEnabled ? ( + question.multiple ? ( + + setQuestionCustomSelected(event.target.checked) + } + sx={{ + p: 0.5, + color: alpha("#0288d1", 0.55), + "&.Mui-checked": { color: "#0288d1" }, + }} + /> + } + label={ + + + + 自定义回答 + + + } + 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", + }, + }} + /> + ) : ( + + ) + ) : null} ) : null} - {question.custom ? ( - - setCustom((current) => ({ - ...current, - [index]: event.target.value, - })) - } - placeholder="补充说明" - sx={{ mt: 1 }} - /> - ) : null} + + + + setCustom((current) => ({ + ...current, + [index]: event.target.value, + })) + } + placeholder="输入自定义回答" + 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, + }, + }, + }} + /> + + ); })} diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 65b24e8..7948242 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -785,6 +785,29 @@ describe("useAgentChatSession", () => { ], createdAt: 1001, } 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", () => { 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"); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 19e0231..b49bcef 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -217,7 +217,7 @@ const getQuestionContentSignature = ( description: option.description, })), 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 completedProgress = completeRunningProgress(message.progress); const cancelledTodos = cancelRunningTodos(message.todos); + const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions); + const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); const hasVisibleOutput = message.content.trim().length > 0 || Boolean(message.artifacts?.length) || + Boolean(rejectedPermissions?.length) || + Boolean(rejectedQuestions?.length) || Boolean(completedProgress?.length) || Boolean(cancelledTodos?.length); @@ -384,6 +436,8 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => { content: message.content || "⚠️ **请求已中断**", isError: true, progress: completedProgress, + permissions: rejectedPermissions, + questions: rejectedQuestions, todos: cancelledTodos, }; };