Skip to content
Open
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
84 changes: 84 additions & 0 deletions docs/multiple-panes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Multiple Tmux Panes Feature

## Overview

The task detail view now supports creating multiple tmux panes for a single task. This allows you to have multiple shell sessions or Claude instances running simultaneously within a task's context.

## Usage

### Keybindings

When viewing a task in detail view:

- **Ctrl+N**: Create a new shell pane
- **Ctrl+Shift+N** (or **Ctrl+N**): Create a new Claude pane
- **\\**: Toggle the primary shell pane (existing functionality)

### Creating New Panes

1. Open a task in detail view (press Enter on a task)
2. Press **Ctrl+N** to create a new shell pane
- The new pane will be created next to the Claude pane
- It will have the task's environment variables set (WORKTREE_TASK_ID, WORKTREE_PORT, WORKTREE_PATH)
3. Press **Ctrl+Shift+N** to create a new Claude pane
- A new Claude session will start in the new pane
- This allows you to have multiple AI assistants working on different aspects of the task

### Cleanup

All panes (including extra panes) are automatically cleaned up when:
- You navigate away from the task detail view
- You switch to a different task
- You close the application

The panes are "broken" (moved to their own tmux windows) so they continue running in the background and can be manually attached to later if needed.

## Technical Details

### Database Schema

The feature uses a new `task_panes` table to track all panes associated with a task:

```sql
CREATE TABLE task_panes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
pane_id TEXT NOT NULL,
pane_type TEXT NOT NULL,
title TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
```

### Pane Types

- `claude`: Primary Claude/executor pane
- `shell`: Primary shell pane
- `claude-extra`: Additional Claude panes
- `shell-extra`: Additional shell panes

### Backward Compatibility

The existing `claude_pane_id` and `shell_pane_id` columns on the `tasks` table are maintained for backward compatibility and continue to track the primary panes.

## Use Cases

### Multiple Shell Sessions

You might want multiple shell panes to:
- Run a development server in one pane
- Have a shell for git operations in another
- Monitor logs in a third pane

### Multiple Claude Sessions

Multiple Claude panes can be useful for:
- Having one Claude focused on implementation
- Another Claude reviewing code or documentation
- A third Claude investigating a specific problem or researching best practices

## Implementation Notes

- All extra panes are stored in the `task_panes` table
- Panes are automatically tracked and cleaned up when leaving the detail view
- The feature integrates seamlessly with the existing tmux-based task isolation system
179 changes: 179 additions & 0 deletions internal/db/panes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package db

import (
"database/sql"
"fmt"
)

// TaskPane represents a tmux pane associated with a task.
// Each task can have multiple panes of different types (shell, claude, etc.).
type TaskPane struct {
ID int64
TaskID int64
PaneID string // tmux pane ID (e.g., "%1234")
PaneType string // "claude", "shell", "claude-extra", "shell-extra"
Title string // optional custom title
CreatedAt LocalTime
}

// Pane types
const (
PaneTypeClaude = "claude" // Primary Claude/executor pane
PaneTypeShell = "shell" // Primary shell pane
PaneTypeClaudeExtra = "claude-extra" // Additional Claude panes
PaneTypeShellExtra = "shell-extra" // Additional shell panes
)

// CreateTaskPane creates a new pane record for a task.
func (db *DB) CreateTaskPane(pane *TaskPane) error {
result, err := db.Exec(`
INSERT INTO task_panes (task_id, pane_id, pane_type, title)
VALUES (?, ?, ?, ?)
`, pane.TaskID, pane.PaneID, pane.PaneType, pane.Title)
if err != nil {
return fmt.Errorf("insert task pane: %w", err)
}

id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("get last insert id: %w", err)
}
pane.ID = id
return nil
}

