feat: review ux (#210)

feat: review dialog
This commit is contained in:
Carlos Florêncio
2026-02-08 11:14:15 +00:00
committed by GitHub
parent daccb38354
commit 3717d6982f
40 changed files with 2658 additions and 802 deletions
@@ -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))
@@ -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 }
@@ -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{
@@ -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
@@ -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
@@ -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)
@@ -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
}
@@ -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
}
@@ -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
+31 -28
View File
@@ -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),
}
}
+36 -32
View File
@@ -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)
+12 -11
View File
@@ -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"`
}
+16 -11
View File
@@ -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))
+24 -16
View File
@@ -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
}
+5
View File
@@ -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
+3 -1
View File
@@ -48,13 +48,15 @@ function DialogContent({
className,
children,
showCloseButton = true,
overlayClassName,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
overlayClassName?: string
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
+1
View File
@@ -59,6 +59,7 @@ export default async function Page({ searchParams }: PageProps) {
defaultEditorId: userSettings?.default_editor_id || null,
enablePreviewOnClick: settingsEnablePreviewOnClick,
chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: userSettings?.review_auto_mark_on_scroll ?? true,
loaded: Boolean(userSettings),
},
};
+1
View File
@@ -227,6 +227,7 @@ export default async function SessionPage({
defaultEditorId: userSettings?.default_editor_id || null,
enablePreviewOnClick: userSettings?.enable_preview_on_click ?? false,
chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: userSettings?.review_auto_mark_on_scroll ?? true,
loaded: Boolean(userSettings),
},
};
+7 -2
View File
@@ -98,6 +98,8 @@ interface DiffViewerProps {
hideHeader?: boolean;
/** Callback to open file in editor */
onOpenFile?: (filePath: string) => 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<SelectedLineRange | null>(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 {
@@ -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}
/>
);
});
@@ -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<string, FileInfo> | null;
cumulativeDiff: CumulativeDiff | null;
};
export const ReviewDialog = memo(function ReviewDialog({
open,
onOpenChange,
sessionId,
baseBranch,
taskTitle,
onSendComments,
gitStatusFiles,
cumulativeDiff,
}: ReviewDialogProps) {
const [selectedFile, setSelectedFile] = useState<string | null>(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<ReviewFile[]>(() => {
const fileMap = new Map<string, ReviewFile>();
// 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<string>();
const stale = new Set<string>();
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<string, number> = {};
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<string, React.RefObject<HTMLDivElement | null>>();
for (const file of allFiles) {
refs.set(file.path, createRef<HTMLDivElement>());
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-7xl max-w-[calc(100vw-2rem)] max-h-[85vh] w-full h-[85vh] p-0 gap-0 flex flex-col"
showCloseButton={false}
overlayClassName="backdrop-blur-none bg-black/40"
>
<DialogTitle className="sr-only">Review Changes</DialogTitle>
{/* Top bar */}
<ReviewTopBar
sessionId={sessionId}
reviewedCount={reviewedCount}
totalCount={totalCount}
commentCount={totalCommentCount}
baseBranch={baseBranch}
taskTitle={taskTitle}
splitView={splitView}
onToggleSplitView={handleToggleSplitView}
wordWrap={wordWrap}
onToggleWordWrap={setWordWrap}
onSendComments={handleSendComments}
onClose={() => onOpenChange(false)}
getPendingComments={getPendingComments}
markCommentsSent={markCommentsSent}
/>
{/* Content area */}
<div className="flex flex-1 min-h-0">
{/* File tree sidebar */}
<div className="w-[280px] min-w-[220px] border-r border-border flex-shrink-0 overflow-hidden">
<ReviewFileTree
files={allFiles}
reviewedFiles={reviewedFiles}
staleFiles={staleFiles}
commentCountByFile={commentCountByFile}
selectedFile={selectedFile}
onSelectFile={handleSelectFile}
onToggleReviewed={handleToggleReviewed}
/>
</div>
{/* Diff list */}
<div className="flex-1 min-w-0 overflow-hidden">
{allFiles.length > 0 ? (
<ReviewDiffList
files={allFiles}
reviewedFiles={reviewedFiles}
staleFiles={staleFiles}
sessionId={sessionId}
autoMarkOnScroll={autoMarkOnScroll}
wordWrap={wordWrap}
onToggleReviewed={handleToggleReviewed}
onDiscard={handleDiscard}
fileRefs={fileRefs}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No changes to review
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
});
@@ -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<string>;
staleFiles: Set<string>;
sessionId: string;
autoMarkOnScroll: boolean;
wordWrap: boolean;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
};
export const ReviewDiffList = memo(function ReviewDiffList({
files,
reviewedFiles,
staleFiles,
sessionId,
autoMarkOnScroll,
wordWrap,
onToggleReviewed,
onDiscard,
fileRefs,
}: ReviewDiffListProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
return (
<div ref={scrollContainerRef} className="overflow-y-auto h-full">
{files.map((file) => (
<FileDiffSection
key={file.path}
file={file}
isReviewed={reviewedFiles.has(file.path) && !staleFiles.has(file.path)}
isStale={staleFiles.has(file.path)}
sessionId={sessionId}
autoMarkOnScroll={autoMarkOnScroll}
wordWrap={wordWrap}
onToggleReviewed={onToggleReviewed}
onDiscard={onDiscard}
sectionRef={fileRefs.get(file.path)}
scrollContainer={scrollContainerRef}
/>
))}
</div>
);
});
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<HTMLDivElement | null>;
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
function FileDiffSection({
file,
isReviewed,
isStale,
sessionId,
autoMarkOnScroll,
wordWrap,
onToggleReviewed,
onDiscard,
sectionRef,
scrollContainer,
}: FileDiffSectionProps) {
const [isVisible, setIsVisible] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(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 (
<div ref={sectionRef} className="border-b border-border">
{/* Sentinel for auto-mark-on-scroll */}
<div ref={scrollSentinelRef} className="h-0" />
{/* Sticky file header */}
<div className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-card/95 backdrop-blur-sm border-b border-border/50">
<Checkbox
checked={isReviewed}
onCheckedChange={handleCheckboxChange}
className="h-4 w-4 cursor-pointer"
/>
<span className="text-sm font-medium truncate">{file.path}</span>
<div className="flex-1" />
{isStale && (
<span className="flex items-center gap-1 text-xs text-yellow-500">
<IconAlertTriangle className="h-3.5 w-3.5" />
changed
</span>
)}
<span className="text-xs text-muted-foreground">
{file.additions > 0 && <span className="text-emerald-500">+{file.additions}</span>}
{file.additions > 0 && file.deletions > 0 && ' / '}
{file.deletions > 0 && <span className="text-rose-500">-{file.deletions}</span>}
</span>
{/* File actions */}
<div className="flex items-center gap-0.5">
{/* Copy path */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={handleCopyPath}
>
<IconCopy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy file path</TooltipContent>
</Tooltip>
{/* Discard changes */}
{file.source === 'uncommitted' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100 hover:text-destructive"
onClick={handleDiscard}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Revert changes</TooltipContent>
</Tooltip>
)}
</div>
</div>
{/* Sentinel for lazy loading */}
<div ref={sentinelRef} />
{/* Diff content (lazy rendered) */}
{isVisible && file.diff ? (
<div className="px-2 py-2">
<FileDiffViewer
filePath={file.path}
diff={file.diff}
status={file.status}
enableComments
sessionId={sessionId}
hideHeader
wordWrap={wordWrap}
/>
</div>
) : (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
Loading diff...
</div>
)}
</div>
);
}
@@ -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<string>;
staleFiles: Set<string>;
commentCountByFile: Record<string, number>;
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 (
<div className="flex flex-col h-full overflow-y-auto text-sm">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b border-border">
Files
</div>
<div className="py-1">
{tree.map((node) => (
<TreeNode
key={node.path}
node={node}
reviewedFiles={reviewedFiles}
staleFiles={staleFiles}
commentCountByFile={commentCountByFile}
selectedFile={selectedFile}
onSelectFile={onSelectFile}
onToggleReviewed={onToggleReviewed}
depth={0}
/>
))}
</div>
</div>
);
});
type TreeNodeProps = {
node: FileTreeNode;
reviewedFiles: Set<string>;
staleFiles: Set<string>;
commentCountByFile: Record<string, number>;
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 (
<div>
<button
type="button"
className="flex items-center w-full gap-1 px-2 py-1 hover:bg-muted/50 transition-colors cursor-pointer"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={handleToggle}
>
{expanded ? (
<IconChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<IconChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground truncate">{node.name}</span>
<span className="ml-auto text-[10px] text-muted-foreground/60">
{dirReviewed}/{dirTotal}
</span>
</button>
{expanded && node.children && (
<div>
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
reviewedFiles={reviewedFiles}
staleFiles={staleFiles}
commentCountByFile={commentCountByFile}
selectedFile={selectedFile}
onSelectFile={onSelectFile}
onToggleReviewed={onToggleReviewed}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
// 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 (
<div
className={cn(
'flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors group',
isSelected ? 'bg-accent/50' : 'hover:bg-muted/50'
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onSelectFile(file.path)}
>
<Checkbox
checked={isReviewed && !isStale}
onCheckedChange={(checked) => {
onToggleReviewed(file.path, checked === true);
}}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5"
/>
<FileIcon fileName={node.name} className="h-4 w-4 shrink-0" />
<span className="text-xs truncate flex-1">{node.name}</span>
{/* Changed badge (stale) */}
{isStale && (
<IconAlertTriangle className="h-3 w-3 text-yellow-500 shrink-0" />
)}
{/* Comment count */}
{commentCount > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-blue-500">
<IconMessage className="h-3 w-3" />
{commentCount}
</span>
)}
{/* File status */}
<FileStatusIcon status={file.status as 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'} />
{/* +/- stats */}
<span className="text-[10px] text-muted-foreground/60 whitespace-nowrap">
{file.additions > 0 && <span className="text-emerald-500">+{file.additions}</span>}
{file.additions > 0 && file.deletions > 0 && ' '}
{file.deletions > 0 && <span className="text-rose-500">-{file.deletions}</span>}
</span>
</div>
);
}
/** 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;
}
@@ -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 (
<>
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card/50 min-h-[48px]">
{/* Settings cog */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="px-2 cursor-pointer">
<IconSettings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={(e) => {
e.preventDefault();
handleToggleAutoMark(!reviewAutoMarkOnScroll);
}}
>
<Checkbox
checked={reviewAutoMarkOnScroll}
className="pointer-events-none"
/>
<span className="text-sm flex-1">
Auto-mark reviewed on scroll
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Progress bar */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden max-w-[200px]">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{reviewedCount} of {totalCount} files reviewed
</span>
</div>
{/* Word wrap toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className={`px-2 cursor-pointer ${wordWrap ? 'bg-muted' : ''}`}
onClick={() => onToggleWordWrap(!wordWrap)}
>
<IconTextWrap className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle word wrap</TooltipContent>
</Tooltip>
{/* Split/Unified toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="px-2 cursor-pointer"
onClick={() => onToggleSplitView(!splitView)}
>
{splitView ? (
<IconLayoutRows className="h-4 w-4" />
) : (
<IconLayoutColumns className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{splitView ? 'Switch to unified view' : 'Switch to split view'}</TooltipContent>
</Tooltip>
{/* Fix Comments button */}
{commentCount > 0 && (
<Button size="sm" variant="outline" className="cursor-pointer" onClick={handleFixComments}>
<IconMessageForward className="h-4 w-4" />
Fix Comments
<span className="ml-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
{commentCount}
</span>
</Button>
)}
<VcsSplitButton
sessionId={sessionId}
baseBranch={baseBranch}
taskTitle={taskTitle}
/>
{/* Close button */}
<Button size="sm" variant="ghost" className="px-2 cursor-pointer" onClick={onClose}>
<IconX className="h-4 w-4" />
</Button>
</div>
</>
);
});
+117
View File
@@ -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 ?? []);
}
@@ -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,
});
}
@@ -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);
+395 -264
View File
@@ -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<ReviewFile[]>(() => {
const fileMap = new Map<string, ReviewFile>();
// 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<string>();
const stale = new Set<string>();
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<string, number> = {};
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<string, React.RefObject<HTMLDivElement | null>>();
for (const file of allFiles) {
refs.set(file.path, createRef<HTMLDivElement>());
}
return refs;
}, [allFiles]);
// Scroll to selected file when selectedDiff changes
const scrolledRef = useRef<string | null>(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<FileInfo[]>(() => {
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<Array<{ path: string; diff: string; additions: number; deletions: number }>>(() => {
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 (
<div className="flex flex-col gap-2 h-full">
{/* Header */}
<div className="flex items-center justify-between gap-3">
{selectedDiffPath && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs cursor-pointer ml-auto"
onClick={onClearSelected}
>
Clear
</Button>
</TooltipTrigger>
<TooltipContent>Show all changes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs cursor-pointer"
disabled={!canDiscard || isDiscarding}
onClick={handleDiscardClick}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{canDiscard ? 'Discard changes' : hasProvidedContent ? 'Cannot discard committed changes' : 'Select an uncommitted file'}
</TooltipContent>
</Tooltip>
</>
<div className="flex flex-col h-full">
{/* Top bar */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-card/50 min-h-[36px]">
{/* Settings cog */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="px-1.5 h-7 cursor-pointer">
<IconSettings className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={(e) => {
e.preventDefault();
handleToggleAutoMark(!autoMarkOnScroll);
}}
>
<Checkbox
checked={autoMarkOnScroll}
className="pointer-events-none"
/>
<span className="text-sm flex-1">Auto-mark reviewed on scroll</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Progress bar */}
{totalCount > 0 && (
<div className="flex items-center gap-2 min-w-0">
<div className="w-20 h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{reviewedCount}/{totalCount} Reviewed
</span>
</div>
)}
<div className="flex-1" />
{/* Expand to review dialog */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="px-1.5 h-7 cursor-pointer"
onClick={() => setReviewDialogOpen(true)}
>
<IconArrowsMaximize className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand review</TooltipContent>
</Tooltip>
{/* Word wrap */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className={`px-1.5 h-7 cursor-pointer ${wordWrap ? 'bg-muted' : ''}`}
onClick={() => setWordWrap(!wordWrap)}
>
<IconTextWrap className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle word wrap</TooltipContent>
</Tooltip>
{/* Split/Unified */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="px-1.5 h-7 cursor-pointer"
onClick={() => handleToggleSplitView(!splitView)}
>
{splitView ? (
<IconLayoutRows className="h-3.5 w-3.5" />
) : (
<IconLayoutColumns className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{splitView ? 'Unified view' : 'Split view'}</TooltipContent>
</Tooltip>
{/* Fix Comments */}
{totalCommentCount > 0 && (
<Button size="sm" variant="outline" className="h-7 text-xs cursor-pointer" onClick={handleFixComments}>
<IconMessageForward className="h-3.5 w-3.5" />
Fix
<span className="ml-0.5 rounded-full bg-blue-500/30 px-1 py-0 text-[10px] font-medium text-blue-600 dark:text-blue-400">
{totalCommentCount}
</span>
</Button>
)}
</div>
{/* Diff content */}
<SessionPanelContent>
{isSingleDiffSelected && selectedDiffContent ? (
// Render single file diff (from commit, snapshot, or uncommitted)
// Use timestamp from git status to ensure re-render when file changes
<div key={`selected-${selectedDiffPath}-${gitStatus?.timestamp ?? ''}-${selectedDiffContent.length}`} className="space-y-4">
<FileDiffViewer
filePath={selectedDiffPath!}
diff={normalizeDiffContent(selectedDiffContent)}
status="M"
enableComments={!!activeSessionId}
sessionId={activeSessionId ?? undefined}
onOpenFile={onOpenFile}
/>
</div>
) : cumulativeLoading ? (
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{cumulativeLoading && allFiles.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading changes...
</div>
) : !hasAnyChanges ? (
) : allFiles.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No changes
</div>
) : (
<div className="space-y-6">
{/* Uncommitted changes section */}
{hasUncommitted && (
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Uncommitted ({uncommittedFiles.length})
</div>
{uncommittedFiles.map((file) => {
if (!file.diff) return null;
const normalizedDiff = normalizeDiffContent(file.diff);
const diffKey = `uncommitted-${file.path}-${normalizedDiff.length}`;
return (
<div key={diffKey} className="space-y-2">
<FileDiffViewer
filePath={file.path}
diff={normalizedDiff}
status={file.status}
enableComments={!!activeSessionId}
sessionId={activeSessionId ?? undefined}
onOpenFile={onOpenFile}
/>
</div>
);
})}
</div>
)}
) : activeSessionId ? (
<ReviewDiffList
files={allFiles}
reviewedFiles={reviewedFiles}
staleFiles={staleFiles}
sessionId={activeSessionId}
autoMarkOnScroll={autoMarkOnScroll}
wordWrap={wordWrap}
onToggleReviewed={handleToggleReviewed}
onDiscard={handleDiscard}
fileRefs={fileRefs}
/>
) : null}
</div>
{/* Committed changes section */}
{hasCommitted && (
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Committed ({committedFiles.length})
</div>
{committedFiles.map((file) => {
if (!file.diff) return null;
const normalizedDiff = normalizeDiffContent(file.diff);
const diffKey = `committed-${file.path}-${normalizedDiff.length}`;
return (
<div key={diffKey} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileStatusIcon status="modified" />
<Badge variant="secondary" className="rounded-full text-xs">
{file.path}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
<span className="text-emerald-500">+{file.additions}</span>
{' / '}
<span className="text-rose-500">-{file.deletions}</span>
</span>
</div>
<FileDiffViewer
filePath={file.path}
diff={normalizedDiff}
status="M"
enableComments={!!activeSessionId}
sessionId={activeSessionId ?? undefined}
onOpenFile={onOpenFile}
/>
</div>
);
})}
</div>
)}
</div>
)}
</SessionPanelContent>
{/* Discard confirmation dialog */}
<AlertDialog open={showDiscardDialog} onOpenChange={setShowDiscardDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently discard all changes to{' '}
<span className="font-semibold">{selectedDiffPath}</span>. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDiscardConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Discard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Review Dialog */}
{activeSessionId && (
<ReviewDialog
open={reviewDialogOpen}
onOpenChange={setReviewDialogOpen}
sessionId={activeSessionId}
baseBranch={baseBranch}
taskTitle={taskTitle}
onSendComments={handleDialogSendComments}
gitStatusFiles={gitStatus?.files ?? null}
cumulativeDiff={cumulativeDiff}
/>
)}
</div>
);
});
+77 -2
View File
@@ -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<string | null>(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<string>();
// 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}
>
<TabsContent value="diff" className="flex-1 min-h-0">
<SessionPanelContent>
<SessionPanelContent className="flex flex-col">
<div className="space-y-4">
{/* 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 && (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
No changes detected
Your changed files will appear here
</div>
)}
</div>
{/* Review progress — pushed to bottom of panel */}
{totalFileCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="mt-auto flex items-center gap-2 pt-2 cursor-pointer transition-colors"
onClick={() => window.dispatchEvent(new CustomEvent('switch-to-changes-tab'))}
>
<div className="flex-1 h-0.5 rounded-full bg-muted-foreground/10 overflow-hidden">
<div
className="h-full bg-muted-foreground/25 rounded-full transition-all duration-300"
style={{ width: `${reviewProgressPercent}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground/40 whitespace-nowrap">
{reviewedCount}/{totalFileCount} reviewed
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{reviewedCount} of {totalFileCount} files reviewed
</TooltipContent>
</Tooltip>
)}
</SessionPanelContent>
</TabsContent>
<TabsContent value="files" className="flex-1 min-h-0">
+8 -427
View File
@@ -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/<current_branch>
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({
<PreviewControls activeSessionId={activeSessionId ?? null} hasDevScript={hasDevScript} />
<EditorsMenu activeSessionId={activeSessionId ?? null} />
{/* Commit Split Button */}
<div className="inline-flex rounded-md border border-border overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className={`rounded-none border-0 cursor-pointer ${
gitStatus?.files && Object.keys(gitStatus.files).length > 0
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20'
: ''
}`}
onClick={handleOpenCommitDialog}
disabled={isGitLoading || !activeSessionId}
>
<IconGitCommit className={`h-4 w-4 ${gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? 'text-amber-500' : ''}`} />
Commit
{gitStatus?.files && Object.keys(gitStatus.files).length > 0 && (
<span className="ml-1 rounded-full bg-amber-500/20 px-1.5 py-0.5 text-xs font-medium">
{Object.keys(gitStatus.files).length}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{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'}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="rounded-none border-0 border-l px-2 cursor-pointer" disabled={isGitLoading}>
{isGitLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconChevronDown className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleOpenPRDialog}
disabled={isGitLoading || !activeSessionId}
>
<IconGitPullRequest className="h-4 w-4 text-cyan-500" />
<span className="flex-1">Create PR</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handlePull}
disabled={isGitLoading || !activeSessionId}
>
<IconCloudDownload className="h-4 w-4 text-blue-500" />
<span className="flex-1">Pull</span>
{/* Only show pull count when upstream matches current branch (origin/<branch>) */}
{hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && (
<span className="rounded-full bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
↓{gitStatus?.behind}
</span>
)}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="cursor-pointer gap-3"
disabled={isGitLoading || !activeSessionId}
>
<IconCloudUpload className="h-4 w-4 text-green-500" />
<span className="flex-1">Push</span>
{/* Only show push count when upstream matches current branch (origin/<branch>) */}
{hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && (
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-600 dark:text-green-400">
↑{gitStatus?.ahead}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={() => handlePush(false)}
disabled={isGitLoading || !activeSessionId}
>
<IconCloudUpload className="h-4 w-4 text-green-500" />
<span>Push</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={() => handlePush(true)}
disabled={isGitLoading || !activeSessionId}
>
<IconAlertTriangle className="h-4 w-4 text-orange-500" />
<span>Force Push</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleRebase}
disabled={isGitLoading || !activeSessionId}
>
<IconGitCherryPick className="h-4 w-4 text-orange-500" />
<span className="flex-1">Rebase</span>
<span className="text-xs text-muted-foreground">onto {baseBranch || 'origin/main'}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleMerge}
disabled={isGitLoading || !activeSessionId}
>
<IconGitMerge className="h-4 w-4 text-purple-500" />
<span className="flex-1">Merge</span>
<span className="text-xs text-muted-foreground">from {baseBranch || 'origin/main'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<VcsSplitButton
sessionId={activeSessionId ?? null}
baseBranch={baseBranch}
taskTitle={taskTitle}
displayBranch={displayBranch}
/>
{/* More Options (3-dot) Dropdown */}
<DropdownMenu open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
@@ -552,158 +285,6 @@ const TaskTopBar = memo(function TaskTopBar({
</DropdownMenu>
</div>
{/* Commit Dialog */}
<Dialog open={commitDialogOpen} onOpenChange={setCommitDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitCommit className="h-5 w-5 text-amber-500" />
Commit Changes
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="text-sm text-muted-foreground">
{gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? (
<span>
<span className="font-medium text-foreground">{Object.keys(gitStatus.files).length}</span> file{Object.keys(gitStatus.files).length !== 1 ? 's' : ''} changed
{(uncommittedAdditions > 0 || uncommittedDeletions > 0) && (
<span className="ml-2">
(<span className="text-green-600">+{uncommittedAdditions}</span>
{' / '}
<span className="text-red-600">-{uncommittedDeletions}</span>)
</span>
)}
</span>
) : (
<span>No changes to commit</span>
)}
</div>
<Textarea
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={4}
className="resize-none"
autoFocus
/>
<div className="flex items-center gap-2">
<Checkbox
id="stage-all"
checked={stageAll}
onCheckedChange={(checked) => setStageAll(checked === true)}
/>
<Label htmlFor="stage-all" className="text-sm text-muted-foreground cursor-pointer">
Stage all changes before committing
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
onClick={handleCommit}
disabled={!commitMessage.trim() || isGitLoading}
>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Committing...
</>
) : (
<>
<IconCheck className="h-4 w-4 mr-2" />
Commit
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create PR Dialog */}
<Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitPullRequest className="h-5 w-5 text-cyan-500" />
Create Pull Request
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="text-sm text-muted-foreground">
{baseBranch ? (
<span>
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span> → <span className="font-medium text-foreground">{baseBranch}</span>
</span>
) : (
<span>
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span>
</span>
)}
</div>
<div className="space-y-2">
<Label htmlFor="pr-title" className="text-sm">Title</Label>
<input
id="pr-title"
type="text"
placeholder="Pull request title..."
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="pr-body" className="text-sm">Description</Label>
<Textarea
id="pr-body"
placeholder="Describe your changes..."
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
rows={6}
className="resize-none"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="pr-draft"
checked={prDraft}
onCheckedChange={(checked) => setPrDraft(checked === true)}
/>
<Label htmlFor="pr-draft" className="text-sm cursor-pointer">
Create as draft
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
onClick={handleCreatePR}
disabled={!prTitle.trim() || isGitLoading}
className="bg-cyan-600 hover:bg-cyan-700 text-white"
>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
<>
<IconGitPullRequest className="h-4 w-4 mr-2" />
Create PR
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</header>
);
});
+560
View File
@@ -0,0 +1,560 @@
'use client';
import { memo, useState, useCallback } from 'react';
import {
IconGitCommit,
IconGitPullRequest,
IconGitMerge,
IconGitCherryPick,
IconLoader2,
IconCheck,
IconChevronDown,
IconCloudDownload,
IconCloudUpload,
IconAlertTriangle,
} 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 { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { Checkbox } from '@kandev/ui/checkbox';
import { Label } from '@kandev/ui/label';
import { Textarea } from '@kandev/ui/textarea';
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
import { useGitOperations } from '@/hooks/use-git-operations';
import { useToast } from '@/components/toast-provider';
import type { FileInfo } from '@/lib/state/slices';
type VcsSplitButtonProps = {
sessionId: string | null;
baseBranch?: string;
taskTitle?: string;
/** Branch name shown in the PR dialog (e.g. "feature/foo → main") */
displayBranch?: string | null;
};
const VcsSplitButton = memo(function VcsSplitButton({
sessionId,
baseBranch,
taskTitle,
displayBranch,
}: VcsSplitButtonProps) {
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 { toast } = useToast();
const gitStatus = useSessionGitStatus(sessionId);
const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } =
useGitOperations(sessionId);
// Check if remote branch tracks current branch
const currentBranch = gitStatus?.branch;
const remoteBranch = gitStatus?.remote_branch;
const hasMatchingUpstream =
remoteBranch && currentBranch && remoteBranch === `origin/${currentBranch}`;
// Uncommitted file stats
const uncommittedFileCount = gitStatus?.files
? Object.keys(gitStatus.files).length
: 0;
let uncommittedAdditions = 0;
let uncommittedDeletions = 0;
if (gitStatus?.files && uncommittedFileCount > 0) {
for (const file of Object.values(gitStatus.files) as FileInfo[]) {
uncommittedAdditions += file.additions || 0;
uncommittedDeletions += file.deletions || 0;
}
}
const aheadCount = gitStatus?.ahead ?? 0;
const behindCount = gitStatus?.behind ?? 0;
// --- handlers ---
const handleGitOperation = useCallback(
async (
operation: () => Promise<{
success: boolean;
output: string;
error?: string;
}>,
operationName: string
) => {
try {
const result = await operation();
if (result.success) {
toast({
title: `${operationName} successful`,
description:
result.output.slice(0, 200) || `${operationName} completed`,
variant: 'success',
});
} else {
toast({
title: `${operationName} failed`,
description: result.error || 'An error occurred',
variant: 'error',
});
}
} catch (e) {
toast({
title: `${operationName} failed`,
description:
e instanceof Error ? e.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(() => {
const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main';
handleGitOperation(() => rebase(targetBranch), 'Rebase');
}, [handleGitOperation, rebase, baseBranch]);
const handleMerge = useCallback(() => {
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 PR created' : 'PR created',
description: result.pr_url || 'PR created successfully',
variant: 'success',
});
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]);
// --- dynamic primary action ---
const primaryAction =
uncommittedFileCount > 0
? ('commit' as const)
: aheadCount > 0
? ('pr' as const)
: behindCount > 0
? ('rebase' as const)
: ('commit' as const);
const primaryButtonConfig = {
commit: {
icon: <IconGitCommit className="h-4 w-4" />,
label: 'Commit',
badge: uncommittedFileCount > 0 ? uncommittedFileCount : null,
tooltip:
uncommittedFileCount > 0
? `Commit ${uncommittedFileCount} changed file${uncommittedFileCount !== 1 ? 's' : ''}`
: 'No changes to commit',
onClick: handleOpenCommitDialog,
},
pr: {
icon: <IconGitPullRequest className="h-4 w-4" />,
label: 'Create PR',
badge: aheadCount > 0 ? aheadCount : null,
tooltip: `Create PR (${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead)`,
onClick: handleOpenPRDialog,
},
rebase: {
icon: <IconGitCherryPick className="h-4 w-4" />,
label: 'Rebase',
badge: behindCount > 0 ? behindCount : null,
tooltip: `Rebase onto ${baseBranch || 'origin/main'} (${behindCount} behind)`,
onClick: handleRebase,
},
}[primaryAction];
return (
<>
{/* Split button */}
<div className="inline-flex rounded-md border border-border overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 cursor-pointer"
onClick={primaryButtonConfig.onClick}
disabled={isGitLoading || !sessionId}
>
{primaryButtonConfig.icon}
{primaryButtonConfig.label}
{primaryButtonConfig.badge != null && (
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{primaryButtonConfig.badge}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>{primaryButtonConfig.tooltip}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 border-l px-2 cursor-pointer"
disabled={isGitLoading || !sessionId}
>
{isGitLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconChevronDown className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleOpenPRDialog}
disabled={isGitLoading || !sessionId}
>
<IconGitPullRequest className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Create PR</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handlePull}
disabled={isGitLoading || !sessionId}
>
<IconCloudDownload className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Pull</span>
{hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
↓{gitStatus?.behind}
</span>
)}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="cursor-pointer gap-3"
disabled={isGitLoading || !sessionId}
>
<IconCloudUpload className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Push</span>
{hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
↑{gitStatus?.ahead}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={() => handlePush(false)}
disabled={isGitLoading || !sessionId}
>
<IconCloudUpload className="h-4 w-4 text-muted-foreground" />
<span>Push</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={() => handlePush(true)}
disabled={isGitLoading || !sessionId}
>
<IconAlertTriangle className="h-4 w-4 text-muted-foreground" />
<span>Force Push</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleRebase}
disabled={isGitLoading || !sessionId}
>
<IconGitCherryPick className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Rebase</span>
<span className="text-xs text-muted-foreground">
onto {baseBranch || 'origin/main'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-3"
onClick={handleMerge}
disabled={isGitLoading || !sessionId}
>
<IconGitMerge className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">Merge</span>
<span className="text-xs text-muted-foreground">
from {baseBranch || 'origin/main'}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Commit Dialog */}
<Dialog open={commitDialogOpen} onOpenChange={setCommitDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitCommit className="h-5 w-5" />
Commit Changes
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="text-sm text-muted-foreground">
{uncommittedFileCount > 0 ? (
<span>
<span className="font-medium text-foreground">
{uncommittedFileCount}
</span>{' '}
file{uncommittedFileCount !== 1 ? 's' : ''} changed
{(uncommittedAdditions > 0 || uncommittedDeletions > 0) && (
<span className="ml-2">
(
<span className="text-green-600">
+{uncommittedAdditions}
</span>
{' / '}
<span className="text-red-600">
-{uncommittedDeletions}
</span>
)
</span>
)}
</span>
) : (
<span>No changes to commit</span>
)}
</div>
<Textarea
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={4}
className="resize-none"
autoFocus
/>
<div className="flex items-center gap-2">
<Checkbox
id="vcs-stage-all"
checked={stageAll}
onCheckedChange={(checked) => setStageAll(checked === true)}
/>
<Label
htmlFor="vcs-stage-all"
className="text-sm text-muted-foreground cursor-pointer"
>
Stage all changes before committing
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
onClick={handleCommit}
disabled={!commitMessage.trim() || isGitLoading}
>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Committing...
</>
) : (
<>
<IconCheck className="h-4 w-4 mr-2" />
Commit
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create PR Dialog */}
<Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitPullRequest className="h-5 w-5" />
Create Pull Request
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{displayBranch && (
<div className="text-sm text-muted-foreground">
{baseBranch ? (
<span>
Creating PR from{' '}
<span className="font-medium text-foreground">
{displayBranch}
</span>{' '}
→{' '}
<span className="font-medium text-foreground">
{baseBranch}
</span>
</span>
) : (
<span>
Creating PR from{' '}
<span className="font-medium text-foreground">
{displayBranch}
</span>
</span>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="vcs-pr-title" className="text-sm">
Title
</Label>
<input
id="vcs-pr-title"
type="text"
placeholder="Pull request title..."
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="vcs-pr-body" className="text-sm">
Description
</Label>
<Textarea
id="vcs-pr-body"
placeholder="Describe your changes..."
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
rows={6}
className="resize-none"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="vcs-pr-draft"
checked={prDraft}
onCheckedChange={(checked) => setPrDraft(checked === true)}
/>
<Label
htmlFor="vcs-pr-draft"
className="text-sm cursor-pointer"
>
Create as draft
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
onClick={handleCreatePR}
disabled={!prTitle.trim() || isGitLoading}
>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
<>
<IconGitPullRequest className="h-4 w-4 mr-2" />
Create PR
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
});
export { VcsSplitButton };
@@ -53,6 +53,7 @@ export function useEditors() {
defaultEditorId: data.settings.default_editor_id || null,
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
chatSubmitKey: data.settings.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: data.settings.review_auto_mark_on_scroll ?? true,
loaded: true,
});
})
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { getWebSocketClient } from '@/lib/ws/connection';
export type FileReviewState = {
reviewed: boolean;
diffHash: string;
};
type SessionFileReview = {
id: string;
session_id: string;
file_path: string;
reviewed: boolean;
diff_hash: string;
reviewed_at: string | null;
created_at: string;
updated_at: string;
};
type UseSessionFileReviewsReturn = {
reviews: Map<string, FileReviewState>;
markReviewed: (filePath: string, diffHash: string) => void;
markUnreviewed: (filePath: string) => void;
resetReviews: () => void;
loading: boolean;
};
// Shared module-level cache so all hook instances share the same review state
const reviewsCache: Record<string, Map<string, FileReviewState>> = {};
const fetchedSessions = new Set<string>();
let cacheVersion = 0;
function notifyChange() {
cacheVersion++;
window.dispatchEvent(new CustomEvent('file-reviews-change'));
}
export function useSessionFileReviews(sessionId: string | null): UseSessionFileReviewsReturn {
const [reviews, setReviews] = useState<Map<string, FileReviewState>>(
() => (sessionId ? reviewsCache[sessionId] ?? new Map() : new Map())
);
const [loading, setLoading] = useState(false);
const versionRef = useRef(cacheVersion);
// Sync local state when shared cache changes (from other hook instances)
useEffect(() => {
const handler = () => {
if (!sessionId) return;
const cached = reviewsCache[sessionId];
if (cached && cacheVersion !== versionRef.current) {
versionRef.current = cacheVersion;
setReviews(cached);
}
};
window.addEventListener('file-reviews-change', handler);
return () => window.removeEventListener('file-reviews-change', handler);
}, [sessionId]);
// Fetch persisted reviews on mount (once per session)
useEffect(() => {
if (!sessionId || fetchedSessions.has(sessionId)) {
// Already fetched — sync from cache outside the effect's synchronous body
if (sessionId && reviewsCache[sessionId]) {
const cached = reviewsCache[sessionId];
queueMicrotask(() => setReviews(cached));
}
return;
}
const client = getWebSocketClient();
if (!client) return;
fetchedSessions.add(sessionId);
// Use startTransition-like pattern to avoid synchronous setState in effect
queueMicrotask(() => setLoading(true));
client
.request<{ reviews: SessionFileReview[] }>('session.file_review.get', {
session_id: sessionId,
})
.then((response) => {
const map = new Map<string, FileReviewState>();
if (response?.reviews) {
for (const review of response.reviews) {
map.set(review.file_path, {
reviewed: review.reviewed,
diffHash: review.diff_hash,
});
}
}
reviewsCache[sessionId] = map;
setReviews(map);
notifyChange();
})
.catch(() => {
// Ignore errors - reviews are not critical
})
.finally(() => {
setLoading(false);
});
}, [sessionId]);
const markReviewed = useCallback(
(filePath: string, diffHash: string) => {
if (!sessionId) return;
// Optimistic update — shared cache + local state
const next = new Map(reviewsCache[sessionId] ?? new Map());
next.set(filePath, { reviewed: true, diffHash });
reviewsCache[sessionId] = next;
setReviews(next);
notifyChange();
const client = getWebSocketClient();
if (!client) return;
client
.request('session.file_review.update', {
session_id: sessionId,
file_path: filePath,
reviewed: true,
diff_hash: diffHash,
})
.catch(() => {
// Revert on failure
const reverted = new Map(reviewsCache[sessionId] ?? new Map());
reverted.delete(filePath);
reviewsCache[sessionId] = reverted;
setReviews(reverted);
notifyChange();
});
},
[sessionId]
);
const markUnreviewed = useCallback(
(filePath: string) => {
if (!sessionId) return;
// Optimistic update — shared cache + local state
const next = new Map(reviewsCache[sessionId] ?? new Map());
next.set(filePath, { reviewed: false, diffHash: '' });
reviewsCache[sessionId] = next;
setReviews(next);
notifyChange();
const client = getWebSocketClient();
if (!client) return;
client
.request('session.file_review.update', {
session_id: sessionId,
file_path: filePath,
reviewed: false,
diff_hash: '',
})
.catch(() => {
// Ignore failures for unmark
});
},
[sessionId]
);
const resetReviews = useCallback(() => {
if (!sessionId) return;
// Optimistic update — shared cache + local state
reviewsCache[sessionId] = new Map();
setReviews(new Map());
notifyChange();
const client = getWebSocketClient();
if (!client) return;
client
.request('session.file_review.reset', {
session_id: sessionId,
})
.catch(() => {
// Ignore failures for reset
});
}, [sessionId]);
return {
reviews,
markReviewed,
markUnreviewed,
resetReviews,
loading,
};
}
@@ -15,6 +15,7 @@ type DisplaySettings = {
defaultEditorId: string | null;
enablePreviewOnClick: boolean;
chatSubmitKey: 'enter' | 'cmd_enter';
reviewAutoMarkOnScroll: boolean;
loaded: boolean;
};
@@ -58,6 +59,7 @@ export function useUserDisplaySettings({
defaultEditorId: userSettings.defaultEditorId ?? null,
enablePreviewOnClick,
chatSubmitKey: userSettings.chatSubmitKey ?? 'cmd_enter',
reviewAutoMarkOnScroll: userSettings.reviewAutoMarkOnScroll ?? true,
loaded: true,
};
const sameWorkspace = normalized.workspaceId === userSettings.workspaceId;
@@ -108,6 +110,7 @@ export function useUserDisplaySettings({
defaultEditorId: data.settings.default_editor_id || null,
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
chatSubmitKey: data.settings.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: data.settings.review_auto_mark_on_scroll ?? true,
loaded: true,
});
})
+1
View File
@@ -30,6 +30,7 @@ export async function updateUserSettings(
default_editor_id?: string;
enable_preview_on_click?: boolean;
chat_submit_key?: 'enter' | 'cmd_enter';
review_auto_mark_on_scroll?: boolean;
},
options?: ApiRequestOptions
) {
@@ -27,6 +27,7 @@ export const defaultSettingsState: SettingsSliceState = {
defaultEditorId: null,
enablePreviewOnClick: false,
chatSubmitKey: 'cmd_enter',
reviewAutoMarkOnScroll: true,
loaded: false,
},
};
@@ -77,6 +77,7 @@ export type UserSettingsState = {
defaultEditorId: string | null;
enablePreviewOnClick: boolean;
chatSubmitKey: 'enter' | 'cmd_enter';
reviewAutoMarkOnScroll: boolean;
loaded: boolean;
};
+1
View File
@@ -284,6 +284,7 @@ export type UserSettingsUpdatedPayload = {
default_editor_id?: string;
enable_preview_on_click?: boolean;
chat_submit_key?: string;
review_auto_mark_on_scroll?: boolean;
updated_at?: string;
};
+1
View File
@@ -317,6 +317,7 @@ export type UserSettings = {
default_editor_id?: string;
enable_preview_on_click?: boolean;
chat_submit_key?: 'enter' | 'cmd_enter';
review_auto_mark_on_scroll?: boolean;
updated_at: string;
};
+1
View File
@@ -17,6 +17,7 @@ export function registerUsersHandlers(store: StoreApi<AppState>): WsHandlers {
defaultEditorId: message.payload.default_editor_id || null,
enablePreviewOnClick: message.payload.enable_preview_on_click ?? false,
chatSubmitKey: (message.payload.chat_submit_key as 'enter' | 'cmd_enter') ?? 'cmd_enter',
reviewAutoMarkOnScroll: message.payload.review_auto_mark_on_scroll ?? true,
loaded: true,
},
}));