feat: chat improved renderer and comments pagination

This commit is contained in:
Carlos Florêncio
2026-01-15 01:54:19 +00:00
parent 7e90883a30
commit 004fb20618
35 changed files with 4001 additions and 277 deletions
@@ -17,7 +17,34 @@ func NewCommentController(svc *service.Service) *CommentController {
}
func (c *CommentController) ListComments(ctx context.Context, req dto.ListCommentsRequest) (dto.ListCommentsResponse, error) {
comments, err := c.service.ListComments(ctx, req.TaskID)
comments, hasMore, err := c.service.ListCommentsPaginated(ctx, service.ListCommentsRequest{
TaskID: req.TaskID,
Limit: req.Limit,
Before: req.Before,
After: req.After,
Sort: req.Sort,
})
if err != nil {
return dto.ListCommentsResponse{}, err
}
result := make([]*v1.Comment, 0, len(comments))
for _, comment := range comments {
result = append(result, comment.ToAPI())
}
cursor := ""
if len(result) > 0 {
cursor = result[len(result)-1].ID
}
return dto.ListCommentsResponse{
Comments: result,
Total: len(result),
HasMore: hasMore,
Cursor: cursor,
}, nil
}
func (c *CommentController) ListAllComments(ctx context.Context, taskID string) (dto.ListCommentsResponse, error) {
comments, err := c.service.ListComments(ctx, taskID)
if err != nil {
return dto.ListCommentsResponse{}, err
}
@@ -28,6 +55,8 @@ func (c *CommentController) ListComments(ctx context.Context, req dto.ListCommen
return dto.ListCommentsResponse{
Comments: result,
Total: len(result),
HasMore: false,
Cursor: "",
}, nil
}
+2
View File
@@ -122,6 +122,8 @@ type BoardSnapshotDTO struct {
type ListCommentsResponse struct {
Comments []*v1.Comment `json:"comments"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
Cursor string `json:"cursor"`
}
type ListBoardsResponse struct {
@@ -262,6 +262,10 @@ type DeleteTaskRequest struct {
type ListCommentsRequest struct {
TaskID string
Limit int
Before string
After string
Sort string
}
type CreateCommentRequest struct {
@@ -3,6 +3,8 @@ package handlers
import (
"context"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -66,7 +68,41 @@ func (h *CommentHandlers) httpListComments(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "task id is required"})
return
}
resp, err := h.commentController.ListComments(c.Request.Context(), dto.ListCommentsRequest{TaskID: taskID})
before := c.Query("before")
after := c.Query("after")
sort := strings.ToLower(strings.TrimSpace(c.Query("sort")))
limitProvided := strings.TrimSpace(c.Query("limit")) != ""
paginated := limitProvided || before != "" || after != "" || sort != ""
if before != "" && after != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "only one of before or after can be set"})
return
}
limit := 0
if rawLimit := strings.TrimSpace(c.Query("limit")); rawLimit != "" {
if parsed, err := strconv.Atoi(rawLimit); err == nil {
limit = parsed
}
}
if sort != "" && sort != "asc" && sort != "desc" {
c.JSON(http.StatusBadRequest, gin.H{"error": "sort must be asc or desc"})
return
}
var (
resp dto.ListCommentsResponse
err error
)
if paginated {
resp, err = h.commentController.ListComments(c.Request.Context(), dto.ListCommentsRequest{
TaskID: taskID,
Limit: limit,
Before: before,
After: after,
Sort: sort,
})
} else {
resp, err = h.commentController.ListAllComments(c.Request.Context(), taskID)
}
if err != nil {
h.logger.Error("failed to list comments", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list comments"})
@@ -147,6 +183,10 @@ func (h *CommentHandlers) wsAddComment(ctx context.Context, msg *ws.Message) (*w
type wsListCommentsRequest struct {
TaskID string `json:"task_id"`
Limit int `json:"limit"`
Before string `json:"before"`
After string `json:"after"`
Sort string `json:"sort"`
}
func (h *CommentHandlers) wsListComments(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -157,8 +197,29 @@ func (h *CommentHandlers) wsListComments(ctx context.Context, msg *ws.Message) (
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.Before != "" && req.After != "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "only one of before or after can be set", nil)
}
sort := strings.ToLower(strings.TrimSpace(req.Sort))
if sort != "" && sort != "asc" && sort != "desc" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "sort must be asc or desc", nil)
}
resp, err := h.commentController.ListComments(ctx, dto.ListCommentsRequest{TaskID: req.TaskID})
var (
resp dto.ListCommentsResponse
err error
)
if req.Limit != 0 || req.Before != "" || req.After != "" || sort != "" {
resp, err = h.commentController.ListComments(ctx, dto.ListCommentsRequest{
TaskID: req.TaskID,
Limit: req.Limit,
Before: req.Before,
After: req.After,
Sort: sort,
})
} else {
resp, err = h.commentController.ListAllComments(ctx, req.TaskID)
}
if err != nil {
h.logger.Error("failed to list comments", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list comments", nil)
@@ -7,6 +7,13 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type ListCommentsOptions struct {
Limit int
Before string
After string
Sort string
}
// Repository defines the interface for task storage operations
type Repository interface {
// Workspace operations
@@ -48,6 +55,7 @@ type Repository interface {
GetCommentByToolCallID(ctx context.Context, taskID, toolCallID string) (*models.Comment, error)
UpdateComment(ctx context.Context, comment *models.Comment) error
ListComments(ctx context.Context, taskID string) ([]*models.Comment, error)
ListCommentsPaginated(ctx context.Context, taskID string, opts ListCommentsOptions) ([]*models.Comment, bool, error)
DeleteComment(ctx context.Context, id string) error
// Agent Session operations
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
package repository
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
func TestNewMemoryRepository(t *testing.T) {
repo := NewMemoryRepository()
if repo == nil {
t.Fatal("expected non-nil repository")
}
if repo.tasks == nil {
t.Error("expected tasks map to be initialized")
}
if repo.workspaces == nil {
t.Error("expected workspaces map to be initialized")
}
if repo.boards == nil {
t.Error("expected boards map to be initialized")
}
if repo.columns == nil {
t.Error("expected columns map to be initialized")
}
}
func TestMemoryRepository_Close(t *testing.T) {
repo := NewMemoryRepository()
err := repo.Close()
if err != nil {
t.Errorf("expected no error on close, got %v", err)
}
}
// Board CRUD tests
func TestMemoryRepository_BoardCRUD(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
workspace := &models.Workspace{ID: "ws-1", Name: "Workspace"}
if err := repo.CreateWorkspace(ctx, workspace); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
// Create
board := &models.Board{WorkspaceID: workspace.ID, Name: "Test Board", Description: "A test board"}
if err := repo.CreateBoard(ctx, board); err != nil {
t.Fatalf("failed to create board: %v", err)
}
if board.ID == "" {
t.Error("expected board ID to be set")
}
if board.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be set")
}
// Get
retrieved, err := repo.GetBoard(ctx, board.ID)
if err != nil {
t.Fatalf("failed to get board: %v", err)
}
if retrieved.Name != "Test Board" {
t.Errorf("expected name 'Test Board', got %s", retrieved.Name)
}
// Update
board.Name = "Updated Name"
if err := repo.UpdateBoard(ctx, board); err != nil {
t.Fatalf("failed to update board: %v", err)
}
retrieved, _ = repo.GetBoard(ctx, board.ID)
if retrieved.Name != "Updated Name" {
t.Errorf("expected name 'Updated Name', got %s", retrieved.Name)
}
// Delete
if err := repo.DeleteBoard(ctx, board.ID); err != nil {
t.Fatalf("failed to delete board: %v", err)
}
_, err = repo.GetBoard(ctx, board.ID)
if err == nil {
t.Error("expected board to be deleted")
}
}
func TestMemoryRepository_BoardNotFound(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_, err := repo.GetBoard(ctx, "nonexistent")
if err == nil {
t.Error("expected error for nonexistent board")
}
err = repo.UpdateBoard(ctx, &models.Board{ID: "nonexistent", Name: "Test"})
if err == nil {
t.Error("expected error for updating nonexistent board")
}
err = repo.DeleteBoard(ctx, "nonexistent")
if err == nil {
t.Error("expected error for deleting nonexistent board")
}
}
func TestMemoryRepository_ListBoards(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"})
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", WorkspaceID: "ws-1", Name: "Board 1"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", WorkspaceID: "ws-1", Name: "Board 2"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-3", WorkspaceID: "ws-2", Name: "Board 3"})
boards, err := repo.ListBoards(ctx, "ws-1")
if err != nil {
t.Fatalf("failed to list boards: %v", err)
}
if len(boards) != 2 {
t.Errorf("expected 2 boards, got %d", len(boards))
}
}
// Column CRUD tests
func TestMemoryRepository_ColumnCRUD(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
// Create
column := &models.Column{BoardID: "board-123", Name: "To Do", Position: 0, State: v1.TaskStateTODO}
if err := repo.CreateColumn(ctx, column); err != nil {
t.Fatalf("failed to create column: %v", err)
}
if column.ID == "" {
t.Error("expected column ID to be set")
}
// Get
retrieved, err := repo.GetColumn(ctx, column.ID)
if err != nil {
t.Fatalf("failed to get column: %v", err)
}
if retrieved.Name != "To Do" {
t.Errorf("expected name 'To Do', got %s", retrieved.Name)
}
// Update
column.Name = "Done"
column.State = v1.TaskStateCompleted
if err := repo.UpdateColumn(ctx, column); err != nil {
t.Fatalf("failed to update column: %v", err)
}
retrieved, _ = repo.GetColumn(ctx, column.ID)
if retrieved.Name != "Done" {
t.Errorf("expected name 'Done', got %s", retrieved.Name)
}
// Delete
if err := repo.DeleteColumn(ctx, column.ID); err != nil {
t.Fatalf("failed to delete column: %v", err)
}
_, err = repo.GetColumn(ctx, column.ID)
if err == nil {
t.Error("expected column to be deleted")
}
}
func TestMemoryRepository_ColumnNotFound(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_, err := repo.GetColumn(ctx, "nonexistent")
if err == nil {
t.Error("expected error for nonexistent column")
}
err = repo.UpdateColumn(ctx, &models.Column{ID: "nonexistent", Name: "Test"})
if err == nil {
t.Error("expected error for updating nonexistent column")
}
err = repo.DeleteColumn(ctx, "nonexistent")
if err == nil {
t.Error("expected error for deleting nonexistent column")
}
}
func TestMemoryRepository_ListColumns(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "In Progress"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-3", BoardID: "board-456", Name: "Done"})
columns, err := repo.ListColumns(ctx, "board-123")
if err != nil {
t.Fatalf("failed to list columns: %v", err)
}
if len(columns) != 2 {
t.Errorf("expected 2 columns for board-123, got %d", len(columns))
}
}
// Task CRUD tests
func TestMemoryRepository_TaskCRUD(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
// Create
task := &models.Task{
WorkspaceID: "ws-1",
BoardID: "board-123",
ColumnID: "col-123",
Title: "Test Task",
Description: "A test task",
State: v1.TaskStateTODO,
Priority: 5,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if task.ID == "" {
t.Error("expected task ID to be set")
}
if task.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be set")
}
// Get
retrieved, err := repo.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("failed to get task: %v", err)
}
if retrieved.Title != "Test Task" {
t.Errorf("expected title 'Test Task', got %s", retrieved.Title)
}
// Update
task.Title = "Updated Task"
task.Description = "Updated description"
if err := repo.UpdateTask(ctx, task); err != nil {
t.Fatalf("failed to update task: %v", err)
}
retrieved, _ = repo.GetTask(ctx, task.ID)
if retrieved.Title != "Updated Task" {
t.Errorf("expected title 'Updated Task', got %s", retrieved.Title)
}
// Delete
if err := repo.DeleteTask(ctx, task.ID); err != nil {
t.Fatalf("failed to delete task: %v", err)
}
_, err = repo.GetTask(ctx, task.ID)
if err == nil {
t.Error("expected task to be deleted")
}
}
func TestMemoryRepository_TaskNotFound(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_, err := repo.GetTask(ctx, "nonexistent")
if err == nil {
t.Error("expected error for nonexistent task")
}
err = repo.UpdateTask(ctx, &models.Task{ID: "nonexistent", Title: "Test"})
if err == nil {
t.Error("expected error for updating nonexistent task")
}
err = repo.DeleteTask(ctx, "nonexistent")
if err == nil {
t.Error("expected error for deleting nonexistent task")
}
}
func TestMemoryRepository_UpdateTaskState(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateTask(ctx, task)
err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress)
if err != nil {
t.Fatalf("failed to update task state: %v", err)
}
retrieved, _ := repo.GetTask(ctx, "task-123")
if retrieved.State != v1.TaskStateInProgress {
t.Errorf("expected state IN_PROGRESS, got %s", retrieved.State)
}
}
func TestMemoryRepository_UpdateTaskStateNotFound(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
err := repo.UpdateTaskState(ctx, "nonexistent", v1.TaskStateInProgress)
if err == nil {
t.Error("expected error for nonexistent task")
}
}
func TestMemoryRepository_ListTasks(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-456", ColumnID: "col-2", Title: "Task 3"})
tasks, err := repo.ListTasks(ctx, "board-123")
if err != nil {
t.Fatalf("failed to list tasks: %v", err)
}
if len(tasks) != 2 {
t.Errorf("expected 2 tasks for board-123, got %d", len(tasks))
}
}
func TestMemoryRepository_ListTasksByColumn(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"})
tasks, err := repo.ListTasksByColumn(ctx, "col-1")
if err != nil {
t.Fatalf("failed to list tasks by column: %v", err)
}
if len(tasks) != 2 {
t.Errorf("expected 2 tasks for col-1, got %d", len(tasks))
}
}
// Comment CRUD tests
func TestMemoryRepository_CommentCRUD(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
// Create a task first
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
// Create comment
comment := &models.Comment{
TaskID: "task-123",
AuthorType: models.CommentAuthorUser,
AuthorID: "user-123",
Content: "This is a test comment",
RequestsInput: false,
}
if err := repo.CreateComment(ctx, comment); err != nil {
t.Fatalf("failed to create comment: %v", err)
}
if comment.ID == "" {
t.Error("expected comment ID to be set")
}
if comment.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be set")
}
// Get comment
retrieved, err := repo.GetComment(ctx, comment.ID)
if err != nil {
t.Fatalf("failed to get comment: %v", err)
}
if retrieved.Content != "This is a test comment" {
t.Errorf("expected content 'This is a test comment', got %s", retrieved.Content)
}
if retrieved.AuthorType != models.CommentAuthorUser {
t.Errorf("expected author type 'user', got %s", retrieved.AuthorType)
}
// Delete comment
if err := repo.DeleteComment(ctx, comment.ID); err != nil {
t.Fatalf("failed to delete comment: %v", err)
}
_, err = repo.GetComment(ctx, comment.ID)
if err == nil {
t.Error("expected comment to be deleted")
}
}
func TestMemoryRepository_CommentNotFound(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_, err := repo.GetComment(ctx, "nonexistent")
if err == nil {
t.Error("expected error for nonexistent comment")
}
err = repo.DeleteComment(ctx, "nonexistent")
if err == nil {
t.Error("expected error for deleting nonexistent comment")
}
}
func TestMemoryRepository_ListComments(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
// Create a task
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
// Create multiple comments
_ = repo.CreateComment(ctx, &models.Comment{ID: "comment-1", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Comment 1"})
_ = repo.CreateComment(ctx, &models.Comment{ID: "comment-2", TaskID: "task-123", AuthorType: models.CommentAuthorAgent, Content: "Comment 2"})
_ = repo.CreateComment(ctx, &models.Comment{ID: "comment-3", TaskID: "task-456", AuthorType: models.CommentAuthorUser, Content: "Comment 3"})
comments, err := repo.ListComments(ctx, "task-123")
if err != nil {
t.Fatalf("failed to list comments: %v", err)
}
if len(comments) != 2 {
t.Errorf("expected 2 comments for task-123, got %d", len(comments))
}
}
func TestMemoryRepository_ListCommentsPagination(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
_ = repo.CreateComment(ctx, &models.Comment{
ID: "comment-1",
TaskID: "task-123",
AuthorType: models.CommentAuthorUser,
Content: "Comment 1",
CreatedAt: baseTime.Add(-2 * time.Minute),
})
_ = repo.CreateComment(ctx, &models.Comment{
ID: "comment-2",
TaskID: "task-123",
AuthorType: models.CommentAuthorUser,
Content: "Comment 2",
CreatedAt: baseTime.Add(-1 * time.Minute),
})
_ = repo.CreateComment(ctx, &models.Comment{
ID: "comment-3",
TaskID: "task-123",
AuthorType: models.CommentAuthorUser,
Content: "Comment 3",
CreatedAt: baseTime,
})
comments, hasMore, err := repo.ListCommentsPaginated(ctx, "task-123", ListCommentsOptions{
Limit: 2,
Sort: "desc",
})
if err != nil {
t.Fatalf("failed to list comments: %v", err)
}
if len(comments) != 2 {
t.Fatalf("expected 2 comments, got %d", len(comments))
}
if !hasMore {
t.Error("expected hasMore to be true")
}
if comments[0].ID != "comment-3" {
t.Errorf("expected newest comment first, got %s", comments[0].ID)
}
}
func TestMemoryRepository_CommentWithRequestsInput(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
// Create a task
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
// Create agent comment requesting input
comment := &models.Comment{
TaskID: "task-123",
AuthorType: models.CommentAuthorAgent,
AuthorID: "agent-123",
Content: "What should I do next?",
RequestsInput: true,
ACPSessionID: "session-abc",
}
if err := repo.CreateComment(ctx, comment); err != nil {
t.Fatalf("failed to create comment: %v", err)
}
retrieved, _ := repo.GetComment(ctx, comment.ID)
if !retrieved.RequestsInput {
t.Error("expected RequestsInput to be true")
}
if retrieved.ACPSessionID != "session-abc" {
t.Errorf("expected ACPSessionID 'session-abc', got %s", retrieved.ACPSessionID)
}
}
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
@@ -1292,7 +1293,7 @@ func (r *SQLiteRepository) GetComment(ctx context.Context, id string) (*models.C
return comment, nil
}
// ListComments returns all comments for a task ordered by creation time
// ListComments returns all comments for a task ordered by creation time.
func (r *SQLiteRepository) ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, task_id, author_type, author_id, content, requests_input, acp_session_id, type, metadata, created_at
@@ -1325,7 +1326,101 @@ func (r *SQLiteRepository) ListComments(ctx context.Context, taskID string) ([]*
result = append(result, comment)
}
return result, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// ListCommentsPaginated returns comments for a task ordered by creation time with pagination.
func (r *SQLiteRepository) ListCommentsPaginated(ctx context.Context, taskID string, opts ListCommentsOptions) ([]*models.Comment, bool, error) {
limit := opts.Limit
if limit < 0 {
limit = 0
}
sortDir := "ASC"
if strings.EqualFold(opts.Sort, "desc") {
sortDir = "DESC"
}
var cursor *models.Comment
if opts.Before != "" {
var err error
cursor, err = r.GetComment(ctx, opts.Before)
if err != nil {
return nil, false, err
}
if cursor.TaskID != taskID {
return nil, false, fmt.Errorf("comment cursor not found: %s", opts.Before)
}
}
if opts.After != "" {
var err error
cursor, err = r.GetComment(ctx, opts.After)
if err != nil {
return nil, false, err
}
if cursor.TaskID != taskID {
return nil, false, fmt.Errorf("comment cursor not found: %s", opts.After)
}
}
query := `
SELECT id, task_id, author_type, author_id, content, requests_input, acp_session_id, type, metadata, created_at
FROM task_comments WHERE task_id = ?`
args := []interface{}{taskID}
if cursor != nil {
if opts.Before != "" {
query += " AND (created_at < ? OR (created_at = ? AND id < ?))"
} else if opts.After != "" {
query += " AND (created_at > ? OR (created_at = ? AND id > ?))"
}
args = append(args, cursor.CreatedAt, cursor.CreatedAt, cursor.ID)
}
query += fmt.Sprintf(" ORDER BY created_at %s, id %s", sortDir, sortDir)
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit+1)
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, false, err
}
defer rows.Close()
var result []*models.Comment
for rows.Next() {
comment := &models.Comment{}
var requestsInput int
var commentType string
var metadataJSON string
err := rows.Scan(&comment.ID, &comment.TaskID, &comment.AuthorType, &comment.AuthorID, &comment.Content, &requestsInput, &comment.ACPSessionID, &commentType, &metadataJSON, &comment.CreatedAt)
if err != nil {
return nil, false, err
}
comment.RequestsInput = requestsInput == 1
comment.Type = models.CommentType(commentType)
// Deserialize metadata from JSON
if metadataJSON != "" && metadataJSON != "{}" {
if err := json.Unmarshal([]byte(metadataJSON), &comment.Metadata); err != nil {
return nil, false, fmt.Errorf("failed to deserialize comment metadata: %w", err)
}
}
result = append(result, comment)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
hasMore := false
if limit > 0 && len(result) > limit {
hasMore = true
result = result[:limit]
}
return result, hasMore, nil
}
// DeleteComment deletes a comment by ID
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
@@ -596,6 +597,59 @@ func TestSQLiteRepository_ListComments(t *testing.T) {
}
}
func TestSQLiteRepository_ListCommentsPagination(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
ctx := context.Background()
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
_ = repo.CreateComment(ctx, &models.Comment{
ID: "comment-1",
TaskID: "task-123",
AuthorType: models.CommentAuthorUser,
Content: "Comment 1",
CreatedAt: baseTime.Add(-2 * time.Minute),
})
_ = repo.CreateComment(ctx, &models.Comment{
ID: "comment-2",
TaskID: "task-123",
AuthorType: models.CommentAuthorUser,
Content: "Comment 2",
CreatedAt: baseTime.Add(-1 * time.Minute),
})
_ = repo.CreateComment(ctx, &models.Comment{
ID: "comment-3",
TaskID: "task-123",
AuthorType: models.CommentAuthorUser,
Content: "Comment 3",
CreatedAt: baseTime,
})
comments, hasMore, err := repo.ListCommentsPaginated(ctx, "task-123", ListCommentsOptions{
Limit: 2,
Sort: "desc",
})
if err != nil {
t.Fatalf("failed to list comments: %v", err)
}
if len(comments) != 2 {
t.Fatalf("expected 2 comments, got %d", len(comments))
}
if !hasMore {
t.Error("expected hasMore to be true")
}
if comments[0].ID != "comment-3" {
t.Errorf("expected newest comment first, got %s", comments[0].ID)
}
}
func TestSQLiteRepository_CommentWithRequestsInput(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
@@ -0,0 +1,6 @@
package service
const (
DefaultCommentsPageSize = 10
MaxCommentsPageSize = 100
)
+26 -1
View File
@@ -193,6 +193,14 @@ type UpdateEnvironmentRequest struct {
BuildConfig map[string]string `json:"build_config,omitempty"`
}
type ListCommentsRequest struct {
TaskID string
Limit int
Before string
After string
Sort string
}
// CreateRepositoryScriptRequest contains the data for creating a repository script
type CreateRepositoryScriptRequest struct {
RepositoryID string `json:"repository_id"`
@@ -1284,11 +1292,28 @@ func (s *Service) GetComment(ctx context.Context, id string) (*models.Comment, e
return s.repo.GetComment(ctx, id)
}
// ListComments returns all comments for a task
// ListComments returns all comments for a task.
func (s *Service) ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) {
return s.repo.ListComments(ctx, taskID)
}
// ListCommentsPaginated returns comments for a task with pagination options.
func (s *Service) ListCommentsPaginated(ctx context.Context, req ListCommentsRequest) ([]*models.Comment, bool, error) {
limit := req.Limit
if limit <= 0 && (req.Before != "" || req.After != "") {
limit = DefaultCommentsPageSize
}
if limit > MaxCommentsPageSize {
limit = MaxCommentsPageSize
}
return s.repo.ListCommentsPaginated(ctx, req.TaskID, repository.ListCommentsOptions{
Limit: limit,
Before: req.Before,
After: req.After,
Sort: req.Sort,
})
}
// DeleteComment deletes a comment
func (s *Service) DeleteComment(ctx context.Context, id string) error {
if err := s.repo.DeleteComment(ctx, id); err != nil {
+931
View File
@@ -0,0 +1,931 @@
# Task Chat Interface - Final Implementation Plan
Comprehensive plan to build a high-performance, feature-rich task chat interface with SSR, lazy loading, and command support.
**Status:** Updated after rebase from main (Jan 2026)
**Key Changes:**
- ✅ SSR already implemented - fetches last 10 comments on server
- ✅ TanStack Virtual v3.13.18 already integrated
- ✅ Basic tool call rendering already exists
- ✅ Pagination API implemented (limit/before/after/sort + has_more/cursor)
- ✅ Smart auto-scroll added (only scrolls when at bottom)
- ✅ Lazy loading added (scroll-to-top fetch for older messages)
- ✅ CodeMirror input implemented (markdown, line wrapping, shortcut adapter, Mod-Enter submit)
- ✅ Slash + mention autocomplete added (dummy command examples)
- ✅ Message rendering refactor with adapter-style components
- ✅ Inline diff rendering (metadata diff uses @git-diff-view/react when structured)
- ⚠️ Rich content rendering partially implemented (thinking/todos/diff metadata cards, status/error/progress)
- ❌ Rich content (thinking, todos, diffs) not fully implemented
---
## Architecture Overview
### Data Flow
```
1. Initial Load (SSR):
Server Component → HTTP GET /api/tasks/:id/comments?limit=10&sort=desc
→ Hydrate store with last 10 messages
→ Client renders, scrolled to bottom
2. WebSocket Subscription:
Client mounts → ws.subscribe(taskId)
→ Receives comment.added notifications
→ Appends new messages to store
→ Auto-scroll if at bottom
3. Lazy Loading (Scroll Up):
User scrolls to top → Detect via TanStack Virtual
→ If not all messages loaded: HTTP GET /api/tasks/:id/comments?before=:oldestId&limit=20
→ Prepend to store
→ Maintain scroll position
```
### Tech Stack
**Core:**
- React 18 + Next.js 14 (SSR)
- TanStack Virtual v3 (virtualization)
- Zustand + Immer (state)
- WebSocket (real-time)
**UI:**
- Tailwind CSS + shadcn/ui
- CodeMirror 6 (input + file editing)
- react-markdown + remark-gfm (display)
- @git-diff-view/react (existing)
**New Dependencies:**
```json
{
"@codemirror/view": "^6.34.1",
"@codemirror/state": "^6.4.1",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/autocomplete": "^6.18.1",
"@codemirror/commands": "^6.7.0",
"@codemirror/language": "^6.10.3",
"@codemirror/theme-one-dark": "^6.1.2",
"codemirror": "^6.0.1"
}
```
**Total:** ~150KB gzipped (unified solution for chat + file editing)
**Already Installed:**
- `@tanstack/react-virtual`: v3.13.18 ✅
- `react-markdown` + `remark-gfm` ✅
- `@git-diff-view/react` ✅
---
## Current Implementation Status
### ✅ Already Implemented (After Rebase)
1. **SSR (Server-Side Rendering)**
- `app/task/[id]/page.tsx` fetches comments via `listTaskComments()`
- State hydration via `StateHydrator`
- Fetches last 10 comments on initial load (sorted asc for display)
2. **TanStack Virtual**
- Version: 3.13.18
- Location: `task-chat-panel.tsx` (line 256)
- Current config: `estimateSize: 96`, `overscan: 6`
- Auto-scroll to bottom on new messages
3. **HTTP API Endpoint**
- Backend: `GET /api/v1/tasks/:id/comments` (limit/before/after/sort)
- Frontend: `listTaskComments(taskId, params)` in `lib/http/index.ts`
- Response: `{ comments, total, has_more, cursor }`
4. **Basic Tool Call Rendering**
- Expandable tool call cards
- Tool icons and status indicators
- Args/result display
### ❌ Still Missing (Needs Implementation)
1. **Rich Content Display**
- Basic thinking/todos/diff blocks rendered when metadata exists
- Error/status/progress cards added
- Inline diff viewer now supported for structured metadata
- Remaining: tool call enhancements, error card details, thinking toggle, diff extraction from tool results
4. **CodeMirror Input**
- Still using basic Textarea
- No markdown mode or syntax highlighting
- No command/mention autocomplete
5. **Rich Content Display**
- No thinking message display
- No todo list visualization
- No inline diff viewer
- Basic error display only
---
## Implementation Phases
### Phase 1: Pagination API + Lazy Loading (Week 1)
#### 1.1 Backend API Enhancements
**Update Existing Endpoint:**
```go
// File: apps/backend/internal/task/dto/requests.go
type ListCommentsRequest struct {
TaskID string
Limit int // default 10, max 100
Before string // comment ID for pagination (older)
After string // comment ID for newer messages
Sort string // "asc" | "desc", default "desc"
}
// File: apps/backend/internal/task/dto/dto.go
type ListCommentsResponse struct {
Comments []*v1.Comment `json:"comments"`
Total int `json:"total"`
HasMore bool `json:"has_more"` // NEW
Cursor string `json:"cursor"` // NEW
}
```
**Implementation Steps:**
1. Update `ListCommentsRequest` DTO to accept query params
2. Modify `CommentController.ListComments()` to pass pagination params
3. Update `Service.ListComments()` to support cursor-based queries
4. Modify SQLite repository query with `WHERE created_at < ?` and `LIMIT`
5. Calculate `has_more` by checking if count > limit
6. Return cursor as the last comment's ID
**Handler Update:**
```go
// File: apps/backend/internal/task/handlers/comment_handlers.go
func (h *CommentHandlers) httpListComments(c *gin.Context) {
taskID := c.Param("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
before := c.Query("before")
sort := c.DefaultQuery("sort", "desc")
resp, err := h.commentController.ListComments(c.Request.Context(), dto.ListCommentsRequest{
TaskID: taskID,
Limit: limit,
Before: before,
Sort: sort,
})
// ... return resp with has_more and cursor
}
```
**Index Check:**
```sql
-- Should already exist from schema
CREATE INDEX IF NOT EXISTS idx_comments_task_created
ON task_comments(task_id, created_at DESC);
```
#### 1.2 Update SSR to Limit to 10
**Server Component:** `app/task/[id]/page.tsx`
```typescript
// Update to only fetch last 10 messages
comments = await listTaskComments(id, { limit: 10 }, { cache: 'no-store' });
```
**Frontend HTTP Client:** `lib/http/index.ts`
```typescript
export async function listTaskComments(
taskId: string,
params?: { limit?: number; before?: string; sort?: string },
options?: ApiRequestOptions
) {
const query = new URLSearchParams();
if (params?.limit) query.set('limit', params.limit.toString());
if (params?.before) query.set('before', params.before);
if (params?.sort) query.set('sort', params.sort);
const url = `/api/v1/tasks/${taskId}/comments${query.toString() ? `?${query}` : ''}`;
return fetchJson<ListCommentsResponse>(url, options);
}
```
**Types Update:** `lib/types/http.ts`
```typescript
export interface ListCommentsResponse {
comments: Comment[];
total: number;
has_more: boolean; // NEW
cursor: string; // NEW
}
```
#### 1.3 Store Enhancements
**Zustand Store:**
```typescript
type CommentsState = {
taskId: string | null;
items: Comment[]; // Sorted by created_at ASC
isLoading: boolean;
hasMore: boolean;
oldestCursor: string | null; // For lazy loading
// Actions
setComments(taskId: string, comments: Comment[]);
prependComments(comments: Comment[]); // For lazy load
addComment(comment: Comment); // For new messages
setCommentsMetadata(meta: { hasMore?, isLoading? });
};
```
#### 1.4 Lazy Loading Hook
**`useLazyLoadComments.ts`:**
```typescript
export function useLazyLoadComments(taskId: string) {
const { hasMore, oldestCursor, isLoading } = useStore(s => s.comments);
const prependComments = useStore(s => s.prependComments);
const loadMore = useCallback(async () => {
if (!hasMore || isLoading) return;
setCommentsMetadata({ isLoading: true });
const response = await fetch(
`/api/tasks/${taskId}/comments?limit=20&before=${oldestCursor}`
);
const { comments, has_more, cursor } = await response.json();
prependComments(comments);
setCommentsMetadata({
hasMore: has_more,
oldestCursor: cursor,
isLoading: false
});
}, [taskId, hasMore, oldestCursor, isLoading]);
return { loadMore, hasMore, isLoading };
}
```
---
### Phase 2: Smart Auto-Scroll + Lazy Load Detection (Week 1)
#### 2.1 Enhance Existing Virtual List
**Update:** `task-chat-panel.tsx` (already has TanStack Virtual at line 256)
**Add Lazy Load Detection:**
```typescript
// In TaskChatPanel component, after virtualizer setup (line 261)
const { loadMore, hasMore, isLoading } = useLazyLoadComments(taskId);
// Detect scroll to top for lazy loading
useEffect(() => {
const [firstItem] = virtualizer.getVirtualItems();
if (!firstItem) return;
// If first visible item is index 0 and we have more
if (firstItem.index === 0 && hasMore && !isLoading) {
loadMore();
}
}, [virtualizer.getVirtualItems(), hasMore, isLoading]);
// Show loading indicator at top when loading more
const showLoadingIndicator = isLoading && hasMore;
```
#### 2.2 Smart Auto-Scroll
**Replace Existing Auto-Scroll Logic** (currently at line 263-267 in `task-chat-panel.tsx`):
```typescript
// Add ref to track if user was at bottom
const wasAtBottomRef = useRef(true);
// Check if at bottom on scroll
const checkAtBottom = useCallback(() => {
const element = messagesContainerRef.current;
if (!element) return;
const { scrollTop, scrollHeight, clientHeight } = element;
wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 50;
}, []);
// Add scroll listener
useEffect(() => {
const element = messagesContainerRef.current;
if (!element) return;
element.addEventListener('scroll', checkAtBottom);
return () => element.removeEventListener('scroll', checkAtBottom);
}, [checkAtBottom]);
// REPLACE existing auto-scroll effect (line 264-267)
// OLD: Always scrolls to bottom
// NEW: Only scroll if user was at bottom
useEffect(() => {
if (itemCount === 0) return;
if (wasAtBottomRef.current) {
virtualizer.scrollToIndex(itemCount - 1, {
align: 'end',
behavior: 'smooth',
});
}
}, [itemCount, virtualizer]);
```
#### 2.3 Maintain Scroll Position on Prepend
**Strategy:**
```typescript
// Before prepending new messages
const scrollElement = parentRef.current;
const oldScrollHeight = scrollElement.scrollHeight;
const oldScrollTop = scrollElement.scrollTop;
// Prepend messages to store
prependComments(newComments);
// After render, restore scroll position
requestAnimationFrame(() => {
const newScrollHeight = scrollElement.scrollHeight;
scrollElement.scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight);
});
```
---
### Phase 3: Component Architecture (Week 1-2)
#### 3.1 Component Hierarchy
```
TaskChatPanel (refactored)
├── TaskChatHeader
│ ├── Agent selector
│ ├── Settings (thinking, diff view)
│ └── Loading indicator
├── TaskChatMessages (TanStack Virtual)
│ ├── Lazy load indicator (top)
│ └── MessageRenderer (type router)
│ ├── UserMessage
│ ├── AssistantMessage + ToolCallList
│ ├── ThinkingMessage
│ ├── ToolCallCard (recursive for sub-agents)
│ ├── TodoListCard
│ ├── DiffCard
│ ├── ErrorCard
│ └── SystemMessage
└── TaskChatInput (CodeMirror with commands)
```
#### 3.2 Message Components
**Key Components:**
1. **MessageRenderer** - Type router based on `comment.type`
2. **UserMessage** - Avatar, timestamp, markdown content
3. **AssistantMessage** - Avatar, duration, tokens, copy button, markdown + tool calls
4. **ToolCallCard** - Collapsible, status badge, nested sub-agents, specialized renderers
5. **TodoListCard** - Progress bar, status icons, animated updates
6. **DiffCard** - File stats, expandable, uses @git-diff-view/react
7. **ErrorCard** - Error icon, message, stack trace
8. **ThinkingMessage** - Italic, bordered, toggle visibility
9. **SystemMessage** - Session info, warnings, status updates
**Shared Components:**
- `MessageHeader` - Avatar, name, timestamp, metadata
- `CopyButton` - Copy message content
- `ExpandButton` - Expand/collapse long content
- `StatusBadge` - Status indicators with animations
#### 3.3 State Management
**Expansion State (Zustand):**
```typescript
type UIState = {
expandedTools: Set<string>;
expandedMessages: Set<string>;
showThinking: boolean;
diffViewMode: 'unified' | 'split';
toggleToolExpanded(id: string): void;
toggleMessageExpanded(id: string): void;
};
```
**Persistence:**
- Store expansion state in localStorage via Zustand persist
- Restore on mount
- Clear on task change
---
### Phase 4: CodeMirror Input with Commands (Week 2)
#### 4.1 CodeMirror Setup
**Chat Input Configuration:**
```typescript
import { EditorView, basicSetup } from '@codemirror/basic-setup';
import { markdown } from '@codemirror/lang-markdown';
import { autocompletion } from '@codemirror/autocomplete';
import { keymap } from '@codemirror/view';
const chatExtensions = [
basicSetup({ lineNumbers: false }), // Minimal for chat
markdown(),
autocompletion({ override: [commandCompletions, mentionCompletions] }),
keymap.of([{ key: "Mod-Enter", run: handleSubmit }]),
EditorView.theme({
"&": { maxHeight: "300px", minHeight: "60px" },
".cm-content": { padding: "12px" },
".cm-scroller": { overflow: "auto" }
})
];
```
**Features:**
- Markdown mode with syntax awareness
- Slash commands (/read, /edit, /bash, etc.)
- File mentions (@filename.ts)
- Syntax highlighting for inline code
- Cmd+Enter to submit
- Auto-grow up to 300px height
#### 4.2 Command Autocomplete
**Command Completion Source:**
```typescript
import { CompletionContext } from '@codemirror/autocomplete';
const COMMANDS = [
{ label: '/read', detail: 'Read a file', info: 'Usage: /read <file_path>' },
{ label: '/edit', detail: 'Edit a file', info: 'Usage: /edit <file_path>' },
{ label: '/bash', detail: 'Run bash command', info: 'Usage: /bash <command>' },
{ label: '/grep', detail: 'Search in files', info: 'Usage: /grep <pattern>' },
{ label: '/help', detail: 'Show commands', info: 'Display all available commands' },
];
function commandCompletions(context: CompletionContext) {
const word = context.matchBefore(/\/\w*/);
if (!word || (word.from === word.to && !context.explicit)) return null;
return {
from: word.from,
options: COMMANDS.map(cmd => ({
label: cmd.label,
detail: cmd.detail,
info: cmd.info,
apply: cmd.label + ' ', // Add space after command
}))
};
}
```
**File Mention Completion:**
```typescript
function mentionCompletions(context: CompletionContext) {
const word = context.matchBefore(/@[\w\/\-\.]*/);
if (!word) return null;
// Get available files from git status or file tree
const files = getChangedFiles(); // From store or context
return {
from: word.from,
options: files.map(file => ({
label: `@${file.path}`,
detail: file.type,
info: `Reference ${file.path}`,
apply: `@${file.path}`,
}))
};
}
```
**Keyboard Navigation:**
- Built-in: Arrow up/down, Enter, Escape
- Tab to accept completion
- Fuzzy matching included
#### 4.3 Submit Handler
**Extract Content and Send:**
```typescript
function handleSubmit(view: EditorView): boolean {
const content = view.state.doc.toString();
if (!content.trim()) return false;
// Parse commands (optional - backend can also parse)
const commands = extractCommands(content);
// Send to backend
client.request('comment.add', {
task_id: taskId,
content,
metadata: commands.length > 0 ? { commands } : undefined
});
// Clear editor
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '' }
});
return true;
}
```
#### 4.4 File Editing Integration
**Unified Editor for Multiple Contexts:**
**1. Chat Input (Minimal):**
```typescript
const chatExtensions = [
basicSetup({ lineNumbers: false }),
markdown(),
autocompletion({ override: [commandCompletions, mentionCompletions] }),
];
```
**2. File Viewer (Read-only):**
```typescript
const viewerExtensions = [
basicSetup(),
EditorView.editable.of(false),
EditorState.readOnly.of(true),
languageForFile(filename),
syntaxHighlighting(oneDarkHighlightStyle),
];
```
**3. File Editor (Full-featured):**
```typescript
const editorExtensions = [
basicSetup(),
languageForFile(filename),
syntaxHighlighting(oneDarkHighlightStyle),
lintGutter(),
autocompletion(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
debouncedSave(update.state.doc.toString());
}
}),
];
```
**Language Detection:**
```typescript
function languageForFile(filename: string) {
const ext = filename.split('.').pop();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return javascript({ jsx: true, typescript: ext.includes('ts') });
case 'py':
return python();
case 'go':
return go();
case 'md':
return markdown();
// ... 100+ languages supported
default:
return [];
}
}
```
**Shared Configuration:**
```typescript
// Reusable theme and settings
export const baseTheme = EditorView.theme({
"&": { height: "100%" },
".cm-scroller": { fontFamily: "var(--font-mono)" },
".cm-gutters": { backgroundColor: "var(--muted)" },
});
export const baseExtensions = [
baseTheme,
EditorState.tabSize.of(2),
EditorState.allowMultipleSelections.of(true),
];
```
---
### Phase 5: Rich Content Rendering (Week 2-3)
#### 5.1 Tool Call Enhancements
**Features:**
- Recursive rendering of sub-agent calls (Task tool)
- Tool-specific summaries (Read: filename, Bash: command preview)
- Status badges with animations (pending, running, success, error)
- Collapsible params and results
- Copy button for results
- Specialized renderers (Bash output with ANSI colors, JSON pretty-print)
**Metadata Structure:**
```typescript
type ToolCallMetadata = {
tool: {
id: string;
name: string;
args: Record<string, any>;
result: string | null;
status: 'pending' | 'running' | 'success' | 'error';
duration_ms?: number;
sub_agent_calls?: ToolCallMetadata[];
};
};
```
#### 5.2 Todo List Visualization
**Features:**
- Progress bar (completed / total)
- Status icons (Circle → Loader → CheckCircle)
- Active form vs content based on status
- Animated transitions
- Group by status (optional)
**Backend Integration:**
- Agent sends `progress` type comments
- Metadata contains todos array
- Updates sent as new comments (immutable)
- Frontend shows latest state
#### 5.3 Diff Integration
**Features:**
- Extract diffs from tool call results (Edit, Write tools)
- Show file stats (additions, deletions)
- Expandable diff viewer using @git-diff-view/react
- Syntax highlighting per language
- "Open in Changes" button → navigate to Changes tab
**Diff Sources:**
1. Tool result with `diff` field
2. Tool result with `old_content` and `new_content`
3. Unified diff string in result
#### 5.4 Thinking Display
**Features:**
- Toggle via settings
- Italic text with left border
- Markdown rendering
- Collapsed by default for long thoughts
- Visual distinction from regular messages
**Backend:**
- Agent sends `thinking` type comments
- Or `metadata.thinking` in content comments
#### 5.5 Error Handling
**Features:**
- Error cards with red border
- Error icon and title
- Error message
- Collapsible stack trace
- Retry button (if applicable)
- Copy error details
---
### Phase 6: Performance & Polish (Week 3-4)
#### 6.1 Performance Optimizations
**Memoization:**
```typescript
// Memoize message components
const MessageItem = memo(MessageRenderer, (prev, next) => {
return prev.comment.id === next.comment.id &&
prev.comment.updated_at === next.comment.updated_at;
});
// Memoize filtered comments
const visibleComments = useMemo(() => {
return comments.filter(c => {
if (c.type === 'thinking' && !showThinking) return false;
return true;
});
}, [comments, showThinking]);
```
**Debouncing:**
- Scroll position checks: 100ms
- Lazy load triggers: 200ms
- Auto-scroll: 50ms
- Input changes: 300ms (for autocomplete)
**Code Splitting:**
```typescript
// Lazy load heavy components
const MonacoDiffEditor = lazy(() => import('./MonacoDiffEditor'));
const MermaidRenderer = lazy(() => import('./MermaidRenderer'));
```
**Bundle Size:**
- Target: ~150KB increase (CodeMirror core + essential languages)
- Use tree-shaking
- Import only needed language modes
- Lazy load additional languages on demand
#### 6.2 UX Polish
**Interactions:**
- Smooth height transitions (CSS transitions)
- Hover states on interactive elements
- Loading skeletons for lazy load
- Copy feedback (checkmark animation)
- Keyboard shortcuts (j/k navigation, / for command palette)
**Visual:**
- Consistent spacing and alignment
- Color-coded message types
- Status indicators with meaningful colors
- Avatar fallbacks with initials
- Timestamp formatting (relative times)
- Duration formatting (ms, s, m)
**Accessibility:**
- Keyboard navigation
- Focus management in autocomplete
#### 6.3 Error Boundaries
**Wrap Critical Components:**
```typescript
<ErrorBoundary
fallback={<ErrorFallback />}
onError={(error) => console.error('Message render error:', error)}
>
<MessageRenderer comment={comment} />
</ErrorBoundary>
```
**Graceful Degradation:**
- If markdown fails, show plain text
- If diff parsing fails, show unified diff string
- If tool metadata is invalid, show raw JSON
---
## Backend Changes
### API Enhancements
**New Endpoints:**
```
GET /api/tasks/:id/comments?limit=10&before=:cursor&sort=desc
POST /api/tasks/:id/comments (existing, no changes)
```
**Response Format:**
```json
{
"comments": [
{
"id": "cmt_123",
"task_id": "tsk_456",
"author_type": "agent",
"content": "...",
"type": "content",
"metadata": {...},
"created_at": "2025-01-14T12:00:00Z"
}
],
"has_more": true,
"cursor": "cmt_100"
}
```
**Indexes:**
```sql
CREATE INDEX IF NOT EXISTS idx_comments_task_created
ON task_comments(task_id, created_at DESC);
```
### WebSocket (No Changes)
Existing `comment.added` notification works as-is.
### Comment Type Usage
**Agent Integration:**
- `message` - User messages
- `content` - Agent text responses
- `tool_call` - Tool executions
- `progress` - Todo list updates
- `thinking` - Agent reasoning
- `error` - Error messages
- `status` - System notifications
---
## File Structure
```
apps/web/
├── app/task/[id]/
│ ├── page.tsx (SSR: fetch initial comments)
│ └── page-client.tsx (Client: hydrate + subscribe)
├── components/task/
│ ├── task-chat-panel.tsx (Main container, refactored)
│ └── chat/
│ ├── TaskChatHeader.tsx
│ ├── TaskChatMessages.tsx (TanStack Virtual)
│ ├── TaskChatInput.tsx (Lexical + commands)
│ ├── messages/
│ │ ├── MessageRenderer.tsx
│ │ ├── UserMessage.tsx
│ │ ├── AssistantMessage.tsx
│ │ ├── ThinkingMessage.tsx
│ │ ├── SystemMessage.tsx
│ │ └── ErrorCard.tsx
│ ├── tools/
│ │ ├── ToolCallCard.tsx
│ │ ├── ToolStatusBadge.tsx
│ │ └── ToolIcon.tsx
│ ├── content/
│ │ ├── TodoListCard.tsx
│ │ ├── TodoItem.tsx
│ │ ├── DiffCard.tsx
│ │ └── CopyButton.tsx
│ ├── input/
│ │ ├── CodeMirrorEditor.tsx
│ │ ├── commandCompletions.ts
│ │ ├── mentionCompletions.ts
│ │ └── editorExtensions.ts
│ └── hooks/
│ ├── useLazyLoadComments.ts
│ ├── useSmartAutoScroll.ts
│ └── useChatVirtualizer.ts
└── lib/
├── state/store.ts (Enhanced with pagination)
└── api/comments.ts (New: fetch comments API)
apps/backend/
└── internal/task/
├── handlers/
│ └── comment_handlers.go (Add pagination support)
└── repository/
└── sqlite.go (Cursor-based queries)
```
**Total new files:** ~25
**Modified files:** 4 (page.tsx, page-client.tsx, task-chat-panel.tsx, store.ts, comment_handlers.go, sqlite.go)
---
## Implementation Timeline
### Week 1: Pagination & Smart Scroll
- [x] ~~SSR setup~~ (Already done)
- [x] ~~TanStack Virtual~~ (Already done v3.13.18)
- [x] ~~Basic tool calls~~ (Already done)
- [ ] Backend pagination API (limit, before, cursor)
- [ ] Update SSR to limit to 10 messages
- [ ] Lazy loading on scroll up
- [ ] Smart auto-scroll (only if at bottom)
- [ ] Scroll position preservation on prepend
### Week 2: Rich Content
- [ ] Enhanced message components (copy, timestamps)
- [ ] Nested tool call rendering (sub-agents)
- [ ] Todo list visualization with progress bars
- [ ] Inline diff viewer integration
- [ ] Thinking message display
- [ ] Error card enhancements
### Week 3: CodeMirror Input
- [ ] Replace Textarea with CodeMirror
- [ ] Markdown mode with syntax highlighting
- [ ] Slash command autocomplete (/read, /edit, /bash)
- [ ] File mention autocomplete (@file.ts)
- [ ] Keyboard navigation (arrows, enter, escape)
- [ ] Command parsing & execution
- [ ] File viewer/editor config (bonus)
### Week 4: Polish & Testing
- [ ] Performance optimization (memoization audit)
- [ ] Error boundaries for message rendering
- [ ] Accessibility (keyboard nav)
- [ ] Documentation updates
---
+135
View File
@@ -149,3 +149,138 @@
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.22);
}
}
.cm-tooltip {
border: 1px solid var(--border) !important;
background: var(--popover, oklch(0.205 0 0)) !important;
color: var(--popover-foreground, oklch(0.985 0 0)) !important;
border-radius: calc(var(--radius) + 2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
padding: 8px;
}
.cm-tooltip-autocomplete {
padding: 0;
background: var(--popover, oklch(0.205 0 0)) !important;
}
.cm-tooltip-autocomplete ul {
list-style: none;
margin: 0;
padding: 12px;
max-height: 240px;
overflow: auto;
}
.cm-tooltip-autocomplete li {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
color: var(--popover-foreground, oklch(0.985 0 0));
}
.cm-tooltip-autocomplete li[aria-selected="true"] {
background: color-mix(in oklab, var(--primary) 20%, var(--popover)) !important;
color: var(--primary-foreground) !important;
border-radius: 0;
}
.cm-tooltip-autocomplete li[aria-selected="true"]:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.cm-tooltip-autocomplete li[aria-selected="true"]:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
.cm-completionLabel {
font-weight: 500;
}
.cm-completionDetail {
margin-left: auto;
font-size: 0.875rem;
color: var(--muted-foreground) !important;
}
.cm-completionMatchedText {
text-decoration: none;
color: var(--primary) !important;
}
.cm-tooltip-autocomplete li[aria-selected="true"] .cm-completionMatchedText {
color: var(--primary-foreground);
}
.cm-completionIcon {
opacity: 0.65;
}
.cm-completionIcon {
width: 14px;
height: 14px;
display: inline-block;
margin-right: 6px;
color: var(--muted-foreground);
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.cm-completionIcon-slash {
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 9a2 2 0 1 1 2 -2v10a2 2 0 1 1 -2 -2h10a2 2 0 1 1 -2 2v-10a2 2 0 1 1 2 2h-10'/></svg>");
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 9a2 2 0 1 1 2 -2v10a2 2 0 1 1 -2 -2h10a2 2 0 1 1 -2 2v-10a2 2 0 1 1 2 2h-10'/></svg>");
}
.cm-completionIcon-mention {
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0'/><path d='M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'/></svg>");
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0'/><path d='M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'/></svg>");
}
.cm-tooltip-autocomplete li[aria-selected="true"] .cm-completionIcon {
color: var(--primary-foreground);
}
.task-chat-editor .cm-placeholder {
color: var(--muted-foreground) !important;
}
.task-chat-editor .cm-editor {
background: transparent;
box-shadow: none;
outline: none;
}
.task-chat-editor .cm-editor.cm-focused {
outline: none;
}
.task-chat-editor .cm-scroller,
.task-chat-editor .cm-content,
.task-chat-editor .cm-line,
.task-chat-editor .cm-line span {
background: transparent;
color: var(--foreground);
}
.task-chat-editor .cm-cursor {
border-left-color: var(--foreground);
}
.task-chat-editor .cm-selectionBackground {
background: color-mix(in oklab, var(--primary) 25%, var(--background)) !important;
}
.task-chat-editor .cm-content ::selection {
background: color-mix(in oklab, var(--primary) 30%, var(--background));
}
+9 -8
View File
@@ -10,7 +10,7 @@ import { IconX } from '@tabler/icons-react';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { TaskChatPanel } from '@/components/task/task-chat-panel';
import { TaskTopBar } from '@/components/task/task-top-bar';
import type { Task, Comment } from '@/lib/types/http';
import type { Task } from '@/lib/types/http';
import type { OpenFileTab } from '@/lib/types/backend';
import { FILE_EXTENSION_COLORS } from '@/lib/types/backend';
import { TaskFilesPanel } from '@/components/task/task-files-panel';
@@ -21,6 +21,7 @@ import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useRepositories } from '@/hooks/use-repositories';
import { useRepositoryBranches } from '@/hooks/use-repository-branches';
import { listTaskComments } from '@/lib/http';
const AGENTS = [
{ id: 'codex', label: 'Codex' },
@@ -113,19 +114,19 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
}
const fetchComments = async () => {
const client = getWebSocketClient();
if (!client) return;
setIsLoadingComments(true);
store.getState().setCommentsLoading(true);
try {
const response = await client.request<{ comments: Comment[] }>('comment.list', { task_id: task.id }, 10000);
console.log('[API] comment.list response:', JSON.stringify(response, null, 2));
store.getState().setComments(task.id, response.comments ?? []);
const response = await listTaskComments(task.id, { limit: 10, sort: 'desc' });
const orderedComments = [...(response.comments ?? [])].reverse();
store.getState().setComments(task.id, orderedComments, {
hasMore: response.has_more,
oldestCursor: response.cursor || (orderedComments[0]?.id ?? null),
});
} catch (error) {
console.error('Failed to fetch comments:', error);
store.getState().setComments(task.id, []);
store.getState().setComments(task.id, [], { hasMore: false, oldestCursor: null });
} finally {
setIsLoadingComments(false);
}
+11 -2
View File
@@ -12,8 +12,17 @@ export default async function TaskPage({ params }: { params: Promise<{ id: strin
try {
const { id } = await params;
task = await fetchTask(id, { cache: 'no-store' });
comments = await listTaskComments(id, { cache: 'no-store' });
initialState = taskToState(task, comments.comments);
comments = await listTaskComments(
id,
{ limit: 10, sort: 'desc' },
{ cache: 'no-store' }
);
const orderedComments = [...comments.comments].reverse();
initialState = taskToState(task, {
items: orderedComments,
hasMore: comments.has_more,
oldestCursor: comments.cursor || (orderedComments[0]?.id ?? null),
});
} catch {
initialState = null;
task = null;
@@ -0,0 +1,74 @@
'use client';
import type { Comment } from '@/lib/types/http';
import { ChatMessage } from '@/components/task/chat/messages/chat-message';
import { StatusMessage } from '@/components/task/chat/messages/status-message';
import { ToolCallMessage } from '@/components/task/chat/messages/tool-call-message';
import { ThinkingMessage } from '@/components/task/chat/messages/thinking-message';
import { TodoMessage } from '@/components/task/chat/messages/todo-message';
type AdapterContext = {
isTaskDescription: boolean;
};
type MessageAdapter = {
matches: (comment: Comment, ctx: AdapterContext) => boolean;
render: (comment: Comment, ctx: AdapterContext) => JSX.Element;
};
const adapters: MessageAdapter[] = [
{
matches: (comment) => comment.type === 'thinking',
render: (comment) => <ThinkingMessage comment={comment} />,
},
{
matches: (comment) => comment.type === 'todo',
render: (comment) => <TodoMessage comment={comment} />,
},
{
matches: (comment) => comment.type === 'tool_call',
render: (comment) => <ToolCallMessage comment={comment} />,
},
{
matches: (comment) => comment.type === 'error' || comment.type === 'status' || comment.type === 'progress',
render: (comment) => <StatusMessage comment={comment} />,
},
{
matches: () => true,
render: (comment, ctx) => {
if (ctx.isTaskDescription) {
return (
<ChatMessage
comment={comment}
label="Task"
className="ml-auto bg-primary/20 text-foreground border border-primary/40"
showRichBlocks
/>
);
}
if (comment.author_type === 'user') {
return (
<ChatMessage
comment={comment}
label="You"
className="ml-auto bg-primary text-primary-foreground"
/>
);
}
return (
<ChatMessage
comment={comment}
label="Agent"
className="bg-muted text-foreground"
showRichBlocks={comment.type === 'message' || comment.type === 'content' || !comment.type}
/>
);
},
},
];
export function MessageRenderer({ comment, isTaskDescription }: { comment: Comment; isTaskDescription: boolean }) {
const ctx = { isTaskDescription };
const adapter = adapters.find((entry) => entry.matches(comment, ctx)) ?? adapters[adapters.length - 1];
return adapter.render(comment, ctx);
}
@@ -0,0 +1,42 @@
'use client';
import ReactMarkdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
import type { Comment } from '@/lib/types/http';
import { RichBlocks } from '@/components/task/chat/messages/rich-blocks';
type ChatMessageProps = {
comment: Comment;
label: string;
className: string;
showRichBlocks?: boolean;
};
export function ChatMessage({ comment, label, className, showRichBlocks }: ChatMessageProps) {
const isUser = comment.author_type === 'user';
return (
<div className={cn('max-w-[85%] rounded-lg px-4 py-3 text-sm', className)}>
<p className="text-[11px] uppercase tracking-wide mb-2 opacity-70">
{label}
{comment.requests_input ? (
<span className="ml-2 rounded-full bg-amber-500/20 px-2 py-0.5 text-[10px] text-amber-300">
Needs input
</span>
) : null}
</p>
{isUser ? (
<p className="whitespace-pre-wrap">{comment.content || '(empty)'}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-2 prose-p:leading-relaxed prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-pre:my-3 prose-code:px-1.5 prose-code:py-0.5 prose-code:bg-background/50 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-pre:bg-background/80 prose-pre:text-xs prose-strong:text-foreground prose-headings:text-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{comment.content || '(empty)'}
</ReactMarkdown>
{showRichBlocks ? <RichBlocks comment={comment} /> : null}
</div>
)}
</div>
);
}
@@ -0,0 +1,108 @@
'use client';
import { IconBrain, IconCode, IconListCheck } from '@tabler/icons-react';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import type { Comment } from '@/lib/types/http';
import type { DiffPayload, RichMetadata } from '@/components/task/chat/types';
function resolveDiffPayload(diff: unknown): DiffPayload | null {
if (!diff) return null;
if (typeof diff === 'string') {
return null;
}
if (Array.isArray(diff)) {
return { hunks: diff };
}
if (typeof diff === 'object' && diff !== null) {
const candidate = diff as Partial<DiffPayload>;
if (Array.isArray(candidate.hunks)) {
return {
hunks: candidate.hunks,
oldFile: candidate.oldFile,
newFile: candidate.newFile,
};
}
}
return null;
}
export function RichBlocks({ comment }: { comment: Comment }) {
const metadata = comment.metadata as RichMetadata | undefined;
const { resolvedTheme } = useTheme();
if (!metadata) return null;
const todos = metadata.todos ?? [];
const todoItems = todos
.map((item) => (typeof item === 'string' ? { text: item, done: false } : item))
.filter((item) => item.text);
const diffPayload = resolveDiffPayload(metadata.diff);
const diffText = typeof metadata.diff === 'string' ? metadata.diff : null;
const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
return (
<>
{metadata.thinking && (
<div className="mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs">
<div className="flex items-center gap-2 text-muted-foreground mb-1 uppercase tracking-wide">
<IconBrain className="h-3.5 w-3.5" />
<span>Thinking</span>
</div>
<div className="whitespace-pre-wrap text-foreground/80">{metadata.thinking}</div>
</div>
)}
{todoItems.length > 0 && (
<div className="mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs">
<div className="flex items-center gap-2 text-muted-foreground mb-1 uppercase tracking-wide">
<IconListCheck className="h-3.5 w-3.5" />
<span>Todos</span>
</div>
<div className="space-y-1">
{todoItems.map((todo) => (
<div key={todo.text} className="flex items-center gap-2">
<span
className={cn(
'h-1.5 w-1.5 rounded-full',
todo.done ? 'bg-green-500' : 'bg-muted-foreground/60'
)}
/>
<span className={cn(todo.done && 'line-through text-muted-foreground')}>
{todo.text}
</span>
</div>
))}
</div>
</div>
)}
{diffPayload && (
<div className="mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs">
<div className="flex items-center gap-2 text-muted-foreground mb-1 uppercase tracking-wide">
<IconCode className="h-3.5 w-3.5" />
<span>Diff</span>
</div>
<DiffView
data={{
hunks: diffPayload.hunks,
oldFile: diffPayload.oldFile ?? { fileName: 'before', fileLang: 'plaintext' },
newFile: diffPayload.newFile ?? { fileName: 'after', fileLang: 'plaintext' },
}}
diffViewMode={DiffModeEnum.Unified}
diffViewTheme={diffTheme}
/>
</div>
)}
{!diffPayload && diffText && (
<div className="mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs">
<div className="flex items-center gap-2 text-muted-foreground mb-1 uppercase tracking-wide">
<IconCode className="h-3.5 w-3.5" />
<span>Diff</span>
</div>
<pre className="whitespace-pre-wrap break-words text-[11px] text-foreground/80">
{diffText}
</pre>
</div>
)}
</>
);
}
@@ -0,0 +1,47 @@
'use client';
import { IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { Comment } from '@/lib/types/http';
import type { StatusMetadata } from '@/components/task/chat/types';
export function StatusMessage({ comment }: { comment: Comment }) {
const metadata = comment.metadata as StatusMetadata | undefined;
const progress =
typeof metadata?.progress === 'number' ? Math.min(Math.max(metadata.progress, 0), 100) : null;
const statusLine = metadata?.stage || metadata?.status;
const message = metadata?.message || comment.content || statusLine || 'Status update';
const Icon = comment.type === 'error' ? IconAlertTriangle : IconInfoCircle;
const iconClass = comment.type === 'error' ? 'text-red-500' : 'text-muted-foreground';
return (
<div
className={cn(
'max-w-[85%] rounded-lg border px-4 py-3 text-sm',
comment.type === 'error'
? 'border-red-500/40 bg-red-500/10 text-foreground'
: 'border-border/50 bg-muted/40 text-foreground'
)}
>
<div className="flex items-center gap-2 mb-2 text-[11px] uppercase tracking-wide opacity-70">
<Icon className={cn('h-3.5 w-3.5', iconClass)} />
<span>{comment.type === 'error' ? 'Error' : 'Status'}</span>
</div>
<div className="whitespace-pre-wrap">{message}</div>
{progress !== null && (
<div className="mt-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
<span>{statusLine ?? 'Progress'}</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-1.5 rounded-full bg-muted/70">
<div
className="h-full rounded-full bg-primary/80 transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,20 @@
'use client';
import { IconBrain } from '@tabler/icons-react';
import type { Comment } from '@/lib/types/http';
import type { RichMetadata } from '@/components/task/chat/types';
export function ThinkingMessage({ comment }: { comment: Comment }) {
const metadata = comment.metadata as RichMetadata | undefined;
const text = metadata?.thinking ?? comment.content;
if (!text) return null;
return (
<div className="max-w-[85%] rounded-lg border border-border/50 bg-background/60 px-4 py-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground mb-2 uppercase tracking-wide text-[11px]">
<IconBrain className="h-3.5 w-3.5" />
<span>Thinking</span>
</div>
<div className="whitespace-pre-wrap text-foreground/80">{text}</div>
</div>
);
}
@@ -0,0 +1,57 @@
'use client';
import { IconListCheck } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { Comment } from '@/lib/types/http';
import type { RichMetadata } from '@/components/task/chat/types';
export function TodoMessage({ comment }: { comment: Comment }) {
const metadata = comment.metadata as RichMetadata | undefined;
const todos = metadata?.todos ?? [];
const todoItems = todos
.map((item) => (typeof item === 'string' ? { text: item, done: false } : item))
.filter((item) => item.text);
if (!todoItems.length) {
return null;
}
const completed = todoItems.filter((todo) => todo.done).length;
const progress = Math.round((completed / todoItems.length) * 100);
return (
<div className="max-w-[85%] rounded-lg border border-border/50 bg-background/60 px-4 py-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground mb-2 uppercase tracking-wide text-[11px]">
<IconListCheck className="h-3.5 w-3.5" />
<span>Todos</span>
</div>
<div className="mb-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
<span>{completed} of {todoItems.length} done</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 rounded-full bg-muted/70">
<div
className="h-full rounded-full bg-primary/80 transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="space-y-1 text-xs">
{todoItems.map((todo) => (
<div key={todo.text} className="flex items-center gap-2">
<span
className={cn(
'h-1.5 w-1.5 rounded-full',
todo.done ? 'bg-green-500' : 'bg-muted-foreground/60'
)}
/>
<span className={cn(todo.done && 'line-through text-muted-foreground')}>
{todo.text}
</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,138 @@
'use client';
import { useState } from 'react';
import {
IconCheck,
IconChevronDown,
IconChevronRight,
IconCode,
IconEdit,
IconEye,
IconFile,
IconLoader2,
IconSearch,
IconTerminal2,
IconX,
} from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { Comment } from '@/lib/types/http';
import type { ToolCallMetadata } from '@/components/task/chat/types';
function getToolIcon(toolName: string | undefined, className: string) {
const name = toolName?.toLowerCase() ?? '';
if (name === 'edit' || name.includes('edit') || name.includes('replace') || name.includes('write') || name.includes('save')) {
return <IconEdit className={className} />;
}
if (name === 'read' || name.includes('view') || name.includes('read')) {
return <IconEye className={className} />;
}
if (name === 'search' || name.includes('search') || name.includes('find') || name.includes('retrieval')) {
return <IconSearch className={className} />;
}
if (name === 'execute' || name.includes('terminal') || name.includes('exec') || name.includes('launch') || name.includes('process')) {
return <IconTerminal2 className={className} />;
}
if (name === 'delete' || name === 'move' || name.includes('file') || name.includes('create')) {
return <IconFile className={className} />;
}
return <IconCode className={className} />;
}
function getStatusIcon(status?: string) {
switch (status) {
case 'complete':
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
case 'error':
return <IconX className="h-3.5 w-3.5 text-red-500" />;
case 'running':
return <IconLoader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
default:
return null;
}
}
export function ToolCallMessage({ comment }: { comment: Comment }) {
const [isExpanded, setIsExpanded] = useState(false);
const metadata = comment.metadata as ToolCallMetadata | undefined;
const toolName = metadata?.tool_name ?? '';
const title = metadata?.title ?? comment.content ?? 'Tool call';
const status = metadata?.status;
const args = metadata?.args;
const result = metadata?.result;
const toolIcon = getToolIcon(toolName, 'h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0');
const hasDetails = args && Object.keys(args).length > 0;
let filePath: string | undefined;
const rawPath = args?.path ?? args?.file ?? args?.file_path;
if (typeof rawPath === 'string') {
filePath = rawPath;
}
if (!filePath && Array.isArray(args?.locations) && args.locations.length > 0) {
const firstLoc = args.locations[0] as { path?: string } | undefined;
if (firstLoc?.path) {
filePath = firstLoc.path;
}
}
return (
<div className="rounded-md border border-border/40 bg-muted/20 overflow-hidden max-w-[85%]">
<button
type="button"
onClick={() => hasDetails && setIsExpanded(!isExpanded)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-sm text-left',
hasDetails && 'cursor-pointer hover:bg-muted/40 transition-colors'
)}
disabled={!hasDetails}
>
{toolIcon}
<span className="flex-1 font-mono text-xs text-muted-foreground truncate">
{title}
</span>
{filePath && (
<span className="text-xs text-muted-foreground/60 truncate max-w-[200px]">
{filePath}
</span>
)}
{getStatusIcon(status)}
{hasDetails && (
isExpanded
? <IconChevronDown className="h-4 w-4 text-muted-foreground/50" />
: <IconChevronRight className="h-4 w-4 text-muted-foreground/50" />
)}
</button>
{isExpanded && hasDetails && (
<div className="border-t border-border/30 bg-background/50 p-3 space-y-2">
{args && Object.entries(args).map(([key, value]) => {
const strValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
const isLongValue = strValue.length > 100 || strValue.includes('\n');
return (
<div key={key} className="text-xs">
<span className="font-medium text-muted-foreground">{key}:</span>
{isLongValue ? (
<pre className="mt-1 p-2 bg-muted/50 rounded text-[11px] overflow-x-auto max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all">
{strValue}
</pre>
) : (
<span className="ml-2 font-mono text-foreground/80">{strValue}</span>
)}
</div>
);
})}
{result && (
<div className="text-xs border-t border-border/30 pt-2 mt-2">
<span className="font-medium text-muted-foreground">Result:</span>
<pre className="mt-1 p-2 bg-muted/50 rounded text-[11px] overflow-x-auto max-h-[150px] overflow-y-auto whitespace-pre-wrap">
{result}
</pre>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,23 @@
'use client';
export function TypingIndicator() {
return (
<div className="flex items-center gap-2 px-4 py-3 max-w-[85%] rounded-lg bg-muted text-muted-foreground">
<div className="flex items-center gap-1" role="status" aria-label="Agent is typing">
<span className="text-[11px] uppercase tracking-wide opacity-70 mr-2">Agent</span>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '0ms', animationDuration: '1s' }}
/>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '150ms', animationDuration: '1s' }}
/>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '300ms', animationDuration: '1s' }}
/>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
'use client';
import { IconCheck, IconChevronDown } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
type TodoItem = {
text: string;
done?: boolean;
};
type TodoSummaryProps = {
todos: TodoItem[];
};
export function TodoSummary({ todos }: TodoSummaryProps) {
if (!todos.length) return null;
const completed = todos.filter((todo) => todo.done).length;
return (
<div className="rounded-md border border-border/60 bg-muted/40 px-3 py-2 text-xs">
<div className="flex items-center justify-between text-[11px] uppercase tracking-wide text-muted-foreground">
<span>Todos ({todos.length})</span>
<IconChevronDown className="h-3.5 w-3.5" />
</div>
<div className="mt-2 space-y-1">
{todos.map((todo) => (
<div key={todo.text} className="flex items-center gap-2">
{todo.done ? (
<IconCheck className="h-3.5 w-3.5 text-green-500" />
) : (
<span className="h-3.5 w-3.5" />
)}
<span className={cn(todo.done && 'line-through text-muted-foreground')}>
{todo.text}
</span>
</div>
))}
</div>
<div className="mt-2 text-[11px] text-muted-foreground">
{completed} of {todos.length} completed
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
'use client';
export type ToolCallMetadata = {
tool_call_id?: string;
tool_name?: string;
title?: string;
status?: 'pending' | 'running' | 'complete' | 'error';
args?: Record<string, unknown>;
result?: string;
};
export type StatusMetadata = {
progress?: number;
status?: string;
stage?: string;
message?: string;
};
export type TodoMetadata =
| { text: string; done?: boolean }
| string;
export type RichMetadata = {
thinking?: string;
todos?: TodoMetadata[];
diff?: unknown;
};
export type DiffPayload = {
hunks: unknown[];
oldFile?: { fileName?: string; fileLang?: string };
newFile?: { fileName?: string; fileLang?: string };
};
@@ -0,0 +1,185 @@
'use client';
import CodeMirror from '@uiw/react-codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
import { autocompletion, closeBrackets, closeBracketsKeymap, CompletionContext } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { Prec } from '@codemirror/state';
import type { Extension } from '@codemirror/state';
import { cn } from '@/lib/utils';
import { SHORTCUTS } from '@/lib/keyboard/constants';
import { matchesShortcut, shortcutToCodeMirrorKeybinding } from '@/lib/keyboard/utils';
type TaskChatInputProps = {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
disabled?: boolean;
className?: string;
planModeEnabled?: boolean;
};
export function TaskChatInput({
value,
onChange,
onSubmit,
placeholder,
disabled,
className,
planModeEnabled,
}: TaskChatInputProps) {
const submitKey = shortcutToCodeMirrorKeybinding(SHORTCUTS.SUBMIT);
const completionSource = (context: CompletionContext) => {
const slashMatch = context.matchBefore(/(^|\s)\/[\w-]*/);
if (slashMatch) {
const from = slashMatch.from + (slashMatch.text.startsWith('/') ? 0 : 1);
return {
from,
options: [
{ label: '/plan', type: 'slash', info: 'Start a planning response' },
{ label: '/todo', type: 'slash', info: 'Add a todo list' },
{ label: '/review', type: 'slash', info: 'Request a review' },
{ label: '/summarize', type: 'slash', info: 'Summarize the task' },
],
};
}
const mentionMatch = context.matchBefore(/(^|\s)@[\w-]*/);
if (mentionMatch) {
const from = mentionMatch.from + (mentionMatch.text.startsWith('@') ? 0 : 1);
return {
from,
options: [
{ label: '@agent', type: 'mention', info: 'Notify the agent' },
{ label: '@reviewer', type: 'mention', info: 'Tag a reviewer' },
{ label: '@designer', type: 'mention', info: 'Tag design feedback' },
],
};
}
return null;
};
const editorTheme = EditorView.theme({
'&': {
color: 'var(--foreground)',
fontSize: '0.875rem',
backgroundColor: 'transparent',
},
'.cm-editor': {
borderRadius: 'inherit',
background: 'transparent',
outline: 'none',
boxShadow: 'none',
overflow: 'hidden',
},
'.cm-editor.cm-focused': {
outline: 'none',
boxShadow: 'none',
},
'.cm-scroller': {
fontFamily: 'var(--font-sans)',
borderRadius: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
overflow: 'auto',
},
'.cm-content': {
padding: '0.5rem',
fontFamily: 'var(--font-sans)',
color: 'var(--foreground)',
background: 'transparent',
},
'.cm-line': {
color: 'var(--foreground)',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
opacity: '0.8',
},
'.cm-tooltip-autocomplete, .cm-tooltip-autocomplete li, .cm-completionDetail': {
fontSize: '0.875rem',
},
'.cm-cursor': {
borderLeftColor: 'var(--foreground)',
},
'.cm-selectionBackground': {
backgroundColor: 'var(--primary)',
opacity: '0.7',
},
'.cm-selectionMatch': {
backgroundColor: 'var(--primary)',
opacity: '0.6',
},
'.cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--primary)',
opacity: '0.7',
},
});
const handleSubmitKey = () => {
onSubmit();
return true;
};
const submitKeymap = Prec.highest(
keymap.of([
{ key: submitKey, run: handleSubmitKey, preventDefault: true },
{ key: 'Mod-Enter', run: handleSubmitKey, preventDefault: true },
])
);
const extensions: Extension[] = [
EditorView.lineWrapping,
editorTheme,
EditorView.domEventHandlers({
keydown: (event) => {
if (matchesShortcut(event, SHORTCUTS.SUBMIT)) {
event.preventDefault();
event.stopPropagation();
onSubmit();
return true;
}
return false;
},
}),
history(),
closeBrackets(),
markdown(),
cmPlaceholder(placeholder ?? ''),
autocompletion({ override: [completionSource], aboveCursor: true }),
submitKeymap,
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
];
return (
<div
className={cn(
'task-chat-editor rounded-md border border-input bg-input/20 dark:bg-input/30 focus-within:border-ring focus-within:ring-[2px] focus-within:ring-ring/30 overflow-hidden',
planModeEnabled &&
'border-dashed border-primary/60 !bg-primary/20 dark:!bg-primary/20 shadow-[inset_0_0_0_1px_rgba(59,130,246,0.35)]',
className
)}
>
<CodeMirror
value={value}
onChange={onChange}
editable={!disabled}
minHeight="90px"
maxHeight="200px"
extensions={extensions}
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: false,
}}
className="text-sm"
/>
</div>
);
}
+105 -252
View File
@@ -1,28 +1,10 @@
'use client';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
IconBrain,
IconCheck,
IconChevronDown,
IconChevronRight,
IconCode,
IconEdit,
IconEye,
IconFile,
IconListCheck,
IconLoader2,
IconPaperclip,
IconSearch,
IconTerminal2,
IconX,
} from '@tabler/icons-react';
import ReactMarkdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { IconBrain, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Textarea } from '@kandev/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
@@ -35,8 +17,12 @@ import { cn } from '@/lib/utils';
import { useAppStore } from '@/components/state-provider';
import type { Comment } from '@/lib/types/http';
import { SHORTCUTS } from '@/lib/keyboard/constants';
import { useKeyboardShortcutHandler } from '@/hooks/use-keyboard-shortcut';
import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip';
import { TaskChatInput } from '@/components/task/task-chat-input';
import { useLazyLoadComments } from '@/hooks/use-lazy-load-comments';
import { MessageRenderer } from '@/components/task/chat/message-renderer';
import { TypingIndicator } from '@/components/task/chat/messages/typing-indicator';
import { TodoSummary } from '@/components/task/chat/todo-summary';
type AgentOption = {
id: string;
@@ -51,159 +37,6 @@ type TaskChatPanelProps = {
taskDescription?: string;
};
type ToolCallMetadata = {
tool_call_id?: string;
tool_name?: string;
title?: string;
status?: 'pending' | 'running' | 'complete' | 'error';
args?: Record<string, unknown>;
result?: string;
};
function getToolIcon(toolName: string | undefined, className: string) {
const name = toolName?.toLowerCase() ?? '';
// Match ACP ToolKind values: read, edit, delete, move, search, execute
if (name === 'edit' || name.includes('edit') || name.includes('replace') || name.includes('write') || name.includes('save')) {
return <IconEdit className={className} />;
}
if (name === 'read' || name.includes('view') || name.includes('read')) {
return <IconEye className={className} />;
}
if (name === 'search' || name.includes('search') || name.includes('find') || name.includes('retrieval')) {
return <IconSearch className={className} />;
}
if (name === 'execute' || name.includes('terminal') || name.includes('exec') || name.includes('launch') || name.includes('process')) {
return <IconTerminal2 className={className} />;
}
if (name === 'delete' || name === 'move' || name.includes('file') || name.includes('create')) {
return <IconFile className={className} />;
}
return <IconCode className={className} />;
}
function getStatusIcon(status?: string) {
switch (status) {
case 'complete':
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
case 'error':
return <IconX className="h-3.5 w-3.5 text-red-500" />;
case 'running':
return <IconLoader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
default:
return null;
}
}
function ToolCallCard({ comment }: { comment: Comment }) {
const [isExpanded, setIsExpanded] = useState(false);
const metadata = comment.metadata as ToolCallMetadata | undefined;
const toolName = metadata?.tool_name ?? '';
const title = metadata?.title ?? comment.content ?? 'Tool call';
const status = metadata?.status;
const args = metadata?.args;
const result = metadata?.result;
const toolIcon = getToolIcon(toolName, 'h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0');
const hasDetails = args && Object.keys(args).length > 0;
// Extract file path from various possible sources
let filePath: string | undefined;
const rawPath = args?.path ?? args?.file ?? args?.file_path;
if (typeof rawPath === 'string') {
filePath = rawPath;
}
// Also try to get path from locations array if available
if (!filePath && Array.isArray(args?.locations) && args.locations.length > 0) {
const firstLoc = args.locations[0] as { path?: string } | undefined;
if (firstLoc?.path) {
filePath = firstLoc.path;
}
}
return (
<div className="rounded-md border border-border/40 bg-muted/20 overflow-hidden">
<button
type="button"
onClick={() => hasDetails && setIsExpanded(!isExpanded)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-sm text-left',
hasDetails && 'cursor-pointer hover:bg-muted/40 transition-colors'
)}
disabled={!hasDetails}
>
{toolIcon}
<span className="flex-1 font-mono text-xs text-muted-foreground truncate">
{title}
</span>
{filePath && (
<span className="text-xs text-muted-foreground/60 truncate max-w-[200px]">
{filePath}
</span>
)}
{getStatusIcon(status)}
{hasDetails && (
isExpanded
? <IconChevronDown className="h-4 w-4 text-muted-foreground/50" />
: <IconChevronRight className="h-4 w-4 text-muted-foreground/50" />
)}
</button>
{isExpanded && hasDetails && (
<div className="border-t border-border/30 bg-background/50 p-3 space-y-2">
{args && Object.entries(args).map(([key, value]) => {
const strValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
const isLongValue = strValue.length > 100 || strValue.includes('\n');
return (
<div key={key} className="text-xs">
<span className="font-medium text-muted-foreground">{key}:</span>
{isLongValue ? (
<pre className="mt-1 p-2 bg-muted/50 rounded text-[11px] overflow-x-auto max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all">
{strValue}
</pre>
) : (
<span className="ml-2 font-mono text-foreground/80">{strValue}</span>
)}
</div>
);
})}
{result && (
<div className="text-xs border-t border-border/30 pt-2 mt-2">
<span className="font-medium text-muted-foreground">Result:</span>
<pre className="mt-1 p-2 bg-muted/50 rounded text-[11px] overflow-x-auto max-h-[150px] overflow-y-auto whitespace-pre-wrap">
{result}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function TypingIndicator() {
return (
<div className="flex items-center gap-2 px-4 py-3 max-w-[85%] rounded-lg bg-muted text-muted-foreground">
<div className="flex items-center gap-1" role="status" aria-label="Agent is typing">
<span className="text-[11px] uppercase tracking-wide opacity-70 mr-2">Agent</span>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '0ms', animationDuration: '1s' }}
/>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '150ms', animationDuration: '1s' }}
/>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '300ms', animationDuration: '1s' }}
/>
</div>
</div>
);
}
export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskDescription }: TaskChatPanelProps) {
const [messageInput, setMessageInput] = useState('');
const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? '');
@@ -212,14 +45,27 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
const [isAwaitingResponse, setIsAwaitingResponse] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const lastAgentMessageCountRef = useRef(0);
const wasAtBottomRef = useRef(true);
const commentsState = useAppStore((state) => state.comments);
const comments = commentsState?.items ?? [];
const commentsLoading = commentsState?.isLoading ?? false;
const isInitialLoading = commentsLoading && comments.length === 0;
const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadComments(
commentsState?.taskId ?? null
);
// Filter to only show message, content, and tool_call types (not progress, etc)
const visibleComments = comments.filter(
(c) => c.type === 'message' || c.type === 'content' || c.type === 'tool_call' || !c.type
// Filter to only show chat-relevant types.
const visibleComments = comments.filter((comment) =>
!comment.type ||
comment.type === 'message' ||
comment.type === 'content' ||
comment.type === 'tool_call' ||
comment.type === 'progress' ||
comment.type === 'status' ||
comment.type === 'error' ||
comment.type === 'thinking' ||
comment.type === 'todo'
);
// Create a synthetic "user" message for the task description
@@ -237,6 +83,15 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
? [taskDescriptionMessage, ...visibleComments]
: visibleComments;
const latestTodos = [...visibleComments]
.reverse()
.find((comment) => comment.type === 'todo' || (comment.metadata as { todos?: unknown })?.todos);
const todoItems = (latestTodos?.metadata as { todos?: Array<{ text: string; done?: boolean } | string> } | undefined)
?.todos
?.map((item) => (typeof item === 'string' ? { text: item, done: false } : item))
.filter((item) => item.text) ?? [];
// Count agent messages to detect new responses
const agentMessageCount = visibleComments.filter((c) => c.author_type !== 'user').length;
@@ -260,19 +115,61 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
overscan: 6,
});
const checkAtBottom = useCallback(() => {
const element = messagesContainerRef.current;
if (!element) return;
const { scrollTop, scrollHeight, clientHeight } = element;
wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 48;
}, []);
useEffect(() => {
const element = messagesContainerRef.current;
if (!element) return;
element.addEventListener('scroll', checkAtBottom);
return () => element.removeEventListener('scroll', checkAtBottom);
}, [checkAtBottom]);
// Scroll to bottom when new comments arrive or when typing indicator appears
useEffect(() => {
if (itemCount === 0) return;
virtualizer.scrollToIndex(itemCount - 1, { align: 'end', behavior: 'smooth' });
if (wasAtBottomRef.current) {
virtualizer.scrollToIndex(itemCount - 1, { align: 'end' });
}
}, [itemCount, virtualizer]);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const virtualItems = virtualizer.getVirtualItems();
useEffect(() => {
const [firstItem] = virtualItems;
if (!firstItem) return;
const element = messagesContainerRef.current;
if (!element) return;
if (firstItem.index !== 0 || element.scrollTop > 40) {
return;
}
if (!hasMore || isLoadingMore) {
return;
}
const prevScrollHeight = element.scrollHeight;
const prevScrollTop = element.scrollTop;
loadMore().then((added) => {
if (!added) return;
requestAnimationFrame(() => {
const nextScrollHeight = element.scrollHeight;
element.scrollTop = prevScrollTop + (nextScrollHeight - prevScrollHeight);
});
});
}, [virtualItems, hasMore, isLoadingMore, loadMore]);
const handleSubmit = async (event?: FormEvent) => {
event?.preventDefault();
const trimmed = messageInput.trim();
if (!trimmed || isSending) return;
setIsSending(true);
setIsAwaitingResponse(true);
setMessageInput('');
flushSync(() => {
setMessageInput('');
});
try {
await onSend(trimmed);
} finally {
@@ -280,80 +177,30 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
}
};
// Use keyboard shortcut hook for Cmd+Enter / Ctrl+Enter
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
handleSubmit(event as unknown as FormEvent);
});
const formatContent = (comment: Comment) => {
// For content type comments, the content might be streamed chunks
// For message type, show as-is
return comment.content || '(empty)';
};
const renderMessage = (comment: Comment) => {
if (comment.type === 'tool_call') {
return <ToolCallCard comment={comment} />;
}
const isUser = comment.author_type === 'user';
const isTaskDescription = comment.id === 'task-description';
let label: string;
let containerClass: string;
if (isTaskDescription) {
label = 'Task';
containerClass = 'ml-auto bg-primary/20 text-foreground border border-primary/40';
} else if (isUser) {
label = 'You';
containerClass = 'ml-auto bg-primary text-primary-foreground';
} else {
label = 'Agent';
containerClass = 'bg-muted text-foreground';
}
return (
<div className={cn('max-w-[85%] rounded-lg px-4 py-3 text-sm', containerClass)}>
<p
className={cn(
'text-[11px] uppercase tracking-wide mb-2',
isTaskDescription ? 'text-primary font-medium' : 'opacity-70'
)}
>
{label}
</p>
{isUser ? (
<p className="whitespace-pre-wrap">{formatContent(comment)}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-2 prose-p:leading-relaxed prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-pre:my-3 prose-code:px-1.5 prose-code:py-0.5 prose-code:bg-background/50 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-pre:bg-background/80 prose-pre:text-xs prose-strong:text-foreground prose-headings:text-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{formatContent(comment)}
</ReactMarkdown>
</div>
)}
</div>
);
};
return (
<>
<div
ref={messagesContainerRef}
className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3"
className="relative flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3"
>
{(isLoading || commentsLoading) && (
{isLoadingMore && hasMore && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 text-xs text-muted-foreground">
Loading older messages...
</div>
)}
{(isLoading || isInitialLoading) && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<IconLoader2 className="h-5 w-5 animate-spin mr-2" />
<span>Loading comments...</span>
</div>
)}
{!isLoading && !commentsLoading && allMessages.length === 0 && (
{!isLoading && !isInitialLoading && allMessages.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<span>No messages yet. Start the conversation!</span>
</div>
)}
{!isLoading && !commentsLoading && itemCount > 0 && (
{!isLoading && !isInitialLoading && itemCount > 0 && (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const isTypingRow = showTypingIndicator && virtualRow.index === allMessages.length;
@@ -366,9 +213,16 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<div className="pb-3">
{isTypingRow ? <TypingIndicator /> : renderMessage(message)}
</div>
<div className="pb-3">
{isTypingRow ? (
<TypingIndicator />
) : (
<MessageRenderer
comment={message}
isTaskDescription={message.id === 'task-description'}
/>
)}
</div>
</div>
);
})}
@@ -376,16 +230,15 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
)}
</div>
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-2">
<Textarea
{todoItems.length > 0 && (
<TodoSummary todos={todoItems} />
)}
<TaskChatInput
value={messageInput}
onChange={(event) => setMessageInput(event.target.value)}
onKeyDown={handleKeyDown}
onChange={setMessageInput}
onSubmit={() => handleSubmit()}
placeholder="Write to submit work to the agent..."
className={cn(
'min-h-[90px] resize-none',
planModeEnabled &&
'border-dashed border-primary/60 !bg-primary/20 dark:!bg-primary/20 shadow-[inset_0_0_0_1px_rgba(59,130,246,0.35)]'
)}
planModeEnabled={planModeEnabled}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
+35
View File
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { listTaskComments } from '@/lib/http';
import { useAppStore } from '@/components/state-provider';
export function useLazyLoadComments(taskId: string | null) {
const { hasMore, oldestCursor, isLoading } = useAppStore((state) => state.comments);
const prependComments = useAppStore((state) => state.prependComments);
const setCommentsMetadata = useAppStore((state) => state.setCommentsMetadata);
const loadMore = useCallback(async () => {
if (!taskId || !hasMore || isLoading || !oldestCursor) {
return 0;
}
setCommentsMetadata({ isLoading: true });
try {
const response = await listTaskComments(taskId, {
limit: 20,
before: oldestCursor,
sort: 'desc',
});
const orderedComments = [...(response.comments ?? [])].reverse();
prependComments(orderedComments, {
hasMore: response.has_more,
oldestCursor: response.cursor || (orderedComments[0]?.id ?? null),
});
return orderedComments.length;
} catch {
setCommentsMetadata({ isLoading: false });
return 0;
}
}, [taskId, hasMore, isLoading, oldestCursor, prependComments, setCommentsMetadata]);
return { loadMore, hasMore, isLoading };
}
+13 -2
View File
@@ -161,8 +161,19 @@ export async function fetchTask(taskId: string, options?: ApiRequestOptions) {
return fetchJson<Task>(`/api/v1/tasks/${taskId}`, options);
}
export async function listTaskComments(taskId: string, options?: ApiRequestOptions) {
return fetchJson<ListCommentsResponse>(`/api/v1/tasks/${taskId}/comments`, options);
export async function listTaskComments(
taskId: string,
params?: { limit?: number; before?: string; after?: string; sort?: 'asc' | 'desc' },
options?: ApiRequestOptions
) {
const query = new URLSearchParams();
if (params?.limit) query.set('limit', params.limit.toString());
if (params?.before) query.set('before', params.before);
if (params?.after) query.set('after', params.after);
if (params?.sort) query.set('sort', params.sort);
const suffix = query.toString();
const url = `/api/v1/tasks/${taskId}/comments${suffix ? `?${suffix}` : ''}`;
return fetchJson<ListCommentsResponse>(url, options);
}
export async function listExecutors(options?: ApiRequestOptions): Promise<ListExecutorsResponse> {
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { detectPlatform, isMac, formatShortcut, matchesShortcut } from './utils';
import { detectPlatform, isMac, formatShortcut, matchesShortcut, shortcutToCodeMirrorKeybinding } from './utils';
import { KEYS, SHORTCUTS } from './constants';
import type { KeyboardShortcut } from './constants';
@@ -313,3 +313,21 @@ describe('matchesShortcut', () => {
});
});
describe('shortcutToCodeMirrorKeybinding', () => {
it('formats ctrlOrCmd shortcuts with Mod', () => {
expect(shortcutToCodeMirrorKeybinding(SHORTCUTS.SUBMIT)).toBe('Mod-Enter');
});
it('formats explicit modifiers', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrl: true, shift: true },
};
expect(shortcutToCodeMirrorKeybinding(shortcut)).toBe('Ctrl-Shift-s');
});
it('formats without modifiers', () => {
const shortcut: KeyboardShortcut = { key: KEYS.ESCAPE };
expect(shortcutToCodeMirrorKeybinding(shortcut)).toBe('Escape');
});
});
+20
View File
@@ -154,3 +154,23 @@ export function matchesShortcut(
return true;
}
/**
* Convert a keyboard shortcut definition to a CodeMirror keybinding string.
*/
export function shortcutToCodeMirrorKeybinding(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
if (shortcut.modifiers) {
if (shortcut.modifiers.ctrlOrCmd) {
parts.push('Mod');
} else {
if (shortcut.modifiers.ctrl) parts.push('Ctrl');
if (shortcut.modifiers.cmd) parts.push('Mod');
if (shortcut.modifiers.alt) parts.push('Alt');
if (shortcut.modifiers.shift) parts.push('Shift');
}
}
parts.push(shortcut.key);
return parts.join('-');
}
+7 -2
View File
@@ -34,7 +34,10 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial<AppState> {
};
}
export function taskToState(task: Task, comments?: Comment[]): Partial<AppState> {
export function taskToState(
task: Task,
comments?: { items: Comment[]; hasMore?: boolean; oldestCursor?: string | null }
): Partial<AppState> {
return {
tasks: {
activeTaskId: task.id,
@@ -42,8 +45,10 @@ export function taskToState(task: Task, comments?: Comment[]): Partial<AppState>
comments: comments
? {
taskId: task.id,
items: comments,
items: comments.items,
isLoading: false,
hasMore: comments.hasMore ?? false,
oldestCursor: comments.oldestCursor ?? (comments.items[0]?.id ?? null),
}
: undefined,
};
+60 -3
View File
@@ -147,6 +147,8 @@ export type CommentsState = {
taskId: string | null;
items: Comment[];
isLoading: boolean;
hasMore: boolean;
oldestCursor: string | null;
};
export type AppState = {
@@ -187,10 +189,16 @@ export type AppState = {
setUserSettings: (settings: UserSettingsState) => void;
setTerminalOutput: (terminalId: string, data: string) => void;
setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void;
setComments: (taskId: string, comments: Comment[]) => void;
setComments: (
taskId: string,
comments: Comment[],
meta?: { hasMore?: boolean; oldestCursor?: string | null }
) => void;
setCommentsTaskId: (taskId: string | null) => void;
addComment: (comment: Comment) => void;
updateComment: (comment: Comment) => void;
prependComments: (comments: Comment[], meta?: { hasMore?: boolean; oldestCursor?: string | null }) => void;
setCommentsMetadata: (meta: { hasMore?: boolean; isLoading?: boolean; oldestCursor?: string | null }) => void;
setCommentsLoading: (loading: boolean) => void;
setGitStatus: (taskId: string, gitStatus: Omit<GitStatusState, 'taskId'>) => void;
clearGitStatus: () => void;
@@ -231,7 +239,7 @@ const defaultState: AppState = {
timestamp: null,
},
connection: { status: 'disconnected', error: null },
comments: { taskId: null, items: [], isLoading: false },
comments: { taskId: null, items: [], isLoading: false, hasMore: false, oldestCursor: null },
hydrate: () => undefined,
setActiveWorkspace: () => undefined,
setWorkspaces: () => undefined,
@@ -254,6 +262,8 @@ const defaultState: AppState = {
setCommentsTaskId: () => undefined,
addComment: () => undefined,
updateComment: () => undefined,
prependComments: () => undefined,
setCommentsMetadata: () => undefined,
setCommentsLoading: () => undefined,
setGitStatus: () => undefined,
clearGitStatus: () => undefined,
@@ -286,6 +296,8 @@ function mergeInitialState(
| 'setCommentsTaskId'
| 'addComment'
| 'updateComment'
| 'prependComments'
| 'setCommentsMetadata'
| 'setCommentsLoading'
| 'setGitStatus'
| 'clearGitStatus'
@@ -432,11 +444,19 @@ export function createAppStore(initialState?: Partial<AppState>) {
draft.connection.status = status;
draft.connection.error = error;
}),
setComments: (taskId, comments) =>
setComments: (taskId, comments, meta) =>
set((draft) => {
draft.comments.taskId = taskId;
draft.comments.items = comments;
draft.comments.isLoading = false;
draft.comments.hasMore = meta?.hasMore ?? false;
if (meta?.oldestCursor !== undefined) {
draft.comments.oldestCursor = meta.oldestCursor;
} else if (comments.length) {
draft.comments.oldestCursor = comments[0].id;
} else {
draft.comments.oldestCursor = null;
}
}),
setCommentsTaskId: (taskId) =>
set((draft) => {
@@ -459,6 +479,9 @@ export function createAppStore(initialState?: Partial<AppState>) {
const exists = draft.comments.items.some((c) => c.id === comment.id);
if (!exists) {
draft.comments.items.push(comment);
if (!draft.comments.oldestCursor) {
draft.comments.oldestCursor = comment.id;
}
}
}
}),
@@ -472,6 +495,40 @@ export function createAppStore(initialState?: Partial<AppState>) {
}
}
}),
prependComments: (comments, meta) =>
set((draft) => {
if (draft.comments.items === null) {
draft.comments.items = [];
}
const existingIds = new Set(draft.comments.items.map((comment) => comment.id));
const incoming = comments.filter((comment) => !existingIds.has(comment.id));
if (incoming.length) {
draft.comments.items = [...incoming, ...draft.comments.items];
}
if (meta?.hasMore !== undefined) {
draft.comments.hasMore = meta.hasMore;
}
if (meta?.oldestCursor !== undefined) {
draft.comments.oldestCursor = meta.oldestCursor;
} else if (draft.comments.items.length) {
draft.comments.oldestCursor = draft.comments.items[0].id;
} else {
draft.comments.oldestCursor = null;
}
draft.comments.isLoading = false;
}),
setCommentsMetadata: (meta) =>
set((draft) => {
if (meta.hasMore !== undefined) {
draft.comments.hasMore = meta.hasMore;
}
if (meta.isLoading !== undefined) {
draft.comments.isLoading = meta.isLoading;
}
if (meta.oldestCursor !== undefined) {
draft.comments.oldestCursor = meta.oldestCursor;
}
}),
setCommentsLoading: (loading) =>
set((draft) => {
draft.comments.isLoading = loading;
+11 -1
View File
@@ -215,10 +215,20 @@ export type ListEnvironmentsResponse = {
export type ListCommentsResponse = {
comments: Comment[];
total: number;
has_more: boolean;
cursor: string;
};
export type CommentAuthorType = 'user' | 'agent';
export type CommentType = 'message' | 'content' | 'tool_call' | 'progress' | 'error' | 'status';
export type CommentType =
| 'message'
| 'content'
| 'tool_call'
| 'progress'
| 'error'
| 'status'
| 'thinking'
| 'todo';
export type Comment = {
id: string;
+2
View File
@@ -22,6 +22,8 @@
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/autocomplete": "^6.18.1",
"@codemirror/commands": "^6.7.0",
"@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.10",
"@dnd-kit/core": "^6.3.1",