From 004fb20618866799f2b780f0fb90e0ea69ff2916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 15 Jan 2026 01:54:19 +0000 Subject: [PATCH] feat: chat improved renderer and comments pagination --- .../internal/task/controller/comment.go | 31 +- apps/backend/internal/task/dto/dto.go | 2 + apps/backend/internal/task/dto/requests.go | 4 + .../task/handlers/comment_handlers.go | 65 +- .../internal/task/repository/interface.go | 8 + .../internal/task/repository/memory.go | 1067 +++++++++++++++++ .../internal/task/repository/memory_test.go | 514 ++++++++ .../internal/task/repository/sqlite.go | 99 +- .../internal/task/repository/sqlite_test.go | 54 + .../internal/task/service/constants.go | 6 + apps/backend/internal/task/service/service.go | 27 +- apps/web/agent_chat_plan.md | 931 ++++++++++++++ apps/web/app/globals.css | 135 +++ apps/web/app/task/[id]/page-client.tsx | 17 +- apps/web/app/task/[id]/page.tsx | 13 +- .../components/task/chat/message-renderer.tsx | 74 ++ .../task/chat/messages/chat-message.tsx | 42 + .../task/chat/messages/rich-blocks.tsx | 108 ++ .../task/chat/messages/status-message.tsx | 47 + .../task/chat/messages/thinking-message.tsx | 20 + .../task/chat/messages/todo-message.tsx | 57 + .../task/chat/messages/tool-call-message.tsx | 138 +++ .../task/chat/messages/typing-indicator.tsx | 23 + .../web/components/task/chat/todo-summary.tsx | 45 + apps/web/components/task/chat/types.ts | 33 + apps/web/components/task/task-chat-input.tsx | 185 +++ apps/web/components/task/task-chat-panel.tsx | 357 ++---- apps/web/hooks/use-lazy-load-comments.ts | 35 + apps/web/lib/http/index.ts | 15 +- apps/web/lib/keyboard/utils.test.ts | 20 +- apps/web/lib/keyboard/utils.ts | 20 + apps/web/lib/ssr/mapper.ts | 9 +- apps/web/lib/state/store.ts | 63 +- apps/web/lib/types/http.ts | 12 +- apps/web/package.json | 2 + 35 files changed, 4001 insertions(+), 277 deletions(-) create mode 100644 apps/backend/internal/task/repository/memory.go create mode 100644 apps/backend/internal/task/repository/memory_test.go create mode 100644 apps/backend/internal/task/service/constants.go create mode 100644 apps/web/agent_chat_plan.md create mode 100644 apps/web/components/task/chat/message-renderer.tsx create mode 100644 apps/web/components/task/chat/messages/chat-message.tsx create mode 100644 apps/web/components/task/chat/messages/rich-blocks.tsx create mode 100644 apps/web/components/task/chat/messages/status-message.tsx create mode 100644 apps/web/components/task/chat/messages/thinking-message.tsx create mode 100644 apps/web/components/task/chat/messages/todo-message.tsx create mode 100644 apps/web/components/task/chat/messages/tool-call-message.tsx create mode 100644 apps/web/components/task/chat/messages/typing-indicator.tsx create mode 100644 apps/web/components/task/chat/todo-summary.tsx create mode 100644 apps/web/components/task/chat/types.ts create mode 100644 apps/web/components/task/task-chat-input.tsx create mode 100644 apps/web/hooks/use-lazy-load-comments.ts diff --git a/apps/backend/internal/task/controller/comment.go b/apps/backend/internal/task/controller/comment.go index ce2e13dab1..1ea66920a1 100644 --- a/apps/backend/internal/task/controller/comment.go +++ b/apps/backend/internal/task/controller/comment.go @@ -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 } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 41965a57d7..df1ac67276 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -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 { diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 82f6a18dd8..2ef89d7cb7 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -262,6 +262,10 @@ type DeleteTaskRequest struct { type ListCommentsRequest struct { TaskID string + Limit int + Before string + After string + Sort string } type CreateCommentRequest struct { diff --git a/apps/backend/internal/task/handlers/comment_handlers.go b/apps/backend/internal/task/handlers/comment_handlers.go index ddc42e8911..e5bcead56c 100644 --- a/apps/backend/internal/task/handlers/comment_handlers.go +++ b/apps/backend/internal/task/handlers/comment_handlers.go @@ -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) diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index aa05e35c81..f7a3f98bc4 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -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 diff --git a/apps/backend/internal/task/repository/memory.go b/apps/backend/internal/task/repository/memory.go new file mode 100644 index 0000000000..b785a59528 --- /dev/null +++ b/apps/backend/internal/task/repository/memory.go @@ -0,0 +1,1067 @@ +package repository + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// MemoryRepository provides in-memory task storage operations +type MemoryRepository struct { + workspaces map[string]*models.Workspace + tasks map[string]*models.Task + boards map[string]*models.Board + columns map[string]*models.Column + comments map[string]*models.Comment + repositories map[string]*models.Repository + repoScripts map[string]*models.RepositoryScript + agentSessions map[string]*models.AgentSession + executors map[string]*models.Executor + environments map[string]*models.Environment + taskBoards map[string]map[string]struct{} + taskPlacements map[string]map[string]*taskPlacement + mu sync.RWMutex +} + +// Ensure MemoryRepository implements Repository interface +var _ Repository = (*MemoryRepository)(nil) + +// NewMemoryRepository creates a new in-memory task repository +func NewMemoryRepository() *MemoryRepository { + repo := &MemoryRepository{ + workspaces: make(map[string]*models.Workspace), + tasks: make(map[string]*models.Task), + boards: make(map[string]*models.Board), + columns: make(map[string]*models.Column), + comments: make(map[string]*models.Comment), + repositories: make(map[string]*models.Repository), + repoScripts: make(map[string]*models.RepositoryScript), + agentSessions: make(map[string]*models.AgentSession), + executors: make(map[string]*models.Executor), + environments: make(map[string]*models.Environment), + taskBoards: make(map[string]map[string]struct{}), + taskPlacements: make(map[string]map[string]*taskPlacement), + } + repo.seedDefaults() + return repo +} + +func (r *MemoryRepository) seedDefaults() { + now := time.Now().UTC() + r.executors[models.ExecutorIDLocalPC] = &models.Executor{ + ID: models.ExecutorIDLocalPC, + Name: "Local PC", + Type: models.ExecutorTypeLocalPC, + Status: models.ExecutorStatusActive, + IsSystem: true, + Config: map[string]string{}, + CreatedAt: now, + UpdatedAt: now, + } + r.executors[models.ExecutorIDLocalDocker] = &models.Executor{ + ID: models.ExecutorIDLocalDocker, + Name: "Local Docker", + Type: models.ExecutorTypeLocalDocker, + Status: models.ExecutorStatusActive, + IsSystem: false, + Config: map[string]string{"docker_host": "unix:///var/run/docker.sock"}, + CreatedAt: now, + UpdatedAt: now, + } + r.executors[models.ExecutorIDRemoteDocker] = &models.Executor{ + ID: models.ExecutorIDRemoteDocker, + Name: "Remote Docker", + Type: models.ExecutorTypeRemoteDocker, + Status: models.ExecutorStatusDisabled, + IsSystem: false, + Config: map[string]string{}, + CreatedAt: now, + UpdatedAt: now, + } + r.environments[models.EnvironmentIDLocal] = &models.Environment{ + ID: models.EnvironmentIDLocal, + Name: "Local", + Kind: models.EnvironmentKindLocalPC, + WorktreeRoot: "~/kandev", + CreatedAt: now, + UpdatedAt: now, + } +} + +type taskPlacement struct { + boardID string + columnID string + position int +} + +// Close is a no-op for in-memory repository +func (r *MemoryRepository) Close() error { + return nil +} + +// Workspace operations + +// CreateWorkspace creates a new workspace +func (r *MemoryRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error { + r.mu.Lock() + defer r.mu.Unlock() + + if workspace.ID == "" { + workspace.ID = uuid.New().String() + } + now := time.Now().UTC() + workspace.CreatedAt = now + workspace.UpdatedAt = now + + r.workspaces[workspace.ID] = workspace + return nil +} + +// GetWorkspace retrieves a workspace by ID +func (r *MemoryRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + workspace, ok := r.workspaces[id] + if !ok { + return nil, fmt.Errorf("workspace not found: %s", id) + } + return workspace, nil +} + +// UpdateWorkspace updates an existing workspace +func (r *MemoryRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.workspaces[workspace.ID]; !ok { + return fmt.Errorf("workspace not found: %s", workspace.ID) + } + workspace.UpdatedAt = time.Now().UTC() + r.workspaces[workspace.ID] = workspace + return nil +} + +// DeleteWorkspace deletes a workspace by ID +func (r *MemoryRepository) DeleteWorkspace(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.workspaces[id]; !ok { + return fmt.Errorf("workspace not found: %s", id) + } + delete(r.workspaces, id) + return nil +} + +// ListWorkspaces returns all workspaces +func (r *MemoryRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Workspace, 0, len(r.workspaces)) + for _, workspace := range r.workspaces { + result = append(result, workspace) + } + return result, nil +} + +// Task operations + +// CreateTask creates a new task +func (r *MemoryRepository) CreateTask(ctx context.Context, task *models.Task) error { + r.mu.Lock() + defer r.mu.Unlock() + + if task.ID == "" { + task.ID = uuid.New().String() + } + now := time.Now().UTC() + task.CreatedAt = now + task.UpdatedAt = now + + r.tasks[task.ID] = task + if task.BoardID != "" { + r.addTaskPlacement(task.ID, task.BoardID, task.ColumnID, task.Position) + } + return nil +} + +// GetTask retrieves a task by ID +func (r *MemoryRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + task, ok := r.tasks[id] + if !ok { + return nil, fmt.Errorf("task not found: %s", id) + } + return task, nil +} + +// UpdateTask updates an existing task +func (r *MemoryRepository) UpdateTask(ctx context.Context, task *models.Task) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.tasks[task.ID]; !ok { + return fmt.Errorf("task not found: %s", task.ID) + } + task.UpdatedAt = time.Now().UTC() + r.tasks[task.ID] = task + if task.BoardID != "" { + r.addTaskPlacement(task.ID, task.BoardID, task.ColumnID, task.Position) + } + return nil +} + +// DeleteTask deletes a task by ID +func (r *MemoryRepository) DeleteTask(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.tasks[id]; !ok { + return fmt.Errorf("task not found: %s", id) + } + delete(r.tasks, id) + delete(r.taskBoards, id) + delete(r.taskPlacements, id) + return nil +} + +// ListTasks returns all tasks for a board +func (r *MemoryRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.Task + for _, task := range r.tasks { + placement, ok := r.getTaskPlacement(task.ID, boardID) + if !ok { + continue + } + copy := *task + copy.BoardID = placement.boardID + copy.ColumnID = placement.columnID + copy.Position = placement.position + result = append(result, ©) + } + return result, nil +} + +// ListTasksByColumn returns all tasks in a column +func (r *MemoryRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.Task + for _, task := range r.tasks { + placements, ok := r.taskPlacements[task.ID] + if !ok { + continue + } + for _, placement := range placements { + if placement.columnID != columnID { + continue + } + copy := *task + copy.BoardID = placement.boardID + copy.ColumnID = placement.columnID + copy.Position = placement.position + result = append(result, ©) + } + } + return result, nil +} + +// UpdateTaskState updates the state of a task +func (r *MemoryRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { + r.mu.Lock() + defer r.mu.Unlock() + + task, ok := r.tasks[id] + if !ok { + return fmt.Errorf("task not found: %s", id) + } + task.State = state + task.UpdatedAt = time.Now().UTC() + return nil +} + +// AddTaskToBoard adds a task to a board with placement +func (r *MemoryRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.tasks[taskID]; !ok { + return fmt.Errorf("task not found: %s", taskID) + } + r.addTaskPlacement(taskID, boardID, columnID, position) + return nil +} + +// RemoveTaskFromBoard removes a task from a board +func (r *MemoryRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.tasks[taskID]; !ok { + return fmt.Errorf("task not found: %s", taskID) + } + if placements, ok := r.taskPlacements[taskID]; ok { + delete(placements, boardID) + } + if boards, ok := r.taskBoards[taskID]; ok { + delete(boards, boardID) + } + return nil +} + +// Board operations + +// CreateBoard creates a new board +func (r *MemoryRepository) CreateBoard(ctx context.Context, board *models.Board) error { + r.mu.Lock() + defer r.mu.Unlock() + + if board.ID == "" { + board.ID = uuid.New().String() + } + now := time.Now().UTC() + board.CreatedAt = now + board.UpdatedAt = now + + r.boards[board.ID] = board + return nil +} + +// GetBoard retrieves a board by ID +func (r *MemoryRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + board, ok := r.boards[id] + if !ok { + return nil, fmt.Errorf("board not found: %s", id) + } + return board, nil +} + +// UpdateBoard updates an existing board +func (r *MemoryRepository) UpdateBoard(ctx context.Context, board *models.Board) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.boards[board.ID]; !ok { + return fmt.Errorf("board not found: %s", board.ID) + } + board.UpdatedAt = time.Now().UTC() + r.boards[board.ID] = board + return nil +} + +// DeleteBoard deletes a board by ID +func (r *MemoryRepository) DeleteBoard(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.boards[id]; !ok { + return fmt.Errorf("board not found: %s", id) + } + delete(r.boards, id) + return nil +} + +// ListBoards returns all boards +func (r *MemoryRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Board, 0, len(r.boards)) + for _, board := range r.boards { + if workspaceID != "" && board.WorkspaceID != workspaceID { + continue + } + result = append(result, board) + } + return result, nil +} + +// Column operations + +// CreateColumn creates a new column +func (r *MemoryRepository) CreateColumn(ctx context.Context, column *models.Column) error { + r.mu.Lock() + defer r.mu.Unlock() + + if column.ID == "" { + column.ID = uuid.New().String() + } + now := time.Now().UTC() + column.CreatedAt = now + column.UpdatedAt = now + + r.columns[column.ID] = column + return nil +} + +// GetColumn retrieves a column by ID +func (r *MemoryRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + column, ok := r.columns[id] + if !ok { + return nil, fmt.Errorf("column not found: %s", id) + } + return column, nil +} + +// GetColumnByState retrieves a column by board ID and state +func (r *MemoryRepository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, column := range r.columns { + if column.BoardID == boardID && column.State == state { + return column, nil + } + } + return nil, fmt.Errorf("column not found for board %s with state %s", boardID, state) +} + +// UpdateColumn updates an existing column +func (r *MemoryRepository) UpdateColumn(ctx context.Context, column *models.Column) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.columns[column.ID]; !ok { + return fmt.Errorf("column not found: %s", column.ID) + } + column.UpdatedAt = time.Now().UTC() + r.columns[column.ID] = column + return nil +} + +// DeleteColumn deletes a column by ID +func (r *MemoryRepository) DeleteColumn(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.columns[id]; !ok { + return fmt.Errorf("column not found: %s", id) + } + delete(r.columns, id) + return nil +} + +// ListColumns returns all columns for a board +func (r *MemoryRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.Column + for _, column := range r.columns { + if column.BoardID == boardID { + result = append(result, column) + } + } + return result, nil +} + +// Repository operations + +func (r *MemoryRepository) CreateRepository(ctx context.Context, repository *models.Repository) error { + r.mu.Lock() + defer r.mu.Unlock() + + if repository.ID == "" { + repository.ID = uuid.New().String() + } + now := time.Now().UTC() + repository.CreatedAt = now + repository.UpdatedAt = now + r.repositories[repository.ID] = repository + return nil +} + +func (r *MemoryRepository) GetRepository(ctx context.Context, id string) (*models.Repository, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + repository, ok := r.repositories[id] + if !ok { + return nil, fmt.Errorf("repository not found: %s", id) + } + return repository, nil +} + +func (r *MemoryRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.repositories[repository.ID]; !ok { + return fmt.Errorf("repository not found: %s", repository.ID) + } + repository.UpdatedAt = time.Now().UTC() + r.repositories[repository.ID] = repository + return nil +} + +func (r *MemoryRepository) DeleteRepository(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.repositories[id]; !ok { + return fmt.Errorf("repository not found: %s", id) + } + delete(r.repositories, id) + for scriptID, script := range r.repoScripts { + if script.RepositoryID == id { + delete(r.repoScripts, scriptID) + } + } + return nil +} + +func (r *MemoryRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Repository, 0) + for _, repository := range r.repositories { + if repository.WorkspaceID == workspaceID { + result = append(result, repository) + } + } + return result, nil +} + +// Repository script operations + +func (r *MemoryRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + r.mu.Lock() + defer r.mu.Unlock() + + if script.ID == "" { + script.ID = uuid.New().String() + } + now := time.Now().UTC() + script.CreatedAt = now + script.UpdatedAt = now + r.repoScripts[script.ID] = script + return nil +} + +func (r *MemoryRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + script, ok := r.repoScripts[id] + if !ok { + return nil, fmt.Errorf("repository script not found: %s", id) + } + return script, nil +} + +func (r *MemoryRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.repoScripts[script.ID]; !ok { + return fmt.Errorf("repository script not found: %s", script.ID) + } + script.UpdatedAt = time.Now().UTC() + r.repoScripts[script.ID] = script + return nil +} + +func (r *MemoryRepository) DeleteRepositoryScript(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.repoScripts[id]; !ok { + return fmt.Errorf("repository script not found: %s", id) + } + delete(r.repoScripts, id) + return nil +} + +func (r *MemoryRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.RepositoryScript, 0) + for _, script := range r.repoScripts { + if script.RepositoryID == repositoryID { + result = append(result, script) + } + } + return result, nil +} + +func (r *MemoryRepository) addTaskPlacement(taskID, boardID, columnID string, position int) { + if _, ok := r.taskBoards[taskID]; !ok { + r.taskBoards[taskID] = make(map[string]struct{}) + } + r.taskBoards[taskID][boardID] = struct{}{} + + if _, ok := r.taskPlacements[taskID]; !ok { + r.taskPlacements[taskID] = make(map[string]*taskPlacement) + } + r.taskPlacements[taskID][boardID] = &taskPlacement{ + boardID: boardID, + columnID: columnID, + position: position, + } +} + +func (r *MemoryRepository) getTaskPlacement(taskID, boardID string) (*taskPlacement, bool) { + placements, ok := r.taskPlacements[taskID] + if !ok { + return nil, false + } + placement, ok := placements[boardID] + return placement, ok +} + +// Comment operations + +// CreateComment creates a new comment +func (r *MemoryRepository) CreateComment(ctx context.Context, comment *models.Comment) error { + r.mu.Lock() + defer r.mu.Unlock() + + if comment.ID == "" { + comment.ID = uuid.New().String() + } + if comment.CreatedAt.IsZero() { + comment.CreatedAt = time.Now().UTC() + } + if comment.AuthorType == "" { + comment.AuthorType = models.CommentAuthorUser + } + if comment.Type == "" { + comment.Type = models.CommentTypeMessage + } + + r.comments[comment.ID] = comment + return nil +} + +// GetComment retrieves a comment by ID +func (r *MemoryRepository) GetComment(ctx context.Context, id string) (*models.Comment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + comment, ok := r.comments[id] + if !ok { + return nil, fmt.Errorf("comment not found: %s", id) + } + return comment, nil +} + +// GetCommentByToolCallID retrieves a comment by task ID and tool_call_id in metadata. +func (r *MemoryRepository) GetCommentByToolCallID(ctx context.Context, taskID, toolCallID string) (*models.Comment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, comment := range r.comments { + if comment.TaskID != taskID || comment.Metadata == nil { + continue + } + if value, ok := comment.Metadata["tool_call_id"]; ok { + if toolCallIDValue, ok := value.(string); ok && toolCallIDValue == toolCallID { + return comment, nil + } + } + } + return nil, fmt.Errorf("comment not found: %s", toolCallID) +} + +// UpdateComment updates an existing comment. +func (r *MemoryRepository) UpdateComment(ctx context.Context, comment *models.Comment) error { + r.mu.Lock() + defer r.mu.Unlock() + + existing, ok := r.comments[comment.ID] + if !ok { + return fmt.Errorf("comment not found: %s", comment.ID) + } + comment.CreatedAt = existing.CreatedAt + r.comments[comment.ID] = comment + return nil +} + +// ListComments returns all comments for a task ordered by creation time. +func (r *MemoryRepository) ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.Comment + for _, comment := range r.comments { + if comment.TaskID == taskID { + result = append(result, comment) + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].CreatedAt.Equal(result[j].CreatedAt) { + return result[i].ID < result[j].ID + } + return result[i].CreatedAt.Before(result[j].CreatedAt) + }) + return result, nil +} + +// ListCommentsPaginated returns comments for a task ordered by creation time with pagination. +func (r *MemoryRepository) ListCommentsPaginated(ctx context.Context, taskID string, opts ListCommentsOptions) ([]*models.Comment, bool, error) { + limit := opts.Limit + if limit < 0 { + limit = 0 + } + + r.mu.RLock() + defer r.mu.RUnlock() + + var cursor *models.Comment + if opts.Before != "" { + comment, ok := r.comments[opts.Before] + if !ok { + return nil, false, fmt.Errorf("comment not found: %s", opts.Before) + } + if comment.TaskID != taskID { + return nil, false, fmt.Errorf("comment cursor not found: %s", opts.Before) + } + cursor = comment + } + if opts.After != "" { + comment, ok := r.comments[opts.After] + if !ok { + return nil, false, fmt.Errorf("comment not found: %s", opts.After) + } + if comment.TaskID != taskID { + return nil, false, fmt.Errorf("comment cursor not found: %s", opts.After) + } + cursor = comment + } + + var result []*models.Comment + for _, comment := range r.comments { + if comment.TaskID == taskID { + result = append(result, comment) + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].CreatedAt.Equal(result[j].CreatedAt) { + return result[i].ID < result[j].ID + } + return result[i].CreatedAt.Before(result[j].CreatedAt) + }) + + if cursor != nil { + filtered := result[:0] + for _, comment := range result { + if opts.Before != "" { + if comment.CreatedAt.Before(cursor.CreatedAt) || (comment.CreatedAt.Equal(cursor.CreatedAt) && comment.ID < cursor.ID) { + filtered = append(filtered, comment) + } + } else if opts.After != "" { + if comment.CreatedAt.After(cursor.CreatedAt) || (comment.CreatedAt.Equal(cursor.CreatedAt) && comment.ID > cursor.ID) { + filtered = append(filtered, comment) + } + } + } + result = filtered + } + + if strings.EqualFold(opts.Sort, "desc") { + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + } + + hasMore := false + if limit > 0 && len(result) > limit { + hasMore = true + result = result[:limit] + } + return result, hasMore, nil +} + +// DeleteComment deletes a comment by ID +func (r *MemoryRepository) DeleteComment(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.comments[id]; !ok { + return fmt.Errorf("comment not found: %s", id) + } + delete(r.comments, id) + return nil +} + +// AgentSession operations + +// CreateAgentSession creates a new agent session +func (r *MemoryRepository) CreateAgentSession(ctx context.Context, session *models.AgentSession) error { + r.mu.Lock() + defer r.mu.Unlock() + + if session.ID == "" { + session.ID = uuid.New().String() + } + now := time.Now().UTC() + if session.StartedAt.IsZero() { + session.StartedAt = now + } + session.UpdatedAt = now + + r.agentSessions[session.ID] = session + return nil +} + +// GetAgentSession retrieves an agent session by ID +func (r *MemoryRepository) GetAgentSession(ctx context.Context, id string) (*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + session, ok := r.agentSessions[id] + if !ok { + return nil, fmt.Errorf("agent session not found: %s", id) + } + return session, nil +} + +// GetAgentSessionByTaskID retrieves the most recent agent session for a task +func (r *MemoryRepository) GetAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var latest *models.AgentSession + for _, session := range r.agentSessions { + if session.TaskID == taskID { + if latest == nil || session.StartedAt.After(latest.StartedAt) { + latest = session + } + } + } + if latest == nil { + return nil, fmt.Errorf("no agent session found for task: %s", taskID) + } + return latest, nil +} + +// GetActiveAgentSessionByTaskID retrieves the active agent session for a task +func (r *MemoryRepository) GetActiveAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, session := range r.agentSessions { + if session.TaskID == taskID && (session.Status == models.AgentSessionStatusRunning || session.Status == models.AgentSessionStatusWaiting || session.Status == models.AgentSessionStatusPending) { + return session, nil + } + } + return nil, fmt.Errorf("no active agent session found for task: %s", taskID) +} + +// UpdateAgentSession updates an existing agent session +func (r *MemoryRepository) UpdateAgentSession(ctx context.Context, session *models.AgentSession) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.agentSessions[session.ID]; !ok { + return fmt.Errorf("agent session not found: %s", session.ID) + } + session.UpdatedAt = time.Now().UTC() + r.agentSessions[session.ID] = session + return nil +} + +// UpdateAgentSessionStatus updates the status of an agent session +func (r *MemoryRepository) UpdateAgentSessionStatus(ctx context.Context, id string, status models.AgentSessionStatus, errorMessage string) error { + r.mu.Lock() + defer r.mu.Unlock() + + session, ok := r.agentSessions[id] + if !ok { + return fmt.Errorf("agent session not found: %s", id) + } + session.Status = status + session.ErrorMessage = errorMessage + session.UpdatedAt = time.Now().UTC() + if status == models.AgentSessionStatusCompleted || status == models.AgentSessionStatusFailed || status == models.AgentSessionStatusStopped { + now := time.Now().UTC() + session.CompletedAt = &now + } + return nil +} + +// ListAgentSessions returns all agent sessions for a task +func (r *MemoryRepository) ListAgentSessions(ctx context.Context, taskID string) ([]*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.AgentSession + for _, session := range r.agentSessions { + if session.TaskID == taskID { + result = append(result, session) + } + } + return result, nil +} + +// ListActiveAgentSessions returns all active agent sessions +func (r *MemoryRepository) ListActiveAgentSessions(ctx context.Context) ([]*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.AgentSession + for _, session := range r.agentSessions { + if session.Status == models.AgentSessionStatusRunning || session.Status == models.AgentSessionStatusWaiting || session.Status == models.AgentSessionStatusPending { + result = append(result, session) + } + } + return result, nil +} + +// DeleteAgentSession deletes an agent session by ID +func (r *MemoryRepository) DeleteAgentSession(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.agentSessions[id]; !ok { + return fmt.Errorf("agent session not found: %s", id) + } + delete(r.agentSessions, id) + return nil +} + +// Executor operations + +func (r *MemoryRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { + r.mu.Lock() + defer r.mu.Unlock() + + if executor.ID == "" { + executor.ID = uuid.New().String() + } + now := time.Now().UTC() + executor.CreatedAt = now + executor.UpdatedAt = now + + r.executors[executor.ID] = executor + return nil +} + +func (r *MemoryRepository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + executor, ok := r.executors[id] + if !ok { + return nil, fmt.Errorf("executor not found: %s", id) + } + return executor, nil +} + +func (r *MemoryRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.executors[executor.ID]; !ok { + return fmt.Errorf("executor not found: %s", executor.ID) + } + executor.UpdatedAt = time.Now().UTC() + r.executors[executor.ID] = executor + return nil +} + +func (r *MemoryRepository) DeleteExecutor(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.executors[id]; !ok { + return fmt.Errorf("executor not found: %s", id) + } + delete(r.executors, id) + return nil +} + +func (r *MemoryRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Executor, 0, len(r.executors)) + for _, executor := range r.executors { + result = append(result, executor) + } + return result, nil +} + +// Environment operations + +func (r *MemoryRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { + r.mu.Lock() + defer r.mu.Unlock() + + if environment.ID == "" { + environment.ID = uuid.New().String() + } + now := time.Now().UTC() + environment.CreatedAt = now + environment.UpdatedAt = now + r.environments[environment.ID] = environment + return nil +} + +func (r *MemoryRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + environment, ok := r.environments[id] + if !ok { + return nil, fmt.Errorf("environment not found: %s", id) + } + return environment, nil +} + +func (r *MemoryRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.environments[environment.ID]; !ok { + return fmt.Errorf("environment not found: %s", environment.ID) + } + environment.UpdatedAt = time.Now().UTC() + r.environments[environment.ID] = environment + return nil +} + +func (r *MemoryRepository) DeleteEnvironment(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.environments[id]; !ok { + return fmt.Errorf("environment not found: %s", id) + } + delete(r.environments, id) + return nil +} + +func (r *MemoryRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Environment, 0, len(r.environments)) + for _, environment := range r.environments { + result = append(result, environment) + } + return result, nil +} diff --git a/apps/backend/internal/task/repository/memory_test.go b/apps/backend/internal/task/repository/memory_test.go new file mode 100644 index 0000000000..da1659aec9 --- /dev/null +++ b/apps/backend/internal/task/repository/memory_test.go @@ -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) + } +} diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index ea8211fd79..5fa3e1c3f9 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -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 diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index d62febbadd..972b0eb175 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -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() diff --git a/apps/backend/internal/task/service/constants.go b/apps/backend/internal/task/service/constants.go new file mode 100644 index 0000000000..34d8c5eea5 --- /dev/null +++ b/apps/backend/internal/task/service/constants.go @@ -0,0 +1,6 @@ +package service + +const ( + DefaultCommentsPageSize = 10 + MaxCommentsPageSize = 100 +) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 2a395560f1..be4220c8ff 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -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 { diff --git a/apps/web/agent_chat_plan.md b/apps/web/agent_chat_plan.md new file mode 100644 index 0000000000..d411f8d56a --- /dev/null +++ b/apps/web/agent_chat_plan.md @@ -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(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; + expandedMessages: Set; + 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 ' }, + { label: '/edit', detail: 'Edit a file', info: 'Usage: /edit ' }, + { label: '/bash', detail: 'Run bash command', info: 'Usage: /bash ' }, + { label: '/grep', detail: 'Search in files', info: 'Usage: /grep ' }, + { 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; + 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 +} + onError={(error) => console.error('Message render error:', error)} +> + + +``` + +**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 + +--- diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2f6a165c38..7cedbeca6c 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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,"); + -webkit-mask-image: url("data:image/svg+xml;utf8,"); +} + +.cm-completionIcon-mention { + mask-image: url("data:image/svg+xml;utf8,"); + -webkit-mask-image: url("data:image/svg+xml;utf8,"); +} + +.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)); +} diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 5d1e5d156b..2f3e2ddf81 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -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); } diff --git a/apps/web/app/task/[id]/page.tsx b/apps/web/app/task/[id]/page.tsx index 0a6c219adc..482a68e816 100644 --- a/apps/web/app/task/[id]/page.tsx +++ b/apps/web/app/task/[id]/page.tsx @@ -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; diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx new file mode 100644 index 0000000000..96597c8ad5 --- /dev/null +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -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) => , + }, + { + matches: (comment) => comment.type === 'todo', + render: (comment) => , + }, + { + matches: (comment) => comment.type === 'tool_call', + render: (comment) => , + }, + { + matches: (comment) => comment.type === 'error' || comment.type === 'status' || comment.type === 'progress', + render: (comment) => , + }, + { + matches: () => true, + render: (comment, ctx) => { + if (ctx.isTaskDescription) { + return ( + + ); + } + if (comment.author_type === 'user') { + return ( + + ); + } + return ( + + ); + }, + }, +]; + +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); +} diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx new file mode 100644 index 0000000000..2945935bac --- /dev/null +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -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 ( +
+

+ {label} + {comment.requests_input ? ( + + Needs input + + ) : null} +

+ {isUser ? ( +

{comment.content || '(empty)'}

+ ) : ( +
+ + {comment.content || '(empty)'} + + {showRichBlocks ? : null} +
+ )} +
+ ); +} diff --git a/apps/web/components/task/chat/messages/rich-blocks.tsx b/apps/web/components/task/chat/messages/rich-blocks.tsx new file mode 100644 index 0000000000..06def6aca2 --- /dev/null +++ b/apps/web/components/task/chat/messages/rich-blocks.tsx @@ -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; + 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 && ( +
+
+ + Thinking +
+
{metadata.thinking}
+
+ )} + {todoItems.length > 0 && ( +
+
+ + Todos +
+
+ {todoItems.map((todo) => ( +
+ + + {todo.text} + +
+ ))} +
+
+ )} + {diffPayload && ( +
+
+ + Diff +
+ +
+ )} + {!diffPayload && diffText && ( +
+
+ + Diff +
+
+            {diffText}
+          
+
+ )} + + ); +} diff --git a/apps/web/components/task/chat/messages/status-message.tsx b/apps/web/components/task/chat/messages/status-message.tsx new file mode 100644 index 0000000000..2ef710d65b --- /dev/null +++ b/apps/web/components/task/chat/messages/status-message.tsx @@ -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 ( +
+
+ + {comment.type === 'error' ? 'Error' : 'Status'} +
+
{message}
+ {progress !== null && ( +
+
+ {statusLine ?? 'Progress'} + {Math.round(progress)}% +
+
+
+
+
+ )} +
+ ); +} diff --git a/apps/web/components/task/chat/messages/thinking-message.tsx b/apps/web/components/task/chat/messages/thinking-message.tsx new file mode 100644 index 0000000000..9d05cdc89a --- /dev/null +++ b/apps/web/components/task/chat/messages/thinking-message.tsx @@ -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 ( +
+
+ + Thinking +
+
{text}
+
+ ); +} diff --git a/apps/web/components/task/chat/messages/todo-message.tsx b/apps/web/components/task/chat/messages/todo-message.tsx new file mode 100644 index 0000000000..bc66f5845e --- /dev/null +++ b/apps/web/components/task/chat/messages/todo-message.tsx @@ -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 ( +
+
+ + Todos +
+
+
+ {completed} of {todoItems.length} done + {progress}% +
+
+
+
+
+
+ {todoItems.map((todo) => ( +
+ + + {todo.text} + +
+ ))} +
+
+ ); +} diff --git a/apps/web/components/task/chat/messages/tool-call-message.tsx b/apps/web/components/task/chat/messages/tool-call-message.tsx new file mode 100644 index 0000000000..6a77017b1d --- /dev/null +++ b/apps/web/components/task/chat/messages/tool-call-message.tsx @@ -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 ; + } + if (name === 'read' || name.includes('view') || name.includes('read')) { + return ; + } + if (name === 'search' || name.includes('search') || name.includes('find') || name.includes('retrieval')) { + return ; + } + if (name === 'execute' || name.includes('terminal') || name.includes('exec') || name.includes('launch') || name.includes('process')) { + return ; + } + if (name === 'delete' || name === 'move' || name.includes('file') || name.includes('create')) { + return ; + } + return ; +} + +function getStatusIcon(status?: string) { + switch (status) { + case 'complete': + return ; + case 'error': + return ; + case 'running': + return ; + 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 ( +
+ + + {isExpanded && hasDetails && ( +
+ {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 ( +
+ {key}: + {isLongValue ? ( +
+                    {strValue}
+                  
+ ) : ( + {strValue} + )} +
+ ); + })} + {result && ( +
+ Result: +
+                {result}
+              
+
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/components/task/chat/messages/typing-indicator.tsx b/apps/web/components/task/chat/messages/typing-indicator.tsx new file mode 100644 index 0000000000..4871f1a062 --- /dev/null +++ b/apps/web/components/task/chat/messages/typing-indicator.tsx @@ -0,0 +1,23 @@ +'use client'; + +export function TypingIndicator() { + return ( +
+
+ Agent + + + +
+
+ ); +} diff --git a/apps/web/components/task/chat/todo-summary.tsx b/apps/web/components/task/chat/todo-summary.tsx new file mode 100644 index 0000000000..40b92e4bab --- /dev/null +++ b/apps/web/components/task/chat/todo-summary.tsx @@ -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 ( +
+
+ Todos ({todos.length}) + +
+
+ {todos.map((todo) => ( +
+ {todo.done ? ( + + ) : ( + + )} + + {todo.text} + +
+ ))} +
+
+ {completed} of {todos.length} completed +
+
+ ); +} diff --git a/apps/web/components/task/chat/types.ts b/apps/web/components/task/chat/types.ts new file mode 100644 index 0000000000..1c7a0ba7b5 --- /dev/null +++ b/apps/web/components/task/chat/types.ts @@ -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; + 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 }; +}; diff --git a/apps/web/components/task/task-chat-input.tsx b/apps/web/components/task/task-chat-input.tsx new file mode 100644 index 0000000000..803133830c --- /dev/null +++ b/apps/web/components/task/task-chat-input.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 2dc6c05358..737248a2ce 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -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; - 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 ; - } - if (name === 'read' || name.includes('view') || name.includes('read')) { - return ; - } - if (name === 'search' || name.includes('search') || name.includes('find') || name.includes('retrieval')) { - return ; - } - if (name === 'execute' || name.includes('terminal') || name.includes('exec') || name.includes('launch') || name.includes('process')) { - return ; - } - if (name === 'delete' || name === 'move' || name.includes('file') || name.includes('create')) { - return ; - } - return ; -} - -function getStatusIcon(status?: string) { - switch (status) { - case 'complete': - return ; - case 'error': - return ; - case 'running': - return ; - 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 ( -
- - - {isExpanded && hasDetails && ( -
- {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 ( -
- {key}: - {isLongValue ? ( -
-                    {strValue}
-                  
- ) : ( - {strValue} - )} -
- ); - })} - {result && ( -
- Result: -
-                {result}
-              
-
- )} -
- )} -
- ); -} - -function TypingIndicator() { - return ( -
-
- Agent - - - -
-
- ); -} - 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(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 ; - } - - 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 ( -
-

- {label} -

- {isUser ? ( -

{formatContent(comment)}

- ) : ( -
- - {formatContent(comment)} - -
- )} -
- ); - }; return ( <>
- {(isLoading || commentsLoading) && ( + {isLoadingMore && hasMore && ( +
+ Loading older messages... +
+ )} + {(isLoading || isInitialLoading) && (
Loading comments...
)} - {!isLoading && !commentsLoading && allMessages.length === 0 && ( + {!isLoading && !isInitialLoading && allMessages.length === 0 && (
No messages yet. Start the conversation!
)} - {!isLoading && !commentsLoading && itemCount > 0 && ( + {!isLoading && !isInitialLoading && itemCount > 0 && (
{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)` }} > -
- {isTypingRow ? : renderMessage(message)} -
+
+ {isTypingRow ? ( + + ) : ( + + )} +
); })} @@ -376,16 +230,15 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD )}
-