@@ -750,6 +750,7 @@ function readImageAttachment(file: File, t: WorkspaceTranslate): Promise<Workspa
750750
751751export function PersonalWorkspacePage ( {
752752 conversationQueuesFollowUps = false ,
753+ conversationSupportsSteering = false ,
753754 conversationSessionId,
754755 conversationHistoryState,
755756 agents = [ { agentId : "codex" , available : true , capability : "代码与项目执行" , label : "Codex" } ] ,
@@ -767,6 +768,8 @@ export function PersonalWorkspacePage({
767768} : {
768769 /** The bound Session's mode queues a message sent while its Turn runs. */
769770 conversationQueuesFollowUps ?: boolean ;
771+ /** The bound managed executor offers native exact-turn steering. */
772+ conversationSupportsSteering ?: boolean ;
770773 conversationSessionId ?: string ;
771774 conversationHistoryState ?: ConversationHistoryStatus ;
772775 agents ?: WorkspaceAgentOption [ ] ;
@@ -806,6 +809,8 @@ export function PersonalWorkspacePage({
806809 }
807810 } ) ;
808811 const [ sending , setSending ] = useState ( false ) ;
812+ const [ steering , setSteering ] = useState ( false ) ;
813+ const steeringRequests = useRef ( new Map < string , { sessionId : string ; turnId : string ; text : string ; id : string } > ( ) ) ;
809814 const [ actionDraft , setActionDraft ] = useState < WorkspaceActionDraft | null > ( null ) ;
810815 const [ loopxMode , setLoopxMode ] = useState < LoopXModeSnapshot | null > ( null ) ;
811816 const [ loopxDelivery , setLoopxDelivery ] = useState < "queue" | "inbox" | "steer" > ( "queue" ) ;
@@ -848,8 +853,9 @@ export function PersonalWorkspacePage({
848853 input . style . height = "auto" ;
849854 input . style . height = `${ Math . min ( input . scrollHeight , 120 ) } px` ;
850855 } , [ composer , selectedGoalId , managerChatOpen ] ) ;
851- function setComposerDraft ( key : string , value : string ) {
856+ function setComposerDraft ( key : string , value : string , expectedValue ?: string ) {
852857 setDrafts ( ( current ) => {
858+ if ( expectedValue !== undefined && current [ key ] !== expectedValue ) return current ;
853859 const next = { ...current } ;
854860 if ( value ) {
855861 next [ key ] = value ;
@@ -1027,18 +1033,17 @@ export function PersonalWorkspacePage({
10271033 setGoalConversationReceiptVisible ( true ) ;
10281034 }
10291035 } , [ goalMessages , selectedGoal , selectedGoalTab ] ) ;
1030- // A managed runtime Session admits one Turn at a time. While the current
1031- // Session shows a Turn in flight, a new message would only be rejected, so
1032- // the composer waits and points to the reply's own adjust/interrupt
1033- // controls. Two deliveries stay open because the service queues them behind
1034- // the running Turn: LoopX mode through its own queue, and any message to an
1035- // attached host Session.
1036+ // One composer for both conversations. Running managed Codex work receives
1037+ // exact-turn instructions; attached hosts and LoopX mode keep their queues.
10361038 const loopxDeliveryOpen = Boolean ( conversationSessionId && loopxMode ?. session_id === conversationSessionId
10371039 && loopxMode ?. enabled && loopxMode . active_turn_id ) ;
1038- const conversationTurnRunning = ! loopxDeliveryOpen && ! conversationQueuesFollowUps && Boolean ( conversationSessionId )
1039- && managerMessages . some ( ( message ) => message . pending && Boolean ( message . sourceTurnId )
1040- && message . sourceSessionId === conversationSessionId ) ;
1041- const composerBlocked = sending || conversationTurnRunning ;
1040+ const runningMessage = managerMessages . find ( ( message ) => message . pending && Boolean ( message . sourceTurnId )
1041+ && message . sourceSessionId === conversationSessionId ) ;
1042+ const conversationTurnRunning = ! loopxDeliveryOpen && ! conversationQueuesFollowUps && Boolean ( runningMessage ) ;
1043+ const steeringTurnId = conversationTurnRunning && conversationSupportsSteering && ! readOnly
1044+ && callbacks . onSteerConversationTurn ? runningMessage ?. sourceTurnId : undefined ;
1045+ const composerBlocked = steering || ( ! steeringTurnId && ( sending || conversationTurnRunning ) ) ;
1046+ const quickPromptBlocked = steering || sending || conversationTurnRunning ;
10421047 const managerChatItems = useMemo (
10431048 ( ) => items . filter ( ( item ) => item . kind === "message"
10441049 || ( item . kind === "proposal" && ( sessionProposalIds . includes ( item . proposal . previewId )
@@ -1626,7 +1631,36 @@ export function PersonalWorkspacePage({
16261631 async function sendMessage ( messageOverride ?: string ) {
16271632 const pendingImages = messageOverride ? [ ] : imageAttachments ;
16281633 const message = ( messageOverride ?? composer ) . trim ( ) || ( pendingImages . length ? t ( "composer.imageAnalysisPrompt" ) : "" ) ;
1629- if ( ! message || sending || conversationHistoryState ?. sendBlocked ) return ;
1634+ if ( ! message || composerBlocked || conversationHistoryState ?. sendBlocked ) return ;
1635+ const previousSteering = steeringRequests . current . get ( composerDraftKey ) ;
1636+ const retry = previousSteering && previousSteering . sessionId === conversationSessionId && previousSteering . text === message
1637+ ? previousSteering : undefined ;
1638+ if ( ( retry || steeringTurnId ) && conversationSessionId && callbacks . onSteerConversationTurn ) {
1639+ if ( pendingImages . length ) {
1640+ setImageAttachmentError ( locale === "zh-CN" ? "本轮追加指令暂不支持图片,图片和草稿已保留。" : "This turn accepts text instructions only. Images and draft retained." ) ;
1641+ return ;
1642+ }
1643+ const request = retry ?? { sessionId : conversationSessionId , turnId : steeringTurnId ! , text : message , id : crypto . randomUUID ( ) } ;
1644+ steeringRequests . current . set ( composerDraftKey , request ) ;
1645+ setSteering ( true ) ;
1646+ setActionFeedback ( null ) ;
1647+ setImageAttachmentError ( null ) ;
1648+ try {
1649+ await callbacks . onSteerConversationTurn ( selectedGoalId ?? "manager" , request . turnId , message , request . id ) ;
1650+ steeringRequests . current . delete ( composerDraftKey ) ;
1651+ if ( ! messageOverride ) setComposerDraft ( composerDraftKey , "" , composer ) ;
1652+ setActionFeedback ( locale === "zh-CN" ? "执行器已接收本轮追加指令。" : "The executor accepted instructions for this turn." ) ;
1653+ } catch ( error ) {
1654+ // Unknown delivery retries the original Turn even after it completes.
1655+ // A confirmed non-delivery may use a new ingress after recovery.
1656+ if ( error instanceof ChatApiError && error . payload . delivery_state === "not_delivered" ) {
1657+ steeringRequests . current . delete ( composerDraftKey ) ;
1658+ }
1659+ setActionFeedback ( error instanceof Error ? error . message : t ( "feedback.sendGenericError" ) ) ;
1660+ } finally { setSteering ( false ) ; }
1661+ return ;
1662+ }
1663+ if ( sending ) return ;
16301664 followConversationRef . current = true ;
16311665 setShowLatestMessage ( false ) ;
16321666 if ( loopxMode ?. session_id === conversationSessionId && loopxMode ?. enabled && loopxMode . active_turn_id && conversationSessionId ) {
@@ -1969,16 +2003,16 @@ export function PersonalWorkspacePage({
19692003 < summary > { locale === "zh-CN" ? "快捷提问" : "Suggestions" } </ summary >
19702004 { selectedGoal ? (
19712005 < div className = "personal-quick-prompts" >
1972- < button aria-label = { t ( "composer.nextAction" ) } disabled = { composerBlocked } onClick = { ( ) => void sendMessage ( t ( "composer.nextActionPrompt" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < MessageCircleQuestion size = { 13 } /> < span > { t ( "composer.nextAction" ) } </ span > </ button >
1973- < button aria-label = { t ( "composer.agentProgress" ) } disabled = { composerBlocked } onClick = { ( ) => void sendMessage ( t ( "composer.agentProgressPrompt" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < Send size = { 13 } /> < span > { t ( "composer.agentProgress" ) } </ span > </ button >
2006+ < button aria-label = { t ( "composer.nextAction" ) } disabled = { quickPromptBlocked } onClick = { ( ) => void sendMessage ( t ( "composer.nextActionPrompt" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < MessageCircleQuestion size = { 13 } /> < span > { t ( "composer.nextAction" ) } </ span > </ button >
2007+ < button aria-label = { t ( "composer.agentProgress" ) } disabled = { quickPromptBlocked } onClick = { ( ) => void sendMessage ( t ( "composer.agentProgressPrompt" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < Send size = { 13 } /> < span > { t ( "composer.agentProgress" ) } </ span > </ button >
19742008 < button aria-label = { t ( "composer.monitor" ) } disabled = { sending } onClick = { ( ) => prepareScheduleDraft ( "monitor" , selectedGoalId ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < CalendarClock size = { 13 } /> < span > { t ( "composer.monitor" ) } </ span > </ button >
1975- < button aria-label = { t ( "composer.blockers" ) } disabled = { composerBlocked || ! stewardPromptText ( "gate" ) } onClick = { ( ) => void sendMessage ( stewardPromptText ( "gate" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < AlertCircle size = { 13 } /> < span > { t ( "composer.blockers" ) } </ span > </ button >
1976- < button aria-label = { t ( "composer.evidence" ) } disabled = { composerBlocked || ! stewardPromptText ( "evidence" ) } onClick = { ( ) => void sendMessage ( stewardPromptText ( "evidence" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < FileText size = { 13 } /> < span > { t ( "composer.evidence" ) } </ span > </ button >
2009+ < button aria-label = { t ( "composer.blockers" ) } disabled = { quickPromptBlocked || ! stewardPromptText ( "gate" ) } onClick = { ( ) => void sendMessage ( stewardPromptText ( "gate" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < AlertCircle size = { 13 } /> < span > { t ( "composer.blockers" ) } </ span > </ button >
2010+ < button aria-label = { t ( "composer.evidence" ) } disabled = { quickPromptBlocked || ! stewardPromptText ( "evidence" ) } onClick = { ( ) => void sendMessage ( stewardPromptText ( "evidence" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < FileText size = { 13 } /> < span > { t ( "composer.evidence" ) } </ span > </ button >
19772011 </ div >
19782012 ) : (
19792013 < div className = "personal-quick-prompts" >
1980- < button aria-label = { t ( "composer.globalTasks" ) } disabled = { composerBlocked } onClick = { ( ) => void sendMessage ( t ( "composer.globalTasksPrompt" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < MessageCircleQuestion size = { 13 } /> < span > { t ( "composer.globalTasks" ) } </ span > </ button >
1981- < button aria-label = { t ( "composer.globalProgress" ) } disabled = { composerBlocked } onClick = { ( ) => void sendMessage ( t ( "composer.globalProgressPrompt" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < Send size = { 13 } /> < span > { t ( "composer.globalProgress" ) } </ span > </ button >
2014+ < button aria-label = { t ( "composer.globalTasks" ) } disabled = { quickPromptBlocked } onClick = { ( ) => void sendMessage ( t ( "composer.globalTasksPrompt" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < MessageCircleQuestion size = { 13 } /> < span > { t ( "composer.globalTasks" ) } </ span > </ button >
2015+ < button aria-label = { t ( "composer.globalProgress" ) } disabled = { quickPromptBlocked } onClick = { ( ) => void sendMessage ( t ( "composer.globalProgressPrompt" ) ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < Send size = { 13 } /> < span > { t ( "composer.globalProgress" ) } </ span > </ button >
19822016 < button aria-label = { t ( "composer.createGoal" ) } onClick = { requestGoalCreate } title = { t ( "composer.createGoalHint" ) } type = "button" > < Plus size = { 13 } /> < span > { t ( "composer.createGoal" ) } </ span > </ button >
19832017 </ div >
19842018 ) }
@@ -1991,7 +2025,9 @@ export function PersonalWorkspacePage({
19912025 </ figure >
19922026 ) ) } </ div > : null }
19932027 { imageAttachmentError ? < p className = "personal-composer-error" role = "alert" > { imageAttachmentError } </ p > : null }
1994- { conversationTurnRunning ? < p className = "personal-composer-status" role = "status" > { t ( "composer.turnRunning" ) } </ p > : null }
2028+ { conversationTurnRunning ? < p className = "personal-composer-status" role = "status" > { steeringTurnId
2029+ ? ( locale === "zh-CN" ? "本轮进行中 · 发消息可调整当前工作" : "Turn in progress · send instructions to adjust this work" )
2030+ : t ( "composer.turnRunning" ) } </ p > : null }
19952031 < div
19962032 className = "personal-channel-composer"
19972033 onDragOver = { ( event ) => {
@@ -2034,7 +2070,9 @@ export function PersonalWorkspacePage({
20342070 />
20352071 < button aria-label = { t ( "composer.send" ) } disabled = { ( ! composer . trim ( ) && imageAttachments . length === 0 ) || composerBlocked || conversationHistoryState ?. sendBlocked } onClick = { ( ) => void sendMessage ( ) } title = { t ( "composer.sendMessageHint" ) } type = "button" > < Send size = { 18 } /> </ button >
20362072 </ div >
2037- { conversationOpen ? < div className = "personal-composer-hint" > { sending
2073+ { conversationOpen ? < div className = "personal-composer-hint" > { steering
2074+ ? ( locale === "zh-CN" ? "正在发送本轮追加指令…" : "Sending instructions for this turn…" )
2075+ : sending && ! steeringTurnId
20382076 ? ( locale === "zh-CN" ? "正在回复 · 修改当前任务请使用“调整本轮”" : "Reply in progress · use Adjust turn to change the current task" )
20392077 : ( locale === "zh-CN" ? "Enter 发送 · Shift+Enter 换行" : "Enter to send · Shift+Enter for a new line" ) } </ div > : null }
20402078 </ > }
0 commit comments