diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index cd2353b210..1109f4c257 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -666,6 +666,10 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio if running, err := e.repo.GetExecutorRunningBySessionID(ctx, session.ID); err == nil && running != nil { if running.ResumeToken != "" && startAgent { req.ACPSessionID = running.ResumeToken + // Clear TaskDescription so the agent doesn't receive an automatic prompt on resume. + // The session context is restored via ACP session/load; sending a prompt here would + // cause the agent to start working immediately instead of waiting for user input. + req.TaskDescription = "" e.logger.Info("found resume token for session resumption", zap.String("task_id", task.ID), zap.String("session_id", session.ID)) diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 6e77f07159..9cbc78f0e0 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -427,6 +427,17 @@ func (m *mockRepository) GetTaskPlan(ctx context.Context, taskID string) (*model func (m *mockRepository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { return nil } func (m *mockRepository) DeleteTaskPlan(ctx context.Context, taskID string) error { return nil } +// Session File Review operations +func (m *mockRepository) UpsertSessionFileReview(ctx context.Context, review *models.SessionFileReview) error { + return nil +} +func (m *mockRepository) GetSessionFileReviews(ctx context.Context, sessionID string) ([]*models.SessionFileReview, error) { + return nil, nil +} +func (m *mockRepository) DeleteSessionFileReviews(ctx context.Context, sessionID string) error { + return nil +} + // Close operation func (m *mockRepository) Close() error { return nil } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 4cfe455b87..a320df2200 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -361,6 +361,15 @@ func (m *mockRepository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPl func (m *mockRepository) DeleteTaskPlan(ctx context.Context, taskID string) error { return nil } +func (m *mockRepository) UpsertSessionFileReview(ctx context.Context, review *models.SessionFileReview) error { + return nil +} +func (m *mockRepository) GetSessionFileReviews(ctx context.Context, sessionID string) ([]*models.SessionFileReview, error) { + return nil, nil +} +func (m *mockRepository) DeleteSessionFileReviews(ctx context.Context, sessionID string) error { + return nil +} func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service { log, err := logger.NewLogger(logger.LoggingConfig{ diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 28e40e4bf1..af36bd9023 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/gin-gonic/gin" @@ -14,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/controller" "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/task/service" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -86,6 +88,10 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionSessionGitSnapshots, h.wsGetGitSnapshots) dispatcher.RegisterFunc(ws.ActionSessionGitCommits, h.wsGetSessionCommits) dispatcher.RegisterFunc(ws.ActionSessionCumulativeDiff, h.wsGetCumulativeDiff) + // Session file review handlers + dispatcher.RegisterFunc(ws.ActionSessionFileReviewGet, h.wsGetSessionFileReviews) + dispatcher.RegisterFunc(ws.ActionSessionFileReviewUpdate, h.wsUpdateSessionFileReview) + dispatcher.RegisterFunc(ws.ActionSessionFileReviewReset, h.wsResetSessionFileReviews) // Task plan handlers dispatcher.RegisterFunc(ws.ActionTaskPlanCreate, h.wsCreateTaskPlan) dispatcher.RegisterFunc(ws.ActionTaskPlanGet, h.wsGetTaskPlan) @@ -769,6 +775,97 @@ func (h *TaskHandlers) wsGetCumulativeDiff(ctx context.Context, msg *ws.Message) } +// Session File Review Handlers + +type wsGetSessionFileReviewsRequest struct { + SessionID string `json:"session_id"` +} + +func (h *TaskHandlers) wsGetSessionFileReviews(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetSessionFileReviewsRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + reviews, err := h.repo.GetSessionFileReviews(ctx, req.SessionID) + if err != nil { + h.logger.Error("failed to get session file reviews", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get session file reviews", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "session_id": req.SessionID, + "reviews": reviews, + }) +} + +type wsUpdateSessionFileReviewRequest struct { + SessionID string `json:"session_id"` + FilePath string `json:"file_path"` + Reviewed bool `json:"reviewed"` + DiffHash string `json:"diff_hash"` +} + +func (h *TaskHandlers) wsUpdateSessionFileReview(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateSessionFileReviewRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + if req.FilePath == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "file_path is required", nil) + } + + review := &models.SessionFileReview{ + SessionID: req.SessionID, + FilePath: req.FilePath, + Reviewed: req.Reviewed, + DiffHash: req.DiffHash, + } + if req.Reviewed { + now := time.Now().UTC() + review.ReviewedAt = &now + } + + if err := h.repo.UpsertSessionFileReview(ctx, review); err != nil { + h.logger.Error("failed to update session file review", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update session file review", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "review": review, + }) +} + +type wsResetSessionFileReviewsRequest struct { + SessionID string `json:"session_id"` +} + +func (h *TaskHandlers) wsResetSessionFileReviews(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsResetSessionFileReviewsRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + if err := h.repo.DeleteSessionFileReviews(ctx, req.SessionID); err != nil { + h.logger.Error("failed to reset session file reviews", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to reset session file reviews", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + }) +} + // Task Plan Handlers // wsCreateTaskPlan creates a new task plan diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 557d6b3383..ed32bd8270 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -413,6 +413,18 @@ type TaskPlan struct { UpdatedAt time.Time `json:"updated_at"` } +// SessionFileReview tracks per-file review state within a session +type SessionFileReview struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + FilePath string `json:"file_path"` + Reviewed bool `json:"reviewed"` + DiffHash string `json:"diff_hash"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ToAPI converts internal Task to API type func (t *Task) ToAPI() *v1.Task { // Convert TaskRepository models to API types diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 5eebc380fa..cbd6b06ab4 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -140,6 +140,11 @@ type Repository interface { DeleteEnvironment(ctx context.Context, id string) error ListEnvironments(ctx context.Context) ([]*models.Environment, error) + // Session File Review operations + UpsertSessionFileReview(ctx context.Context, review *models.SessionFileReview) error + GetSessionFileReviews(ctx context.Context, sessionID string) ([]*models.SessionFileReview, error) + DeleteSessionFileReviews(ctx context.Context, sessionID string) error + // Task Plan operations CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 31ec960ba2..f129ac7870 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -366,6 +366,26 @@ func (r *Repository) initSchema() error { return err } + reviewSchema := ` + CREATE TABLE IF NOT EXISTS session_file_reviews ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + file_path TEXT NOT NULL, + reviewed INTEGER NOT NULL DEFAULT 0, + diff_hash TEXT NOT NULL DEFAULT '', + reviewed_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, + UNIQUE(session_id, file_path) + ); + CREATE INDEX IF NOT EXISTS idx_session_file_reviews_session ON session_file_reviews(session_id); + ` + + if _, err := r.db.Exec(reviewSchema); err != nil { + return err + } + if err := r.ensureDefaultWorkspace(); err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/review.go b/apps/backend/internal/task/repository/sqlite/review.go new file mode 100644 index 0000000000..ce7c315553 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/review.go @@ -0,0 +1,74 @@ +package sqlite + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/task/models" +) + +// UpsertSessionFileReview inserts or updates a file review record. +func (r *Repository) UpsertSessionFileReview(ctx context.Context, review *models.SessionFileReview) error { + if review.ID == "" { + review.ID = uuid.New().String() + } + now := time.Now().UTC() + if review.CreatedAt.IsZero() { + review.CreatedAt = now + } + review.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO session_file_reviews ( + id, session_id, file_path, reviewed, diff_hash, reviewed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, file_path) DO UPDATE SET + reviewed = excluded.reviewed, + diff_hash = excluded.diff_hash, + reviewed_at = excluded.reviewed_at, + updated_at = excluded.updated_at + `, review.ID, review.SessionID, review.FilePath, review.Reviewed, + review.DiffHash, review.ReviewedAt, review.CreatedAt, review.UpdatedAt) + + return err +} + +// GetSessionFileReviews retrieves all file reviews for a session. +func (r *Repository) GetSessionFileReviews(ctx context.Context, sessionID string) ([]*models.SessionFileReview, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, session_id, file_path, reviewed, diff_hash, reviewed_at, created_at, updated_at + FROM session_file_reviews + WHERE session_id = ? + ORDER BY file_path + `, sessionID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var result []*models.SessionFileReview + for rows.Next() { + review := &models.SessionFileReview{} + err := rows.Scan( + &review.ID, &review.SessionID, &review.FilePath, &review.Reviewed, + &review.DiffHash, &review.ReviewedAt, &review.CreatedAt, &review.UpdatedAt, + ) + if err != nil { + return nil, err + } + result = append(result, review) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// DeleteSessionFileReviews deletes all file reviews for a session. +func (r *Repository) DeleteSessionFileReviews(ctx context.Context, sessionID string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM session_file_reviews WHERE session_id = ?`, sessionID) + return err +} diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 0be0329266..40f1651d85 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -44,14 +44,15 @@ func (c *Controller) GetUserSettings(ctx context.Context) (dto.UserSettingsRespo func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserSettingsRequest) (dto.UserSettingsResponse, error) { settings, err := c.svc.UpdateUserSettings(ctx, &service.UpdateUserSettingsRequest{ - WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, - RepositoryIDs: req.RepositoryIDs, - InitialSetupComplete: req.InitialSetupComplete, - PreferredShell: req.PreferredShell, - DefaultEditorID: req.DefaultEditorID, - EnablePreviewOnClick: req.EnablePreviewOnClick, - ChatSubmitKey: req.ChatSubmitKey, + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + RepositoryIDs: req.RepositoryIDs, + InitialSetupComplete: req.InitialSetupComplete, + PreferredShell: req.PreferredShell, + DefaultEditorID: req.DefaultEditorID, + EnablePreviewOnClick: req.EnablePreviewOnClick, + ChatSubmitKey: req.ChatSubmitKey, + ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll, }) if err != nil { return dto.UserSettingsResponse{}, err diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index c0d9c519e7..bcabbb86d7 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -14,16 +14,17 @@ type UserDTO struct { } type UserSettingsDTO struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` - InitialSetupComplete bool `json:"initial_setup_complete"` - PreferredShell string `json:"preferred_shell"` - DefaultEditorID string `json:"default_editor_id"` - EnablePreviewOnClick bool `json:"enable_preview_on_click"` - ChatSubmitKey string `json:"chat_submit_key"` - UpdatedAt string `json:"updated_at"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` + ChatSubmitKey string `json:"chat_submit_key"` + ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"` + UpdatedAt string `json:"updated_at"` } type UserResponse struct { @@ -42,14 +43,15 @@ type ShellOption struct { } type UpdateUserSettingsRequest struct { - WorkspaceID *string `json:"workspace_id,omitempty"` - BoardID *string `json:"board_id,omitempty"` - RepositoryIDs *[]string `json:"repository_ids,omitempty"` - InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` - PreferredShell *string `json:"preferred_shell,omitempty"` - DefaultEditorID *string `json:"default_editor_id,omitempty"` - EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` - ChatSubmitKey *string `json:"chat_submit_key,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` + InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` + PreferredShell *string `json:"preferred_shell,omitempty"` + DefaultEditorID *string `json:"default_editor_id,omitempty"` + EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` + ChatSubmitKey *string `json:"chat_submit_key,omitempty"` + ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"` } func FromUser(user *models.User) UserDTO { @@ -63,15 +65,16 @@ func FromUser(user *models.User) UserDTO { func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { return UserSettingsDTO{ - UserID: settings.UserID, - WorkspaceID: settings.WorkspaceID, - BoardID: settings.BoardID, - RepositoryIDs: settings.RepositoryIDs, - InitialSetupComplete: settings.InitialSetupComplete, - PreferredShell: settings.PreferredShell, - DefaultEditorID: settings.DefaultEditorID, - EnablePreviewOnClick: settings.EnablePreviewOnClick, - ChatSubmitKey: settings.ChatSubmitKey, - UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), + UserID: settings.UserID, + WorkspaceID: settings.WorkspaceID, + BoardID: settings.BoardID, + RepositoryIDs: settings.RepositoryIDs, + InitialSetupComplete: settings.InitialSetupComplete, + PreferredShell: settings.PreferredShell, + DefaultEditorID: settings.DefaultEditorID, + EnablePreviewOnClick: settings.EnablePreviewOnClick, + ChatSubmitKey: settings.ChatSubmitKey, + ReviewAutoMarkOnScroll: settings.ReviewAutoMarkOnScroll, + UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } } diff --git a/apps/backend/internal/user/handlers/handlers.go b/apps/backend/internal/user/handlers/handlers.go index bb156fe3cb..159e271668 100644 --- a/apps/backend/internal/user/handlers/handlers.go +++ b/apps/backend/internal/user/handlers/handlers.go @@ -64,14 +64,15 @@ func (h *Handlers) httpGetUserSettings(c *gin.Context) { } type httpUpdateUserSettingsRequest struct { - WorkspaceID *string `json:"workspace_id,omitempty"` - BoardID *string `json:"board_id,omitempty"` - RepositoryIDs *[]string `json:"repository_ids,omitempty"` - InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` - PreferredShell *string `json:"preferred_shell,omitempty"` - DefaultEditorID *string `json:"default_editor_id,omitempty"` - EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` - ChatSubmitKey *string `json:"chat_submit_key,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` + InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` + PreferredShell *string `json:"preferred_shell,omitempty"` + DefaultEditorID *string `json:"default_editor_id,omitempty"` + EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` + ChatSubmitKey *string `json:"chat_submit_key,omitempty"` + ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"` } func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { @@ -81,14 +82,15 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { return } resp, err := h.controller.UpdateUserSettings(c.Request.Context(), dto.UpdateUserSettingsRequest{ - WorkspaceID: body.WorkspaceID, - BoardID: body.BoardID, - RepositoryIDs: body.RepositoryIDs, - InitialSetupComplete: body.InitialSetupComplete, - PreferredShell: body.PreferredShell, - DefaultEditorID: body.DefaultEditorID, - EnablePreviewOnClick: body.EnablePreviewOnClick, - ChatSubmitKey: body.ChatSubmitKey, + WorkspaceID: body.WorkspaceID, + BoardID: body.BoardID, + RepositoryIDs: body.RepositoryIDs, + InitialSetupComplete: body.InitialSetupComplete, + PreferredShell: body.PreferredShell, + DefaultEditorID: body.DefaultEditorID, + EnablePreviewOnClick: body.EnablePreviewOnClick, + ChatSubmitKey: body.ChatSubmitKey, + ReviewAutoMarkOnScroll: body.ReviewAutoMarkOnScroll, }) if err != nil { h.logger.Error("failed to update user settings", zap.Error(err)) @@ -107,14 +109,15 @@ func (h *Handlers) wsGetUser(ctx context.Context, msg *ws.Message) (*ws.Message, } type wsUpdateUserSettingsRequest struct { - WorkspaceID *string `json:"workspace_id,omitempty"` - BoardID *string `json:"board_id,omitempty"` - RepositoryIDs *[]string `json:"repository_ids,omitempty"` - InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` - PreferredShell *string `json:"preferred_shell,omitempty"` - DefaultEditorID *string `json:"default_editor_id,omitempty"` - EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` - ChatSubmitKey *string `json:"chat_submit_key,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` + InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` + PreferredShell *string `json:"preferred_shell,omitempty"` + DefaultEditorID *string `json:"default_editor_id,omitempty"` + EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` + ChatSubmitKey *string `json:"chat_submit_key,omitempty"` + ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"` } func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -123,14 +126,15 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (* return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } resp, err := h.controller.UpdateUserSettings(ctx, dto.UpdateUserSettingsRequest{ - WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, - RepositoryIDs: req.RepositoryIDs, - InitialSetupComplete: req.InitialSetupComplete, - PreferredShell: req.PreferredShell, - DefaultEditorID: req.DefaultEditorID, - EnablePreviewOnClick: req.EnablePreviewOnClick, - ChatSubmitKey: req.ChatSubmitKey, + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + RepositoryIDs: req.RepositoryIDs, + InitialSetupComplete: req.InitialSetupComplete, + PreferredShell: req.PreferredShell, + DefaultEditorID: req.DefaultEditorID, + EnablePreviewOnClick: req.EnablePreviewOnClick, + ChatSubmitKey: req.ChatSubmitKey, + ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll, }) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil) diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 783b07acda..88da4e7a04 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -10,15 +10,16 @@ type User struct { } type UserSettings struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` - InitialSetupComplete bool `json:"initial_setup_complete"` - PreferredShell string `json:"preferred_shell"` - DefaultEditorID string `json:"default_editor_id"` - EnablePreviewOnClick bool `json:"enable_preview_on_click"` - ChatSubmitKey string `json:"chat_submit_key"` // "enter" | "cmd_enter" - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` + ChatSubmitKey string `json:"chat_submit_key"` // "enter" | "cmd_enter" + ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index e1d9b848ee..5f0cfff639 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -24,14 +24,15 @@ type Service struct { } type UpdateUserSettingsRequest struct { - WorkspaceID *string - BoardID *string - RepositoryIDs *[]string - InitialSetupComplete *bool - PreferredShell *string - DefaultEditorID *string - EnablePreviewOnClick *bool - ChatSubmitKey *string + WorkspaceID *string + BoardID *string + RepositoryIDs *[]string + InitialSetupComplete *bool + PreferredShell *string + DefaultEditorID *string + EnablePreviewOnClick *bool + ChatSubmitKey *string + ReviewAutoMarkOnScroll *bool } func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { @@ -101,6 +102,9 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting s.logger.Info("[Settings] Setting ChatSubmitKey", zap.String("value", key)) settings.ChatSubmitKey = key } + if req.ReviewAutoMarkOnScroll != nil { + settings.ReviewAutoMarkOnScroll = *req.ReviewAutoMarkOnScroll + } settings.UpdatedAt = time.Now().UTC() if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { return nil, err @@ -121,9 +125,10 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, "default_editor_id": settings.DefaultEditorID, - "enable_preview_on_click": settings.EnablePreviewOnClick, - "chat_submit_key": settings.ChatSubmitKey, - "updated_at": settings.UpdatedAt.Format(time.RFC3339), + "enable_preview_on_click": settings.EnablePreviewOnClick, + "chat_submit_key": settings.ChatSubmitKey, + "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll, + "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { s.logger.Error("failed to publish user settings event", zap.Error(err)) diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index aa3f943a7c..016a744bb1 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -112,14 +112,15 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod settings.CreatedAt = settings.UpdatedAt } settingsPayload, err := json.Marshal(map[string]interface{}{ - "workspace_id": settings.WorkspaceID, - "board_id": settings.BoardID, - "repository_ids": settings.RepositoryIDs, - "initial_setup_complete": settings.InitialSetupComplete, - "preferred_shell": settings.PreferredShell, - "default_editor_id": settings.DefaultEditorID, - "enable_preview_on_click": settings.EnablePreviewOnClick, - "chat_submit_key": settings.ChatSubmitKey, + "workspace_id": settings.WorkspaceID, + "board_id": settings.BoardID, + "repository_ids": settings.RepositoryIDs, + "initial_setup_complete": settings.InitialSetupComplete, + "preferred_shell": settings.PreferredShell, + "default_editor_id": settings.DefaultEditorID, + "enable_preview_on_click": settings.EnablePreviewOnClick, + "chat_submit_key": settings.ChatSubmitKey, + "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll, }) if err != nil { return err @@ -158,14 +159,15 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin return settings, nil } var payload struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` - InitialSetupComplete bool `json:"initial_setup_complete"` - PreferredShell string `json:"preferred_shell"` - DefaultEditorID string `json:"default_editor_id"` - EnablePreviewOnClick bool `json:"enable_preview_on_click"` - ChatSubmitKey string `json:"chat_submit_key"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` + ChatSubmitKey string `json:"chat_submit_key"` + ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -182,5 +184,11 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin if settings.ChatSubmitKey == "" { settings.ChatSubmitKey = "cmd_enter" } + // Default to true when not set + if payload.ReviewAutoMarkOnScroll != nil { + settings.ReviewAutoMarkOnScroll = *payload.ReviewAutoMarkOnScroll + } else { + settings.ReviewAutoMarkOnScroll = true + } return settings, nil } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 06631dbabd..ee60a6c204 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -192,6 +192,11 @@ const ( ActionUserShellCreate = "user_shell.create" // Create a new user shell terminal (assigns ID and label) ActionUserShellStop = "user_shell.stop" // Stop a user shell terminal + // Session file review actions + ActionSessionFileReviewGet = "session.file_review.get" // Get all file reviews for a session + ActionSessionFileReviewUpdate = "session.file_review.update" // Upsert a single file review + ActionSessionFileReviewReset = "session.file_review.reset" // Delete all reviews for a session + // Session git actions (requests) ActionSessionGitSnapshots = "session.git.snapshots" // Get git snapshots for a session ActionSessionGitCommits = "session.git.commits" // Get commits for a session diff --git a/apps/packages/ui/src/dialog.tsx b/apps/packages/ui/src/dialog.tsx index e62692d5eb..19fd9a4735 100644 --- a/apps/packages/ui/src/dialog.tsx +++ b/apps/packages/ui/src/dialog.tsx @@ -48,13 +48,15 @@ function DialogContent({ className, children, showCloseButton = true, + overlayClassName, ...props }: React.ComponentProps & { showCloseButton?: boolean + overlayClassName?: string }) { return ( - + void; + /** External word wrap override (controlled mode) */ + wordWrap?: boolean; } type AnnotationMetadata = { @@ -121,6 +123,7 @@ function arePropsEqual(prevProps: DiffViewerProps, nextProps: DiffViewerProps): if (prevProps.hideHeader !== nextProps.hideHeader) return false; if (prevProps.className !== nextProps.className) return false; if (prevProps.onOpenFile !== nextProps.onOpenFile) return false; + if (prevProps.wordWrap !== nextProps.wordWrap) return false; // Comments array - compare by length and IDs const prevComments = prevProps.comments; @@ -148,6 +151,7 @@ export const DiffViewer = memo(function DiffViewer({ compact = false, hideHeader = false, onOpenFile, + wordWrap: wordWrapProp, }: DiffViewerProps) { const { resolvedTheme } = useTheme(); const [selectedLines, setSelectedLines] = useState(null); @@ -156,8 +160,9 @@ export const DiffViewer = memo(function DiffViewer({ // Global view mode (synced via localStorage) const [globalViewMode, setGlobalViewMode] = useGlobalViewMode(); - // Local state for word wrap (per-diff) - const [wordWrap, setWordWrap] = useState(false); + // Local state for word wrap (per-diff), overridable via prop + const [wordWrapLocal, setWordWrap] = useState(false); + const wordWrap = wordWrapProp ?? wordWrapLocal; // Use internal comment management if sessionId provided const { diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx index 193da2032c..64cb95cae4 100644 --- a/apps/web/components/diff/file-diff-viewer.tsx +++ b/apps/web/components/diff/file-diff-viewer.tsx @@ -30,6 +30,8 @@ interface FileDiffViewerProps { hideHeader?: boolean; /** Callback to open file in editor */ onOpenFile?: (filePath: string) => void; + /** External word wrap override */ + wordWrap?: boolean; } /** @@ -54,6 +56,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({ compact, hideHeader, onOpenFile, + wordWrap, }: FileDiffViewerProps) { // Memoize the transformation - only recalculates when raw data changes const data = useMemo( @@ -73,6 +76,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({ compact={compact} hideHeader={hideHeader} onOpenFile={onOpenFile} + wordWrap={wordWrap} /> ); }); diff --git a/apps/web/components/review/review-dialog.tsx b/apps/web/components/review/review-dialog.tsx new file mode 100644 index 0000000000..99ad30705c --- /dev/null +++ b/apps/web/components/review/review-dialog.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { memo, useMemo, useCallback, createRef, useState } from 'react'; +import { Dialog, DialogContent, DialogTitle } from '@kandev/ui/dialog'; +import type { DiffComment } from '@/lib/diff/types'; +import type { FileInfo, CumulativeDiff } from '@/lib/state/slices/session-runtime/types'; +import { useDiffCommentsStore } from '@/lib/state/slices/diff-comments/diff-comments-slice'; +import { useSessionFileReviews } from '@/hooks/use-session-file-reviews'; +import { useGitOperations } from '@/hooks/use-git-operations'; +import { useAppStore } from '@/components/state-provider'; +import { useToast } from '@/components/toast-provider'; +import { ReviewTopBar } from './review-top-bar'; +import { ReviewFileTree } from './review-file-tree'; +import { ReviewDiffList } from './review-diff-list'; +import type { ReviewFile } from './types'; +import { hashDiff, normalizeDiffContent } from './types'; + +type ReviewDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + sessionId: string; + baseBranch?: string; + taskTitle?: string; + onSendComments: (comments: DiffComment[]) => void; + gitStatusFiles: Record | null; + cumulativeDiff: CumulativeDiff | null; +}; + + +export const ReviewDialog = memo(function ReviewDialog({ + open, + onOpenChange, + sessionId, + baseBranch, + taskTitle, + onSendComments, + gitStatusFiles, + cumulativeDiff, +}: ReviewDialogProps) { + const [selectedFile, setSelectedFile] = useState(null); + const [splitView, setSplitView] = useState(() => { + if (typeof window === 'undefined') return false; + return localStorage.getItem('diff-view-mode') === 'split'; + }); + const [wordWrap, setWordWrap] = useState(false); + const autoMarkOnScroll = useAppStore((s) => s.userSettings.reviewAutoMarkOnScroll); + + const { reviews, markReviewed, markUnreviewed } = + useSessionFileReviews(sessionId); + + const bySession = useDiffCommentsStore((s) => s.bySession); + const getPendingComments = useDiffCommentsStore((s) => s.getPendingComments); + const markCommentsSent = useDiffCommentsStore((s) => s.markCommentsSent); + + const { discard } = useGitOperations(sessionId); + const { toast } = useToast(); + + // Merge uncommitted + committed files into a single list + const allFiles = useMemo(() => { + const fileMap = new Map(); + + // Uncommitted changes from git status + if (gitStatusFiles) { + for (const [path, file] of Object.entries(gitStatusFiles)) { + const diff = file.diff ? normalizeDiffContent(file.diff) : ''; + if (diff) { + fileMap.set(path, { + path, + diff, + status: file.status, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + staged: file.staged, + source: 'uncommitted', + }); + } + } + } + + // Committed changes from cumulative diff + if (cumulativeDiff?.files) { + for (const [path, file] of Object.entries(cumulativeDiff.files)) { + if (fileMap.has(path)) continue; // Uncommitted takes priority + const diff = file.diff ? normalizeDiffContent(file.diff) : ''; + if (diff) { + fileMap.set(path, { + path, + diff, + status: file.status || 'modified', + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + staged: false, + source: 'committed', + }); + } + } + } + + return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path)); + }, [gitStatusFiles, cumulativeDiff]); + + // Compute stale files and reviewed files sets + const { reviewedFiles, staleFiles } = useMemo(() => { + const reviewed = new Set(); + const stale = new Set(); + + for (const file of allFiles) { + const reviewState = reviews.get(file.path); + if (!reviewState?.reviewed) continue; + + const currentHash = hashDiff(file.diff); + if (reviewState.diffHash && reviewState.diffHash !== currentHash) { + stale.add(file.path); + } else { + reviewed.add(file.path); + } + } + + return { reviewedFiles: reviewed, staleFiles: stale }; + }, [allFiles, reviews]); + + // Compute comment counts per file + const commentCountByFile = useMemo(() => { + const counts: Record = {}; + const sessionComments = bySession[sessionId]; + if (!sessionComments) return counts; + + for (const [filePath, comments] of Object.entries(sessionComments)) { + if (comments.length > 0) { + counts[filePath] = comments.length; + } + } + return counts; + }, [bySession, sessionId]); + + const totalCommentCount = useMemo(() => { + return Object.values(commentCountByFile).reduce((sum, c) => sum + c, 0); + }, [commentCountByFile]); + + // File refs for scrollIntoView + const fileRefs = useMemo(() => { + const refs = new Map>(); + for (const file of allFiles) { + refs.set(file.path, createRef()); + } + return refs; + }, [allFiles]); + + const handleToggleSplitView = useCallback((split: boolean) => { + setSplitView(split); + const mode = split ? 'split' : 'unified'; + localStorage.setItem('diff-view-mode', mode); + window.dispatchEvent(new CustomEvent('diff-view-mode-change', { detail: mode })); + }, []); + + const handleSelectFile = useCallback( + (path: string) => { + setSelectedFile(path); + const ref = fileRefs.get(path); + if (ref?.current) { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, + [fileRefs] + ); + + const handleToggleReviewed = useCallback( + (path: string, reviewed: boolean) => { + if (reviewed) { + const file = allFiles.find((f) => f.path === path); + const diffHash = file ? hashDiff(file.diff) : ''; + markReviewed(path, diffHash); + } else { + markUnreviewed(path); + } + }, + [allFiles, markReviewed, markUnreviewed] + ); + + const handleSendComments = useCallback( + (comments: DiffComment[]) => { + onSendComments(comments); + onOpenChange(false); + }, + [onSendComments, onOpenChange] + ); + + const handleDiscard = useCallback( + async (path: string) => { + try { + const result = await discard([path]); + if (result.success) { + toast({ title: 'Changes discarded', description: path, variant: 'success' }); + } else { + toast({ title: 'Discard failed', description: result.error || 'An error occurred', variant: 'error' }); + } + } catch (e) { + toast({ title: 'Discard failed', description: e instanceof Error ? e.message : 'An error occurred', variant: 'error' }); + } + }, + [discard, toast] + ); + + const reviewedCount = reviewedFiles.size; + const totalCount = allFiles.length; + + return ( + + + Review Changes + {/* Top bar */} + onOpenChange(false)} + getPendingComments={getPendingComments} + markCommentsSent={markCommentsSent} + /> + + {/* Content area */} +
+ {/* File tree sidebar */} +
+ +
+ + {/* Diff list */} +
+ {allFiles.length > 0 ? ( + + ) : ( +
+ No changes to review +
+ )} +
+
+
+
+ ); +}); diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx new file mode 100644 index 0000000000..48fe1fee15 --- /dev/null +++ b/apps/web/components/review/review-diff-list.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { memo, useEffect, useRef, useState, useCallback } from 'react'; +import { + IconAlertTriangle, + IconCopy, + IconArrowBackUp, +} from '@tabler/icons-react'; +import { Checkbox } from '@kandev/ui/checkbox'; +import { Button } from '@kandev/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { FileDiffViewer } from '@/components/diff'; +import { useToast } from '@/components/toast-provider'; +import type { ReviewFile } from './types'; + +type ReviewDiffListProps = { + files: ReviewFile[]; + reviewedFiles: Set; + staleFiles: Set; + sessionId: string; + autoMarkOnScroll: boolean; + wordWrap: boolean; + onToggleReviewed: (path: string, reviewed: boolean) => void; + onDiscard: (path: string) => void; + fileRefs: Map>; +}; + +export const ReviewDiffList = memo(function ReviewDiffList({ + files, + reviewedFiles, + staleFiles, + sessionId, + autoMarkOnScroll, + wordWrap, + onToggleReviewed, + onDiscard, + fileRefs, +}: ReviewDiffListProps) { + const scrollContainerRef = useRef(null); + + return ( +
+ {files.map((file) => ( + + ))} +
+ ); +}); + +type FileDiffSectionProps = { + file: ReviewFile; + isReviewed: boolean; + isStale: boolean; + sessionId: string; + autoMarkOnScroll: boolean; + wordWrap: boolean; + onToggleReviewed: (path: string, reviewed: boolean) => void; + onDiscard: (path: string) => void; + sectionRef?: React.RefObject; + scrollContainer: React.RefObject; +}; + +function FileDiffSection({ + file, + isReviewed, + isStale, + sessionId, + autoMarkOnScroll, + wordWrap, + onToggleReviewed, + onDiscard, + sectionRef, + scrollContainer, +}: FileDiffSectionProps) { + const [isVisible, setIsVisible] = useState(false); + const sentinelRef = useRef(null); + const autoMarkedRef = useRef(false); + const { toast } = useToast(); + + // Lazy rendering: mount the diff viewer only when section becomes visible + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { rootMargin: '200px 0px', root: scrollContainer.current } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [scrollContainer]); + + // Auto-mark on scroll: when the file section leaves the top of the scroll container + const scrollSentinelRef = useRef(null); + + useEffect(() => { + if (!autoMarkOnScroll || isReviewed || isStale) { + autoMarkedRef.current = false; + return; + } + + const sentinel = scrollSentinelRef.current; + const root = scrollContainer.current; + if (!sentinel || !root) return; + + const observer = new IntersectionObserver( + ([entry]) => { + // File has scrolled above the scroll container (not intersecting + bounding rect is above root) + if (!entry.isIntersecting && entry.boundingClientRect.top < root.getBoundingClientRect().top && !autoMarkedRef.current) { + autoMarkedRef.current = true; + console.debug('[review] auto-mark reviewed:', file.path); + onToggleReviewed(file.path, true); + } + }, + { threshold: 0, root } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [autoMarkOnScroll, file.path, isReviewed, isStale, onToggleReviewed, scrollContainer]); + + const handleCheckboxChange = useCallback( + (checked: boolean | 'indeterminate') => { + onToggleReviewed(file.path, checked === true); + }, + [file.path, onToggleReviewed] + ); + + const handleCopyPath = useCallback(() => { + navigator.clipboard.writeText(file.path); + toast({ title: 'Copied', description: file.path, variant: 'success' }); + }, [file.path, toast]); + + const handleDiscard = useCallback(() => { + onDiscard(file.path); + }, [file.path, onDiscard]); + + return ( +
+ {/* Sentinel for auto-mark-on-scroll */} +
+ + {/* Sticky file header */} +
+ + {file.path} + +
+ + {isStale && ( + + + changed + + )} + + + {file.additions > 0 && +{file.additions}} + {file.additions > 0 && file.deletions > 0 && ' / '} + {file.deletions > 0 && -{file.deletions}} + + + {/* File actions */} +
+ {/* Copy path */} + + + + + Copy file path + + + {/* Discard changes */} + {file.source === 'uncommitted' && ( + + + + + Revert changes + + )} +
+
+ + {/* Sentinel for lazy loading */} +
+ + {/* Diff content (lazy rendered) */} + {isVisible && file.diff ? ( +
+ +
+ ) : ( +
+ Loading diff... +
+ )} +
+ ); +} diff --git a/apps/web/components/review/review-file-tree.tsx b/apps/web/components/review/review-file-tree.tsx new file mode 100644 index 0000000000..c4db04c85f --- /dev/null +++ b/apps/web/components/review/review-file-tree.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { memo, useState, useCallback, useMemo } from 'react'; +import { + IconChevronDown, + IconChevronRight, + IconMessage, + IconAlertTriangle, +} from '@tabler/icons-react'; +import { Checkbox } from '@kandev/ui/checkbox'; +import { cn } from '@kandev/ui/lib/utils'; +import { FileIcon } from '@/components/ui/file-icon'; +import { FileStatusIcon } from '@/components/task/file-status-icon'; +import type { ReviewFile, FileTreeNode } from './types'; +import { buildFileTree } from './types'; + +type ReviewFileTreeProps = { + files: ReviewFile[]; + reviewedFiles: Set; + staleFiles: Set; + commentCountByFile: Record; + selectedFile: string | null; + onSelectFile: (path: string) => void; + onToggleReviewed: (path: string, reviewed: boolean) => void; +}; + +export const ReviewFileTree = memo(function ReviewFileTree({ + files, + reviewedFiles, + staleFiles, + commentCountByFile, + selectedFile, + onSelectFile, + onToggleReviewed, +}: ReviewFileTreeProps) { + const tree = useMemo(() => buildFileTree(files), [files]); + + return ( +
+
+ Files +
+
+ {tree.map((node) => ( + + ))} +
+
+ ); +}); + +type TreeNodeProps = { + node: FileTreeNode; + reviewedFiles: Set; + staleFiles: Set; + commentCountByFile: Record; + selectedFile: string | null; + onSelectFile: (path: string) => void; + onToggleReviewed: (path: string, reviewed: boolean) => void; + depth: number; +}; + +function TreeNode({ + node, + reviewedFiles, + staleFiles, + commentCountByFile, + selectedFile, + onSelectFile, + onToggleReviewed, + depth, +}: TreeNodeProps) { + const [expanded, setExpanded] = useState(true); + + const handleToggle = useCallback(() => { + setExpanded((prev) => !prev); + }, []); + + if (node.isDir) { + // Calculate aggregate stats for directory + const dirFiles = collectFiles(node); + const dirReviewed = dirFiles.filter((f) => reviewedFiles.has(f.path) && !staleFiles.has(f.path)).length; + const dirTotal = dirFiles.length; + + return ( +
+ + {expanded && node.children && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); + } + + // File node + const file = node.file!; + const isReviewed = reviewedFiles.has(file.path); + const isStale = staleFiles.has(file.path); + const commentCount = commentCountByFile[file.path] ?? 0; + const isSelected = selectedFile === file.path; + + return ( +
onSelectFile(file.path)} + > + { + onToggleReviewed(file.path, checked === true); + }} + onClick={(e) => e.stopPropagation()} + className="h-3.5 w-3.5" + /> + + {node.name} + + {/* Changed badge (stale) */} + {isStale && ( + + )} + + {/* Comment count */} + {commentCount > 0 && ( + + + {commentCount} + + )} + + {/* File status */} + + + {/* +/- stats */} + + {file.additions > 0 && +{file.additions}} + {file.additions > 0 && file.deletions > 0 && ' '} + {file.deletions > 0 && -{file.deletions}} + +
+ ); +} + +/** Collect all leaf files from a tree node recursively */ +function collectFiles(node: FileTreeNode): ReviewFile[] { + if (!node.isDir) return node.file ? [node.file] : []; + const result: ReviewFile[] = []; + for (const child of node.children ?? []) { + result.push(...collectFiles(child)); + } + return result; +} diff --git a/apps/web/components/review/review-top-bar.tsx b/apps/web/components/review/review-top-bar.tsx new file mode 100644 index 0000000000..251de1c061 --- /dev/null +++ b/apps/web/components/review/review-top-bar.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { memo, useCallback } from 'react'; +import { + IconSettings, + IconX, + IconMessageForward, + IconLayoutColumns, + IconLayoutRows, + IconTextWrap, +} from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@kandev/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { Checkbox } from '@kandev/ui/checkbox'; +import type { DiffComment } from '@/lib/diff/types'; +import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { updateUserSettings } from '@/lib/api'; +import { VcsSplitButton } from '@/components/vcs-split-button'; + +type ReviewTopBarProps = { + sessionId: string; + reviewedCount: number; + totalCount: number; + commentCount: number; + baseBranch?: string; + taskTitle?: string; + splitView: boolean; + onToggleSplitView: (split: boolean) => void; + wordWrap: boolean; + onToggleWordWrap: (wrap: boolean) => void; + onSendComments: (comments: DiffComment[]) => void; + onClose: () => void; + getPendingComments: () => DiffComment[]; + markCommentsSent: (ids: string[]) => void; +}; + +export const ReviewTopBar = memo(function ReviewTopBar({ + sessionId, + reviewedCount, + totalCount, + commentCount, + baseBranch, + taskTitle, + splitView, + onToggleSplitView, + wordWrap, + onToggleWordWrap, + onSendComments, + onClose, + getPendingComments, + markCommentsSent, +}: ReviewTopBarProps) { + const reviewAutoMarkOnScroll = useAppStore( + (state) => state.userSettings.reviewAutoMarkOnScroll + ); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const userSettings = useAppStore((state) => state.userSettings); + + const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0; + + const handleFixComments = useCallback(() => { + const comments = getPendingComments(); + if (comments.length === 0) return; + onSendComments(comments); + markCommentsSent(comments.map((c) => c.id)); + }, [getPendingComments, onSendComments, markCommentsSent]); + + const handleToggleAutoMark = useCallback( + (checked: boolean) => { + const next = { ...userSettings, reviewAutoMarkOnScroll: checked }; + setUserSettings(next); + + const client = getWebSocketClient(); + const payload = { review_auto_mark_on_scroll: checked }; + if (client) { + client.request('user.settings.update', payload).catch(() => { + updateUserSettings(payload, { cache: 'no-store' }).catch(() => {}); + }); + } else { + updateUserSettings(payload, { cache: 'no-store' }).catch(() => {}); + } + }, + [userSettings, setUserSettings] + ); + + return ( + <> +
+ {/* Settings cog */} + + + + + + { + e.preventDefault(); + handleToggleAutoMark(!reviewAutoMarkOnScroll); + }} + > + + + Auto-mark reviewed on scroll + + + + + + {/* Progress bar */} +
+
+
+
+ + {reviewedCount} of {totalCount} files reviewed + +
+ + {/* Word wrap toggle */} + + + + + Toggle word wrap + + + {/* Split/Unified toggle */} + + + + + {splitView ? 'Switch to unified view' : 'Switch to split view'} + + + {/* Fix Comments button */} + {commentCount > 0 && ( + + )} + + + + {/* Close button */} + +
+ + ); +}); diff --git a/apps/web/components/review/types.ts b/apps/web/components/review/types.ts new file mode 100644 index 0000000000..cde2f8b7dd --- /dev/null +++ b/apps/web/components/review/types.ts @@ -0,0 +1,117 @@ +export type ReviewFile = { + path: string; + diff: string; + status: string; + additions: number; + deletions: number; + staged: boolean; + source: 'uncommitted' | 'committed'; +}; + +export type FileTreeNode = { + name: string; + path: string; + isDir: boolean; + children?: FileTreeNode[]; + file?: ReviewFile; +}; + +/** + * Normalize diff content by handling edge cases from the backend. + * Handles concatenated diffs with "--- Staged changes ---" separator. + */ +export function normalizeDiffContent(diffContent: string): string { + if (!diffContent || typeof diffContent !== 'string') return ''; + let trimmed = diffContent.trim(); + if (!trimmed) return ''; + + const stagedSeparator = '--- Staged changes ---'; + if (trimmed.includes(stagedSeparator)) { + const parts = trimmed.split(stagedSeparator); + trimmed = (parts[1] || parts[0]).trim(); + } + + return trimmed; +} + +/** + * Simple djb2 hash of a string — fast, non-cryptographic. + * Used to detect if a diff has changed since the user last reviewed it. + */ +export function hashDiff(content: string): string { + let hash = 5381; + for (let i = 0; i < content.length; i++) { + hash = ((hash << 5) + hash + content.charCodeAt(i)) | 0; + } + return hash.toString(36); +} + +/** + * Build a hierarchical file tree from flat file paths. + * Collapses single-child directories (e.g., `src/components` as one node if `src` has no other children). + */ +export function buildFileTree(files: ReviewFile[]): FileTreeNode[] { + const root: FileTreeNode = { name: '', path: '', isDir: true, children: [] }; + + for (const file of files) { + const parts = file.path.split('/'); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + const partPath = parts.slice(0, i + 1).join('/'); + + if (isLast) { + current.children!.push({ + name: part, + path: file.path, + isDir: false, + file, + }); + } else { + let child = current.children!.find((c) => c.isDir && c.name === part); + if (!child) { + child = { name: part, path: partPath, isDir: true, children: [] }; + current.children!.push(child); + } + current = child; + } + } + } + + // Collapse single-child directories + function collapse(node: FileTreeNode): FileTreeNode { + if (!node.isDir || !node.children) return node; + + node.children = node.children.map(collapse); + + if (node.children.length === 1 && node.children[0].isDir && node.name !== '') { + const child = node.children[0]; + return { + ...child, + name: `${node.name}/${child.name}`, + }; + } + + return node; + } + + const collapsed = collapse(root); + + // Sort: directories first, then files, alphabetically + function sortTree(nodes: FileTreeNode[]): FileTreeNode[] { + return nodes.sort((a, b) => { + if (a.isDir && !b.isDir) return -1; + if (!a.isDir && b.isDir) return 1; + return a.name.localeCompare(b.name); + }).map((node) => { + if (node.isDir && node.children) { + return { ...node, children: sortTree(node.children) }; + } + return node; + }); + } + + return sortTree(collapsed.children ?? []); +} diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index 0d99549a39..2303c39117 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -371,6 +371,7 @@ export function EditorsSettings() { defaultEditorId: response.settings.default_editor_id || null, enablePreviewOnClick: response.settings.enable_preview_on_click ?? false, chatSubmitKey: response.settings.chat_submit_key ?? 'cmd_enter', + reviewAutoMarkOnScroll: response.settings.review_auto_mark_on_scroll ?? true, loaded: true, }); } diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index 38dc4914de..ae1758de4f 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -138,6 +138,17 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ } }, [leftTab, hasChanges]); + // Listen for external requests to switch to 'changes' tab + useEffect(() => { + const handler = () => { + if (hasChanges) { + setLeftTab('changes'); + } + }; + window.addEventListener('switch-to-changes-tab', handler); + return () => window.removeEventListener('switch-to-changes-tab', handler); + }, [hasChanges]); + // Request changes state - triggers focus and tooltip on chat input const [showRequestChangesTooltip, setShowRequestChangesTooltip] = useState(false); diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index 7fb9ddb4bb..1f5c24654c 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -1,30 +1,39 @@ 'use client'; -import { memo, useMemo, useState } from 'react'; -import { IconArrowBackUp } from '@tabler/icons-react'; -import { Badge } from '@kandev/ui/badge'; -import { Button } from '@kandev/ui/button'; -import { SessionPanelContent } from '@kandev/ui/pannel-session'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { memo, useMemo, useCallback, createRef, useState, useEffect, useRef } from 'react'; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@kandev/ui/alert-dialog'; + IconSettings, + IconTextWrap, + IconLayoutColumns, + IconLayoutRows, + IconMessageForward, + IconArrowsMaximize, +} from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { Checkbox } from '@kandev/ui/checkbox'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@kandev/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { useToast } from '@/components/toast-provider'; import { useAppStore } from '@/components/state-provider'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff'; import { useGitOperations } from '@/hooks/use-git-operations'; -import { FileDiffViewer } from '@/components/diff'; -import type { FileInfo } from '@/lib/state/slices'; +import { useSessionFileReviews } from '@/hooks/use-session-file-reviews'; +import { useDiffCommentsStore } from '@/lib/state/slices/diff-comments/diff-comments-slice'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { updateUserSettings } from '@/lib/api'; +import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment'; +import { ReviewDiffList } from '@/components/review/review-diff-list'; +import { ReviewDialog } from '@/components/review/review-dialog'; +import type { ReviewFile } from '@/components/review/types'; +import { hashDiff, normalizeDiffContent } from '@/components/review/types'; +import type { DiffComment } from '@/lib/diff/types'; import type { SelectedDiff } from './task-layout'; -import { FileStatusIcon } from './file-status-icon'; type TaskChangesPanelProps = { selectedDiff: SelectedDiff | null; @@ -33,277 +42,399 @@ type TaskChangesPanelProps = { onOpenFile?: (filePath: string) => void; }; -/** - * Normalize diff content by handling edge cases from the backend. - * - * Handles: - * 1. Multiple diffs concatenated with "--- Staged changes ---" separator - * 2. Diffs missing headers - */ -function normalizeDiffContent(diffContent: string): string { - if (!diffContent || typeof diffContent !== 'string') return ''; - - let trimmed = diffContent.trim(); - if (!trimmed) return ''; - - // Handle concatenated diffs with "--- Staged changes ---" separator - const stagedSeparator = '--- Staged changes ---'; - if (trimmed.includes(stagedSeparator)) { - const parts = trimmed.split(stagedSeparator); - trimmed = (parts[1] || parts[0]).trim(); - } - - return trimmed; -} - const TaskChangesPanel = memo(function TaskChangesPanel({ selectedDiff, onClearSelected, - onOpenFile, }: TaskChangesPanelProps) { - const [showDiscardDialog, setShowDiscardDialog] = useState(false); + const [splitView, setSplitView] = useState(() => { + if (typeof window === 'undefined') return false; + return localStorage.getItem('diff-view-mode') === 'split'; + }); + const [wordWrap, setWordWrap] = useState(false); + const [reviewDialogOpen, setReviewDialogOpen] = useState(false); const { toast } = useToast(); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const autoMarkOnScroll = useAppStore((s) => s.userSettings.reviewAutoMarkOnScroll); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const userSettings = useAppStore((state) => state.userSettings); + const taskTitle = useAppStore((state) => { + if (!state.tasks.activeTaskId) return undefined; + return state.kanban.tasks.find((t) => t.id === state.tasks.activeTaskId)?.title; + }); + const baseBranch = useAppStore((state) => { + if (!activeSessionId) return undefined; + return state.taskSessions.items[activeSessionId]?.base_branch; + }); + const gitStatus = useSessionGitStatus(activeSessionId); - const { discard, isLoading: isDiscarding } = useGitOperations(activeSessionId); - // Fetch cumulative diff (all commits combined) when no specific file is selected - const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff( - selectedDiff ? null : activeSessionId + const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(activeSessionId); + const { discard } = useGitOperations(activeSessionId); + + const { reviews, markReviewed, markUnreviewed } = useSessionFileReviews(activeSessionId); + + const bySession = useDiffCommentsStore((s) => s.bySession); + const getPendingComments = useDiffCommentsStore((s) => s.getPendingComments); + const markCommentsSent = useDiffCommentsStore((s) => s.markCommentsSent); + + // Merge uncommitted + committed files into a single sorted list + const allFiles = useMemo(() => { + const fileMap = new Map(); + + // Uncommitted changes from git status + if (gitStatus?.files) { + for (const [path, file] of Object.entries(gitStatus.files)) { + const diff = file.diff ? normalizeDiffContent(file.diff) : ''; + if (diff) { + fileMap.set(path, { + path, + diff, + status: file.status, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + staged: file.staged, + source: 'uncommitted', + }); + } + } + } + + // Committed changes from cumulative diff + if (cumulativeDiff?.files) { + for (const [path, file] of Object.entries(cumulativeDiff.files)) { + if (fileMap.has(path)) continue; // Uncommitted takes priority + const diff = file.diff ? normalizeDiffContent(file.diff) : ''; + if (diff) { + fileMap.set(path, { + path, + diff, + status: file.status || 'modified', + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + staged: false, + source: 'committed', + }); + } + } + } + + return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path)); + }, [gitStatus, cumulativeDiff]); + + // Reviewed / stale sets + const { reviewedFiles, staleFiles } = useMemo(() => { + const reviewed = new Set(); + const stale = new Set(); + + for (const file of allFiles) { + const reviewState = reviews.get(file.path); + if (!reviewState?.reviewed) continue; + + const currentHash = hashDiff(file.diff); + if (reviewState.diffHash && reviewState.diffHash !== currentHash) { + stale.add(file.path); + } else { + reviewed.add(file.path); + } + } + + return { reviewedFiles: reviewed, staleFiles: stale }; + }, [allFiles, reviews]); + + // Comment counts + const commentCountByFile = useMemo(() => { + const counts: Record = {}; + if (!activeSessionId) return counts; + const sessionComments = bySession[activeSessionId]; + if (!sessionComments) return counts; + + for (const [filePath, comments] of Object.entries(sessionComments)) { + if (comments.length > 0) { + counts[filePath] = comments.length; + } + } + return counts; + }, [bySession, activeSessionId]); + + const totalCommentCount = useMemo(() => { + return Object.values(commentCountByFile).reduce((sum, c) => sum + c, 0); + }, [commentCountByFile]); + + // File refs for scrollIntoView + const fileRefs = useMemo(() => { + const refs = new Map>(); + for (const file of allFiles) { + refs.set(file.path, createRef()); + } + return refs; + }, [allFiles]); + + // Scroll to selected file when selectedDiff changes + const scrolledRef = useRef(null); + useEffect(() => { + if (!selectedDiff?.path || scrolledRef.current === selectedDiff.path) return; + scrolledRef.current = selectedDiff.path; + + const ref = fileRefs.get(selectedDiff.path); + if (ref?.current) { + // Small delay to let lazy-rendered content mount + requestAnimationFrame(() => { + ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + onClearSelected(); + }, [selectedDiff, fileRefs, onClearSelected]); + + // Reset scroll tracking when selectedDiff is cleared + useEffect(() => { + if (!selectedDiff) { + scrolledRef.current = null; + } + }, [selectedDiff]); + + const handleToggleSplitView = useCallback((split: boolean) => { + setSplitView(split); + const mode = split ? 'split' : 'unified'; + localStorage.setItem('diff-view-mode', mode); + window.dispatchEvent(new CustomEvent('diff-view-mode-change', { detail: mode })); + }, []); + + const handleToggleReviewed = useCallback( + (path: string, reviewed: boolean) => { + if (reviewed) { + const file = allFiles.find((f) => f.path === path); + const diffHash = file ? hashDiff(file.diff) : ''; + markReviewed(path, diffHash); + } else { + markUnreviewed(path); + } + }, + [allFiles, markReviewed, markUnreviewed] ); - const selectedDiffPath = selectedDiff?.path ?? null; - // Use provided diff content (from commit or snapshot) or look up from current git status - const hasProvidedContent = Boolean(selectedDiff?.content); - - // Convert git status files to array for display (uncommitted changes) - const uncommittedFiles = useMemo(() => { - if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { - return []; - } - return Object.values(gitStatus.files) as FileInfo[]; - }, [gitStatus]); - - // Convert cumulative diff files to array (committed changes) - const committedFiles = useMemo>(() => { - if (!cumulativeDiff?.files || Object.keys(cumulativeDiff.files).length === 0) { - return []; - } - // Create a Set of uncommitted file paths for efficient lookup - const uncommittedPaths = new Set(uncommittedFiles.map(f => f.path)); - - return Object.entries(cumulativeDiff.files) - .filter(([path, file]) => file.diff && !uncommittedPaths.has(path)) // Exclude uncommitted files - .map(([path, file]) => ({ - path, - diff: file.diff!, - additions: file.additions ?? 0, - deletions: file.deletions ?? 0, - })); - }, [cumulativeDiff, uncommittedFiles]); - - const selectedFile = selectedDiffPath && gitStatus?.files ? gitStatus.files[selectedDiffPath] : null; - // Prioritize current git status for uncommitted files, use provided content only for historical diffs - // This ensures that when a file is modified by the agent, we show the latest diff - const selectedDiffContent = selectedFile?.diff ?? selectedDiff?.content ?? ''; - const isSingleDiffSelected = Boolean(selectedDiffPath && (hasProvidedContent || selectedFile)); - // Discard only works for uncommitted changes (not committed/historical diffs) - const canDiscard = Boolean(selectedDiffPath && selectedFile && !hasProvidedContent); - - const hasUncommitted = uncommittedFiles.length > 0; - const hasCommitted = committedFiles.length > 0; - const hasAnyChanges = hasUncommitted || hasCommitted; - - const handleDiscardClick = () => { - setShowDiscardDialog(true); - }; - - const handleDiscardConfirm = async () => { - if (!selectedDiffPath) { - return; - } - - try { - const result = await discard([selectedDiffPath]); - if (result.success) { - toast({ - title: 'Changes discarded', - description: `Successfully discarded changes to ${selectedDiffPath}`, - variant: 'success', - }); - onClearSelected(); - } else { - toast({ - title: 'Failed to discard changes', - description: result.error || 'An unknown error occurred', - variant: 'error', - }); + const handleDiscard = useCallback( + async (path: string) => { + try { + const result = await discard([path]); + if (result.success) { + toast({ title: 'Changes discarded', description: path, variant: 'success' }); + } else { + toast({ title: 'Discard failed', description: result.error || 'An error occurred', variant: 'error' }); + } + } catch (e) { + toast({ title: 'Discard failed', description: e instanceof Error ? e.message : 'An error occurred', variant: 'error' }); } - } catch (error) { - toast({ - title: 'Failed to discard changes', - description: error instanceof Error ? error.message : 'An unknown error occurred', - variant: 'error', + }, + [discard, toast] + ); + + const handleToggleAutoMark = useCallback( + (checked: boolean) => { + const next = { ...userSettings, reviewAutoMarkOnScroll: checked }; + setUserSettings(next); + + const client = getWebSocketClient(); + const payload = { review_auto_mark_on_scroll: checked }; + if (client) { + client.request('user.settings.update', payload).catch(() => { + updateUserSettings(payload, { cache: 'no-store' }).catch(() => { }); + }); + } else { + updateUserSettings(payload, { cache: 'no-store' }).catch(() => { }); + } + }, + [userSettings, setUserSettings] + ); + + const handleFixComments = useCallback(() => { + if (!activeSessionId || !activeTaskId) return; + const comments = getPendingComments(); + if (comments.length === 0) return; + + const markdown = formatReviewCommentsAsMarkdown(comments); + if (!markdown) return; + + const client = getWebSocketClient(); + if (client) { + client.request('message.add', { + task_id: activeTaskId, + session_id: activeSessionId, + content: markdown, + }).catch(() => { + toast({ title: 'Failed to send comments', variant: 'error' }); }); - } finally { - setShowDiscardDialog(false); } - }; + + markCommentsSent(comments.map((c) => c.id)); + }, [activeSessionId, activeTaskId, getPendingComments, markCommentsSent, toast]); + + const handleDialogSendComments = useCallback( + (comments: DiffComment[]) => { + if (!activeTaskId || !activeSessionId || comments.length === 0) return; + const client = getWebSocketClient(); + if (!client) return; + const markdown = formatReviewCommentsAsMarkdown(comments); + client.request('message.add', { + task_id: activeTaskId, + session_id: activeSessionId, + content: markdown, + }, 10000).catch(() => { + toast({ title: 'Failed to send comments', variant: 'error' }); + }); + setReviewDialogOpen(false); + }, + [activeTaskId, activeSessionId, toast] + ); + + const reviewedCount = reviewedFiles.size; + const totalCount = allFiles.length; + const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0; return ( -
- {/* Header */} -
- {selectedDiffPath && ( - <> - - - - - Show all changes - - - - - - - - - {canDiscard ? 'Discard changes' : hasProvidedContent ? 'Cannot discard committed changes' : 'Select an uncommitted file'} - - - +
+ {/* Top bar */} +
+ {/* Settings cog */} + + + + + + { + e.preventDefault(); + handleToggleAutoMark(!autoMarkOnScroll); + }} + > + + Auto-mark reviewed on scroll + + + + + {/* Progress bar */} + {totalCount > 0 && ( +
+
+
+
+ + {reviewedCount}/{totalCount} Reviewed + +
+ )} + +
+ + {/* Expand to review dialog */} + + + + + Expand review + + + {/* Word wrap */} + + + + + Toggle word wrap + + + {/* Split/Unified */} + + + + + {splitView ? 'Unified view' : 'Split view'} + + + {/* Fix Comments */} + {totalCommentCount > 0 && ( + )}
- {/* Diff content */} - - {isSingleDiffSelected && selectedDiffContent ? ( - // Render single file diff (from commit, snapshot, or uncommitted) - // Use timestamp from git status to ensure re-render when file changes -
- -
- ) : cumulativeLoading ? ( + {/* Content */} +
+ {cumulativeLoading && allFiles.length === 0 ? (
Loading changes...
- ) : !hasAnyChanges ? ( + ) : allFiles.length === 0 ? (
No changes
- ) : ( -
- {/* Uncommitted changes section */} - {hasUncommitted && ( -
-
- Uncommitted ({uncommittedFiles.length}) -
- {uncommittedFiles.map((file) => { - if (!file.diff) return null; - const normalizedDiff = normalizeDiffContent(file.diff); - const diffKey = `uncommitted-${file.path}-${normalizedDiff.length}`; - return ( -
- -
- ); - })} -
- )} + ) : activeSessionId ? ( + + ) : null} +
- {/* Committed changes section */} - {hasCommitted && ( -
-
- Committed ({committedFiles.length}) -
- {committedFiles.map((file) => { - if (!file.diff) return null; - const normalizedDiff = normalizeDiffContent(file.diff); - const diffKey = `committed-${file.path}-${normalizedDiff.length}`; - return ( -
-
-
- - - {file.path} - -
- - +{file.additions} - {' / '} - -{file.deletions} - -
- -
- ); - })} -
- )} -
- )} -
- - {/* Discard confirmation dialog */} - - - - Discard changes? - - This will permanently discard all changes to{' '} - {selectedDiffPath}. This action cannot be undone. - - - - Cancel - - Discard - - - - + {/* Review Dialog */} + {activeSessionId && ( + + )}
); }); diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index e7393f5824..838a2af450 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -33,6 +33,9 @@ import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useSessionCommits } from '@/hooks/domains/session/use-session-commits'; import { useGitOperations } from '@/hooks/use-git-operations'; +import { useSessionFileReviews } from '@/hooks/use-session-file-reviews'; +import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff'; +import { hashDiff, normalizeDiffContent } from '@/components/review/types'; import { getWebSocketClient } from '@/lib/ws/connection'; import { deleteFile } from '@/lib/ws/workspace-files'; import type { FileInfo } from '@/lib/state/store'; @@ -76,6 +79,8 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, const openEditor = useOpenSessionInEditor(activeSessionId ?? null); const gitOps = useGitOperations(activeSessionId ?? null); const { toast } = useToast(); + const { reviews } = useSessionFileReviews(activeSessionId); + const { diff: cumulativeDiff } = useCumulativeDiff(activeSessionId); // Track if we've initialized the tab for this session const hasInitializedTabRef = useRef(null); @@ -196,6 +201,52 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, })); }, [gitStatus]); + // Compute review progress across all files (uncommitted + committed) + const { reviewedCount, totalFileCount } = useMemo(() => { + const paths = new Set(); + + // Uncommitted + if (gitStatus?.files) { + for (const [path, file] of Object.entries(gitStatus.files)) { + const diff = file.diff ? normalizeDiffContent(file.diff) : ''; + if (diff) paths.add(path); + } + } + + // Committed (skip duplicates) + if (cumulativeDiff?.files) { + for (const [path, file] of Object.entries(cumulativeDiff.files)) { + if (paths.has(path)) continue; + const diff = file.diff ? normalizeDiffContent(file.diff) : ''; + if (diff) paths.add(path); + } + } + + let reviewed = 0; + for (const path of paths) { + const state = reviews.get(path); + if (!state?.reviewed) continue; + + // Check staleness: find the diff content for this path + let diffContent = ''; + const uncommittedFile = gitStatus?.files?.[path]; + if (uncommittedFile?.diff) { + diffContent = normalizeDiffContent(uncommittedFile.diff); + } else if (cumulativeDiff?.files?.[path]?.diff) { + diffContent = normalizeDiffContent(cumulativeDiff.files[path].diff!); + } + + if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) { + continue; // stale — don't count + } + reviewed++; + } + + return { reviewedCount: reviewed, totalFileCount: paths.size }; + }, [gitStatus, cumulativeDiff, reviews]); + + const reviewProgressPercent = totalFileCount > 0 ? (reviewedCount / totalFileCount) * 100 : 0; + // Clear pending stage/unstage spinners when git status updates useEffect(() => { if (pendingStageFiles.size > 0) { @@ -303,7 +354,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, rightContent={tabRightContent} > - +
{/* Uncommitted changes section */} {changedFiles.length > 0 && ( @@ -478,10 +529,34 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, {/* Empty state */} {changedFiles.length === 0 && commits.length === 0 && (
- No changes detected + Your changed files will appear here
)}
+ {/* Review progress — pushed to bottom of panel */} + {totalFileCount > 0 && ( + + +
window.dispatchEvent(new CustomEvent('switch-to-changes-tab'))} + > +
+
+
+ + {reviewedCount}/{totalFileCount} reviewed + +
+ + + {reviewedCount} of {totalFileCount} files reviewed + + + )} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index c4b0cb52f1..a152b4697b 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo, useState, useCallback } from 'react'; +import { memo, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { @@ -9,50 +9,25 @@ import { IconCopy, IconDots, IconGitBranch, - IconGitMerge, - IconGitPullRequest, - IconChevronDown, IconCheck, - IconLoader2, - IconCloudDownload, - IconCloudUpload, - IconGitCherryPick, - IconGitCommit, - IconAlertTriangle, IconSettings, } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogClose, -} from '@kandev/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, } from '@kandev/ui/dropdown-menu'; -import { Textarea } from '@kandev/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover'; -import { Checkbox } from '@kandev/ui/checkbox'; -import { Label } from '@kandev/ui/label'; import { CommitStatBadge } from '@/components/diff-stat'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; -import { useGitOperations } from '@/hooks/use-git-operations'; -import type { FileInfo } from '@/lib/state/slices'; import { formatUserHomePath } from '@/lib/utils'; import { EditorsMenu } from '@/components/task/editors-menu'; -import { useToast } from '@/components/toast-provider'; import { PreviewControls } from '@/components/task/preview/preview-controls'; +import { VcsSplitButton } from '@/components/vcs-split-button'; import { DEBUG_UI } from '@/lib/config'; type TaskTopBarProps = { @@ -90,142 +65,13 @@ const TaskTopBar = memo(function TaskTopBar({ const [popoverOpen, setPopoverOpen] = useState(false); const [copiedRepo, setCopiedRepo] = useState(false); const [copiedWorktree, setCopiedWorktree] = useState(false); - const [commitDialogOpen, setCommitDialogOpen] = useState(false); - const [commitMessage, setCommitMessage] = useState(''); - const [stageAll, setStageAll] = useState(true); - const [prDialogOpen, setPrDialogOpen] = useState(false); - const [prTitle, setPrTitle] = useState(''); - const [prBody, setPrBody] = useState(''); - const [prDraft, setPrDraft] = useState(true); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const router = useRouter(); - const { toast } = useToast(); const gitStatus = useSessionGitStatus(activeSessionId ?? null); - const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } = useGitOperations(activeSessionId ?? null); - // Use worktree branch if available, otherwise fall back to base branch const displayBranch = worktreeBranch || baseBranch; - // Check if the remote branch actually tracks the current branch (not just origin/main as base) - // Pull/Push badges should only show when remote_branch is origin/ - const currentBranch = gitStatus?.branch; - const remoteBranch = gitStatus?.remote_branch; - const hasMatchingUpstream = remoteBranch && currentBranch && remoteBranch === `origin/${currentBranch}`; - - // Calculate total additions and deletions from uncommitted files. - let uncommittedAdditions = 0; - let uncommittedDeletions = 0; - if (gitStatus?.files && Object.keys(gitStatus.files).length > 0) { - for (const file of Object.values(gitStatus.files) as FileInfo[]) { - uncommittedAdditions += file.additions || 0; - uncommittedDeletions += file.deletions || 0; - } - } - - const handleGitOperation = useCallback(async ( - operation: () => Promise<{ success: boolean; output: string; error?: string; conflict_files?: string[] }>, - operationName: string - ) => { - try { - const result = await operation(); - if (result.success) { - toast({ - title: `${operationName} successful`, - description: result.output.slice(0, 200) || `${operationName} completed successfully`, - variant: 'success', - }); - } else { - toast({ - title: `${operationName} failed`, - description: result.error || 'An error occurred', - variant: 'error', - }); - - } - } catch (error) { - toast({ - title: `${operationName} failed`, - description: error instanceof Error ? error.message : 'An unexpected error occurred', - variant: 'error', - }); - } - }, [toast]); - - const handlePull = useCallback(() => { - handleGitOperation(() => pull(), 'Pull'); - }, [handleGitOperation, pull]); - - const handlePush = useCallback((force = false) => { - handleGitOperation(() => push({ force }), force ? 'Force Push' : 'Push'); - }, [handleGitOperation, push]); - - const handleRebase = useCallback(() => { - // Rebase onto the base branch (e.g., main) - // Backend expects just the branch name without 'origin/' prefix - const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main'; - handleGitOperation(() => rebase(targetBranch), 'Rebase'); - }, [handleGitOperation, rebase, baseBranch]); - - const handleMerge = useCallback(() => { - // Merge from the base branch (e.g., main) - // Backend expects just the branch name without 'origin/' prefix - const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main'; - handleGitOperation(() => merge(targetBranch), 'Merge'); - }, [handleGitOperation, merge, baseBranch]); - - const handleOpenCommitDialog = useCallback(() => { - setCommitMessage(''); - setStageAll(true); - setCommitDialogOpen(true); - }, []); - - const handleCommit = useCallback(async () => { - if (!commitMessage.trim()) return; - setCommitDialogOpen(false); - await handleGitOperation(() => commit(commitMessage.trim(), stageAll), 'Commit'); - setCommitMessage(''); - }, [commitMessage, stageAll, handleGitOperation, commit]); - - const handleOpenPRDialog = useCallback(() => { - setPrTitle(taskTitle || ''); - setPrBody(''); - setPrDialogOpen(true); - }, [taskTitle]); - - const handleCreatePR = useCallback(async () => { - if (!prTitle.trim()) return; - setPrDialogOpen(false); - try { - const result = await createPR(prTitle.trim(), prBody.trim(), baseBranch, prDraft); - if (result.success) { - toast({ - title: prDraft ? 'Draft Pull Request created' : 'Pull Request created', - description: result.pr_url || 'PR created successfully', - variant: 'success', - }); - // Open the PR URL in a new tab - if (result.pr_url) { - window.open(result.pr_url, '_blank'); - } - } else { - toast({ - title: 'Create PR failed', - description: result.error || 'An error occurred', - variant: 'error', - }); - } - } catch (e) { - toast({ - title: 'Create PR failed', - description: e instanceof Error ? e.message : 'An error occurred', - variant: 'error', - }); - } - setPrTitle(''); - setPrBody(''); - }, [prTitle, prBody, baseBranch, prDraft, createPR, toast]); - const handleBranchClick = (e: React.MouseEvent) => { e.stopPropagation(); if (displayBranch) { @@ -400,125 +246,12 @@ const TaskTopBar = memo(function TaskTopBar({ - {/* Commit Split Button */} -
- - - - - - {gitStatus?.files && Object.keys(gitStatus.files).length > 0 - ? `Commit ${Object.keys(gitStatus.files).length} changed file${Object.keys(gitStatus.files).length !== 1 ? 's' : ''}` - : 'No changes to commit'} - - - - - - - - - - Create PR - - - - - Pull - {/* Only show pull count when upstream matches current branch (origin/) */} - {hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && ( - - ↓{gitStatus?.behind} - - )} - - - - - Push - {/* Only show push count when upstream matches current branch (origin/) */} - {hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && ( - - ↑{gitStatus?.ahead} - - )} - - - handlePush(false)} - disabled={isGitLoading || !activeSessionId} - > - - Push - - handlePush(true)} - disabled={isGitLoading || !activeSessionId} - > - - Force Push - - - - - - - Rebase - onto {baseBranch || 'origin/main'} - - - - Merge - from {baseBranch || 'origin/main'} - - - -
+ {/* More Options (3-dot) Dropdown */} @@ -552,158 +285,6 @@ const TaskTopBar = memo(function TaskTopBar({
- {/* Commit Dialog */} - - - - - - Commit Changes - - -
-
- {gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? ( - - {Object.keys(gitStatus.files).length} file{Object.keys(gitStatus.files).length !== 1 ? 's' : ''} changed - {(uncommittedAdditions > 0 || uncommittedDeletions > 0) && ( - - (+{uncommittedAdditions} - {' / '} - -{uncommittedDeletions}) - - )} - - ) : ( - No changes to commit - )} -
-