// GetTaskPanes retrieves all panes for a task.
func (db *DB) GetTaskPanes(taskID int64) ([]*TaskPane, error) {
rows, err := db.Query(`
SELECT id, task_id, pane_id, pane_type, COALESCE(title, ''), created_at
FROM task_panes
WHERE task_id = ?
ORDER BY created_at ASC
`, taskID)
if err != nil {
return nil, fmt.Errorf("query task panes: %w", err)
}
defer rows.Close()

var panes []*TaskPane
for rows.Next() {
p := &TaskPane{}
if err := rows.Scan(&p.ID, &p.TaskID, &p.PaneID, &p.PaneType, &p.Title, &p.CreatedAt); err != nil {
return nil, fmt.Errorf("scan task pane: %w", err)
}
panes = append(panes, p)
}
return panes, nil
}

// GetTaskPaneByID retrieves a specific pane by ID.
func (db *DB) GetTaskPaneByID(id int64) (*TaskPane, error) {
p := &TaskPane{}
err := db.QueryRow(`
SELECT id, task_id, pane_id, pane_type, COALESCE(title, ''), created_at
FROM task_panes
WHERE id = ?
`, id).Scan(&p.ID, &p.TaskID, &p.PaneID, &p.PaneType, &p.Title, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query task pane: %w", err)
}
return p, nil
}

// UpdateTaskPaneTitle updates the title of a pane.
func (db *DB) UpdateTaskPaneTitle(id int64, title string) error {
_, err := db.Exec(`
UPDATE task_panes SET title = ?
WHERE id = ?
`, title, id)
if err != nil {
return fmt.Errorf("update task pane title: %w", err)
}
return nil
}

// DeleteTaskPane deletes a pane record.
func (db *DB) DeleteTaskPane(id int64) error {
_, err := db.Exec("DELETE FROM task_panes WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete task pane: %w", err)
}
return nil
}

// DeleteTaskPaneByPaneID deletes a pane by its tmux pane ID.
func (db *DB) DeleteTaskPaneByPaneID(taskID int64, paneID string) error {
_, err := db.Exec("DELETE FROM task_panes WHERE task_id = ? AND pane_id = ?", taskID, paneID)
if err != nil {
return fmt.Errorf("delete task pane by pane id: %w", err)
}
return nil
}

// ClearTaskPanes deletes all panes for a task.
func (db *DB) ClearTaskPanes(taskID int64) error {
_, err := db.Exec("DELETE FROM task_panes WHERE task_id = ?", taskID)
if err != nil {
return fmt.Errorf("clear task panes: %w", err)
}
return nil
}

// GetPrimaryPanes returns the primary Claude and Shell panes for a task.
// Returns (claudePane, shellPane, error)
func (db *DB) GetPrimaryPanes(taskID int64) (*TaskPane, *TaskPane, error) {
panes, err := db.GetTaskPanes(taskID)
if err != nil {
return nil, nil, err
}

var claudePane, shellPane *TaskPane
for _, p := range panes {
if p.PaneType == PaneTypeClaude && claudePane == nil {
claudePane = p
} else if p.PaneType == PaneTypeShell && shellPane == nil {
shellPane = p
}
}
return claudePane, shellPane, nil
}

// SyncPaneFromTask syncs the primary pane IDs from the task table to task_panes.
// This is used during migration to populate the task_panes table from existing data.
func (db *DB) SyncPaneFromTask(taskID int64, claudePaneID, shellPaneID string) error {
// Clear existing primary panes
_, err := db.Exec(`
DELETE FROM task_panes
WHERE task_id = ? AND pane_type IN (?, ?)
`, taskID, PaneTypeClaude, PaneTypeShell)
if err != nil {
return fmt.Errorf("clear primary panes: %w", err)
}

// Create Claude pane if it exists
if claudePaneID != "" {
_, err = db.Exec(`
INSERT INTO task_panes (task_id, pane_id, pane_type, title)
VALUES (?, ?, ?, ?)
`, taskID, claudePaneID, PaneTypeClaude, "Claude")
if err != nil {
return fmt.Errorf("insert claude pane: %w", err)
}
}

// Create Shell pane if it exists
if shellPaneID != "" {
_, err = db.Exec(`
INSERT INTO task_panes (task_id, pane_id, pane_type, title)
VALUES (?, ?, ?, ?)
`, taskID, shellPaneID, PaneTypeShell, "Shell")
if err != nil {
return fmt.Errorf("insert shell pane: %w", err)
}
}

return nil
}
Loading