@@ -8,23 +8,62 @@ import { MessageList } from "@/components/chat/message-list";
88import type { ChatMessage } from "@/components/chat/fixtures" ;
99import type { ChunkSource } from "@/lib/use-streaming-text" ;
1010import { streamChat , type ChatWireMessage } from "@/lib/chat-stream" ;
11+ import {
12+ handlePortfolioQueueChatCommand ,
13+ resolvePortfolioQueueChatAction ,
14+ type HandlePortfolioQueueChatCommandDeps ,
15+ type HandlePortfolioQueueChatCommandResult ,
16+ } from "@/lib/chat-portfolio-queue-actions" ;
17+ import { fetchPortfolioQueueItems } from "@/lib/portfolio-queue-actions" ;
1118
1219// The chat-rail's content integration (#6518): the first point the persistent rail (#6513) holds a live
1320// conversation. Pure wiring — it composes the standalone composer (#6514), message list (#6515), and streaming
1421// renderer (#6516) around the read-only streaming backend (#6517), and owns nothing but the conversation state.
15- // Strictly ask-a-question / read-only: the only network call it can make is `streamChat` → `POST /api/chat`; it
16- // never touches an action endpoint (portfolio release/requeue, governor pause/resume) — that surface is a
17- // separate, later, flag-gated issue.
22+ //
23+ // #7075: portfolio release/requeue is resolved first via resolvePortfolioQueueChatAction; only unresolved
24+ // text falls through to streamChat. Action dispatch reuses the already-built handlePortfolioQueueChatCommand
25+ // pipeline (no new routes / fetches).
1826
1927const ASSISTANT_NAME = "LoopOver" ;
2028/** Inline failure note appended after a failed turn — keeps history visible instead of StateBoundary wipe (#7077). */
2129const TURN_FAILED_MESSAGE =
2230 "The latest response failed to complete. Any partial answer above is incomplete — you can try again." ;
2331
32+ /** Local queue administration is not a chokepoint content-write (#6838 / #7075); satisfy the registry brand. */
33+ const allowAdministrativeGate = ( ) => ( { decision : { stage : "allow" } } ) ;
34+
2435/** Injectable so tests can drive the stream deterministically; defaults to the real `POST /api/chat` bridge. */
2536export type StreamChatFn = ( messages : ChatWireMessage [ ] ) => AsyncIterable < string > ;
2637
27- export function ChatConversation ( { streamChatImpl = streamChat } : { streamChatImpl ?: StreamChatFn } = { } ) {
38+ export type PortfolioQueueChatCommandFn = (
39+ text : string ,
40+ deps : HandlePortfolioQueueChatCommandDeps ,
41+ ) => Promise < HandlePortfolioQueueChatCommandResult > ;
42+
43+ export type ChatConversationProps = {
44+ streamChatImpl ?: StreamChatFn ;
45+ /** Defaults to the real end-to-end portfolio release/requeue handler (#7075). */
46+ handlePortfolioQueueChatCommandImpl ?: PortfolioQueueChatCommandFn ;
47+ /** Partial override of production portfolio-command deps (tests inject loadItems / gates / env). */
48+ portfolioQueueChatDeps ?: Partial < HandlePortfolioQueueChatCommandDeps > ;
49+ } ;
50+
51+ function defaultPortfolioQueueChatDeps (
52+ overrides : Partial < HandlePortfolioQueueChatCommandDeps > = { } ,
53+ ) : HandlePortfolioQueueChatCommandDeps {
54+ return {
55+ loadItems : ( ) => fetchPortfolioQueueItems ( ) ,
56+ buildGovernorInput : ( ) => ( { } ) ,
57+ evaluateGate : allowAdministrativeGate ,
58+ ...overrides ,
59+ } ;
60+ }
61+
62+ export function ChatConversation ( {
63+ streamChatImpl = streamChat ,
64+ handlePortfolioQueueChatCommandImpl = handlePortfolioQueueChatCommand ,
65+ portfolioQueueChatDeps,
66+ } : ChatConversationProps = { } ) {
2867 const [ messages , setMessages ] = useState < ChatMessage [ ] > ( [ ] ) ;
2968 const [ activeSource , setActiveSource ] = useState < ChunkSource | null > ( null ) ;
3069 const [ streaming , setStreaming ] = useState ( false ) ;
@@ -42,6 +81,37 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
4281 content : text ,
4382 timestamp : new Date ( ) . toISOString ( ) ,
4483 } ;
84+
85+ // #7075: try portfolio release/requeue resolution BEFORE opening a read-only stream.
86+ const portfolioResolved = resolvePortfolioQueueChatAction ( text ) ;
87+ if ( portfolioResolved . ok ) {
88+ setMessages ( ( prev ) => [ ...prev , userMessage ] ) ;
89+ // Reuse the composer-disable flag for the action round-trip (no streaming source / typing indicator).
90+ setStreaming ( true ) ;
91+ void ( async ( ) => {
92+ try {
93+ const result = await handlePortfolioQueueChatCommandImpl (
94+ text ,
95+ defaultPortfolioQueueChatDeps ( portfolioQueueChatDeps ) ,
96+ ) ;
97+ setMessages ( ( prev ) => [ ...prev , ...result . messages ] ) ;
98+ } catch {
99+ setMessages ( ( prev ) => [
100+ ...prev ,
101+ {
102+ id : nextId ( ) ,
103+ role : "system" ,
104+ content : TURN_FAILED_MESSAGE ,
105+ timestamp : new Date ( ) . toISOString ( ) ,
106+ } ,
107+ ] ) ;
108+ } finally {
109+ setStreaming ( false ) ;
110+ }
111+ } ) ( ) ;
112+ return ;
113+ }
114+
45115 // What the backend grounds against: the prior user/assistant turns plus this question, in wire shape.
46116 const history : ChatWireMessage [ ] = [ ...messages , userMessage ]
47117 . filter ( ( message ) : message is ChatMessage & { role : "user" | "assistant" } => message . role !== "system" )
@@ -114,7 +184,7 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
114184 // would be read as a functional update and *call* it instead of storing it.
115185 setActiveSource ( ( ) => source ) ;
116186 } ,
117- [ messages , streamChatImpl ] ,
187+ [ messages , streamChatImpl , handlePortfolioQueueChatCommandImpl , portfolioQueueChatDeps ] ,
118188 ) ;
119189
120190 return (
0 commit comments