Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
94 changes: 94 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
130 changes: 130 additions & 0 deletions internal/tui/model_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down