From 5b292ba9332f3c4103c3b2c918108abfce33b59d Mon Sep 17 00:00:00 2001 From: RestartFU Date: Tue, 28 Jul 2026 09:19:17 -0400 Subject: [PATCH 1/4] fix(chat): keep worktree picker open Update choice rows in place so metadata refreshes do not dismiss an open popover. Co-authored-by: Codex --- src/chat/option-picker.c | 52 ++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/chat/option-picker.c b/src/chat/option-picker.c index b390d08e..5d61ef88 100644 --- a/src/chat/option-picker.c +++ b/src/chat/option-picker.c @@ -5,6 +5,7 @@ typedef struct char *label; char *description; GtkLabel *row_label; + GtkLabel *description_label; GtkImage *check; GtkListBoxRow *row; } Choice; @@ -130,6 +131,7 @@ append_choice (XdOptionPicker *self, choice->label = g_strdup (label); choice->description = g_strdup (description); choice->row_label = GTK_LABEL (gtk_label_new (label)); + choice->description_label = GTK_LABEL (detail); choice->check = GTK_IMAGE ( gtk_image_new_from_icon_name ("object-select-symbolic")); choice->row = GTK_LIST_BOX_ROW (row); @@ -159,13 +161,33 @@ append_choice (XdOptionPicker *self, g_ptr_array_add (self->choices, choice); } +static void +update_choice (Choice *choice, + const char *label, + const char *description) +{ + if (g_strcmp0 (choice->label, label) != 0) + { + g_free (choice->label); + choice->label = g_strdup (label); + gtk_label_set_label (choice->row_label, label); + } + + if (g_strcmp0 (choice->description, description) != 0) + { + g_free (choice->description); + choice->description = g_strdup (description); + gtk_label_set_label (choice->description_label, description); + } +} + void xd_option_picker_set_choices (XdOptionPicker *self, const char *const *labels, const char *const *descriptions) { - GtkWidget *child; guint old_selected; + guint length = 0; g_return_if_fail (XD_IS_OPTION_PICKER (self)); g_return_if_fail (labels != NULL); @@ -173,14 +195,30 @@ xd_option_picker_set_choices (XdOptionPicker *self, old_selected = self->selected; - while ((child = gtk_widget_get_first_child (GTK_WIDGET (self->list))) != NULL) - gtk_list_box_remove (self->list, child); - g_ptr_array_set_size (self->choices, 0); - self->selected = 0; + /* + * Keep existing rows alive. Remote metadata can arrive while this popover is + * open; replacing every row then makes GTK dismiss it. Updating rows in + * place also avoids rebuilding identical static pickers. + */ + for (; labels[length] != NULL; length++) + { + if (length < self->choices->len) + update_choice (g_ptr_array_index (self->choices, length), + labels[length], descriptions[length]); + else + append_choice (self, labels[length], descriptions[length]); + } - for (guint i = 0; labels[i] != NULL; i++) - append_choice (self, labels[i], descriptions[i]); + while (self->choices->len > length) + { + Choice *choice = + g_ptr_array_index (self->choices, self->choices->len - 1); + + gtk_list_box_remove (self->list, GTK_WIDGET (choice->row)); + g_ptr_array_remove_index (self->choices, self->choices->len - 1); + } + self->selected = 0; sync_selection (self); if (old_selected != 0) From 97a0fd51afd533c07270d60d63bfda54dc4dcfcc Mon Sep 17 00:00:00 2001 From: RestartFU Date: Tue, 28 Jul 2026 09:24:39 -0400 Subject: [PATCH 2/4] fix(remote): preserve inline tree editors Keep client-only placeholder rows during daemon tree reconciliation so refreshes cannot discard active new-chat or new-folder input. Co-authored-by: Codex --- src/remote/remote-tree.c | 40 +++++++++++++++++++++++++++++++++--- tests/test-remote.c | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/remote/remote-tree.c b/src/remote/remote-tree.c index 08a45a6a..e0c9990e 100644 --- a/src/remote/remote-tree.c +++ b/src/remote/remote-tree.c @@ -50,6 +50,21 @@ static void set_root_state (XdRemoteTree *self, XdNodeState state); /* --- reconciling ---------------------------------------------------------- */ +/* + * A row with no daemon id is an inline editor owned by the client. + * + * The sidebar inserts one while a new folder or chat is being named. A tree + * reply knows nothing about that placeholder, so treating the reply as an + * exhaustive list would remove the entry while the user is typing. + */ +static gboolean +is_client_placeholder (XdNode *node) +{ + return xd_node_get_kind (node) == XD_NODE_FOLDER + ? xd_node_get_folder_id (node) == NULL + : xd_node_get_chat_id (node) == NULL; +} + /* * Brings @store to exactly @desired, moving as little as possible. * @@ -62,10 +77,29 @@ reconcile_children (GListStore *store, GPtrArray *desired) { GListModel *model = G_LIST_MODEL (store); + g_autoptr (GPtrArray) target = + g_ptr_array_new_with_free_func (g_object_unref); for (guint i = 0; i < desired->len; i++) + g_ptr_array_add (target, g_object_ref (g_ptr_array_index (desired, i))); + + /* + * Keep client placeholders at their current positions while reconciling + * every daemon-owned row around them. Their row, entry text and focus then + * survive a refresh that finishes while the user is naming something. + */ + for (guint i = 0; i < g_list_model_get_n_items (model); i++) + { + g_autoptr (XdNode) node = g_list_model_get_item (model, i); + + if (is_client_placeholder (node)) + g_ptr_array_insert (target, MIN (i, target->len), + g_steal_pointer (&node)); + } + + for (guint i = 0; i < target->len; i++) { - XdNode *wanted = g_ptr_array_index (desired, i); + XdNode *wanted = g_ptr_array_index (target, i); guint at; if (i < g_list_model_get_n_items (model)) @@ -82,8 +116,8 @@ reconcile_children (GListStore *store, g_list_store_insert (store, i, wanted); } - while (g_list_model_get_n_items (model) > desired->len) - g_list_store_remove (store, desired->len); + while (g_list_model_get_n_items (model) > target->len) + g_list_store_remove (store, target->len); } static int diff --git a/tests/test-remote.c b/tests/test-remote.c index 5a65422d..91d43e2a 100644 --- a/tests/test-remote.c +++ b/tests/test-remote.c @@ -1045,6 +1045,49 @@ test_folders_and_chats_are_managed_from_the_client (void) daemon_stop (&daemon); } +/* + * A tree refresh can finish while the sidebar is showing an inline editor for + * a new folder or chat. Those rows have no daemon id yet and must survive the + * authoritative server snapshot until the user submits or cancels them. + */ +static void +test_tree_refresh_keeps_client_placeholders (void) +{ + Daemon daemon = { 0 }; + g_autoptr (XdRemoteClient) client = NULL; + g_autoptr (XdRemoteTree) tree = NULL; + g_autoptr (XdNode) folder_placeholder = NULL; + g_autoptr (XdNode) chat_placeholder = NULL; + XdNode *root; + XdNode *folder; + Wait loading = { 0 }; + + daemon_start (&daemon); + + client = xd_remote_client_new ("127.0.0.1", daemon.port); + tree = paired_tree (&daemon, client); + root = xd_remote_tree_get_root (tree); + folder = child_at (root, 0); + + folder_placeholder = xd_node_new_folder (NULL, "New Folder", NULL); + xd_node_set_parent (folder_placeholder, root); + g_list_store_insert (xd_node_get_children (root), 0, folder_placeholder); + + chat_placeholder = + xd_node_new_chat (NULL, "New Chat", folder); + g_list_store_insert (xd_node_get_children (folder), 0, chat_placeholder); + + g_signal_connect_swapped (tree, "loaded", G_CALLBACK (on_done), &loading); + xd_remote_tree_refresh (tree); + wait_for (&loading); + g_signal_handlers_disconnect_by_data (tree, &loading); + + g_assert_true (child_at (root, 0) == folder_placeholder); + g_assert_true (child_at (folder, 0) == chat_placeholder); + + daemon_stop (&daemon); +} + static void set_remote_agent_option (XdRemoteClient *client, const char *chat_id, @@ -3802,6 +3845,7 @@ main (int argc, char *argv[]) ADD ("/remote/client-pairs-and-reads-the-tree", test_client_pairs_and_reads_the_tree); ADD ("/remote/token-reconnects-and-strangers-are-turned-away", test_token_reconnects_and_strangers_are_turned_away); ADD ("/remote/folders-and-chats-are-managed-from-the-client", test_folders_and_chats_are_managed_from_the_client); + ADD ("/remote/tree-refresh-keeps-client-placeholders", test_tree_refresh_keeps_client_placeholders); ADD ("/remote/new-chat-inherits-last-changed-agent", test_remote_new_chat_inherits_last_changed_agent); ADD ("/remote/folder-context-is-managed-from-the-client", test_folder_context_is_managed_from_the_client); ADD ("/remote/agent-secrets-are-managed-without-reading-values", test_agent_secrets_are_managed_without_reading_values); From 2dd0dfd0211001c388899636e82bc374385b755b Mon Sep 17 00:00:00 2001 From: RestartFU Date: Tue, 28 Jul 2026 09:38:10 -0400 Subject: [PATCH 3/4] fix(sidebar): ignore transient deselection Keep the active node through remove-and-reinsert reconciliation and skip duplicate selection opens so background tree updates cannot steal focus. Co-authored-by: Codex --- src/tree/sidebar.c | 9 ++++++++- src/xd-window.c | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/tree/sidebar.c b/src/tree/sidebar.c index 5d1618ca..14d35714 100644 --- a/src/tree/sidebar.c +++ b/src/tree/sidebar.c @@ -1719,7 +1719,14 @@ on_selection_changed (GtkSingleSelection *selection, if (row != NULL) node = gtk_tree_list_row_get_item (row); - if (node == self->selected) + /* + * Reconciliation can remove and reinsert the selected row in one main-loop + * turn. GtkSingleSelection reports a brief empty selection between those + * operations. Empty selection does not close the current chat, so it must + * not erase the node identity used to recognize the same row when it comes + * back. + */ + if (node == NULL || node == self->selected) return; /* A real selection made while a remote is still connecting wins over what diff --git a/src/xd-window.c b/src/xd-window.c index bead5321..bcbf2b54 100644 --- a/src/xd-window.c +++ b/src/xd-window.c @@ -151,7 +151,20 @@ on_node_selected (XdSidebar *sidebar, XdNode *node, gpointer user_data) { - show_chat (user_data, node); + XdWindow *self = user_data; + + /* + * Model changes can produce more than one selection notification around the + * same node. Reopening the chat takes focus back to the composer, dismissing + * an open picker or finishing an inline editor in the sidebar. + * + * Activation remains separate below, so deliberately opening the row still + * does what the user asked. + */ + if (xd_chat_view_get_chat (self->chat_view) == node) + return; + + show_chat (self, node); } /* From 3335358ceb1d113c1df30eadc80645d8b78c430a Mon Sep 17 00:00:00 2001 From: RestartFU Date: Tue, 28 Jul 2026 09:47:19 -0400 Subject: [PATCH 4/4] fix(chat): scroll long option pickers Cap popover height so repositories with many worktrees fit on screen instead of producing an oversized popup that is immediately dismissed. Co-authored-by: Codex --- src/chat/option-picker.c | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/chat/option-picker.c b/src/chat/option-picker.c index 5d61ef88..9a07815e 100644 --- a/src/chat/option-picker.c +++ b/src/chat/option-picker.c @@ -311,6 +311,7 @@ xd_option_picker_init (XdOptionPicker *self) GtkWidget *button_content = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget *popover = gtk_popover_new (); GtkWidget *panel = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *scroller = gtk_scrolled_window_new (); self->choices = g_ptr_array_new_with_free_func ((GDestroyNotify) choice_free); @@ -330,7 +331,21 @@ xd_option_picker_init (XdOptionPicker *self) g_signal_connect (self->list, "row-activated", G_CALLBACK (on_row_activated), self); - gtk_box_append (GTK_BOX (panel), GTK_WIDGET (self->list)); + /* + * A repository can have dozens of worktrees. Let short pickers keep their + * natural height, but cap long ones so the popover can fit on screen and + * scroll instead of being dismissed by the display server. + */ + gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroller), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_max_content_height ( + GTK_SCROLLED_WINDOW (scroller), 420); + gtk_scrolled_window_set_propagate_natural_height ( + GTK_SCROLLED_WINDOW (scroller), TRUE); + gtk_scrolled_window_set_child (GTK_SCROLLED_WINDOW (scroller), + GTK_WIDGET (self->list)); + + gtk_box_append (GTK_BOX (panel), scroller); gtk_widget_add_css_class (panel, "xd-menu"); gtk_popover_set_child (GTK_POPOVER (popover), panel); gtk_popover_set_has_arrow (GTK_POPOVER (popover), FALSE);