From 768288c23ab7bbe590404dab71a949b9256c23fa Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 24 Jan 2026 07:28:52 -0600 Subject: [PATCH] feat: focus executor pane when jumping to task from notification When using 'g' to jump to a task from a notification, the executor pane is now automatically focused instead of leaving focus on the TUI pane. This allows users to immediately start interacting with the AI assistant without having to manually switch panes. Changes: - Add focusExecutor flag to taskLoadedMsg - Add loadTaskWithFocus helper that sets the flag - Add focusExecutorOnJoin field to DetailModel - Focus the Claude pane after panes are joined when flag is set - Add test for the new behavior Co-Authored-By: Claude Opus 4.5 --- internal/ui/app.go | 22 ++++++++++++---- internal/ui/app_test.go | 56 +++++++++++++++++++++++++++++++++++++++++ internal/ui/detail.go | 47 ++++++++++++++++++++++++++++------ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 3761616..02a8511 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -640,7 +640,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.executor.ResumeTask(msg.task.ID) } var initCmd tea.Cmd - m.detailView, initCmd = NewDetailModel(msg.task, m.db, m.executor, m.width, m.height) + m.detailView, initCmd = NewDetailModel(msg.task, m.db, m.executor, m.width, m.height, msg.focusExecutor) // Set task position in column for display pos, total := m.kanban.GetTaskPosition() m.detailView.SetPosition(pos, total) @@ -1140,7 +1140,8 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Clear notification after jumping m.notification = "" m.notifyTaskID = 0 - return m, m.loadTask(taskID) + // Use loadTaskWithFocus to automatically focus the executor pane + return m, m.loadTaskWithFocus(taskID) } return m, nil @@ -2504,8 +2505,9 @@ type tasksLoadedMsg struct { } type taskLoadedMsg struct { - task *db.Task - err error + task *db.Task + err error + focusExecutor bool // Focus executor pane after entering detail view (e.g., from notification jump) } type taskCreatedMsg struct { @@ -2606,6 +2608,16 @@ func (m *AppModel) loadTasks() tea.Cmd { } func (m *AppModel) loadTask(id int64) tea.Cmd { + return m.loadTaskWithOptions(id, false) +} + +// loadTaskWithFocus loads a task and focuses the executor pane when entering detail view. +// Used when jumping to a task from a notification. +func (m *AppModel) loadTaskWithFocus(id int64) tea.Cmd { + return m.loadTaskWithOptions(id, true) +} + +func (m *AppModel) loadTaskWithOptions(id int64, focusExecutor bool) tea.Cmd { // Check PR state asynchronously (don't block UI) if m.executor != nil { go m.executor.CheckPRStateAndUpdateTask(id) @@ -2613,7 +2625,7 @@ func (m *AppModel) loadTask(id int64) tea.Cmd { return func() tea.Msg { task, err := m.db.GetTask(id) - return taskLoadedMsg{task: task, err: err} + return taskLoadedMsg{task: task, err: err, focusExecutor: focusExecutor} } } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 804e107..8479d84 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -670,3 +670,59 @@ func TestJumpToNotificationKey_DetailView_NoNotification(t *testing.T) { t.Error("expected nil command when no notification is active") } } + +func TestJumpToNotificationKey_FocusExecutor(t *testing.T) { + // Create app model with kanban board and notification + tasks := []*db.Task{ + {ID: 1, Title: "Task 1", Status: db.StatusBacklog}, + {ID: 2, Title: "Task 2", Status: db.StatusBlocked}, + } + + // Create a mock database for the loadTask call + mockDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer mockDB.Close() + + // Insert test task + testTask := &db.Task{ID: 2, Title: "Task 2", Status: db.StatusBlocked} + if err := mockDB.CreateTask(testTask); err != nil { + t.Fatalf("Failed to create test task: %v", err) + } + + m := &AppModel{ + width: 100, + height: 50, + currentView: ViewDashboard, + keys: DefaultKeyMap(), + notification: "⚠ Task #2 needs input: Task 2 (g to jump)", + notifyTaskID: 2, + kanban: NewKanbanBoard(100, 50), + db: mockDB, + } + m.kanban.SetTasks(tasks) + m.kanban.SelectTask(1) // Start with task 1 selected + + // Press 'g' to jump to notification + gMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}} + _, cmd := m.updateDashboard(gMsg) + + // Verify command was returned + if cmd == nil { + t.Fatal("expected command to be returned") + } + + // Execute the command to get the message + msg := cmd() + + // Verify the message has focusExecutor set to true + loadedMsg, ok := msg.(taskLoadedMsg) + if !ok { + t.Fatalf("expected taskLoadedMsg, got %T", msg) + } + + if !loadedMsg.focusExecutor { + t.Error("expected focusExecutor to be true when jumping from notification") + } +} diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 9b11f68..182476d 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -98,6 +98,9 @@ type DetailModel struct { notification string // current notification message notifyTaskID int64 // task that triggered the notification notifyUntil time.Time + + // Focus executor pane after joining (e.g., when jumping from notification) + focusExecutorOnJoin bool } // Message types for async pane loading @@ -253,19 +256,21 @@ func (m *DetailModel) ClaudePaneID() string { // NewDetailModel creates a new detail model. // Returns the model and an optional command for async pane setup. -func NewDetailModel(t *db.Task, database *db.DB, exec *executor.Executor, width, height int) (*DetailModel, tea.Cmd) { +// If focusExecutor is true, the executor pane will be focused after panes are joined. +func NewDetailModel(t *db.Task, database *db.DB, exec *executor.Executor, width, height int, focusExecutor bool) (*DetailModel, tea.Cmd) { log := GetLogger() - log.Info("NewDetailModel: creating for task %d (%s)", t.ID, t.Title) + log.Info("NewDetailModel: creating for task %d (%s), focusExecutor=%v", t.ID, t.Title, focusExecutor) log.Debug("NewDetailModel: TMUX env=%q, DaemonSession=%q, ClaudeSessionID=%q", os.Getenv("TMUX"), t.DaemonSession, t.ClaudeSessionID) m := &DetailModel{ - task: t, - database: database, - executor: exec, - width: width, - height: height, - focused: true, // Initially focused when viewing details + task: t, + database: database, + executor: exec, + width: width, + height: height, + focused: true, // Initially focused when viewing details + focusExecutorOnJoin: focusExecutor, } // Load logs @@ -440,6 +445,10 @@ func (m *DetailModel) Update(msg tea.Msg) (*DetailModel, tea.Cmd) { m.daemonSessionID = msg.daemonSessionID m.cachedWindowTarget = msg.windowTarget m.paneError = "" + // Focus executor pane if requested (e.g., when jumping from notification) + if m.focusExecutorOnJoin && m.claudePaneID != "" { + m.focusExecutorPane() + } } m.viewport.SetContent(m.renderContent()) return m, nil @@ -1285,6 +1294,28 @@ func (m *DetailModel) joinTmuxPanes() { log.Info("joinTmuxPanes: completed for task %d, claudePaneID=%q, workdirPaneID=%q, tuiPaneID=%q", m.task.ID, m.claudePaneID, m.workdirPaneID, m.tuiPaneID) + + // Focus executor pane if requested (e.g., when jumping from notification) + if m.focusExecutorOnJoin && m.claudePaneID != "" { + m.focusExecutorPane() + } +} + +// focusExecutorPane focuses the executor (Claude) pane. +func (m *DetailModel) focusExecutorPane() { + if m.claudePaneID == "" { + return + } + log := GetLogger() + log.Info("focusExecutorPane: focusing Claude pane %q", m.claudePaneID) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := exec.CommandContext(ctx, "tmux", "select-pane", "-t", m.claudePaneID).Run() + if err != nil { + log.Error("focusExecutorPane: select-pane failed: %v", err) + } else { + m.focused = false // TUI is no longer focused, executor is + } } // joinTmuxPane is a compatibility wrapper for joinTmuxPanes.