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
44 changes: 43 additions & 1 deletion pkg/tui/dialog/session_browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ type sessionBrowserDialog struct {
keyMap sessionBrowserKeyMap
openedAt time.Time // when dialog was opened, for stable time display
starFilter int // 0 = all, 1 = starred only, 2 = unstarred only

// Double-click detection
lastClickTime time.Time
lastClickIndex int
}

// NewSessionBrowserDialog creates a new session browser dialog
Expand Down Expand Up @@ -106,6 +110,26 @@ func (d *sessionBrowserDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
d.filterSessions()
return d, cmd

case tea.MouseClickMsg:
// Scrollbar clicks already handled above; this handles list item clicks
if msg.Button == tea.MouseLeft {
if idx := d.mouseYToSessionIndex(msg.Y); idx >= 0 {
now := time.Now()
if idx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold {
d.selected = idx
d.lastClickTime = time.Time{}
return d, tea.Sequence(
core.CmdHandler(CloseDialogMsg{}),
core.CmdHandler(messages.LoadSessionMsg{SessionID: d.filtered[d.selected].ID}),
)
}
d.selected = idx
d.lastClickTime = now
d.lastClickIndex = idx
}
}
return d, nil

case tea.KeyPressMsg:
if cmd := HandleQuit(msg); cmd != nil {
return d, cmd
Expand Down Expand Up @@ -216,6 +240,24 @@ func (d *sessionBrowserDialog) filterSessions() {
d.scrollview.SetScrollOffset(0)
}

// mouseYToSessionIndex converts a mouse Y position to a session index in the filtered list.
// Returns -1 if the position is not on a session.
func (d *sessionBrowserDialog) mouseYToSessionIndex(y int) int {
dialogRow, _ := d.Position()
visLines := d.scrollview.VisibleHeight()
listStartY := dialogRow + sessionBrowserListStartY

if y < listStartY || y >= listStartY+visLines {
return -1
}
lineInView := y - listStartY
idx := d.scrollview.ScrollOffset() + lineInView
if idx < 0 || idx >= len(d.filtered) {
return -1
}
return idx
}

func (d *sessionBrowserDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) {
dialogWidth = max(min(d.Width()*85/100, 96), 60)
maxHeight = min(d.Height()*70/100, 30)
Expand Down Expand Up @@ -317,7 +359,7 @@ func (d *sessionBrowserDialog) renderSession(sess session.Summary, selected bool
title = "Untitled"
}

suffix := fmt.Sprintf(" (%d) • %s", sess.NumMessages, d.timeAgo(sess.CreatedAt))
suffix := fmt.Sprintf(" (%d msg) • %s", sess.NumMessages, d.timeAgo(sess.CreatedAt))

starWidth := 3
maxTitleLen := max(1, maxWidth-len(suffix)-starWidth)
Expand Down
103 changes: 103 additions & 0 deletions pkg/tui/dialog/session_browser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,106 @@ func TestSessionBrowserScrolling(t *testing.T) {
expectedTitle := fmt.Sprintf("Session %d", d.selected+1)
require.Contains(t, view, expectedTitle, "view should contain selected session")
}

func TestSessionBrowserMouseClickSelectsSession(t *testing.T) {
sessions := []session.Summary{
{ID: "1", Title: "Session 1", CreatedAt: time.Now()},
{ID: "2", Title: "Session 2", CreatedAt: time.Now()},
{ID: "3", Title: "Session 3", CreatedAt: time.Now()},
}

dialog := NewSessionBrowserDialog(sessions)
d := dialog.(*sessionBrowserDialog)
d.Init()
d.Update(tea.WindowSizeMsg{Width: 100, Height: 50})

// Initially selected should be 0
require.Equal(t, 0, d.selected)

// Get the dialog position to calculate where to click
dialogRow, _ := d.Position()
listStartY := dialogRow + sessionBrowserListStartY

// Single-click on the second session (line index 1)
clickMsg := tea.MouseClickMsg{
X: 20,
Y: listStartY + 1,
Button: tea.MouseLeft,
}
updated, cmd := d.Update(clickMsg)
d = updated.(*sessionBrowserDialog)

// Selection should have moved to session 2
require.Equal(t, 1, d.selected, "single click should select session")
// Single click should not produce a load command
require.Nil(t, cmd, "single click should not trigger load")

// Single-click on the third session
clickMsg = tea.MouseClickMsg{
X: 20,
Y: listStartY + 2,
Button: tea.MouseLeft,
}
updated, cmd = d.Update(clickMsg)
d = updated.(*sessionBrowserDialog)

require.Equal(t, 2, d.selected, "single click should select third session")
require.Nil(t, cmd, "single click on different session should not trigger load")
}

func TestSessionBrowserDoubleClickOpensSession(t *testing.T) {
sessions := []session.Summary{
{ID: "sess-1", Title: "Session 1", CreatedAt: time.Now()},
{ID: "sess-2", Title: "Session 2", CreatedAt: time.Now()},
{ID: "sess-3", Title: "Session 3", CreatedAt: time.Now()},
}

dialog := NewSessionBrowserDialog(sessions)
d := dialog.(*sessionBrowserDialog)
d.Init()
d.Update(tea.WindowSizeMsg{Width: 100, Height: 50})

dialogRow, _ := d.Position()
listStartY := dialogRow + sessionBrowserListStartY

// First click selects
clickMsg := tea.MouseClickMsg{
X: 20,
Y: listStartY + 1,
Button: tea.MouseLeft,
}
updated, _ := d.Update(clickMsg)
d = updated.(*sessionBrowserDialog)
require.Equal(t, 1, d.selected)

// Second click on the same item (double-click) should trigger load
updated, cmd := d.Update(clickMsg)
d = updated.(*sessionBrowserDialog)
require.Equal(t, 1, d.selected, "selection should stay on double-clicked session")
require.NotNil(t, cmd, "double-click should produce a command to load the session")
}

func TestSessionBrowserClickOutsideListIgnored(t *testing.T) {
sessions := []session.Summary{
{ID: "1", Title: "Session 1", CreatedAt: time.Now()},
{ID: "2", Title: "Session 2", CreatedAt: time.Now()},
}

dialog := NewSessionBrowserDialog(sessions)
d := dialog.(*sessionBrowserDialog)
d.Init()
d.Update(tea.WindowSizeMsg{Width: 100, Height: 50})

// Click way outside the list area
clickMsg := tea.MouseClickMsg{
X: 5,
Y: 0,
Button: tea.MouseLeft,
}
updated, cmd := d.Update(clickMsg)
d = updated.(*sessionBrowserDialog)

// Selection should remain at 0
require.Equal(t, 0, d.selected, "click outside list should not change selection")
require.Nil(t, cmd, "click outside list should not produce a command")
}