@@ -42,6 +42,7 @@ pub enum Event {
42 // Navigation
42 // Navigation
43 NavigateToChat(String),
43 NavigateToChat(String),
44 NavigateToSessions,
44 NavigateToSessions,
47 DismissError,
48 DismissError,
@@ -101,8 +102,11 @@ pub struct Model {
101 error: Option<String>,
102 error: Option<String>,
102 sessions: Vec<SessionSummary>,
103 sessions: Vec<SessionSummary>,
103 current_session_id: Option<String>,
104 current_session_id: Option<String>,
105+ current_session_title: String,
104 messages: Vec<MessageView>,
106 messages: Vec<MessageView>,
105 draft_message: String,
107 draft_message: String,
108+ /// True between sending a message and the assistant reply arriving.
106 /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
110 /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
107 crash_logs: Vec<String>,
111 crash_logs: Vec<String>,
@@ -135,8 +139,11 @@ pub struct ViewModel {
135 pub error: Option<String>,
139 pub error: Option<String>,
136 pub sessions: Vec<SessionView>,
140 pub sessions: Vec<SessionView>,
137 pub current_session_id: Option<String>,
141 pub current_session_id: Option<String>,
142+ pub current_session_title: String,
138 pub messages: Vec<MessageView>,
143 pub messages: Vec<MessageView>,
139 pub draft_message: String,
144 pub draft_message: String,
145+ /// True while waiting for the assistant to reply after the user sends.
146+ pub generating: bool,
140 pub crash_log_count: u32,
147 pub crash_log_count: u32,
141 pub latest_crash_log: Option<String>,
148 pub latest_crash_log: Option<String>,
@@ -216,7 +223,8 @@ impl App for OpencodeApp {
216 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
223 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
217 match event {
224 match event {
218 Event::Start => {
225 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.
@@ -264,6 +272,13 @@ impl App for OpencodeApp {
264 let body = response.take_body().unwrap_or_default();
272 let body = response.take_body().unwrap_or_default();
265 model.sessions = parse_sessions(&body);
273 model.sessions = parse_sessions(&body);
266 model.connected = true;
274 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();
269 Err(e) => {
284 Err(e) => {
@@ -275,18 +290,45 @@ impl App for OpencodeApp {
276 Event::SelectSession(id) => {
291 Event::SelectSession(id) => {
277 model.current_session_id = Some(id.clone());
292 model.current_session_id = Some(id.clone());
293+ model.current_session_title = model
296+ .find(|s| s.id == id)
297+ .map(|s| s.title.clone())
298+ .unwrap_or_default();
278 Command::event(Event::NavigateToChat(id))
299 Command::event(Event::NavigateToChat(id))
281 Event::NavigateToChat(id) => {
302 Event::NavigateToChat(id) => {
282 model.current_session_id = Some(id.clone());
303 model.current_session_id = Some(id.clone());
304+ model.current_session_title = model
307+ .find(|s| s.id == id)
308+ .map(|s| s.title.clone())
309+ .unwrap_or_default();
283 model.messages.clear();
310 model.messages.clear();
284 render().and(Command::event(Event::LoadMessages(id)))
311 render().and(Command::event(Event::LoadMessages(id)))
287 Event::NavigateToSessions => {
314 Event::NavigateToSessions => {
288 model.current_session_id = None;
315 model.current_session_id = None;
316+ model.current_session_title.clear();
317+ model.messages.clear();
318+ model.generating = false;
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();
289 model.messages.clear();
327 model.messages.clear();
328+ model.generating = false;
329+ model.connected = false;
330+ model.auth_required = false;
331+ model.loading = false;
@@ -303,6 +345,16 @@ impl App for OpencodeApp {
303 Ok(mut response) => {
345 Ok(mut response) => {
304 let body = response.take_body().unwrap_or_default();
346 let body = response.take_body().unwrap_or_default();
305 let id = parse_session_id(&body);
347 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(
356+ SessionSummary { id: id.clone(), title },
306 Command::event(Event::NavigateToChat(id))
358 Command::event(Event::NavigateToChat(id))
308 Err(e) => {
360 Err(e) => {
@@ -320,6 +372,7 @@ impl App for OpencodeApp {
321 Event::MessagesLoaded(result) => {
373 Event::MessagesLoaded(result) => {
322 model.loading = false;
374 model.loading = false;
375+ model.generating = false;
323 match result {
376 match result {
324 Ok(mut response) => {
377 Ok(mut response) => {
325 let body = response.take_body().unwrap_or_default();
378 let body = response.take_body().unwrap_or_default();
@@ -338,6 +391,7 @@ impl App for OpencodeApp {
338 return render();
391 return render();
340 model.draft_message.clear();
393 model.draft_message.clear();
394+ model.generating = true;
341 let body = serde_json::json!({
395 let body = serde_json::json!({
342 "sessionID": session_id,
396 "sessionID": session_id,
343 "parts": [{ "type": "text", "text": text }]
397 "parts": [{ "type": "text", "text": text }]
@@ -357,10 +411,12 @@ impl App for OpencodeApp {
357 if let Some(session_id) = model.current_session_id.clone() {
411 if let Some(session_id) = model.current_session_id.clone() {
358 Command::event(Event::LoadMessages(session_id))
412 Command::event(Event::LoadMessages(session_id))
414+ model.generating = false;
363 Err(e) => {
418 Err(e) => {
419+ model.generating = false;
364 model.error = Some(format!("Failed to send message: {e}"));
420 model.error = Some(format!("Failed to send message: {e}"));
@@ -418,8 +474,10 @@ impl App for OpencodeApp {
419 .collect(),
475 .collect(),
420 current_session_id: model.current_session_id.clone(),
476 current_session_id: model.current_session_id.clone(),
477+ current_session_title: model.current_session_title.clone(),
421 messages: model.messages.clone(),
478 messages: model.messages.clone(),
422 draft_message: model.draft_message.clone(),
479 draft_message: model.draft_message.clone(),
480+ generating: model.generating,
423 crash_log_count: model.crash_logs.len() as u32,
481 crash_log_count: model.crash_logs.len() as u32,
424 latest_crash_log: model.crash_logs.last().cloned(),
482 latest_crash_log: model.crash_logs.last().cloned(),
@@ -604,11 +662,15 @@ mod test {
604 use super::*;
662 use super::*;
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.
608 let app = OpencodeApp;
669 let app = OpencodeApp;
609 let mut model = Model::default();
670 let mut model = Model::default();
671+ model.server_url = "http://10.0.0.1:4096".to_string();
610 app.update(Event::Start, &mut model).expect_only_render();
672 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");
@@ -628,6 +690,7 @@ mod test {
628 fn connect_emits_http_effect() {
690 fn connect_emits_http_effect() {
629 let app = OpencodeApp;
691 let app = OpencodeApp;
630 let mut model = Model::default();
692 let mut model = Model::default();
693+ model.server_url = "http://localhost:4096".to_string();
631 app.update(Event::Start, &mut model);
694 app.update(Event::Start, &mut model);
632 let mut cmd = app.update(Event::Connect, &mut model);
695 let mut cmd = app.update(Event::Connect, &mut model);
633 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
696 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
@@ -669,6 +732,8 @@ mod test {
669 let app = OpencodeApp;
732 let app = OpencodeApp;
670 let mut model = Model::default();
733 let mut model = Model::default();
671 model.current_session_id = Some("test".to_string());
734 model.current_session_id = Some("test".to_string());
735+ model.current_session_title = "Test".to_string();
736+ model.generating = true;
672 model.messages.push(MessageView {
737 model.messages.push(MessageView {
673 id: "m1".to_string(),
738 id: "m1".to_string(),
674 role: "user".to_string(),
739 role: "user".to_string(),
@@ -677,7 +742,53 @@ mod test {
678 app.update(Event::NavigateToSessions, &mut model);
743 app.update(Event::NavigateToSessions, &mut model);
679 assert!(model.current_session_id.is_none());
744 assert!(model.current_session_id.is_none());
745+ assert!(model.current_session_title.is_empty());
680 assert!(model.messages.is_empty());
746 assert!(model.messages.is_empty());
747+ assert!(!model.generating);
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() },
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");
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);
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());
@@ -717,7 +828,8 @@ mod test {
717 // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
828 // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
718 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
829 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
719 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
830 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
720− // 12=NavigateToChat, 13=NavigateToSessions, 14=DismissError
831+ // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
722 // Serialize each Rust variant and check the leading u32 index.
834 // Serialize each Rust variant and check the leading u32 index.
723 let cases: &[(Event, u32)] = &[
835 let cases: &[(Event, u32)] = &[
@@ -735,7 +847,8 @@ mod test {
735 (Event::EventReceived(String::new()), 11),
847 (Event::EventReceived(String::new()), 11),
736 (Event::NavigateToChat(String::new()), 12),
848 (Event::NavigateToChat(String::new()), 12),
737 (Event::NavigateToSessions, 13),
849 (Event::NavigateToSessions, 13),
738− (Event::DismissError, 14),
850+ (Event::NavigateToConnect, 14),
851+ (Event::DismissError, 15),
740 for (event, expected) in cases {
853 for (event, expected) in cases {
741 let bytes = bincode::serialize(event).unwrap();
854 let bytes = bincode::serialize(event).unwrap();