feat: chat improved renderer and comments pagination
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('-');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user