From 7a922a7f46a1900a2f1aec2a26e2823cba1a54bc Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:37:03 -0700 Subject: [PATCH] feat(tui): apply hide and favorite to all selected sessions Space-selection already drives bulk launching. Route the hide (h) and favorite (*) actions through the same marked set so a whole selection can be hidden or starred in one keystroke. With no selection, both keys still act on the cursor session. Batch target state is deterministic: hide the set if any member is visible (else unhide), favorite the eligible set if any member is not a favorite (else unfavorite). Hidden sessions are skipped when favoriting, and a bulk hide drops those sessions from favorites. Config is written once per batch. Closes #196 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 2 +- internal/tui/model.go | 94 +++++++++++++++++++++ internal/tui/model_update_test.go | 130 ++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa59e72..f201512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - **Machine-readable doctor output** — `dispatch doctor --json` prints diagnostics as a single JSON object so scripts and CI can parse them instead of scraping text - **Search query argument** — pass a search string on the command line (`dispatch auth` or `dispatch fix auth bug`) to launch the TUI with the search box pre-filled and the list already filtered - **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer, Finder, or the Linux file manager) +- **Bulk hide and favorite** — with sessions marked via `Space`, `h` and `*` now apply to the whole selection instead of just the cursor session ## [v0.13.0] — 2026-06-30 diff --git a/README.md b/README.md index d578096..7752edc 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess - **Copy resume command** (`Y`) — copy the selected session's full resume command to the system clipboard - **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer on Windows, Finder on macOS, the default file manager on Linux) - **Four launch modes** (`Enter` / `t` / `w` / `e`) — in-place, new tab, new window, split pane (Windows Terminal) with per-session overrides -- **Multi-session open** (`Space` / `L` / `a` / `d`) — select multiple sessions with Space, launch all at once with L, select/deselect all with a/d. Shift+↑/↓ for range selection, Ctrl+click and Shift+click for mouse selection +- **Multi-session open** (`Space` / `L` / `a` / `d`) — select multiple sessions with Space, launch all at once with L, select/deselect all with a/d. Shift+↑/↓ for range selection, Ctrl+click and Shift+click for mouse selection. With a selection active, `h` (hide) and `*` (favorite) apply to every selected session at once - **Attention indicators** — colored dots showing real-time session status: working (blue, executing tools), thinking (cyan, generating response), compacting (magenta, context compaction), waiting (purple), active (green), stale (yellow), interrupted (orange ⚡), idle (gray). Jump to next waiting session with `n`, resume interrupted sessions with `R`, filter by status with `!` - **Host type icons** — sessions display an icon indicating their origin: CLI (desktop), Cloud (cloud), or Actions (gear) - **Incremental auto-refresh** — the session list auto-refreshes within 2 seconds when the Copilot CLI writes new data (WAL file polling when focused). No manual reindex needed for normal use. Tune the interval or turn it off with `auto_refresh_seconds` diff --git a/internal/tui/model.go b/internal/tui/model.go index fa52e7f..9bd77ea 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1499,6 +1499,11 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // handleHideSession toggles the hidden state of the currently selected session. func (m Model) handleHideSession() (tea.Model, tea.Cmd) { + // When one or more sessions are marked with Space, act on the whole set. + if m.sessionList.SelectionCount() > 0 { + return m.handleBulkHide(m.sessionList.SelectedSessions()) + } + sess, ok := m.sessionList.Selected() if !ok { return m, nil @@ -1530,8 +1535,53 @@ func (m Model) handleHideSession() (tea.Model, tea.Cmd) { return m, cmd } +// handleBulkHide hides or unhides every session in sessions with a single +// deterministic target state: if any session is currently visible, the whole +// set is hidden; otherwise the whole set is unhidden. Config is written once. +func (m Model) handleBulkHide(sessions []data.Session) (tea.Model, tea.Cmd) { + if len(sessions) == 0 { + return m, nil + } + + // If any selected session is visible (not hidden), hide the set. + hide := false + for _, sess := range sessions { + if _, hidden := m.hiddenSet[sess.ID]; !hidden { + hide = true + break + } + } + + for _, sess := range sessions { + if hide { + m.hiddenSet[sess.ID] = struct{}{} + // Hiding a favorited session also removes it from favorites. + delete(m.favoritedSet, sess.ID) + } else { + delete(m.hiddenSet, sess.ID) + } + } + + m.cfg.HiddenSessions = sortedKeys(m.hiddenSet) + m.cfg.FavoriteSessions = sortedKeys(m.favoritedSet) + if err := config.Save(m.cfg); err != nil { + m.statusErr = "config save: " + err.Error() + } + + m.sessionList.DeselectAll() + m.sessionList.SetHiddenSessions(m.visibleHiddenSet()) + m.sessionList.SetFavoritedSessions(m.favoritedSet) + cmd := m.loadSessionsCmd() + return m, cmd +} + // handleToggleFavorite toggles the favorited state of the currently selected session. func (m Model) handleToggleFavorite() (tea.Model, tea.Cmd) { + // When one or more sessions are marked with Space, act on the whole set. + if m.sessionList.SelectionCount() > 0 { + return m.handleBulkFavorite(m.sessionList.SelectedSessions()) + } + sess, ok := m.sessionList.Selected() if !ok { return m, nil @@ -1557,6 +1607,50 @@ func (m Model) handleToggleFavorite() (tea.Model, tea.Cmd) { return m, cmd } +// handleBulkFavorite favorites or unfavorites every eligible session in +// sessions with a single deterministic target state: if any non-hidden +// session is not yet a favorite, the whole eligible set is favorited; +// otherwise it is unfavorited. Hidden sessions are skipped (they cannot be +// favorited). Config is written once. +func (m Model) handleBulkFavorite(sessions []data.Session) (tea.Model, tea.Cmd) { + // Only non-hidden sessions are eligible to be favorited. + eligible := make([]data.Session, 0, len(sessions)) + for _, sess := range sessions { + if _, hidden := m.hiddenSet[sess.ID]; !hidden { + eligible = append(eligible, sess) + } + } + if len(eligible) == 0 { + return m, nil + } + + // If any eligible session is not a favorite, favorite the whole set. + favorite := false + for _, sess := range eligible { + if _, fav := m.favoritedSet[sess.ID]; !fav { + favorite = true + break + } + } + + for _, sess := range eligible { + if favorite { + m.favoritedSet[sess.ID] = struct{}{} + } else { + delete(m.favoritedSet, sess.ID) + } + } + + m.cfg.FavoriteSessions = sortedKeys(m.favoritedSet) + if err := config.Save(m.cfg); err != nil { + m.statusErr = "config save: " + err.Error() + } + + m.sessionList.SetFavoritedSessions(m.favoritedSet) + cmd := m.loadSessionsCmd() + return m, cmd +} + // handleEditNote opens the inline note input for the currently selected session. func (m Model) handleEditNote() (tea.Model, tea.Cmd) { sess, ok := m.sessionList.Selected() diff --git a/internal/tui/model_update_test.go b/internal/tui/model_update_test.go index 6ab8504..46509f0 100644 --- a/internal/tui/model_update_test.go +++ b/internal/tui/model_update_test.go @@ -1712,6 +1712,136 @@ func TestHandleHideSession_Unhide(t *testing.T) { } } +// --------------------------------------------------------------------------- +// handleHideSession / handleToggleFavorite: bulk (multi-selection) +// --------------------------------------------------------------------------- + +func TestHandleBulkHide_HidesAllSelected(t *testing.T) { + m := newTestModel() + m.sessionList.SetSessions([]data.Session{ + {ID: "s1", Cwd: "/a"}, {ID: "s2", Cwd: "/b"}, {ID: "s3", Cwd: "/c"}, + }) + m.sessionList.SelectAll() + + result, cmd := m.handleHideSession() + rm := result.(Model) + for _, id := range []string{"s1", "s2", "s3"} { + if _, ok := rm.hiddenSet[id]; !ok { + t.Errorf("%s should be hidden", id) + } + } + if rm.sessionList.SelectionCount() != 0 { + t.Errorf("selection should be cleared after bulk hide, got %d", rm.sessionList.SelectionCount()) + } + if cmd == nil { + t.Error("should return reload cmd") + } +} + +func TestHandleBulkHide_UnhidesWhenAllHidden(t *testing.T) { + m := newTestModel() + m.hiddenSet["s1"] = struct{}{} + m.hiddenSet["s2"] = struct{}{} + m.showHidden = true // so hidden sessions are visible and selectable + m.sessionList.SetSessions([]data.Session{{ID: "s1", Cwd: "/a"}, {ID: "s2", Cwd: "/b"}}) + m.sessionList.SelectAll() + + result, _ := m.handleHideSession() + rm := result.(Model) + for _, id := range []string{"s1", "s2"} { + if _, ok := rm.hiddenSet[id]; ok { + t.Errorf("%s should be unhidden", id) + } + } +} + +func TestHandleBulkHide_MixedVisibilityHidesAll(t *testing.T) { + m := newTestModel() + m.hiddenSet["s1"] = struct{}{} + m.showHidden = true + m.sessionList.SetSessions([]data.Session{{ID: "s1", Cwd: "/a"}, {ID: "s2", Cwd: "/b"}}) + m.sessionList.SelectAll() + + result, _ := m.handleHideSession() + rm := result.(Model) + // s2 was visible, so the whole set is hidden. + for _, id := range []string{"s1", "s2"} { + if _, ok := rm.hiddenSet[id]; !ok { + t.Errorf("%s should be hidden", id) + } + } +} + +func TestHandleBulkHide_RemovesFavorites(t *testing.T) { + m := newTestModel() + m.favoritedSet = make(map[string]struct{}) + m.favoritedSet["s1"] = struct{}{} + m.sessionList.SetSessions([]data.Session{{ID: "s1", Cwd: "/a"}, {ID: "s2", Cwd: "/b"}}) + m.sessionList.SelectAll() + + result, _ := m.handleHideSession() + rm := result.(Model) + if _, ok := rm.hiddenSet["s1"]; !ok { + t.Error("s1 should be hidden") + } + if _, ok := rm.favoritedSet["s1"]; ok { + t.Error("s1 should no longer be a favorite after bulk hide") + } +} + +func TestHandleBulkFavorite_FavoritesAllSelected(t *testing.T) { + m := newTestModel() + m.favoritedSet = make(map[string]struct{}) + m.sessionList.SetSessions([]data.Session{{ID: "s1", Cwd: "/a"}, {ID: "s2", Cwd: "/b"}}) + m.sessionList.SelectAll() + + result, cmd := m.handleToggleFavorite() + rm := result.(Model) + for _, id := range []string{"s1", "s2"} { + if _, ok := rm.favoritedSet[id]; !ok { + t.Errorf("%s should be favorited", id) + } + } + if cmd == nil { + t.Error("should return reload cmd") + } +} + +func TestHandleBulkFavorite_UnfavoritesWhenAllFav(t *testing.T) { + m := newTestModel() + m.favoritedSet = make(map[string]struct{}) + m.favoritedSet["s1"] = struct{}{} + m.favoritedSet["s2"] = struct{}{} + m.sessionList.SetSessions([]data.Session{{ID: "s1", Cwd: "/a"}, {ID: "s2", Cwd: "/b"}}) + m.sessionList.SelectAll() + + result, _ := m.handleToggleFavorite() + rm := result.(Model) + for _, id := range []string{"s1", "s2"} { + if _, ok := rm.favoritedSet[id]; ok { + t.Errorf("%s should be unfavorited", id) + } + } +} + +func TestHandleBulkFavorite_SkipsHidden(t *testing.T) { + m := newTestModel() + m.favoritedSet = make(map[string]struct{}) + m.hiddenSet["s1"] = struct{}{} + m.showHidden = true + m.sessionList.SetSessions([]data.Session{{ID: "s1", Cwd: "/a"}, {ID: "s2", Cwd: "/b"}}) + m.sessionList.SelectAll() + + result, _ := m.handleToggleFavorite() + rm := result.(Model) + if _, ok := rm.favoritedSet["s1"]; ok { + t.Error("hidden session s1 should not be favorited") + } + if _, ok := rm.favoritedSet["s2"]; !ok { + t.Error("visible session s2 should be favorited") + } +} + // --------------------------------------------------------------------------- // handleKey: shift+arrow range selection // ---------------------------------------------------------------------------