11import { useEffect , useRef , useState } from "react" ;
2- import { Paperclip , Play , Send , RefreshCw , AlertCircle } from "lucide-react" ;
2+ import { Paperclip , Play , Send , RefreshCw , AlertCircle , Plus } from "lucide-react" ;
33import ReactMarkdown from "react-markdown" ;
44import remarkGfm from "remark-gfm" ;
55import { api } from "../api.ts" ;
@@ -28,25 +28,42 @@ export function ChatTab({
2828 agentId,
2929 agentName,
3030 sandboxCapable,
31+ liveChatCapable = true ,
32+ resumeWarm = true ,
3133 resumeSessionId,
3234 onConsumedResume,
3335 initialMessage,
3436 onConsumedInitial,
3537 onSessionStarted,
38+ onNewSession,
3639 disabled = false ,
3740} : {
3841 /** Registry ObjectId — used to address the agent in API calls. */
3942 agentId : string ;
4043 /** Human name — used for display + the `bot` field on logged turns. */
4144 agentName : string ;
4245 sandboxCapable : boolean ;
46+ /** False for library-mode agents with no server-runnable source: we never
47+ * boot a chat-sandbox (it would 400 LIBRARY_AGENT_NO_LIVE_CHAT). Historical
48+ * transcripts stay viewable read-only; the composer is locked with a notice. */
49+ liveChatCapable ?: boolean ;
50+ /** Whether the session being RESUMED still has a live (warm) sandbox. A cold
51+ * session can't be resumed — the local sandbox that held its Claude
52+ * conversation is gone (resume → "No conversation found"). We show its
53+ * transcript read-only and direct the user to a New session. Ignored for a
54+ * fresh chat (resumeSessionId == null). */
55+ resumeWarm ?: boolean ;
4356 resumeSessionId : string | null ;
4457 onConsumedResume : ( ) => void ;
4558 initialMessage ?: string | null ;
4659 onConsumedInitial ?: ( ) => void ;
4760 /** Fired once a sandbox boots and a sessionId is established (new or resumed),
4861 * so the parent can refresh / highlight the session list. */
4962 onSessionStarted ?: ( sessionId : string ) => void ;
63+ /** Start a fresh session (parent's "New chat"). When provided, an ended /
64+ * non-resumable view shows a prominent "Start a new session" button. Only
65+ * passed when the agent can actually chat (live-chat-capable + agents:run). */
66+ onNewSession ?: ( ) => void ;
5067 /** When true the composer is locked (e.g. the agent is archived). Sending is
5168 * refused server-side regardless; this just makes the UI state explicit. */
5269 disabled ?: boolean ;
@@ -58,8 +75,15 @@ export function ChatTab({
5875 const [ input , setInput ] = useState ( "" ) ;
5976 const [ busy , setBusy ] = useState ( false ) ;
6077 const [ err , setErr ] = useState < string | null > ( null ) ;
78+ // True when the resumed session is cold (no warm sandbox) and therefore
79+ // can't be continued — history is read-only and the user must start a New session.
80+ const [ resumeEnded , setResumeEnded ] = useState ( false ) ;
6181 const scrollRef = useRef < HTMLDivElement > ( null ) ;
6282
83+ // Composer is locked for an archived agent, a library agent (no runnable
84+ // source), or a cold session that can't be resumed.
85+ const composerLocked = disabled || ! liveChatCapable || resumeEnded ;
86+
6387 useEffect ( ( ) => {
6488 scrollRef . current ?. scrollTo ( { top : scrollRef . current . scrollHeight } ) ;
6589 } , [ msgs ] ) ;
@@ -69,24 +93,39 @@ export function ChatTab({
6993 setSessionId ( null ) ;
7094 setMsgs ( [ ] ) ;
7195 setErr ( null ) ;
96+ setResumeEnded ( false ) ;
7297 } , [ agentId ] ) ;
7398
7499 useEffect ( ( ) => {
75100 if ( ! resumeSessionId ) return ;
76101 const sid = resumeSessionId ;
77102 onConsumedResume ( ) ;
78103 ( async ( ) => {
104+ let prior : Msg [ ] = [ ] ;
79105 try {
80106 const d = await api . session ( sid ) ;
81- const prior : Msg [ ] = d . entries . map ( ( e ) => ( {
82- role : e . type . includes ( "user" ) ? "user" : "assistant" ,
107+ prior = d . entries . map ( ( e ) => ( {
108+ role : ( e . type . includes ( "user" ) ? "user" : "assistant" ) as Msg [ "role" ] ,
83109 text : e . text ,
84110 } ) ) ;
85- setMsgs ( prior . length ? prior : [ { role : "status" , text : "Resumed — no stored transcript. Continue below." , loading : false } ] ) ;
86111 } catch {
87- setMsgs ( [ { role : "status" , text : "Resumed session — memory is loaded. Continue below." , loading : false } ] ) ;
112+ /* no stored transcript — fall through to the notices below */
113+ }
114+ // Resume is only possible when the agent is live-chat-capable AND this
115+ // session still has a warm sandbox. A cold session can't be resumed: the
116+ // local sandbox that held its Claude conversation is gone, so a resume
117+ // would fail with "No conversation found". Show history read-only and
118+ // direct the user to a New session (which boots a fresh sandbox).
119+ if ( liveChatCapable && resumeWarm ) {
120+ setMsgs ( prior . length ? prior : [ { role : "status" , text : "Resumed — no stored transcript. Continue below." , loading : false } ] ) ;
121+ await boot ( sid ) ;
122+ } else {
123+ setResumeEnded ( true ) ;
124+ const note = ! liveChatCapable
125+ ? "Read-only — this agent has no runnable source, so live chat is unavailable."
126+ : "This conversation has ended — it ran in a local session that's no longer live, so it can't be resumed. Click “+ New” to start a fresh chat." ;
127+ setMsgs ( [ ...prior , { role : "status" , text : note , loading : false } ] ) ;
88128 }
89- await boot ( sid ) ;
90129 } ) ( ) ;
91130 // eslint-disable-next-line react-hooks/exhaustive-deps
92131 } , [ resumeSessionId ] ) ;
@@ -95,7 +134,9 @@ export function ChatTab({
95134 if ( ! initialMessage ) return ;
96135 const m = initialMessage ;
97136 onConsumedInitial ?.( ) ;
98- void send ( m ) ;
137+ // A library agent can't start a live chat; consume the handed-over message
138+ // but don't boot a sandbox (it would 400).
139+ if ( liveChatCapable ) void send ( m ) ;
99140 // eslint-disable-next-line react-hooks/exhaustive-deps
100141 } , [ initialMessage ] ) ;
101142
@@ -123,7 +164,7 @@ export function ChatTab({
123164 }
124165
125166 async function send ( textArg ?: string ) {
126- if ( disabled ) return ;
167+ if ( composerLocked ) return ;
127168 const text = ( textArg ?? input ) . trim ( ) ;
128169 if ( ! text || busy ) return ;
129170
@@ -216,7 +257,13 @@ export function ChatTab({
216257 </ Button >
217258 </ >
218259 ) : (
219- < span className = "text-muted-foreground" > { booting ? "Booting sandbox…" : "Send a message to start a session" } </ span >
260+ < span className = "text-muted-foreground" >
261+ { ! liveChatCapable
262+ ? "Read-only — this agent ran locally and can’t be chatted here"
263+ : booting
264+ ? "Booting sandbox…"
265+ : "Send a message to start a session" }
266+ </ span >
220267 ) }
221268 </ div >
222269
@@ -225,12 +272,24 @@ export function ChatTab({
225272 { err && (
226273 < div className = "flex items-start gap-2 text-sm text-destructive p-3 rounded-md bg-destructive/10 border border-destructive/20" >
227274 < AlertCircle className = "h-4 w-4 shrink-0 mt-0.5" />
228- < span > { err } </ span >
275+ < div className = "flex-1 min-w-0 space-y-2" >
276+ < span > { err } </ span >
277+ { onNewSession && (
278+ < div >
279+ < Button size = "sm" onClick = { onNewSession } className = "gap-1.5" >
280+ < Plus className = "h-3.5 w-3.5" />
281+ Start a new session
282+ </ Button >
283+ </ div >
284+ ) }
285+ </ div >
229286 </ div >
230287 ) }
231288 { msgs . length === 0 && ! err && (
232- < div className = "h-full grid place-items-center text-muted-foreground text-sm" >
233- Talk to { agentName } . It runs with its configured identity and tools.
289+ < div className = "h-full grid place-items-center text-muted-foreground text-sm text-center px-6" >
290+ { liveChatCapable
291+ ? `Talk to ${ agentName } . It runs with its configured identity and tools.`
292+ : `${ agentName } ran in a local session and can’t be chatted here. Any past sessions remain viewable (read-only).` }
234293 </ div >
235294 ) }
236295 { msgs . map ( ( m , i ) => {
@@ -249,26 +308,55 @@ export function ChatTab({
249308
250309 { /* Input */ }
251310 < div className = "px-6 py-4 border-t border-border" >
311+ { ! liveChatCapable ? (
312+ // Library agent (ran locally / no runnable source): no composer at all,
313+ // just a clear notice so it's obvious why there's nothing to type into.
314+ < div className = "flex items-center gap-2 text-sm text-muted-foreground" >
315+ < AlertCircle className = "h-4 w-4 shrink-0" />
316+ < span > This agent ran in a local session and can’t be chatted here. Past sessions are read-only.</ span >
317+ </ div >
318+ ) : resumeEnded && onNewSession ? (
319+ // Cold session — can't be resumed. Offer the New session action right
320+ // here so the user doesn't have to hunt for the sidebar "+ New".
321+ < div className = "flex items-center justify-between gap-3 flex-wrap" >
322+ < span className = "text-sm text-muted-foreground" >
323+ This conversation ran in a local session and can’t be resumed.
324+ </ span >
325+ < Button onClick = { onNewSession } className = "rounded-xl gap-1.5" >
326+ < Plus className = "h-4 w-4" />
327+ < span > Start a new session</ span >
328+ </ Button >
329+ </ div >
330+ ) : (
252331 < div className = "flex gap-2" >
253332 < Textarea
254333 value = { input }
255334 onChange = { ( e ) => setInput ( e . target . value ) }
256335 onKeyDown = { ( e ) => {
257336 if ( e . key === "Enter" && ! e . shiftKey ) {
258337 e . preventDefault ( ) ;
259- if ( ! disabled ) send ( ) ;
338+ if ( ! composerLocked ) send ( ) ;
260339 }
261340 } }
262- disabled = { disabled }
263- placeholder = { disabled ? "This agent is archived — unarchive it to chat." : `Message ${ agentName } … (Enter to send, Shift+Enter for newline)` }
341+ disabled = { composerLocked }
342+ placeholder = {
343+ disabled
344+ ? "This agent is archived — unarchive it to chat."
345+ : ! liveChatCapable
346+ ? "Library agent — no runnable source, so live chat is unavailable. Past sessions are read-only."
347+ : resumeEnded
348+ ? "This chat has ended — click “+ New” to start a fresh session."
349+ : `Message ${ agentName } … (Enter to send, Shift+Enter for newline)`
350+ }
264351 rows = { 2 }
265352 className = "flex-1 resize-none rounded-xl bg-card px-3.5 py-2.5"
266353 />
267- < Button onClick = { ( ) => send ( ) } disabled = { disabled || busy || ! input . trim ( ) } className = "rounded-xl px-4" >
354+ < Button onClick = { ( ) => send ( ) } disabled = { composerLocked || busy || ! input . trim ( ) } className = "rounded-xl px-4" >
268355 { busy ? < RefreshCw className = "h-4 w-4 animate-spin" /> : < Send className = "h-4 w-4" /> }
269356 < span > Send</ span >
270357 </ Button >
271358 </ div >
359+ ) }
272360 </ div >
273361 </ div >
274362 ) ;
0 commit comments