@@ -42,6 +42,7 @@ pub enum Event {
4242 // Navigation
4343 NavigateToChat(String),
4444 NavigateToSessions,
45+ NavigateToConnect,
4546
4647 // Errors
4748 DismissError,
@@ -101,8 +102,11 @@ pub struct Model {
101102 error: Option<String>,
102103 sessions: Vec<SessionSummary>,
103104 current_session_id: Option<String>,
105+ current_session_title: String,
104106 messages: Vec<MessageView>,
105107 draft_message: String,
108+ /// True between sending a message and the assistant reply arriving.
109+ generating: bool,
106110 /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
107111 crash_logs: Vec<String>,
108112}
@@ -135,8 +139,11 @@ pub struct ViewModel {
135139 pub error: Option<String>,
136140 pub sessions: Vec<SessionView>,
137141 pub current_session_id: Option<String>,
142+ pub current_session_title: String,
138143 pub messages: Vec<MessageView>,
139144 pub draft_message: String,
145+ /// True while waiting for the assistant to reply after the user sends.
146+ pub generating: bool,
140147 pub crash_log_count: u32,
141148 pub latest_crash_log: Option<String>,
142149}
@@ -216,7 +223,8 @@ impl App for OpencodeApp {
216223 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
217224 match event {
218225 Event::Start => {
219− model.server_url = "http://localhost:4096".to_string();
226+ // Don't reset server_url here — the shell restores it from
227+ // persisted storage via ServerUrlChanged before any connect.
220228 render()
221229 }
222230
@@ -264,6 +272,13 @@ impl App for OpencodeApp {
264272 let body = response.take_body().unwrap_or_default();
265273 model.sessions = parse_sessions(&body);
266274 model.connected = true;
275+ // Refresh the current session's title in case it was
276+ // populated after creation.
277+ if let Some(id) = model.current_session_id.clone() {
278+ if let Some(s) = model.sessions.iter().find(|s| s.id == id) {
279+ model.current_session_title = s.title.clone();
280+ }
281+ }
267282 render()
268283 }
269284 Err(e) => {
@@ -275,18 +290,45 @@ impl App for OpencodeApp {
275290
276291 Event::SelectSession(id) => {
277292 model.current_session_id = Some(id.clone());
293+ model.current_session_title = model
294+ .sessions
295+ .iter()
296+ .find(|s| s.id == id)
297+ .map(|s| s.title.clone())
298+ .unwrap_or_default();
278299 Command::event(Event::NavigateToChat(id))
279300 }
280301
281302 Event::NavigateToChat(id) => {
282303 model.current_session_id = Some(id.clone());
304+ model.current_session_title = model
305+ .sessions
306+ .iter()
307+ .find(|s| s.id == id)
308+ .map(|s| s.title.clone())
309+ .unwrap_or_default();
283310 model.messages.clear();
284311 render().and(Command::event(Event::LoadMessages(id)))
285312 }
286313
287314 Event::NavigateToSessions => {
288315 model.current_session_id = None;
316+ model.current_session_title.clear();
317+ model.messages.clear();
318+ model.generating = false;
319+ render()
320+ }
321+
322+ Event::NavigateToConnect => {
323+ // Return to the connect screen to switch servers / credentials.
324+ // Keep the sessions list so a re-connect can refresh in place.
325+ model.current_session_id = None;
326+ model.current_session_title.clear();
289327 model.messages.clear();
328+ model.generating = false;
329+ model.connected = false;
330+ model.auth_required = false;
331+ model.loading = false;
290332 render()
291333 }
292334
@@ -303,6 +345,16 @@ impl App for OpencodeApp {
303345 Ok(mut response) => {
304346 let body = response.take_body().unwrap_or_default();
305347 let id = parse_session_id(&body);
348+ // Some servers echo the title in the created payload;
349+ // fall back to "New session" if absent.
350+ let title = extract_string(&body, "title")
351+ .filter(|t| !t.is_empty())
352+ .unwrap_or_else(|| "New session".to_string());
353+ model.current_session_title = title.clone();
354+ model.sessions.insert(
355+ 0,
356+ SessionSummary { id: id.clone(), title },
357+ );
306358 Command::event(Event::NavigateToChat(id))
307359 }
308360 Err(e) => {
@@ -320,6 +372,7 @@ impl App for OpencodeApp {
320372
321373 Event::MessagesLoaded(result) => {
322374 model.loading = false;
375+ model.generating = false;
323376 match result {
324377 Ok(mut response) => {
325378 let body = response.take_body().unwrap_or_default();
@@ -338,6 +391,7 @@ impl App for OpencodeApp {
338391 return render();
339392 };
340393 model.draft_message.clear();
394+ model.generating = true;
341395 let body = serde_json::json!({
342396 "sessionID": session_id,
343397 "parts": [{ "type": "text", "text": text }]
@@ -357,10 +411,12 @@ impl App for OpencodeApp {
357411 if let Some(session_id) = model.current_session_id.clone() {
358412 Command::event(Event::LoadMessages(session_id))
359413 } else {
414+ model.generating = false;
360415 render()
361416 }
362417 }
363418 Err(e) => {
419+ model.generating = false;
364420 model.error = Some(format!("Failed to send message: {e}"));
365421 render()
366422 }
@@ -418,8 +474,10 @@ impl App for OpencodeApp {
418474 })
419475 .collect(),
420476 current_session_id: model.current_session_id.clone(),
477+ current_session_title: model.current_session_title.clone(),
421478 messages: model.messages.clone(),
422479 draft_message: model.draft_message.clone(),
480+ generating: model.generating,
423481 crash_log_count: model.crash_logs.len() as u32,
424482 latest_crash_log: model.crash_logs.last().cloned(),
425483 }
@@ -604,11 +662,15 @@ mod test {
604662 use super::*;
605663
606664 #[test]
607− fn start_sets_default_server_url() {
665+ fn start_does_not_reset_server_url() {
666+ // Start no longer overwrites server_url — the shell restores the
667+ // persisted value via ServerUrlChanged so the user's last server
668+ // survives a relaunch.
608669 let app = OpencodeApp;
609670 let mut model = Model::default();
671+ model.server_url = "http://10.0.0.1:4096".to_string();
610672 app.update(Event::Start, &mut model).expect_only_render();
611− assert_eq!(model.server_url, "http://localhost:4096");
673+ assert_eq!(model.server_url, "http://10.0.0.1:4096");
612674 }
613675
614676 #[test]
@@ -628,6 +690,7 @@ mod test {
628690 fn connect_emits_http_effect() {
629691 let app = OpencodeApp;
630692 let mut model = Model::default();
693+ model.server_url = "http://localhost:4096".to_string();
631694 app.update(Event::Start, &mut model);
632695 let mut cmd = app.update(Event::Connect, &mut model);
633696 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
@@ -669,6 +732,8 @@ mod test {
669732 let app = OpencodeApp;
670733 let mut model = Model::default();
671734 model.current_session_id = Some("test".to_string());
735+ model.current_session_title = "Test".to_string();
736+ model.generating = true;
672737 model.messages.push(MessageView {
673738 id: "m1".to_string(),
674739 role: "user".to_string(),
@@ -677,7 +742,53 @@ mod test {
677742 });
678743 app.update(Event::NavigateToSessions, &mut model);
679744 assert!(model.current_session_id.is_none());
745+ assert!(model.current_session_title.is_empty());
680746 assert!(model.messages.is_empty());
747+ assert!(!model.generating);
748+ }
749+
750+ #[test]
751+ fn select_session_records_title_from_sessions_list() {
752+ let app = OpencodeApp;
753+ let mut model = Model::default();
754+ model.sessions = vec![
755+ SessionSummary { id: "s1".to_string(), title: "First".to_string() },
756+ SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
757+ ];
758+ app.update(Event::SelectSession("s2".to_string()), &mut model);
759+ assert_eq!(model.current_session_id.as_deref(), Some("s2"));
760+ assert_eq!(model.current_session_title, "Second");
761+ let view = app.view(&model);
762+ assert_eq!(view.current_session_title, "Second");
763+ }
764+
765+ #[test]
766+ fn send_message_sets_generating_until_messages_loaded() {
767+ let app = OpencodeApp;
768+ let mut model = Model::default();
769+ model.server_url = "http://localhost:4096".to_string();
770+ model.current_session_id = Some("s1".to_string());
771+ app.update(Event::SendMessage("hello".to_string()), &mut model);
772+ assert!(model.generating);
773+ // Simulate the MessagesLoaded internal event clearing it.
774+ let ok_resp = crux_http::testing::ResponseBuilder::ok().body("[]".to_string()).build();
775+ let result: HttpResult<String> = Ok(ok_resp);
776+ app.update(Event::MessagesLoaded(result), &mut model);
777+ assert!(!model.generating);
778+ }
779+
780+ #[test]
781+ fn failed_send_clears_generating() {
782+ let app = OpencodeApp;
783+ let mut model = Model::default();
784+ model.server_url = "http://localhost:4096".to_string();
785+ model.current_session_id = Some("s1".to_string());
786+ app.update(Event::SendMessage("hello".to_string()), &mut model);
787+ assert!(model.generating);
788+ let err = crux_http::HttpError::Io("boom".to_string());
789+ app.update(Event::MessageSent(Err(err)), &mut model);
790+ assert!(!model.generating);
791+ assert!(model.error.is_some());
681792 }
682793
683794 #[test]
@@ -717,7 +828,8 @@ mod test {
717828 // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
718829 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
719830 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
720− // 12=NavigateToChat, 13=NavigateToSessions, 14=DismissError
831+ // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
832+ // 15=DismissError
721833 //
722834 // Serialize each Rust variant and check the leading u32 index.
723835 let cases: &[(Event, u32)] = &[
@@ -735,7 +847,8 @@ mod test {
735847 (Event::EventReceived(String::new()), 11),
736848 (Event::NavigateToChat(String::new()), 12),
737849 (Event::NavigateToSessions, 13),
738− (Event::DismissError, 14),
850+ (Event::NavigateToConnect, 14),
851+ (Event::DismissError, 15),
739852 ];
740853 for (event, expected) in cases {
741854 let bytes = bincode::serialize(event).unwrap();