-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Fix MCP elicitation deadlock and improve UX #6650
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ffc08f8
03c88a1
181e78a
43d21a5
9588aae
e84ff22
aef4031
7cc24f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,48 @@ | ||
| import { useState } from 'react'; | ||
| import { useState, useEffect, useRef } from 'react'; | ||
| import { ActionRequired } from '../api'; | ||
| import JsonSchemaForm from './ui/JsonSchemaForm'; | ||
| import type { JsonSchema } from './ui/JsonSchemaForm'; | ||
|
|
||
| const ELICITATION_TIMEOUT_SECONDS = 300; | ||
|
|
||
| interface ElicitationRequestProps { | ||
| isCancelledMessage: boolean; | ||
| isClicked: boolean; | ||
| actionRequiredContent: ActionRequired & { type: 'actionRequired' }; | ||
| onSubmit: (elicitationId: string, userData: Record<string, unknown>) => void; | ||
| } | ||
|
|
||
| function formatTime(seconds: number): string { | ||
| const mins = Math.floor(seconds / 60); | ||
| const secs = seconds % 60; | ||
| return `${mins}:${secs.toString().padStart(2, '0')}`; | ||
| } | ||
|
|
||
| export default function ElicitationRequest({ | ||
| isCancelledMessage, | ||
| isClicked, | ||
| actionRequiredContent, | ||
| onSubmit, | ||
| }: ElicitationRequestProps) { | ||
| const [submitted, setSubmitted] = useState(isClicked); | ||
| const [timeRemaining, setTimeRemaining] = useState(ELICITATION_TIMEOUT_SECONDS); | ||
| const startTimeRef = useRef(Date.now()); | ||
|
|
||
| useEffect(() => { | ||
| if (submitted || isCancelledMessage || isClicked) return; | ||
|
|
||
| const interval = setInterval(() => { | ||
| const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000); | ||
| const remaining = Math.max(0, ELICITATION_TIMEOUT_SECONDS - elapsed); | ||
| setTimeRemaining(remaining); | ||
|
|
||
| if (remaining === 0) { | ||
| clearInterval(interval); | ||
| } | ||
| }, 1000); | ||
|
|
||
| return () => clearInterval(interval); | ||
| }, [submitted, isCancelledMessage, isClicked]); | ||
|
|
||
|
Comment on lines
+29
to
46
|
||
| if (actionRequiredContent.data.actionType !== 'elicitation') { | ||
| return null; | ||
|
|
@@ -57,17 +83,67 @@ export default function ElicitationRequest({ | |
| ); | ||
| } | ||
|
|
||
| const isUrgent = timeRemaining <= 60; | ||
| const isExpired = timeRemaining === 0; | ||
|
|
||
| if (isExpired) { | ||
| return ( | ||
| <div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard"> | ||
| <div className="flex items-center gap-2 text-textSubtle"> | ||
| <svg | ||
| className="w-5 h-5" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| fill="none" | ||
| viewBox="0 0 24 24" | ||
| stroke="currentColor" | ||
| strokeWidth={2} | ||
| > | ||
| <path | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" | ||
| /> | ||
| </svg> | ||
| <span>This request has expired. The extension will need to ask again.</span> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex flex-col"> | ||
| <div className="goose-message-content bg-background-muted rounded-2xl rounded-b-none px-4 py-2 text-textStandard"> | ||
| {message || 'Goose needs some information from you.'} | ||
| <div className="flex justify-between items-start gap-4"> | ||
| <span>{message || 'Goose needs some information from you.'}</span> | ||
| </div> | ||
| </div> | ||
| <div className="goose-message-content bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 py-3"> | ||
| <JsonSchemaForm | ||
| schema={requested_schema as JsonSchema} | ||
| onSubmit={handleSubmit} | ||
| submitLabel="Submit" | ||
| /> | ||
| <div | ||
| className={`mt-3 pt-3 border-t border-borderSubtle flex items-center gap-2 text-sm ${isUrgent ? 'text-red-500' : 'text-textSubtle'}`} | ||
| > | ||
| <svg | ||
| className="w-4 h-4 animate-pulse" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| fill="none" | ||
| viewBox="0 0 24 24" | ||
| stroke="currentColor" | ||
| strokeWidth={2} | ||
| > | ||
| <path | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" | ||
| /> | ||
| </svg> | ||
| <span> | ||
| Waiting for your response ({formatTime(timeRemaining)} remaining) | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The countdown is anchored to
startTimeRef = Date.now()on mount and never re-initialized for a newelicitationId(or persisted across unmount/remount), so the UI can show an incorrect remaining time after navigation or if the component is reused. Consider keying the start timestamp byelicitationId(e.g., a module-levelMap) and/or resettingstartTimeRef.current+timeRemainingwheneverelicitationIdchanges (and includeelicitationIdin the effect deps).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The timer resets on mount, which is intentional. If the user navigates away, the server-side timeout continues and the elicitation will expire. When they return, it will be a new elicitation request with a new ID, so a fresh timer is correct.