@@ -13,6 +13,7 @@ import {
1313 type DashboardAgentClientData ,
1414 type DashboardAgentSession ,
1515} from "./DashboardAgentChat" ;
16+ import { DashboardAgentDraft } from "./DashboardAgentDraft" ;
1617import { DashboardAgentHeader } from "./DashboardAgentHeader" ;
1718import {
1819 DashboardAgentHistory ,
@@ -29,21 +30,23 @@ type ActiveChat = {
2930 chatId : string ;
3031 messages : UIMessage [ ] ;
3132 session : DashboardAgentSession | null ;
33+ // Cold start only: the agent run has no warm step-1, so the mounted chat sends
34+ // this first message through the transport to trigger the turn. Undefined for
35+ // head-started and resumed chats — their stream is resumed, not re-sent.
36+ pendingFirstMessage ?: string ;
37+ // True for a head-started chat: the turn is already in flight server-side, so
38+ // the transport must hydrate the session as streaming to resume `session.out`.
39+ streaming ?: boolean ;
3240} ;
3341
3442/**
3543 * The dashboard agent side panel. Owns history, the active chat, and last-chat
36- * persistence; resolves a chat's stored transcript + session before mounting
37- * the inner `DashboardAgentChat` (keyed by chatId) so resume flows in through
38- * the transport's declarative `sessions` option.
44+ * persistence. New chats start in a draft state with no id; the server
45+ * generates the chat id on the first send (`create`) and owns the chat record,
46+ * so the client never invents an id. Existing chats resolve their stored
47+ * transcript + session before mounting `DashboardAgentChat` (keyed by chatId).
3948 */
40- export function DashboardAgentPanel ( {
41- onClose,
42- headStartEnabled = false ,
43- } : {
44- onClose : ( ) => void ;
45- headStartEnabled ?: boolean ;
46- } ) {
49+ export function DashboardAgentPanel ( { onClose } : { onClose : ( ) => void } ) {
4750 const organization = useOrganization ( ) ;
4851 const project = useProject ( ) ;
4952 const environment = useEnvironment ( ) ;
@@ -80,16 +83,17 @@ export function DashboardAgentPanel({
8083 }
8184 } , [ actionPath ] ) ;
8285
83- // Open a chat by id. A new chat mounts immediately with an empty transcript;
84- // an existing one is fetched first so its session hydrates the transport at
85- // mount. A stored id that's gone (deleted / never sent) falls back to fresh.
86+ // Bumped on each open so a slower earlier open can't overwrite a newer one
87+ // when chats are switched rapidly.
88+ const openChatRequestSeq = useRef ( 0 ) ;
89+
90+ // Open an existing chat: fetch its stored transcript + session so resume flows
91+ // in through the transport at mount. A stored id that's gone (deleted / never
92+ // sent) drops back to the draft state.
8693 const openChat = useCallback (
87- async ( id : string , opts ?: { fetchExisting ?: boolean } ) => {
94+ async ( id : string ) => {
8895 setView ( "chat" ) ;
89- if ( ! opts ?. fetchExisting ) {
90- setActive ( { chatId : id , messages : [ ] , session : null } ) ;
91- return ;
92- }
96+ const seq = ++ openChatRequestSeq . current ;
9397 setLoading ( true ) ;
9498 try {
9599 const res = await fetch ( `${ actionPath } ?chatId=${ encodeURIComponent ( id ) } ` ) ;
@@ -99,6 +103,7 @@ export function DashboardAgentPanel({
99103 session ?: { publicAccessToken : string ; lastEventId : string | null } | null ;
100104 } )
101105 : { messages : [ ] , session : null } ;
106+ if ( seq !== openChatRequestSeq . current ) return ;
102107 if ( data . messages && data . messages . length > 0 ) {
103108 setActive ( {
104109 chatId : id ,
@@ -111,16 +116,62 @@ export function DashboardAgentPanel({
111116 : null ,
112117 } ) ;
113118 } else {
114- setActive ( { chatId : generateFriendlyId ( "chat" ) , messages : [ ] , session : null } ) ;
119+ // Nothing stored under this id — drop to a fresh draft.
120+ setActive ( null ) ;
115121 }
116122 } finally {
117- setLoading ( false ) ;
123+ if ( seq === openChatRequestSeq . current ) setLoading ( false ) ;
118124 }
119125 } ,
120126 [ actionPath ]
121127 ) ;
122128
123- // On open, restore the last chat (or start a new one). Runs once per mount.
129+ // Start a new chat by sending its first message. The server generates the id,
130+ // creates the chat record, and kicks off the first turn (head start when
131+ // configured, else a cold session). We then mount the real chat on the server
132+ // id and either resume its stream (head start) or send the message through
133+ // the transport (cold start).
134+ const createChat = useCallback (
135+ async ( text : string ) => {
136+ setView ( "chat" ) ;
137+ setLoading ( true ) ;
138+ try {
139+ const userMessage : UIMessage = {
140+ id : generateFriendlyId ( "msg" ) ,
141+ role : "user" ,
142+ parts : [ { type : "text" , text } ] ,
143+ } ;
144+ const body = new FormData ( ) ;
145+ body . set ( "intent" , "create" ) ;
146+ body . set ( "message" , JSON . stringify ( userMessage ) ) ;
147+ body . set ( "clientData" , JSON . stringify ( clientData ) ) ;
148+ const res = await fetch ( actionPath , { method : "POST" , body } ) ;
149+ const data = ( await res . json ( ) ) as {
150+ chatId ?: string ;
151+ publicAccessToken ?: string ;
152+ headStarted ?: boolean ;
153+ error ?: string ;
154+ } ;
155+ if ( ! res . ok || ! data . chatId || ! data . publicAccessToken ) {
156+ setActive ( null ) ;
157+ return ;
158+ }
159+ setActive ( {
160+ chatId : data . chatId ,
161+ messages : data . headStarted ? [ userMessage ] : [ ] ,
162+ session : { publicAccessToken : data . publicAccessToken } ,
163+ pendingFirstMessage : data . headStarted ? undefined : text ,
164+ streaming : data . headStarted ,
165+ } ) ;
166+ } finally {
167+ setLoading ( false ) ;
168+ }
169+ } ,
170+ [ actionPath , clientData ]
171+ ) ;
172+
173+ // On open, restore the last chat if there is one; otherwise stay in the draft
174+ // state (active = null). Runs once per mount.
124175 const restored = useRef ( false ) ;
125176 useEffect ( ( ) => {
126177 if ( restored . current ) return ;
@@ -131,8 +182,7 @@ export function DashboardAgentPanel({
131182 } catch {
132183 /* localStorage unavailable — start fresh */
133184 }
134- if ( stored ) void openChat ( stored , { fetchExisting : true } ) ;
135- else void openChat ( generateFriendlyId ( "chat" ) ) ;
185+ if ( stored ) void openChat ( stored ) ;
136186 } , [ openChat , storageKey ] ) ;
137187
138188 // Persist the active chat as the one to restore next time.
@@ -146,12 +196,13 @@ export function DashboardAgentPanel({
146196 } , [ active ?. chatId , storageKey ] ) ;
147197
148198 const newChat = useCallback ( ( ) => {
149- void openChat ( generateFriendlyId ( "chat" ) ) ;
150- } , [ openChat ] ) ;
199+ setView ( "chat" ) ;
200+ setActive ( null ) ;
201+ } , [ ] ) ;
151202
152203 const switchChat = useCallback (
153204 ( id : string ) => {
154- void openChat ( id , { fetchExisting : true } ) ;
205+ void openChat ( id ) ;
155206 } ,
156207 [ openChat ]
157208 ) ;
@@ -192,25 +243,33 @@ export function DashboardAgentPanel({
192243 onNewChat = { newChat }
193244 onDelete = { deleteChat }
194245 />
195- ) : loading || ! active ? (
246+ ) : loading ? (
196247 < div className = "flex flex-1 items-center justify-center" >
197248 < Spinner className = "size-5" />
198249 </ div >
199- ) : (
250+ ) : active ? (
200251 < DashboardAgentChat
201252 key = { active . chatId }
202253 chatId = { active . chatId }
203254 initialMessages = { active . messages }
204255 session = { active . session }
256+ pendingFirstMessage = { active . pendingFirstMessage }
257+ streaming = { active . streaming }
205258 clientData = { clientData }
206259 apiOrigin = { apiOrigin }
207260 actionPath = { actionPath }
208261 projectSlug = { project . slug }
209262 environmentSlug = { environment . slug }
210263 currentPage = { currentPage }
211- headStartEnabled = { headStartEnabled }
212264 onTurnSettled = { loadHistory }
213265 />
266+ ) : (
267+ < DashboardAgentDraft
268+ onSubmit = { createChat }
269+ projectSlug = { project . slug }
270+ environmentSlug = { environment . slug }
271+ currentPage = { currentPage }
272+ />
214273 ) }
215274 </ div >
216275 ) ;
0 commit comments