From 39b6e892449417ee07c366db7845cd38fe94ae6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 24 Jan 2026 00:41:43 +0000 Subject: [PATCH] feat: Git status tracking refactor with persistent snapshots and commit history (#86) --- apps/backend/cmd/kandev/main.go | 51 ++- .../internal/agent/handlers/git_handlers.go | 34 ++ .../internal/agent/lifecycle/event_types.go | 23 ++ .../internal/agent/lifecycle/events.go | 36 ++ .../agent/lifecycle/execution_store.go | 32 -- .../internal/agent/lifecycle/manager.go | 26 +- .../internal/agent/lifecycle/streams.go | 6 + .../internal/agentctl/client/client.go | 6 + apps/backend/internal/agentctl/client/git.go | 44 +++ .../internal/agentctl/server/api/git.go | 31 ++ .../internal/agentctl/server/api/server.go | 1 + .../internal/agentctl/server/process/git.go | 243 ++++++++++++- .../agentctl/server/process/manager.go | 2 +- .../server/process/workspace_tracker.go | 51 ++- .../internal/agentctl/types/streams/git.go | 38 +++ apps/backend/internal/agentctl/types/types.go | 18 +- apps/backend/internal/events/types.go | 33 ++ .../websocket/session_notifications.go | 2 + .../internal/orchestrator/event_handlers.go | 180 ++++++++-- apps/backend/internal/orchestrator/service.go | 1 + .../internal/orchestrator/watcher/watcher.go | 71 ++++ apps/backend/internal/task/controller/task.go | 16 + .../task/handlers/process_handlers_test.go | 25 ++ .../internal/task/handlers/task_handlers.go | 81 +++++ apps/backend/internal/task/models/git.go | 64 ++++ .../internal/task/repository/interface.go | 11 + .../internal/task/repository/sqlite/base.go | 53 ++- .../task/repository/sqlite/git_snapshots.go | 275 +++++++++++++++ apps/backend/internal/task/service/service.go | 55 +++ apps/backend/pkg/websocket/actions.go | 8 +- .../web/components/task/task-center-panel.tsx | 26 +- .../components/task/task-changes-panel.tsx | 264 ++++++++++---- apps/web/components/task/task-files-panel.tsx | 323 ++++++++++++------ apps/web/components/task/task-layout.tsx | 21 +- apps/web/components/task/task-top-bar.tsx | 41 ++- .../domains/session/use-cumulative-diff.ts | 77 +++++ .../domains/session/use-session-commits.ts | 56 +++ .../session/use-session-git-snapshots.ts | 63 ++++ .../domains/session/use-session-git-status.ts | 41 +-- apps/web/lib/state/slices/index.ts | 6 + .../session-runtime/session-runtime-slice.ts | 34 ++ .../lib/state/slices/session-runtime/types.ts | 65 ++++ apps/web/lib/state/store.ts | 14 + apps/web/lib/types/backend.ts | 45 +++ apps/web/lib/ws/handlers/git-status.ts | 15 + 45 files changed, 2260 insertions(+), 348 deletions(-) create mode 100644 apps/backend/internal/task/models/git.go create mode 100644 apps/backend/internal/task/repository/sqlite/git_snapshots.go create mode 100644 apps/web/hooks/domains/session/use-cumulative-diff.ts create mode 100644 apps/web/hooks/domains/session/use-session-commits.ts create mode 100644 apps/web/hooks/domains/session/use-session-git-snapshots.ts diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index e06a3695dc..911445a6ef 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -321,25 +321,42 @@ func main() { var result []*ws.Message - // Send git status if available in session metadata - if session.Metadata != nil { - if gitStatus, ok := session.Metadata["git_status"]; ok { - gitStatusData, ok := gitStatus.(map[string]interface{}) - if !ok { - gitStatusData = map[string]interface{}{"data": gitStatus} - } - // Ensure session_id is included - gitStatusData["session_id"] = sessionID - if session.TaskID != "" { - gitStatusData["task_id"] = session.TaskID - } + // Send git status from latest snapshot + latestSnapshot, err := taskRepo.GetLatestGitSnapshot(ctx, sessionID) + if err == nil && latestSnapshot != nil { + // Extract file lists from snapshot metadata + metadata := latestSnapshot.Metadata + modified, _ := metadata["modified"].([]interface{}) + added, _ := metadata["added"].([]interface{}) + deleted, _ := metadata["deleted"].([]interface{}) + untracked, _ := metadata["untracked"].([]interface{}) + renamed, _ := metadata["renamed"].([]interface{}) + timestamp, _ := metadata["timestamp"].(string) - gitStatusNotification, err := ws.NewNotification("session.git.status", gitStatusData) - if err == nil { - result = append(result, gitStatusNotification) - } + gitStatusData := map[string]interface{}{ + "session_id": sessionID, + "task_id": session.TaskID, + "branch": latestSnapshot.Branch, + "remote_branch": latestSnapshot.RemoteBranch, + "ahead": latestSnapshot.Ahead, + "behind": latestSnapshot.Behind, + "files": latestSnapshot.Files, + "modified": modified, + "added": added, + "deleted": deleted, + "untracked": untracked, + "renamed": renamed, + "timestamp": timestamp, } + gitStatusNotification, err := ws.NewNotification("session.git.status", gitStatusData) + if err == nil { + result = append(result, gitStatusNotification) + } + } + + // Send context window if available in session metadata + if session.Metadata != nil { if contextWindow, ok := session.Metadata["context_window"]; ok { notification, err := ws.NewNotification(ws.ActionSessionStateChanged, map[string]interface{}{ "session_id": sessionID, @@ -357,7 +374,7 @@ func main() { return result, nil }) - log.Info("Session data provider configured for session subscriptions (git status)") + log.Info("Session data provider configured for session subscriptions (git status from snapshots)") waitForAgentctlControlHealthy(ctx, cfg, log) if err := orchestratorSvc.Start(ctx); err != nil { diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index 2c6b7bb676..648e0e4393 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -37,6 +37,7 @@ func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorktreeCommit, h.wsCommit) d.RegisterFunc(ws.ActionWorktreeStage, h.wsStage) d.RegisterFunc(ws.ActionWorktreeCreatePR, h.wsCreatePR) + d.RegisterFunc(ws.ActionSessionCommitDiff, h.wsCommitDiff) } // GitPullRequest for worktree.pull action @@ -91,6 +92,12 @@ type GitCreatePRRequest struct { BaseBranch string `json:"base_branch"` } +// GitShowCommitRequest for session.commit_diff action +type GitShowCommitRequest struct { + SessionID string `json:"session_id"` + CommitSHA string `json:"commit_sha"` +} + // wsPull handles worktree.pull action func (h *GitHandlers) wsPull(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitPullRequest @@ -298,6 +305,33 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess return ws.NewResponse(msg.ID, msg.Action, result) } +// wsCommitDiff handles session.commit_diff action +func (h *GitHandlers) wsCommitDiff(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitShowCommitRequest + if err := msg.ParsePayload(&req); err != nil { + return nil, fmt.Errorf("invalid payload: %w", err) + } + + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + if req.CommitSHA == "" { + return nil, fmt.Errorf("commit_sha is required") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitShowCommit(ctx, req.CommitSHA) + if err != nil { + return nil, fmt.Errorf("show commit failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + // getAgentCtlClient gets the agentctl client for a session func (h *GitHandlers) getAgentCtlClient(sessionID string) (*client.Client, error) { execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID) diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index cf268cabb7..0f146140dc 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -71,6 +71,8 @@ type GitStatusEventPayload struct { AgentID string `json:"agent_id"` Branch string `json:"branch"` RemoteBranch string `json:"remote_branch,omitempty"` + HeadCommit string `json:"head_commit,omitempty"` + BaseCommit string `json:"base_commit,omitempty"` Modified []string `json:"modified"` Added []string `json:"added"` Deleted []string `json:"deleted"` @@ -87,6 +89,27 @@ func (p GitStatusEventPayload) GetSessionID() string { return p.SessionID } +// GitCommitEventPayload is the payload for git commit events. +type GitCommitEventPayload struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + CommitSHA string `json:"commit_sha"` + ParentSHA string `json:"parent_sha"` + Message string `json:"message"` + AuthorName string `json:"author_name"` + AuthorEmail string `json:"author_email"` + FilesChanged int `json:"files_changed"` + Insertions int `json:"insertions"` + Deletions int `json:"deletions"` + CommittedAt string `json:"committed_at"` +} + +// GetSessionID returns the session ID for this event (used by event routing). +func (p GitCommitEventPayload) GetSessionID() string { + return p.SessionID +} + // FileChangeEventPayload is the payload for file change notifications. type FileChangeEventPayload struct { TaskID string `json:"task_id"` diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index fba5f0361d..57af6f3d5e 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -207,6 +207,42 @@ func (p *EventPublisher) PublishGitStatus(execution *AgentExecution, update *age } } +// PublishGitCommit publishes a git commit event to the event bus. +func (p *EventPublisher) PublishGitCommit(execution *AgentExecution, commit *agentctl.GitCommitNotification) { + if p.eventBus == nil { + return + } + + sessionID := execution.SessionID + + payload := GitCommitEventPayload{ + TaskID: execution.TaskID, + SessionID: sessionID, + AgentID: execution.ID, + CommitSHA: commit.CommitSHA, + ParentSHA: commit.ParentSHA, + Message: commit.Message, + AuthorName: commit.AuthorName, + AuthorEmail: commit.AuthorEmail, + FilesChanged: commit.FilesChanged, + Insertions: commit.Insertions, + Deletions: commit.Deletions, + CommittedAt: commit.CommittedAt.Format(time.RFC3339Nano), + } + + event := bus.NewEvent(events.GitCommitCreated, "agent-manager", payload) + subject := events.BuildGitCommitSubject(sessionID) + + if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { + p.logger.Error("failed to publish git commit event", + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), + zap.String("session_id", sessionID), + zap.String("commit_sha", commit.CommitSHA), + zap.Error(err)) + } +} + // PublishFileChange publishes a file change notification to the event bus. func (p *EventPublisher) PublishFileChange(execution *AgentExecution, notification *agentctl.FileChangeNotification) { if p.eventBus == nil { diff --git a/apps/backend/internal/agent/lifecycle/execution_store.go b/apps/backend/internal/agent/lifecycle/execution_store.go index 9b48579412..015bffe980 100644 --- a/apps/backend/internal/agent/lifecycle/execution_store.go +++ b/apps/backend/internal/agent/lifecycle/execution_store.go @@ -144,39 +144,7 @@ func (s *ExecutionStore) UpdateError(executionID string, errorMsg string) { } } -// UpdateMetadata updates the metadata of an agent execution using the provided function. -// The function is called outside the lock to prevent deadlocks - it receives a copy of -// the current metadata and returns the new metadata to set. -func (s *ExecutionStore) UpdateMetadata(executionID string, updateFn func(metadata map[string]interface{}) map[string]interface{}) { - // First, get a copy of current metadata - s.mu.RLock() - execution, exists := s.executions[executionID] - if !exists { - s.mu.RUnlock() - return - } - // Copy metadata to avoid races - currentMetadata := make(map[string]interface{}) - if execution.Metadata != nil { - for k, v := range execution.Metadata { - currentMetadata[k] = v - } - } - s.mu.RUnlock() - - // Call update function outside the lock - newMetadata := updateFn(currentMetadata) - - // Apply the result - s.mu.Lock() - defer s.mu.Unlock() - - // Re-check existence (could have been removed) - if execution, exists = s.executions[executionID]; exists { - execution.Metadata = newMetadata - } -} // WithLock executes a function with the store lock held, providing access to the execution. // Returns ErrExecutionNotFound if the execution doesn't exist. diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 60d978e600..d2d4056819 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -109,6 +109,7 @@ func NewManager( mgr.streamManager = NewStreamManager(log, StreamCallbacks{ OnAgentEvent: mgr.handleAgentEvent, OnGitStatus: mgr.handleGitStatusUpdate, + OnGitCommit: mgr.handleGitCommitCreated, OnFileChange: mgr.handleFileChangeNotification, OnShellOutput: mgr.handleShellOutput, OnShellExit: mgr.handleShellExit, @@ -1069,27 +1070,16 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age // handleGitStatusUpdate processes git status updates from the workspace tracker func (m *Manager) handleGitStatusUpdate(execution *AgentExecution, update *agentctl.GitStatusUpdate) { - // Store git status in execution metadata - m.executionStore.UpdateMetadata(execution.ID, func(metadata map[string]interface{}) map[string]interface{} { - metadata["git_status"] = map[string]interface{}{ - "branch": update.Branch, - "remote_branch": update.RemoteBranch, - "modified": update.Modified, - "added": update.Added, - "deleted": update.Deleted, - "untracked": update.Untracked, - "renamed": update.Renamed, - "ahead": update.Ahead, - "behind": update.Behind, - "timestamp": update.Timestamp, - } - return metadata - }) - - // Publish git status update to event bus for WebSocket streaming + // Publish git status update to event bus for WebSocket streaming and persistence m.eventPublisher.PublishGitStatus(execution, update) } +// handleGitCommitCreated processes git commit events from the workspace tracker +func (m *Manager) handleGitCommitCreated(execution *AgentExecution, commit *agentctl.GitCommitNotification) { + // Publish commit event to event bus for WebSocket streaming and orchestrator handling + m.eventPublisher.PublishGitCommit(execution, commit) +} + // handleFileChangeNotification processes file change notifications from the workspace tracker func (m *Manager) handleFileChangeNotification(execution *AgentExecution, notification *agentctl.FileChangeNotification) { m.eventPublisher.PublishFileChange(execution, notification) diff --git a/apps/backend/internal/agent/lifecycle/streams.go b/apps/backend/internal/agent/lifecycle/streams.go index f41792a34b..7f4e61a9e2 100644 --- a/apps/backend/internal/agent/lifecycle/streams.go +++ b/apps/backend/internal/agent/lifecycle/streams.go @@ -14,6 +14,7 @@ import ( type StreamCallbacks struct { OnAgentEvent func(execution *AgentExecution, event agentctl.AgentEvent) OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate) + OnGitCommit func(execution *AgentExecution, commit *agentctl.GitCommitNotification) OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification) OnShellOutput func(execution *AgentExecution, data string) OnShellExit func(execution *AgentExecution, code int) @@ -120,6 +121,11 @@ func (sm *StreamManager) connectWorkspaceStream(execution *AgentExecution) { sm.callbacks.OnGitStatus(execution, update) } }, + OnGitCommit: func(commit *agentctl.GitCommitNotification) { + if sm.callbacks.OnGitCommit != nil { + sm.callbacks.OnGitCommit(execution, commit) + } + }, OnFileChange: func(notification *agentctl.FileChangeNotification) { if sm.callbacks.OnFileChange != nil { sm.callbacks.OnFileChange(execution, notification) diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index 57121a560d..ba3642c4c2 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -254,6 +254,7 @@ func (c *Client) BaseURL() string { // These types are defined in the streams subpackage and re-exported through types. type ( GitStatusUpdate = types.GitStatusUpdate + GitCommitNotification = types.GitCommitNotification FileInfo = types.FileInfo FileListUpdate = types.FileListUpdate FileEntry = types.FileEntry @@ -565,6 +566,7 @@ type WorkspaceStreamCallbacks struct { OnShellOutput func(data string) OnShellExit func(code int) OnGitStatus func(update *GitStatusUpdate) + OnGitCommit func(notification *GitCommitNotification) OnFileChange func(notification *FileChangeNotification) OnFileList func(update *FileListUpdate) OnProcessOutput func(output *types.ProcessOutput) @@ -641,6 +643,10 @@ func (c *Client) StreamWorkspace(ctx context.Context, callbacks WorkspaceStreamC if callbacks.OnGitStatus != nil && msg.GitStatus != nil { callbacks.OnGitStatus(msg.GitStatus) } + case types.WorkspaceMessageTypeGitCommit: + if callbacks.OnGitCommit != nil && msg.GitCommit != nil { + callbacks.OnGitCommit(msg.GitCommit) + } case types.WorkspaceMessageTypeFileChange: if callbacks.OnFileChange != nil && msg.FileChange != nil { callbacks.OnFileChange(msg.FileChange) diff --git a/apps/backend/internal/agentctl/client/git.go b/apps/backend/internal/agentctl/client/git.go index fd95703c05..c5af938477 100644 --- a/apps/backend/internal/agentctl/client/git.go +++ b/apps/backend/internal/agentctl/client/git.go @@ -197,3 +197,47 @@ func (c *Client) gitOperation(ctx context.Context, path string, payload interfac return &result, nil } +// CommitDiffResult represents the result of getting a commit's diff. +type CommitDiffResult struct { + Success bool `json:"success"` + CommitSHA string `json:"commit_sha"` + Message string `json:"message"` + Author string `json:"author"` + Date string `json:"date"` + Files map[string]interface{} `json:"files"` + FilesChanged int `json:"files_changed"` + Insertions int `json:"insertions"` + Deletions int `json:"deletions"` + Error string `json:"error,omitempty"` +} + +// GitShowCommit gets the diff for a specific commit. +func (c *Client) GitShowCommit(ctx context.Context, commitSHA string) (*CommitDiffResult, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/git/commit/"+commitSHA, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var result CommitDiffResult + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w", + resp.StatusCode, truncateBody(respBody), err) + } + + if resp.StatusCode >= 400 { + return &result, fmt.Errorf("git show commit failed with status %d: %s", resp.StatusCode, result.Error) + } + + return &result, nil +} diff --git a/apps/backend/internal/agentctl/server/api/git.go b/apps/backend/internal/agentctl/server/api/git.go index fe22133024..b58e8a1789 100644 --- a/apps/backend/internal/agentctl/server/api/git.go +++ b/apps/backend/internal/agentctl/server/api/git.go @@ -46,6 +46,11 @@ type GitStageRequest struct { Paths []string `json:"paths"` // Empty = stage all } +// GitShowCommitRequest for GET /api/v1/git/commit/:sha +type GitShowCommitRequest struct { + CommitSHA string `uri:"sha" binding:"required"` +} + // GitCreatePRRequest for POST /api/v1/git/create-pr type GitCreatePRRequest struct { Title string `json:"title"` @@ -283,6 +288,32 @@ func (s *Server) handleGitCreatePR(c *gin.Context) { c.JSON(http.StatusOK, result) } +// handleGitShowCommit handles GET /api/v1/git/commit/:sha +func (s *Server) handleGitShowCommit(c *gin.Context) { + var req GitShowCommitRequest + if err := c.ShouldBindUri(&req); err != nil { + c.JSON(http.StatusBadRequest, process.CommitDiffResult{ + Success: false, + Error: "invalid request: " + err.Error(), + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.ShowCommit(c.Request.Context(), req.CommitSHA) + if err != nil { + s.logger.Error("git show commit failed", zap.String("commit_sha", req.CommitSHA), zap.Error(err)) + c.JSON(http.StatusInternalServerError, process.CommitDiffResult{ + Success: false, + CommitSHA: req.CommitSHA, + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, result) +} + // handleGitError handles errors from git operations. func (s *Server) handleGitError(c *gin.Context, operation string, err error) { if errors.Is(err, process.ErrOperationInProgress) { diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 1f6603e168..1042c1122c 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -104,6 +104,7 @@ func (s *Server) setupRoutes() { api.POST("/git/commit", s.handleGitCommit) api.POST("/git/stage", s.handleGitStage) api.POST("/git/create-pr", s.handleGitCreatePR) + api.GET("/git/commit/:sha", s.handleGitShowCommit) } } diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index 7b61ac6c37..ab7f937383 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -12,7 +12,9 @@ import ( "regexp" "strings" "sync" + "time" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -55,8 +57,9 @@ type GitOperationResult struct { // GitOperator executes git operations in a workspace directory. type GitOperator struct { - workDir string - logger *logger.Logger + workDir string + logger *logger.Logger + workspaceTracker *WorkspaceTracker mu sync.Mutex // Prevents concurrent git operations inProgress bool @@ -64,10 +67,11 @@ type GitOperator struct { } // NewGitOperator creates a new GitOperator for the given workspace directory. -func NewGitOperator(workDir string, log *logger.Logger) *GitOperator { +func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *WorkspaceTracker) *GitOperator { return &GitOperator{ - workDir: workDir, - logger: log.WithFields(zap.String("component", "git-operator")), + workDir: workDir, + logger: log.WithFields(zap.String("component", "git-operator")), + workspaceTracker: workspaceTracker, } } @@ -394,6 +398,41 @@ func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool) result.Success = true g.logger.Info("commit completed", zap.String("message", message)) + + // Publish commit notification if we have a workspace tracker + if g.workspaceTracker != nil { + // Get commit details + commitSHA, _ := g.runGitCommand(ctx, "rev-parse", "HEAD") + parentSHA, _ := g.runGitCommand(ctx, "rev-parse", "HEAD~1") + + // Get commit info (author name|author email) + authorInfo, _ := g.runGitCommand(ctx, "show", "-s", "--format=%an|%ae", "HEAD") + authorParts := strings.Split(strings.TrimSpace(authorInfo), "|") + authorName := "" + authorEmail := "" + if len(authorParts) >= 2 { + authorName = authorParts[0] + authorEmail = authorParts[1] + } + + // Get commit stats + filesChanged, insertions, deletions := g.getCommitStats(ctx, strings.TrimSpace(commitSHA)) + + commit := &streams.GitCommitNotification{ + CommitSHA: strings.TrimSpace(commitSHA), + ParentSHA: strings.TrimSpace(parentSHA), + Message: message, + AuthorName: authorName, + AuthorEmail: authorEmail, + FilesChanged: filesChanged, + Insertions: insertions, + Deletions: deletions, + CommittedAt: time.Now().UTC(), + } + + g.workspaceTracker.NotifyGitCommit(commit) + } + return result, nil } @@ -466,7 +505,151 @@ func (g *GitOperator) Abort(ctx context.Context, operation string) (*GitOperatio return result, nil } +// CommitDiffResult represents the result of getting a commit's diff. +type CommitDiffResult struct { + Success bool `json:"success"` + CommitSHA string `json:"commit_sha"` + Message string `json:"message"` + Author string `json:"author"` + Date string `json:"date"` + Files map[string]interface{} `json:"files"` // FileInfo objects with diff content + FilesChanged int `json:"files_changed"` + Insertions int `json:"insertions"` + Deletions int `json:"deletions"` + Error string `json:"error,omitempty"` +} +// ShowCommit gets the diff for a specific commit using git show. +func (g *GitOperator) ShowCommit(ctx context.Context, commitSHA string) (*CommitDiffResult, error) { + result := &CommitDiffResult{ + CommitSHA: commitSHA, + } + + // Validate commit SHA (basic validation - alphanumeric only) + if commitSHA == "" || len(commitSHA) > 64 { + result.Error = "invalid commit SHA" + return result, nil + } + for _, c := range commitSHA { + isDigit := c >= '0' && c <= '9' + isLowerHex := c >= 'a' && c <= 'f' + isUpperHex := c >= 'A' && c <= 'F' + if !isDigit && !isLowerHex && !isUpperHex { + result.Error = "invalid commit SHA: must be hexadecimal" + return result, nil + } + } + + // Get commit metadata + formatOutput, err := g.runGitCommand(ctx, "show", "--no-patch", "--format=%H%n%s%n%an <%ae>%n%aI", commitSHA) + if err != nil { + result.Error = fmt.Sprintf("failed to get commit info: %s", err.Error()) + return result, nil + } + + lines := strings.Split(strings.TrimSpace(formatOutput), "\n") + if len(lines) >= 4 { + result.CommitSHA = lines[0] + result.Message = lines[1] + result.Author = lines[2] + result.Date = lines[3] + } + + // Get the diff with stats + diffOutput, err := g.runGitCommand(ctx, "show", "--format=", "--stat", "--numstat", "-p", commitSHA) + if err != nil { + result.Error = fmt.Sprintf("failed to get commit diff: %s", err.Error()) + return result, nil + } + + // Parse the diff output into files + result.Files = g.parseCommitDiff(diffOutput) + result.FilesChanged = len(result.Files) + + // Calculate total insertions/deletions + for _, fileInfo := range result.Files { + if fi, ok := fileInfo.(map[string]interface{}); ok { + if additions, ok := fi["additions"].(int); ok { + result.Insertions += additions + } + if deletions, ok := fi["deletions"].(int); ok { + result.Deletions += deletions + } + } + } + + result.Success = true + return result, nil +} + +// parseCommitDiff parses git show output into file info map +func (g *GitOperator) parseCommitDiff(output string) map[string]interface{} { + files := make(map[string]interface{}) + + // Split by "diff --git" to get individual file diffs + parts := strings.Split(output, "diff --git ") + if len(parts) <= 1 { + return files + } + + for _, part := range parts[1:] { + if part == "" { + continue + } + + // Re-add the "diff --git " prefix + diffContent := "diff --git " + part + + // Extract file path from the diff header + // Format: diff --git a/path/to/file b/path/to/file + lines := strings.SplitN(diffContent, "\n", 2) + if len(lines) == 0 { + continue + } + + header := lines[0] + // Extract path from "diff --git a/path b/path" + headerParts := strings.Split(header, " ") + if len(headerParts) < 4 { + continue + } + + // Get the b/path part and remove the "b/" prefix + bPath := headerParts[len(headerParts)-1] + filePath := strings.TrimPrefix(bPath, "b/") + + // Determine file status from diff content + status := "modified" + if strings.Contains(diffContent, "new file mode") { + status = "added" + } else if strings.Contains(diffContent, "deleted file mode") { + status = "deleted" + } else if strings.Contains(diffContent, "rename from") { + status = "renamed" + } + + // Count additions and deletions + additions := 0 + deletions := 0 + for _, line := range strings.Split(diffContent, "\n") { + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + additions++ + } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { + deletions++ + } + } + + files[filePath] = map[string]interface{}{ + "status": status, + "staged": false, + "additions": additions, + "deletions": deletions, + "diff": diffContent, + } + } + + return files +} // tryLock attempts to acquire the operation lock without blocking. // Returns true if the lock was acquired, false if an operation is in progress. @@ -576,3 +759,53 @@ func (g *GitOperator) CreatePR(ctx context.Context, title, body, baseBranch stri g.logger.Info("PR created", zap.String("url", result.PRURL)) return result, nil } + +// getCommitStats returns the number of files changed, insertions, and deletions for a commit +func (g *GitOperator) getCommitStats(ctx context.Context, commitSHA string) (filesChanged, insertions, deletions int) { + // git show --stat --format="" HEAD gives us the stat summary + output, err := g.runGitCommand(ctx, "show", "--stat", "--format=", commitSHA) + if err != nil { + return 0, 0, 0 + } + + // The last non-empty line contains the summary like: + // " 3 files changed, 10 insertions(+), 5 deletions(-)" + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) == 0 { + return 0, 0, 0 + } + + summary := lines[len(lines)-1] + // Parse: "N files changed, M insertions(+), K deletions(-)" + // Some variants may be missing insertions or deletions + + // Match files changed + if idx := strings.Index(summary, " file"); idx > 0 { + part := strings.TrimSpace(summary[:idx]) + // Get just the number + parts := strings.Fields(part) + if len(parts) > 0 { + _, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &filesChanged) + } + } + + // Match insertions + if idx := strings.Index(summary, " insertion"); idx > 0 { + // Find the number before " insertion" + start := strings.LastIndex(summary[:idx], " ") + 1 + if start > 0 && start < idx { + _, _ = fmt.Sscanf(summary[start:idx], "%d", &insertions) + } + } + + // Match deletions + if idx := strings.Index(summary, " deletion"); idx > 0 { + // Find the number before " deletion" + start := strings.LastIndex(summary[:idx], " ") + 1 + if start > 0 && start < idx { + _, _ = fmt.Sscanf(summary[start:idx], "%d", &deletions) + } + } + + return filesChanged, insertions, deletions +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 79dc04144d..b834a0960d 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -191,7 +191,7 @@ func (m *Manager) GitOperator() *GitOperator { defer m.gitOperatorMu.Unlock() if m.gitOperator == nil { - m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger) + m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger, m.workspaceTracker) } return m.gitOperator } diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 2d5739844c..4d56b59f23 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -209,6 +209,31 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUp update.RemoteBranch = strings.TrimSpace(string(remoteOut)) } + // Get current HEAD commit SHA + headCmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") + headCmd.Dir = wt.workDir + if headOut, err := headCmd.Output(); err == nil { + update.HeadCommit = strings.TrimSpace(string(headOut)) + } + + // Get base commit SHA (base branch HEAD) + // Use remote branch if available, fall back to main/master + baseBranch := update.RemoteBranch + if baseBranch == "" { + // Try main first, then master + baseBranch = "main" + checkCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "main") + checkCmd.Dir = wt.workDir + if err := checkCmd.Run(); err != nil { + baseBranch = "master" + } + } + baseCmd := exec.CommandContext(ctx, "git", "rev-parse", baseBranch) + baseCmd.Dir = wt.workDir + if baseOut, err := baseCmd.Output(); err == nil { + update.BaseCommit = strings.TrimSpace(string(baseOut)) + } + // Get ahead/behind counts if update.RemoteBranch != "" { countCmd := exec.CommandContext(ctx, "git", "rev-list", "--left-right", "--count", update.Branch+"..."+update.RemoteBranch) @@ -427,8 +452,12 @@ func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *type fileInfo.Additions = len(lines) fileInfo.Deletions = 0 - // Format as a diff + // Format as a proper git diff with all required headers + // The @git-diff-view/react library requires the full git diff format var diffBuilder strings.Builder + diffBuilder.WriteString("diff --git a/" + filePath + " b/" + filePath + "\n") + diffBuilder.WriteString("new file mode 100644\n") + diffBuilder.WriteString("index 0000000..0000000\n") diffBuilder.WriteString("--- /dev/null\n") diffBuilder.WriteString("+++ b/" + filePath + "\n") diffBuilder.WriteString("@@ -0,0 +1," + strconv.Itoa(len(lines)) + " @@\n") @@ -557,6 +586,26 @@ func (wt *WorkspaceTracker) notifyWorkspaceStreamGitStatus(update types.GitStatu } } +// notifyWorkspaceStreamGitCommit sends git commit notification to all workspace stream subscribers +func (wt *WorkspaceTracker) notifyWorkspaceStreamGitCommit(commit *types.GitCommitNotification) { + wt.workspaceSubMu.RLock() + defer wt.workspaceSubMu.RUnlock() + + msg := types.NewWorkspaceGitCommit(commit) + for sub := range wt.workspaceStreamSubscribers { + select { + case sub <- msg: + default: + // Subscriber is slow, skip + } + } +} + +// NotifyGitCommit notifies all subscribers about a new git commit +func (wt *WorkspaceTracker) NotifyGitCommit(commit *types.GitCommitNotification) { + wt.notifyWorkspaceStreamGitCommit(commit) +} + // notifyWorkspaceStreamFileChange sends file change notification to all workspace stream subscribers func (wt *WorkspaceTracker) notifyWorkspaceStreamFileChange(notification types.FileChangeNotification) { wt.workspaceSubMu.RLock() diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index a0b567c4e8..f6646e6baa 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -37,6 +37,12 @@ type GitStatusUpdate struct { // RemoteBranch is the tracked remote branch (e.g., "origin/main"). RemoteBranch string `json:"remote_branch,omitempty"` + // HeadCommit is the current HEAD commit SHA. + HeadCommit string `json:"head_commit,omitempty"` + + // BaseCommit is the base branch HEAD commit SHA (for comparison/diff). + BaseCommit string `json:"base_commit,omitempty"` + // Files contains detailed information about each changed file. Files map[string]FileInfo `json:"files,omitempty"` } @@ -66,3 +72,35 @@ type FileInfo struct { Diff string `json:"diff,omitempty"` } +// GitCommitNotification is sent when a new commit is detected in the workspace. +type GitCommitNotification struct { + // Timestamp is when this notification was created. + Timestamp time.Time `json:"timestamp"` + + // CommitSHA is the SHA of the new commit. + CommitSHA string `json:"commit_sha"` + + // ParentSHA is the SHA of the parent commit. + ParentSHA string `json:"parent_sha"` + + // Message is the commit message. + Message string `json:"message"` + + // AuthorName is the name of the commit author. + AuthorName string `json:"author_name"` + + // AuthorEmail is the email of the commit author. + AuthorEmail string `json:"author_email"` + + // FilesChanged is the number of files changed in the commit. + FilesChanged int `json:"files_changed"` + + // Insertions is the number of lines added. + Insertions int `json:"insertions"` + + // Deletions is the number of lines deleted. + Deletions int `json:"deletions"` + + // CommittedAt is when the commit was made. + CommittedAt time.Time `json:"committed_at"` +} diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go index c0bcfbe3fa..79b2c61230 100644 --- a/apps/backend/internal/agentctl/types/types.go +++ b/apps/backend/internal/agentctl/types/types.go @@ -25,8 +25,9 @@ type ( PermissionRespondResponse = streams.PermissionRespondResponse // Git stream types - GitStatusUpdate = streams.GitStatusUpdate - FileInfo = streams.FileInfo + GitStatusUpdate = streams.GitStatusUpdate + GitCommitNotification = streams.GitCommitNotification + FileInfo = streams.FileInfo // File stream types FileChangeNotification = streams.FileChangeNotification @@ -153,6 +154,7 @@ const ( WorkspaceMessageTypeShellResize WorkspaceMessageType = "shell_resize" WorkspaceMessageTypeProcessOutput WorkspaceMessageType = "process_output" WorkspaceMessageTypeProcessStatus WorkspaceMessageType = "process_status" + WorkspaceMessageTypeGitCommit WorkspaceMessageType = "git_commit" ) // WorkspaceStreamMessage is the unified message format for the workspace stream. @@ -183,6 +185,9 @@ type WorkspaceStreamMessage struct { ProcessOutput *ProcessOutput `json:"process_output,omitempty"` ProcessStatus *ProcessStatusUpdate `json:"process_status,omitempty"` + // Git commit fields (for git_commit) + GitCommit *GitCommitNotification `json:"git_commit,omitempty"` + // Error fields (for error) Error string `json:"error,omitempty"` } @@ -214,6 +219,15 @@ func NewWorkspaceGitStatus(status *GitStatusUpdate) WorkspaceStreamMessage { } } +// NewWorkspaceGitCommit creates a git commit notification message +func NewWorkspaceGitCommit(commit *GitCommitNotification) WorkspaceStreamMessage { + return WorkspaceStreamMessage{ + Type: WorkspaceMessageTypeGitCommit, + Timestamp: timeNowUnixMilli(), + GitCommit: commit, + } +} + // NewWorkspaceFileChange creates a file change message func NewWorkspaceFileChange(notification *FileChangeNotification) WorkspaceStreamMessage { return WorkspaceStreamMessage{ diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 0a0498c2df..5ed1d93606 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -108,6 +108,9 @@ const ( const ( GitStatusUpdated = "git.status.updated" // Git status changed in workspace FileChangeNotified = "file.change.notified" // File changed in workspace + GitCommitCreated = "git.commit.created" // New commit created in workspace + GitSnapshotCreated = "git.snapshot.created" // New git snapshot created + GitCommitRecorded = "git.commit.recorded" // New session commit recorded to database ) // Event types for shell I/O @@ -187,6 +190,36 @@ func BuildGitStatusWildcardSubject() string { return GitStatusUpdated + ".*" } +// BuildGitCommitSubject returns the event subject for git commit events for a session. +func BuildGitCommitSubject(sessionID string) string { + return GitCommitCreated + "." + sessionID +} + +// BuildGitCommitWildcardSubject creates a wildcard subscription for all git commit events +func BuildGitCommitWildcardSubject() string { + return GitCommitCreated + ".*" +} + +// BuildGitSnapshotSubject creates a git snapshot subject for a specific session +func BuildGitSnapshotSubject(sessionID string) string { + return GitSnapshotCreated + "." + sessionID +} + +// BuildGitSnapshotWildcardSubject creates a wildcard subscription for all git snapshot events +func BuildGitSnapshotWildcardSubject() string { + return GitSnapshotCreated + ".*" +} + +// BuildGitCommitRecordedSubject creates a git commit recorded subject for a specific session +func BuildGitCommitRecordedSubject(sessionID string) string { + return GitCommitRecorded + "." + sessionID +} + +// BuildGitCommitRecordedWildcardSubject creates a wildcard subscription for all git commit recorded events +func BuildGitCommitRecordedWildcardSubject() string { + return GitCommitRecorded + ".*" +} + // BuildFileChangeSubject creates a file change subject for a specific session func BuildFileChangeSubject(sessionID string) string { return FileChangeNotified + "." + sessionID diff --git a/apps/backend/internal/gateway/websocket/session_notifications.go b/apps/backend/internal/gateway/websocket/session_notifications.go index 6bc99c9c8f..6a167250ed 100644 --- a/apps/backend/internal/gateway/websocket/session_notifications.go +++ b/apps/backend/internal/gateway/websocket/session_notifications.go @@ -26,6 +26,8 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB } b.subscribe(eventBus, events.BuildGitStatusWildcardSubject(), ws.ActionSessionGitStatus) + b.subscribe(eventBus, events.BuildGitSnapshotWildcardSubject(), ws.ActionSessionGitSnapshot) + b.subscribe(eventBus, events.BuildGitCommitRecordedWildcardSubject(), ws.ActionSessionGitCommit) b.subscribe(eventBus, events.BuildFileChangeWildcardSubject(), ws.ActionWorkspaceFileChanges) b.subscribe(eventBus, events.BuildShellOutputWildcardSubject(), ws.ActionSessionShellOutput) b.subscribe(eventBus, events.BuildShellExitWildcardSubject(), ws.ActionSessionShellOutput) diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 2cc2d2f786..47251740ff 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" + "time" "go.uber.org/zap" @@ -423,7 +424,7 @@ func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID strin } } -// handleGitStatusUpdated handles git status updates and persists them to agent session metadata +// handleGitStatusUpdated handles git status updates by creating git snapshots func (s *Service) handleGitStatusUpdated(ctx context.Context, data watcher.GitStatusData) { s.logger.Debug("handling git status update", zap.String("task_id", data.TaskID), @@ -435,47 +436,99 @@ func (s *Service) handleGitStatusUpdated(ctx context.Context, data watcher.GitSt return } - session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID) - if err != nil { - s.logger.Debug("no task session for git status update", - zap.String("session_id", data.TaskSessionID), - zap.Error(err)) - return + // Create git snapshot instead of storing in session metadata + snapshot := &models.GitSnapshot{ + SessionID: data.TaskSessionID, + SnapshotType: models.SnapshotTypeStatusUpdate, + Branch: data.Branch, + RemoteBranch: data.RemoteBranch, + HeadCommit: data.HeadCommit, + BaseCommit: data.BaseCommit, + Ahead: data.Ahead, + Behind: data.Behind, + Files: data.Files, + TriggeredBy: "git_status_event", + Metadata: map[string]interface{}{ + "modified": data.Modified, + "added": data.Added, + "deleted": data.Deleted, + "untracked": data.Untracked, + "renamed": data.Renamed, + "timestamp": data.Timestamp, + }, } - // Update session metadata with git status - if session.Metadata == nil { - session.Metadata = make(map[string]interface{}) - } - session.Metadata["git_status"] = map[string]interface{}{ - "branch": data.Branch, - "remote_branch": data.RemoteBranch, - "modified": data.Modified, - "added": data.Added, - "deleted": data.Deleted, - "untracked": data.Untracked, - "renamed": data.Renamed, - "ahead": data.Ahead, - "behind": data.Behind, - "files": data.Files, - "timestamp": data.Timestamp, - } - - // Persist to database asynchronously + // Persist snapshot to database asynchronously, but skip if duplicate of latest go func() { - if err := s.repo.UpdateTaskSession(context.Background(), session); err != nil { - s.logger.Error("failed to update agent session with git status", + bgCtx := context.Background() + + // Check if this is a duplicate of the latest snapshot + latest, err := s.repo.GetLatestGitSnapshot(bgCtx, data.TaskSessionID) + if err == nil && latest != nil { + // Compare key fields to detect duplicates + if s.isSnapshotDuplicate(latest, snapshot) { + s.logger.Debug("skipping duplicate git snapshot", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.TaskSessionID)) + return + } + } + + if err := s.repo.CreateGitSnapshot(bgCtx, snapshot); err != nil { + s.logger.Error("failed to create git snapshot", zap.String("task_id", data.TaskID), - zap.String("session_id", session.ID), + zap.String("session_id", data.TaskSessionID), zap.Error(err)) } else { - s.logger.Debug("persisted git status to agent session", + s.logger.Debug("created git snapshot", zap.String("task_id", data.TaskID), - zap.String("session_id", session.ID)) + zap.String("session_id", data.TaskSessionID), + zap.String("snapshot_id", snapshot.ID)) + + // Publish event to notify frontend + if s.eventBus != nil { + event := bus.NewEvent(events.GitSnapshotCreated, "orchestrator", map[string]interface{}{ + "session_id": data.TaskSessionID, + "snapshot": snapshot, + }) + _ = s.eventBus.Publish(bgCtx, events.BuildGitSnapshotSubject(data.TaskSessionID), event) + } } }() } +// isSnapshotDuplicate checks if two snapshots have the same content +func (s *Service) isSnapshotDuplicate(existing, new *models.GitSnapshot) bool { + // Different snapshot types are never duplicates + if existing.SnapshotType != new.SnapshotType { + return false + } + + // Compare branch and commit info + if existing.Branch != new.Branch || + existing.HeadCommit != new.HeadCommit || + existing.Ahead != new.Ahead || + existing.Behind != new.Behind { + return false + } + + // Compare file counts first (quick check) + existingFileCount := len(existing.Files) + newFileCount := len(new.Files) + if existingFileCount != newFileCount { + return false + } + + // Compare file paths (if same count, check if same files) + for path := range new.Files { + if _, exists := existing.Files[path]; !exists { + return false + } + } + + return true +} + // handleContextWindowUpdated handles context window updates and persists them to session metadata func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.ContextWindowData) { s.logger.Debug("handling context window update", @@ -584,3 +637,68 @@ func (s *Service) handlePermissionRequest(ctx context.Context, data watcher.Perm } } } + +// handleGitCommitCreated handles git commit events by creating session commit records +func (s *Service) handleGitCommitCreated(ctx context.Context, data watcher.GitCommitData) { + s.logger.Debug("handling git commit created", + zap.String("task_id", data.TaskID), + zap.String("commit_sha", data.CommitSHA)) + + if data.TaskSessionID == "" { + s.logger.Debug("missing session_id for git commit event", + zap.String("task_id", data.TaskID)) + return + } + + // Parse committed_at timestamp + var committedAt time.Time + if data.CommittedAt != "" { + if t, err := time.Parse(time.RFC3339Nano, data.CommittedAt); err == nil { + committedAt = t + } else { + committedAt = time.Now().UTC() + } + } else { + committedAt = time.Now().UTC() + } + + // Create session commit record + commit := &models.SessionCommit{ + SessionID: data.TaskSessionID, + CommitSHA: data.CommitSHA, + ParentSHA: data.ParentSHA, + AuthorName: data.AuthorName, + AuthorEmail: data.AuthorEmail, + CommitMessage: data.Message, + CommittedAt: committedAt, + FilesChanged: data.FilesChanged, + Insertions: data.Insertions, + Deletions: data.Deletions, + } + + // Persist commit record to database asynchronously + go func() { + bgCtx := context.Background() + if err := s.repo.CreateSessionCommit(bgCtx, commit); err != nil { + s.logger.Error("failed to create session commit record", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.TaskSessionID), + zap.String("commit_sha", data.CommitSHA), + zap.Error(err)) + } else { + s.logger.Debug("created session commit record", + zap.String("task_id", data.TaskID), + zap.String("session_id", data.TaskSessionID), + zap.String("commit_sha", data.CommitSHA)) + + // Publish event to notify frontend + if s.eventBus != nil { + event := bus.NewEvent(events.GitCommitRecorded, "orchestrator", map[string]interface{}{ + "session_id": data.TaskSessionID, + "commit": commit, + }) + _ = s.eventBus.Publish(bgCtx, events.BuildGitCommitRecordedSubject(data.TaskSessionID), event) + } + } + }() +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 71250794e4..1caf337bab 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -159,6 +159,7 @@ func NewService( OnACPSessionCreated: s.handleACPSessionCreated, OnPermissionRequest: s.handlePermissionRequest, OnGitStatusUpdated: s.handleGitStatusUpdated, + OnGitCommitCreated: s.handleGitCommitCreated, OnContextWindowUpdated: s.handleContextWindowUpdated, } s.watcher = watcher.NewWatcher(eventBus, handlers, log) diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index 350766f6c1..a094ef056a 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -60,6 +60,8 @@ type GitStatusData struct { AgentID string `json:"agent_id"` Branch string `json:"branch"` RemoteBranch string `json:"remote_branch"` + HeadCommit string `json:"head_commit"` + BaseCommit string `json:"base_commit"` Modified []string `json:"modified"` Added []string `json:"added"` Deleted []string `json:"deleted"` @@ -71,6 +73,22 @@ type GitStatusData struct { Timestamp string `json:"timestamp"` } +// GitCommitData contains data from git commit events +type GitCommitData struct { + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + CommitSHA string `json:"commit_sha"` + ParentSHA string `json:"parent_sha"` + Message string `json:"message"` + AuthorName string `json:"author_name"` + AuthorEmail string `json:"author_email"` + FilesChanged int `json:"files_changed"` + Insertions int `json:"insertions"` + Deletions int `json:"deletions"` + CommittedAt string `json:"committed_at"` +} + // ContextWindowData contains data from context window events type ContextWindowData struct { TaskID string `json:"task_id"` @@ -108,6 +126,9 @@ type EventHandlers struct { // Git status events OnGitStatusUpdated func(ctx context.Context, data GitStatusData) + // Git commit events + OnGitCommitCreated func(ctx context.Context, data GitCommitData) + // Context window events OnContextWindowUpdated func(ctx context.Context, data ContextWindowData) } @@ -182,6 +203,12 @@ func (w *Watcher) Start(ctx context.Context) error { return err } + // Subscribe to git commit events + if err := w.subscribeToGitCommitEvents(); err != nil { + w.unsubscribeAll() + return err + } + // Subscribe to context window events if err := w.subscribeToContextWindowEvents(); err != nil { w.unsubscribeAll() @@ -506,6 +533,50 @@ func (w *Watcher) createGitStatusHandler() bus.EventHandler { } } +// subscribeToGitCommitEvents subscribes to git commit events +func (w *Watcher) subscribeToGitCommitEvents() error { + if w.handlers.OnGitCommitCreated == nil { + return nil + } + + // Use wildcard to subscribe to all git commit events (git.commit.created.{session_id}) + subject := events.BuildGitCommitWildcardSubject() + + // Use regular subscription (each instance needs all messages) + sub, err := w.eventBus.Subscribe(subject, w.createGitCommitHandler()) + if err != nil { + w.logger.Error("Failed to subscribe to git commit events", + zap.String("subject", subject), + zap.Error(err)) + return err + } + w.subscriptions = append(w.subscriptions, sub) + return nil +} + +// createGitCommitHandler creates a bus.EventHandler for git commit events +func (w *Watcher) createGitCommitHandler() bus.EventHandler { + return func(ctx context.Context, event *bus.Event) error { + var data GitCommitData + if err := w.parseEventData(event.Data, &data); err != nil { + w.logger.Error("Failed to parse git commit event data", + zap.String("event_type", event.Type), + zap.String("event_id", event.ID), + zap.Error(err)) + return nil // Don't return error to continue processing other events + } + + w.logger.Debug("Handling git commit event", + zap.String("task_id", data.TaskID), + zap.String("commit_sha", data.CommitSHA)) + + if w.handlers.OnGitCommitCreated != nil { + w.handlers.OnGitCommitCreated(ctx, data) + } + return nil + } +} + // subscribeToContextWindowEvents subscribes to context window events func (w *Watcher) subscribeToContextWindowEvents() error { if w.handlers.OnContextWindowUpdated == nil { diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 46d9d86194..0ebabe825d 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -4,6 +4,7 @@ import ( "context" "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" ) @@ -145,3 +146,18 @@ func (c *TaskController) UpdateTaskState(ctx context.Context, req dto.UpdateTask } return dto.FromTask(task), nil } + +// GetGitSnapshots retrieves git snapshots for a session +func (c *TaskController) GetGitSnapshots(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { + return c.service.GetGitSnapshots(ctx, sessionID, limit) +} + +// GetSessionCommits retrieves commits for a session +func (c *TaskController) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { + return c.service.GetSessionCommits(ctx, sessionID) +} + +// GetCumulativeDiff retrieves the cumulative diff from base commit to current HEAD +func (c *TaskController) GetCumulativeDiff(ctx context.Context, sessionID string) (*models.CumulativeDiff, error) { + return c.service.GetCumulativeDiff(ctx, sessionID) +} diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index dc174579f5..cd3f46ce3b 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -311,6 +311,31 @@ func (m *mockRepository) Close() error { return nil } +// Git Snapshot operations +func (m *mockRepository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error { + return nil +} +func (m *mockRepository) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return nil, nil +} +func (m *mockRepository) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return nil, nil +} +func (m *mockRepository) GetGitSnapshotsBySession(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { + return nil, nil +} + +// Session Commit operations +func (m *mockRepository) CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error { + return nil +} +func (m *mockRepository) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { + return nil, nil +} +func (m *mockRepository) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) { + return nil, nil +} + func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 7982d47b94..399f7993a8 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -61,6 +61,10 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionTaskMove, h.wsMoveTask) dispatcher.RegisterFunc(ws.ActionTaskState, h.wsUpdateTaskState) dispatcher.RegisterFunc(ws.ActionTaskSessionList, h.wsListTaskSessions) + // Git snapshot and commit handlers + dispatcher.RegisterFunc(ws.ActionSessionGitSnapshots, h.wsGetGitSnapshots) + dispatcher.RegisterFunc(ws.ActionSessionGitCommits, h.wsGetSessionCommits) + dispatcher.RegisterFunc(ws.ActionSessionCumulativeDiff, h.wsGetCumulativeDiff) } // HTTP handlers @@ -575,3 +579,80 @@ func (h *TaskHandlers) wsUpdateTaskState(ctx context.Context, msg *ws.Message) ( } return ws.NewResponse(msg.ID, msg.Action, resp) } + +// Git Snapshot and Commit Handlers + +type wsGetGitSnapshotsRequest struct { + SessionID string `json:"session_id"` + Limit int `json:"limit,omitempty"` +} + +func (h *TaskHandlers) wsGetGitSnapshots(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetGitSnapshotsRequest + 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) + } + + snapshots, err := h.controller.GetGitSnapshots(ctx, req.SessionID, req.Limit) + if err != nil { + h.logger.Error("failed to get git snapshots", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get git snapshots", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "session_id": req.SessionID, + "snapshots": snapshots, + }) +} + +type wsGetSessionCommitsRequest struct { + SessionID string `json:"session_id"` +} + +func (h *TaskHandlers) wsGetSessionCommits(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetSessionCommitsRequest + 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) + } + + commits, err := h.controller.GetSessionCommits(ctx, req.SessionID) + if err != nil { + h.logger.Error("failed to get session commits", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get session commits", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "session_id": req.SessionID, + "commits": commits, + }) +} + +type wsGetCumulativeDiffRequest struct { + SessionID string `json:"session_id"` +} + +func (h *TaskHandlers) wsGetCumulativeDiff(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetCumulativeDiffRequest + 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) + } + + diff, err := h.controller.GetCumulativeDiff(ctx, req.SessionID) + if err != nil { + h.logger.Error("failed to get cumulative diff", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get cumulative diff", nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "cumulative_diff": diff, + }) +} diff --git a/apps/backend/internal/task/models/git.go b/apps/backend/internal/task/models/git.go new file mode 100644 index 0000000000..13363afefe --- /dev/null +++ b/apps/backend/internal/task/models/git.go @@ -0,0 +1,64 @@ +package models + +import "time" + +// SnapshotType represents the type of git snapshot +type SnapshotType string + +const ( + // SnapshotTypeStatusUpdate is for regular git status updates + SnapshotTypeStatusUpdate SnapshotType = "status_update" + // SnapshotTypePreCommit is for snapshots taken before a commit + SnapshotTypePreCommit SnapshotType = "pre_commit" + // SnapshotTypePostCommit is for snapshots taken after a commit + SnapshotTypePostCommit SnapshotType = "post_commit" + // SnapshotTypePreStage is for snapshots taken before staging + SnapshotTypePreStage SnapshotType = "pre_stage" + // SnapshotTypePostStage is for snapshots taken after staging + SnapshotTypePostStage SnapshotType = "post_stage" +) + +// GitSnapshot represents a git status snapshot at a specific point in time +type GitSnapshot struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + SnapshotType SnapshotType `json:"snapshot_type"` + Branch string `json:"branch"` + RemoteBranch string `json:"remote_branch"` + HeadCommit string `json:"head_commit"` + BaseCommit string `json:"base_commit"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Files map[string]interface{} `json:"files"` // FileInfo objects with diff content + TriggeredBy string `json:"triggered_by"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// SessionCommit represents a commit made during a task session +type SessionCommit struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + CommitSHA string `json:"commit_sha"` + ParentSHA string `json:"parent_sha"` + AuthorName string `json:"author_name"` + AuthorEmail string `json:"author_email"` + CommitMessage string `json:"commit_message"` + CommittedAt time.Time `json:"committed_at"` + PreCommitSnapshotID string `json:"pre_commit_snapshot_id"` + PostCommitSnapshotID string `json:"post_commit_snapshot_id"` + FilesChanged int `json:"files_changed"` + Insertions int `json:"insertions"` + Deletions int `json:"deletions"` + CreatedAt time.Time `json:"created_at"` +} + +// CumulativeDiff represents the cumulative diff from base commit to current HEAD +type CumulativeDiff struct { + SessionID string `json:"session_id"` + BaseCommit string `json:"base_commit"` + HeadCommit string `json:"head_commit"` + TotalCommits int `json:"total_commits"` + Files map[string]interface{} `json:"files"` // Cumulative file changes +} + diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index df9c575036..f1d77bbd0c 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -91,6 +91,17 @@ type Repository interface { DeleteTaskSessionWorktree(ctx context.Context, id string) error DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error + // Git Snapshot operations + CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error + GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) + GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) + GetGitSnapshotsBySession(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) + + // Session Commit operations + CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error + GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) + GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) + // Repository operations CreateRepository(ctx context.Context, repository *models.Repository) error GetRepository(ctx context.Context, id string) (*models.Repository, error) diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index bd6f540384..3b15a3d65b 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -17,10 +17,7 @@ func NewWithDB(dbConn *sql.DB) (*Repository, error) { return newRepository(dbConn, false) } -// New creates a new SQLite repository with a database connection (takes ownership). -func New(dbConn *sql.DB) (*Repository, error) { - return newRepository(dbConn, true) -} + func newRepository(dbConn *sql.DB, ownsDB bool) (*Repository, error) { repo := &Repository{db: dbConn, ownsDB: ownsDB} @@ -360,6 +357,54 @@ func (r *Repository) initSchema() error { return err } + // Git tracking tables + gitSchema := ` + CREATE TABLE IF NOT EXISTS task_session_git_snapshots ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + snapshot_type TEXT NOT NULL, + branch TEXT NOT NULL, + remote_branch TEXT DEFAULT '', + head_commit TEXT DEFAULT '', + base_commit TEXT DEFAULT '', + ahead INTEGER DEFAULT 0, + behind INTEGER DEFAULT 0, + files TEXT DEFAULT '{}', + triggered_by TEXT DEFAULT '', + metadata TEXT DEFAULT '{}', + created_at DATETIME NOT NULL, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_git_snapshots_session ON task_session_git_snapshots(session_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_git_snapshots_type ON task_session_git_snapshots(session_id, snapshot_type); + + CREATE TABLE IF NOT EXISTS task_session_commits ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + commit_sha TEXT NOT NULL, + parent_sha TEXT DEFAULT '', + author_name TEXT DEFAULT '', + author_email TEXT DEFAULT '', + commit_message TEXT DEFAULT '', + committed_at DATETIME NOT NULL, + pre_commit_snapshot_id TEXT DEFAULT '', + post_commit_snapshot_id TEXT DEFAULT '', + files_changed INTEGER DEFAULT 0, + insertions INTEGER DEFAULT 0, + deletions INTEGER DEFAULT 0, + created_at DATETIME NOT NULL, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_session_commits_session ON task_session_commits(session_id, committed_at DESC); + CREATE INDEX IF NOT EXISTS idx_session_commits_sha ON task_session_commits(commit_sha); + ` + + if _, err := r.db.Exec(gitSchema); err != nil { + return err + } + // Ensure columns exist for schema migrations if err := r.ensureColumn("boards", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil { return err diff --git a/apps/backend/internal/task/repository/sqlite/git_snapshots.go b/apps/backend/internal/task/repository/sqlite/git_snapshots.go new file mode 100644 index 0000000000..dd4c83b0b7 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/git_snapshots.go @@ -0,0 +1,275 @@ +// Package sqlite provides SQLite-based repository implementations. +package sqlite + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/task/models" +) + +// CreateGitSnapshot inserts a new git snapshot into the database. +func (r *Repository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error { + if snapshot.ID == "" { + snapshot.ID = uuid.New().String() + } + if snapshot.CreatedAt.IsZero() { + snapshot.CreatedAt = time.Now().UTC() + } + + filesJSON := "{}" + if snapshot.Files != nil { + filesBytes, err := json.Marshal(snapshot.Files) + if err != nil { + return fmt.Errorf("failed to serialize git snapshot files: %w", err) + } + filesJSON = string(filesBytes) + } + + metadataJSON := "{}" + if snapshot.Metadata != nil { + metadataBytes, err := json.Marshal(snapshot.Metadata) + if err != nil { + return fmt.Errorf("failed to serialize git snapshot metadata: %w", err) + } + metadataJSON = string(metadataBytes) + } + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO task_session_git_snapshots ( + id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, + ahead, behind, files, triggered_by, metadata, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, snapshot.ID, snapshot.SessionID, string(snapshot.SnapshotType), snapshot.Branch, + snapshot.RemoteBranch, snapshot.HeadCommit, snapshot.BaseCommit, snapshot.Ahead, + snapshot.Behind, filesJSON, snapshot.TriggeredBy, metadataJSON, snapshot.CreatedAt) + + return err +} + +// GetLatestGitSnapshot retrieves the most recent git snapshot for a session. +// Returns sql.ErrNoRows if no snapshot is found. +func (r *Repository) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + snapshot := &models.GitSnapshot{} + var snapshotType string + var filesJSON string + var metadataJSON string + + err := r.db.QueryRowContext(ctx, ` + SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, + ahead, behind, files, triggered_by, metadata, created_at + FROM task_session_git_snapshots + WHERE session_id = ? + ORDER BY created_at DESC LIMIT 1 + `, sessionID).Scan( + &snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch, + &snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit, + &snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy, + &metadataJSON, &snapshot.CreatedAt, + ) + if err != nil { + return nil, err + } + + snapshot.SnapshotType = models.SnapshotType(snapshotType) + if filesJSON != "" && filesJSON != "{}" { + if err := json.Unmarshal([]byte(filesJSON), &snapshot.Files); err != nil { + return nil, fmt.Errorf("failed to deserialize git snapshot files: %w", err) + } + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &snapshot.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize git snapshot metadata: %w", err) + } + } + + return snapshot, nil +} + +// GetFirstGitSnapshot retrieves the oldest git snapshot for a session (first one created). +// Returns sql.ErrNoRows if no snapshot is found. +func (r *Repository) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + snapshot := &models.GitSnapshot{} + var snapshotType string + var filesJSON string + var metadataJSON string + + err := r.db.QueryRowContext(ctx, ` + SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, + ahead, behind, files, triggered_by, metadata, created_at + FROM task_session_git_snapshots + WHERE session_id = ? + ORDER BY created_at ASC LIMIT 1 + `, sessionID).Scan( + &snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch, + &snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit, + &snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy, + &metadataJSON, &snapshot.CreatedAt, + ) + if err != nil { + return nil, err + } + + snapshot.SnapshotType = models.SnapshotType(snapshotType) + if filesJSON != "" && filesJSON != "{}" { + if err := json.Unmarshal([]byte(filesJSON), &snapshot.Files); err != nil { + return nil, fmt.Errorf("failed to deserialize git snapshot files: %w", err) + } + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &snapshot.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize git snapshot metadata: %w", err) + } + } + + return snapshot, nil +} + +// GetGitSnapshotsBySession retrieves all git snapshots for a session, ordered by created_at descending. +// If limit > 0, only that many snapshots are returned. +// Returns an empty slice if no snapshots are found. +func (r *Repository) GetGitSnapshotsBySession(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { + query := ` + SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, + ahead, behind, files, triggered_by, metadata, created_at + FROM task_session_git_snapshots + WHERE session_id = ? + ORDER BY created_at DESC + ` + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := r.db.QueryContext(ctx, query, sessionID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var result []*models.GitSnapshot + for rows.Next() { + snapshot := &models.GitSnapshot{} + var snapshotType string + var filesJSON string + var metadataJSON string + + err := rows.Scan( + &snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch, + &snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit, + &snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy, + &metadataJSON, &snapshot.CreatedAt, + ) + if err != nil { + return nil, err + } + + snapshot.SnapshotType = models.SnapshotType(snapshotType) + if filesJSON != "" && filesJSON != "{}" { + if err := json.Unmarshal([]byte(filesJSON), &snapshot.Files); err != nil { + return nil, fmt.Errorf("failed to deserialize git snapshot files: %w", err) + } + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &snapshot.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize git snapshot metadata: %w", err) + } + } + + result = append(result, snapshot) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// CreateSessionCommit inserts a new commit record into the database. +func (r *Repository) CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error { + if commit.ID == "" { + commit.ID = uuid.New().String() + } + if commit.CreatedAt.IsZero() { + commit.CreatedAt = time.Now().UTC() + } + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO task_session_commits ( + id, session_id, commit_sha, parent_sha, author_name, author_email, + commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id, + files_changed, insertions, deletions, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, commit.ID, commit.SessionID, commit.CommitSHA, commit.ParentSHA, + commit.AuthorName, commit.AuthorEmail, commit.CommitMessage, commit.CommittedAt, + commit.PreCommitSnapshotID, commit.PostCommitSnapshotID, commit.FilesChanged, + commit.Insertions, commit.Deletions, commit.CreatedAt) + + return err +} + +// GetSessionCommits retrieves all commits for a session, ordered by committed_at descending. +// Returns an empty slice if no commits are found. +func (r *Repository) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, session_id, commit_sha, parent_sha, author_name, author_email, + commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id, + files_changed, insertions, deletions, created_at + FROM task_session_commits + WHERE session_id = ? + ORDER BY committed_at DESC + `, sessionID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var result []*models.SessionCommit + for rows.Next() { + commit := &models.SessionCommit{} + err := rows.Scan( + &commit.ID, &commit.SessionID, &commit.CommitSHA, &commit.ParentSHA, + &commit.AuthorName, &commit.AuthorEmail, &commit.CommitMessage, + &commit.CommittedAt, &commit.PreCommitSnapshotID, &commit.PostCommitSnapshotID, + &commit.FilesChanged, &commit.Insertions, &commit.Deletions, &commit.CreatedAt, + ) + if err != nil { + return nil, err + } + result = append(result, commit) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// GetLatestSessionCommit retrieves the most recent commit for a session. +// Returns sql.ErrNoRows if no commit is found. +func (r *Repository) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) { + commit := &models.SessionCommit{} + + err := r.db.QueryRowContext(ctx, ` + SELECT id, session_id, commit_sha, parent_sha, author_name, author_email, + commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id, + files_changed, insertions, deletions, created_at + FROM task_session_commits + WHERE session_id = ? + ORDER BY committed_at DESC LIMIT 1 + `, sessionID).Scan( + &commit.ID, &commit.SessionID, &commit.CommitSHA, &commit.ParentSHA, + &commit.AuthorName, &commit.AuthorEmail, &commit.CommitMessage, + &commit.CommittedAt, &commit.PreCommitSnapshotID, &commit.PostCommitSnapshotID, + &commit.FilesChanged, &commit.Insertions, &commit.Deletions, &commit.CreatedAt, + ) + if err != nil { + return nil, err + } + + return commit, nil +} + diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 2dcc9be0b2..9059d80314 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -2121,3 +2121,58 @@ func (s *Service) publishRepositoryScriptEvent(ctx context.Context, eventType st zap.Error(err)) } } + +// GetGitSnapshots retrieves git snapshots for a session +func (s *Service) GetGitSnapshots(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { + return s.repo.GetGitSnapshotsBySession(ctx, sessionID, limit) +} + +// GetLatestGitSnapshot retrieves the latest git snapshot for a session +func (s *Service) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return s.repo.GetLatestGitSnapshot(ctx, sessionID) +} + +// GetFirstGitSnapshot retrieves the first git snapshot for a session (oldest) +func (s *Service) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return s.repo.GetFirstGitSnapshot(ctx, sessionID) +} + +// GetSessionCommits retrieves commits for a session +func (s *Service) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { + return s.repo.GetSessionCommits(ctx, sessionID) +} + +// GetLatestSessionCommit retrieves the latest commit for a session +func (s *Service) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) { + return s.repo.GetLatestSessionCommit(ctx, sessionID) +} + +// GetCumulativeDiff computes the cumulative diff from base commit to current HEAD +// by using the first snapshot's base_commit and the latest snapshot's files +func (s *Service) GetCumulativeDiff(ctx context.Context, sessionID string) (*models.CumulativeDiff, error) { + // Get the first snapshot to find the base commit + firstSnapshot, err := s.repo.GetFirstGitSnapshot(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get first git snapshot: %w", err) + } + + // Get the latest snapshot for current state + latestSnapshot, err := s.repo.GetLatestGitSnapshot(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get latest git snapshot: %w", err) + } + + // Count total commits for this session + commits, err := s.repo.GetSessionCommits(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get session commits: %w", err) + } + + return &models.CumulativeDiff{ + SessionID: sessionID, + BaseCommit: firstSnapshot.BaseCommit, + HeadCommit: latestSnapshot.HeadCommit, + TotalCommits: len(commits), + Files: latestSnapshot.Files, + }, nil +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index b53cc27c76..28718f884f 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -167,7 +167,13 @@ const ( ActionSessionShellOutput = "session.shell.output" // Shell output notification (also used for exit with type: "exit") // Session git status - ActionSessionGitStatus = "session.git.status" + ActionSessionGitStatus = "session.git.status" + ActionSessionGitSnapshots = "session.git.snapshots" // Get git snapshots for a session + ActionSessionGitCommits = "session.git.commits" // Get commits for a session + ActionSessionCumulativeDiff = "session.cumulative_diff" // Get cumulative diff from base branch + ActionSessionCommitDiff = "session.commit_diff" // Get diff for a specific commit + ActionSessionGitSnapshot = "session.git.snapshot" // Notification: new git snapshot created + ActionSessionGitCommit = "session.git.commit" // Notification: new session commit recorded // Process runner actions ActionSessionProcessOutput = "session.process.output" diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index 24a88f0e46..3a1ca472bf 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -12,37 +12,39 @@ import { FILE_EXTENSION_COLORS } from '@/lib/types/backend'; import { useAppStore } from '@/components/state-provider'; import { SessionTabs, type SessionTab } from '@/components/session-tabs'; +import type { SelectedDiff } from './task-layout'; + type TaskCenterPanelProps = { - selectedDiffPath: string | null; + selectedDiff: SelectedDiff | null; openFileRequest: OpenFileTab | null; - onDiffPathHandled: () => void; + onDiffHandled: () => void; onFileOpenHandled: () => void; sessionId?: string | null; }; export const TaskCenterPanel = memo(function TaskCenterPanel({ - selectedDiffPath: externalSelectedDiffPath, + selectedDiff: externalSelectedDiff, openFileRequest, - onDiffPathHandled, + onDiffHandled, onFileOpenHandled, sessionId = null, }: TaskCenterPanelProps) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const [leftTab, setLeftTab] = useState('chat'); - const [selectedDiffPath, setSelectedDiffPath] = useState(null); + const [selectedDiff, setSelectedDiff] = useState(null); const [notes, setNotes] = useState(''); const [openFileTabs, setOpenFileTabs] = useState([]); - // Handle external diff path selection + // Handle external diff selection useEffect(() => { - if (externalSelectedDiffPath) { + if (externalSelectedDiff) { queueMicrotask(() => { - setSelectedDiffPath(externalSelectedDiffPath); + setSelectedDiff(externalSelectedDiff); setLeftTab('changes'); - onDiffPathHandled(); + onDiffHandled(); }); } - }, [externalSelectedDiffPath, onDiffPathHandled]); + }, [externalSelectedDiff, onDiffHandled]); // Handle external file open request useEffect(() => { @@ -119,8 +121,8 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ setSelectedDiffPath(null)} + selectedDiff={selectedDiff} + onClearSelected={() => setSelectedDiff(null)} /> diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index bfc3994f04..f64d19237f 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -2,7 +2,12 @@ import { memo, useMemo, useState } from 'react'; import { DiffModeEnum, DiffView } from '@git-diff-view/react'; -import { IconArrowBackUp, IconCopy, IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react'; +import { + IconArrowBackUp, + IconCopy, + IconLayoutColumns, + IconLayoutRows, +} from '@tabler/icons-react'; import { useTheme } from 'next-themes'; import { Badge } from '@kandev/ui/badge'; import { Button } from '@kandev/ui/button'; @@ -12,49 +17,129 @@ import { cn } from '@/lib/utils'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; 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 type { FileInfo } from '@/lib/state/slices'; +import type { SelectedDiff } from './task-layout'; type TaskChangesPanelProps = { - selectedDiffPath: string | null; + selectedDiff: SelectedDiff | null; onClearSelected: () => void; }; const DEFAULT_DIFF_MODE: 'unified' | 'split' = 'unified'; +/** + * Normalize diff content for the @git-diff-view/react library. + * + * Handles two edge cases from the backend: + * 1. Multiple diffs concatenated with "--- Staged changes ---" separator + * (we take the staged diff as it's what matters for the commit) + * 2. Diffs missing the "diff --git" header line (for untracked files) + * + * The library expects each hunk to be a complete diff string with headers. + */ +function parseDiffIntoHunks(diffContent: string, filePath?: string): string[] { + if (!diffContent || typeof diffContent !== 'string') return []; + + let trimmed = diffContent.trim(); + if (!trimmed) return []; + + // Handle concatenated diffs with "--- Staged changes ---" separator + // Take the staged diff (after the separator) as it's the authoritative one + const stagedSeparator = '--- Staged changes ---'; + if (trimmed.includes(stagedSeparator)) { + const parts = trimmed.split(stagedSeparator); + // Use the staged diff (second part) if available + trimmed = (parts[1] || parts[0]).trim(); + } + + // If diff is missing the "diff --git" header, add it + // This happens for untracked files where backend generates incomplete diff + if (!trimmed.startsWith('diff --git') && trimmed.includes('@@')) { + const path = filePath || 'file'; + const header = `diff --git a/${path} b/${path}\nnew file mode 100644\nindex 0000000..0000000\n`; + trimmed = header + trimmed; + } + + // Return as single hunk - the library handles parsing internally + return [trimmed]; +} + const TaskChangesPanel = memo(function TaskChangesPanel({ - selectedDiffPath, + selectedDiff, onClearSelected, }: TaskChangesPanelProps) { const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(() => getLocalStorage('task-diff-view-mode', DEFAULT_DIFF_MODE) ); + const { resolvedTheme } = useTheme(); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); + // Fetch cumulative diff (all commits combined) when no specific file is selected + const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff( + selectedDiff ? null : activeSessionId + ); - // Convert git status files to array for display - const changedFiles = useMemo(() => { + const selectedDiffPath = selectedDiff?.path ?? null; + // Use provided diff content (from commit or snapshot) or look up from current git status + const hasProvidedContent = Boolean(selectedDiff?.content); + + // Convert git status files to array for display (uncommitted changes) + const uncommittedFiles = useMemo(() => { if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; } return Object.values(gitStatus.files) as FileInfo[]; }, [gitStatus]); - const diffTargets = useMemo( - () => (selectedDiffPath ? [selectedDiffPath] : changedFiles.map((file: FileInfo) => file.path)), - [selectedDiffPath, changedFiles] - ); + + // Convert cumulative diff files to array (committed changes) + const committedFiles = useMemo>(() => { + if (!cumulativeDiff?.files || Object.keys(cumulativeDiff.files).length === 0) { + return []; + } + return Object.entries(cumulativeDiff.files) + .filter(([, file]) => file.diff) // Only include files with diffs + .map(([path, file]) => ({ + path, + diff: file.diff!, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + })); + }, [cumulativeDiff]); + const diffModeEnum = diffViewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified; const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; - const selectedFile = selectedDiffPath && gitStatus ? gitStatus.files[selectedDiffPath] : null; - const selectedDiffContent = selectedFile?.diff ?? ''; - const isSingleDiffSelected = Boolean(selectedDiffPath && selectedFile); + const selectedFile = selectedDiffPath && gitStatus?.files ? gitStatus.files[selectedDiffPath] : null; + // Use provided content if available, otherwise fall back to git status + const selectedDiffContent = selectedDiff?.content ?? selectedFile?.diff ?? ''; + const isSingleDiffSelected = Boolean(selectedDiffPath && (hasProvidedContent || selectedFile)); + + // Helper to get file language from extension + const getFileLang = (path: string) => { + const ext = path.split('.').pop() || ''; + const langMap: Record = { + ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', + py: 'python', go: 'go', rs: 'rust', java: 'java', + cpp: 'cpp', c: 'c', css: 'css', html: 'html', + json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml', + }; + return langMap[ext] || 'plaintext'; + }; + + const hasUncommitted = uncommittedFiles.length > 0; + const hasCommitted = committedFiles.length > 0; + const hasAnyChanges = hasUncommitted || hasCommitted; return (
+ {/* Header toolbar */}
- - {selectedDiffPath ?? 'All files'} - +
+ + {selectedDiffPath ?? 'All files'} + +
{selectedDiffPath && ( @@ -66,10 +151,10 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ className="h-7 px-2 text-xs cursor-pointer" onClick={onClearSelected} > - All changes + Clear - Show all changes + Show all uncommitted changes )} @@ -90,7 +175,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ - Copy file contents + Copy diff @@ -156,62 +241,103 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
+ + {/* Diff content */} - {changedFiles.length === 0 ? ( + {isSingleDiffSelected && selectedDiffContent ? ( + // Render single file diff (from commit, snapshot, or uncommitted) +
+ +
+ ) : cumulativeLoading ? (
- No changes detected + Loading changes... +
+ ) : !hasAnyChanges ? ( +
+ No changes
) : ( -
- {diffTargets.map((path) => { - const file = gitStatus?.files?.[path]; - if (!file || !file.diff) { - return null; - } - - // Detect file language from extension - const ext = path.split('.').pop() || ''; - const langMap: Record = { - ts: 'typescript', - tsx: 'tsx', - js: 'javascript', - jsx: 'jsx', - py: 'python', - go: 'go', - rs: 'rust', - java: 'java', - cpp: 'cpp', - c: 'c', - css: 'css', - html: 'html', - json: 'json', - md: 'markdown', - yaml: 'yaml', - yml: 'yaml', - }; - const fileLang = langMap[ext] || 'plaintext'; - - return ( -
- {!selectedDiffPath && ( -
- - {path} - -
- )} - +
+ {/* Uncommitted changes section */} + {hasUncommitted && ( +
+
+ Uncommitted ({uncommittedFiles.length})
- ); - })} + {uncommittedFiles.map((file) => { + if (!file.diff) return null; + const fileLang = getFileLang(file.path); + return ( +
+
+ + {file.path} + + + +{file.additions} + {' / '} + -{file.deletions} + +
+ +
+ ); + })} +
+ )} + + {/* Committed changes section */} + {hasCommitted && ( +
+
+ Committed ({committedFiles.length}) +
+ {committedFiles.map((file) => { + if (!file.diff) return null; + const fileLang = getFileLang(file.path); + return ( +
+
+ + {file.path} + + + +{file.additions} + {' / '} + -{file.deletions} + +
+ +
+ ); + })} +
+ )}
)} diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index ab4f4c17ce..b1f05e4bb6 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo, useMemo, useState } from 'react'; +import { memo, useMemo, useState, useCallback } from 'react'; import { IconArrowBackUp, IconExternalLink, @@ -8,19 +8,26 @@ import { IconCircleFilled, IconMinus, IconCheck, + IconChevronRight, + IconGitCommit, + IconLoader2, } from '@tabler/icons-react'; import { TabsContent } from '@kandev/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session'; import { LineStat } from '@/components/diff-stat'; +import { cn } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; 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 { getWebSocketClient } from '@/lib/ws/connection'; import type { FileInfo } from '@/lib/state/store'; import { FileBrowser } from '@/components/task/file-browser'; import { SessionTabs, type SessionTab } from '@/components/session-tabs'; +import { useToast } from '@/components/toast-provider'; type OpenFileTab = { path: string; @@ -29,7 +36,7 @@ type OpenFileTab = { }; type TaskFilesPanelProps = { - onSelectDiffPath: (path: string) => void; + onSelectDiff: (path: string, content?: string) => void; onOpenFile: (file: OpenFileTab) => void; }; @@ -80,12 +87,56 @@ const splitPath = (path: string) => { }; }; -const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFile }: TaskFilesPanelProps) { +const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }: TaskFilesPanelProps) { const [topTab, setTopTab] = useState<'diff' | 'files'>('diff'); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); + const { commits } = useSessionCommits(activeSessionId ?? null); const openEditor = useOpenSessionInEditor(activeSessionId ?? null); const gitOps = useGitOperations(activeSessionId ?? null); + const { toast } = useToast(); + + // State for commit diffs + const [expandedCommit, setExpandedCommit] = useState(null); + const [commitDiffs, setCommitDiffs] = useState>>({}); + const [loadingCommitSha, setLoadingCommitSha] = useState(null); + + // Fetch commit diff + const fetchCommitDiff = useCallback(async (commitSha: string) => { + if (!activeSessionId || commitDiffs[commitSha]) return; + + setLoadingCommitSha(commitSha); + try { + const ws = getWebSocketClient(); + if (!ws) return; + const response = await ws.request<{ success: boolean; files: Record }>( + 'session.commit_diff', + { session_id: activeSessionId, commit_sha: commitSha }, + 10000 + ); + if (response?.success && response.files) { + setCommitDiffs((prev) => ({ ...prev, [commitSha]: response.files })); + } + } catch (err) { + toast({ + title: 'Failed to load commit diff', + description: err instanceof Error ? err.message : 'An unexpected error occurred', + variant: 'error', + }); + } finally { + setLoadingCommitSha(null); + } + }, [activeSessionId, commitDiffs, toast]); + + // Toggle commit expansion + const toggleCommit = useCallback((commitSha: string) => { + if (expandedCommit === commitSha) { + setExpandedCommit(null); + } else { + setExpandedCommit(commitSha); + void fetchCommitDiff(commitSha); + } + }, [expandedCommit, fetchCommitDiff]); // Convert git status files to array for display const changedFiles = useMemo(() => { @@ -123,101 +174,179 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi > - {changedFiles.length === 0 ? ( -
- No changes detected -
- ) : ( -
    - {changedFiles.map((file) => { - const { folder, file: name } = splitPath(file.path); - return ( -
  • onSelectDiffPath(file.path)} - > -
    - {/* Staged indicator */} - {file.staged ? ( - - -
    - -
    -
    - Staged for commit -
    - ) : ( - - - - - Stage file - - )} - + + Stage file + + )} + +
    +
    +
    + + + + + Discard changes + + + + + + Open in editor + +
    + + +
    +
  • + ); + })} +
+
+ )} + + {/* Commits section */} + {commits.length > 0 && ( +
+
+ Commits ({commits.length}) +
+
    + {commits.map((commit) => { + const isExpanded = expandedCommit === commit.commit_sha; + const files = commitDiffs[commit.commit_sha]; + const isLoading = loadingCommitSha === commit.commit_sha; + return ( +
  • + + {isExpanded && files && ( +
      + {Object.entries(files).map(([path, file]) => { + const { folder, file: fileName } = splitPath(path); + return ( +
    • onSelectDiff(path, file.diff)} + > + + + {folder}/ + {fileName} + + + +{file.additions ?? 0} + {'/'} + -{file.deletions ?? 0} + +
    • + ); + })} +
    )} - -
-
-
- - - - - Discard changes - - - - - - Open in editor - -
- - -
- - ); - })} - - )} + + ); + })} + +
+ )} + + {/* Empty state */} + {changedFiles.length === 0 && commits.length === 0 && ( +
+ No changes detected +
+ )} +
diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index 4215d96323..675caac249 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -34,6 +34,11 @@ type TaskLayoutProps = { defaultLayouts?: Record; }; +export type SelectedDiff = { + path: string; + content?: string; // Optional: if provided, use this instead of looking up from git status +}; + export const TaskLayout = memo(function TaskLayout({ workspaceId, boardId, @@ -41,7 +46,7 @@ export const TaskLayout = memo(function TaskLayout({ repository = null, defaultLayouts = {}, }: TaskLayoutProps) { - const [selectedDiffPath, setSelectedDiffPath] = useState(null); + const [selectedDiff, setSelectedDiff] = useState(null); const [openFileRequest, setOpenFileRequest] = useState(null); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const layoutBySession = useLayoutStore((state) => state.columnsBySessionId); @@ -52,8 +57,8 @@ export const TaskLayout = memo(function TaskLayout({ ); const hasDevScript = Boolean(repository?.dev_script?.trim()); - const handleSelectDiffPath = (path: string) => { - setSelectedDiffPath(path); + const handleSelectDiff = (path: string, content?: string) => { + setSelectedDiff({ path, content }); }; const handleOpenFile = (file: OpenFileTab) => { @@ -62,7 +67,7 @@ export const TaskLayout = memo(function TaskLayout({ const topFilesPanel = ( ); @@ -127,9 +132,9 @@ export const TaskLayout = memo(function TaskLayout({ > setSelectedDiffPath(null)} + onDiffHandled={() => setSelectedDiff(null)} onFileOpenHandled={() => setOpenFileRequest(null)} /> @@ -141,9 +146,9 @@ export const TaskLayout = memo(function TaskLayout({ ) : ( setSelectedDiffPath(null)} + onDiffHandled={() => setSelectedDiff(null)} onFileOpenHandled={() => setOpenFileRequest(null)} sessionId={sessionId} /> diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index c3ab252e53..a35779e92d 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -41,6 +41,7 @@ import { Checkbox } from '@kandev/ui/checkbox'; import { Label } from '@kandev/ui/label'; import { CommitStatBadge, LineStat } from '@/components/diff-stat'; 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 type { FileInfo } from '@/lib/state/slices'; import { formatUserHomePath } from '@/lib/utils'; @@ -88,21 +89,30 @@ const TaskTopBar = memo(function TaskTopBar({ const { toast } = useToast(); const gitStatus = useSessionGitStatus(activeSessionId ?? null); + const { commits } = useSessionCommits(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; - // Calculate total additions and deletions from all files. - let totalAdditions = 0; - let totalDeletions = 0; + // 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[]) { - totalAdditions += file.additions || 0; - totalDeletions += file.deletions || 0; + uncommittedAdditions += file.additions || 0; + uncommittedDeletions += file.deletions || 0; } } + // Calculate cumulative additions and deletions from commits. + const commitAdditions = commits.reduce((sum, c) => sum + c.insertions, 0); + const commitDeletions = commits.reduce((sum, c) => sum + c.deletions, 0); + + // Combined total (uncommitted + commits) + const totalAdditions = uncommittedAdditions + commitAdditions; + const totalDeletions = uncommittedDeletions + commitDeletions; + const handleGitOperation = useCallback(async ( operation: () => Promise<{ success: boolean; output: string; error?: string; conflict_files?: string[] }>, operationName: string @@ -121,9 +131,7 @@ const TaskTopBar = memo(function TaskTopBar({ description: result.error || 'An error occurred', variant: 'error', }); - if (result.conflict_files && result.conflict_files.length > 0) { - console.log('Conflict files:', result.conflict_files); - } + } } catch (error) { toast({ @@ -358,7 +366,7 @@ const TaskTopBar = memo(function TaskTopBar({ )} - {/* Git Status: Lines Changed */} + {/* Git Status: Total Lines Changed (uncommitted + commits) */} {(totalAdditions > 0 || totalDeletions > 0) && ( @@ -366,7 +374,9 @@ const TaskTopBar = memo(function TaskTopBar({ - Lines changed + + Total changes{commits.length > 0 ? ` (${commits.length} commit${commits.length !== 1 ? 's' : ''}${uncommittedAdditions > 0 || uncommittedDeletions > 0 ? ' + uncommitted' : ''})` : ''} + )} @@ -494,17 +504,18 @@ const TaskTopBar = memo(function TaskTopBar({ Copy workspace path + {/* TODO: Implement open workspace folder { - // TODO: Implement open workspace folder - console.log('Open workspace folder:', worktreePath); + // Open workspace folder in system file manager }} disabled={!worktreePath} > Open workspace folder + */} @@ -523,11 +534,11 @@ const TaskTopBar = memo(function TaskTopBar({ {gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? ( {Object.keys(gitStatus.files).length} file{Object.keys(gitStatus.files).length !== 1 ? 's' : ''} changed - {(totalAdditions > 0 || totalDeletions > 0) && ( + {(uncommittedAdditions > 0 || uncommittedDeletions > 0) && ( - (+{totalAdditions} + (+{uncommittedAdditions} {' / '} - -{totalDeletions}) + -{uncommittedDeletions}) )} diff --git a/apps/web/hooks/domains/session/use-cumulative-diff.ts b/apps/web/hooks/domains/session/use-cumulative-diff.ts new file mode 100644 index 0000000000..6e791a02da --- /dev/null +++ b/apps/web/hooks/domains/session/use-cumulative-diff.ts @@ -0,0 +1,77 @@ +import { useEffect, useCallback, useState } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import type { CumulativeDiff } from '@/lib/state/slices/session-runtime/types'; + +// Local state cache for cumulative diffs (not stored in global store since it's on-demand) +const cumulativeDiffCache: Record = {}; +const loadingState: Record = {}; + +/** + * Hook to fetch cumulative diff for a session. + * Returns the total diff from base branch to current HEAD. + */ +export function useCumulativeDiff(sessionId: string | null) { + const [diff, setDiff] = useState( + sessionId ? cumulativeDiffCache[sessionId] ?? null : null + ); + const [loading, setLoading] = useState( + sessionId ? loadingState[sessionId] ?? false : false + ); + const [error, setError] = useState(null); + + const fetchCumulativeDiff = useCallback(async () => { + if (!sessionId) return; + + const client = getWebSocketClient(); + if (!client) return; + + setLoading(true); + loadingState[sessionId] = true; + setError(null); + + try { + const response = await client.request<{ cumulative_diff?: CumulativeDiff }>( + 'session.cumulative_diff', + { session_id: sessionId } + ); + + if (response?.cumulative_diff) { + cumulativeDiffCache[sessionId] = response.cumulative_diff; + setDiff(response.cumulative_diff); + } + } catch (err) { + console.error('Failed to fetch cumulative diff:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch cumulative diff'); + } finally { + setLoading(false); + loadingState[sessionId] = false; + } + }, [sessionId]); + + // Fetch on mount if not cached + useEffect(() => { + if (sessionId && !diff && !loading) { + fetchCumulativeDiff(); + } + }, [sessionId, diff, loading, fetchCumulativeDiff]); + + // Update local state when sessionId changes + useEffect(() => { + if (sessionId) { + setDiff(cumulativeDiffCache[sessionId] ?? null); + setLoading(loadingState[sessionId] ?? false); + } else { + setDiff(null); + setLoading(false); + } + }, [sessionId]); + + return { + diff, + loading, + error, + refetch: fetchCumulativeDiff, + }; +} + diff --git a/apps/web/hooks/domains/session/use-session-commits.ts b/apps/web/hooks/domains/session/use-session-commits.ts new file mode 100644 index 0000000000..d6c8831dbb --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-commits.ts @@ -0,0 +1,56 @@ +import { useEffect, useCallback } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import type { SessionCommit } from '@/lib/state/slices/session-runtime/types'; + +/** + * Hook to fetch and manage commits for a session. + * Session commits track all commits made during a task session. + */ +export function useSessionCommits(sessionId: string | null) { + const commits = useAppStore((state) => + sessionId ? state.sessionCommits.bySessionId[sessionId] : undefined + ); + const loading = useAppStore((state) => + sessionId ? state.sessionCommits.loading[sessionId] : false + ); + const setSessionCommits = useAppStore((state) => state.setSessionCommits); + const setSessionCommitsLoading = useAppStore((state) => state.setSessionCommitsLoading); + + const fetchCommits = useCallback(async () => { + if (!sessionId) return; + + const client = getWebSocketClient(); + if (!client) return; + + setSessionCommitsLoading(sessionId, true); + try { + const response = await client.request<{ commits?: SessionCommit[] }>( + 'session.git.commits', + { session_id: sessionId } + ); + + if (response?.commits) { + setSessionCommits(sessionId, response.commits); + } + } catch (error) { + console.error('Failed to fetch session commits:', error); + } finally { + setSessionCommitsLoading(sessionId, false); + } + }, [sessionId, setSessionCommits, setSessionCommitsLoading]); + + // Fetch commits on mount + useEffect(() => { + if (sessionId && !commits) { + fetchCommits(); + } + }, [sessionId, commits, fetchCommits]); + + return { + commits: commits ?? [], + loading, + refetch: fetchCommits, + }; +} + diff --git a/apps/web/hooks/domains/session/use-session-git-snapshots.ts b/apps/web/hooks/domains/session/use-session-git-snapshots.ts new file mode 100644 index 0000000000..9c8a2f9c4b --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-git-snapshots.ts @@ -0,0 +1,63 @@ +import { useEffect, useCallback } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import type { GitSnapshot } from '@/lib/state/slices/session-runtime/types'; + +/** + * Hook to fetch and manage git snapshots for a session. + * Git snapshots provide historical tracking of git status at key moments. + */ +export function useSessionGitSnapshots(sessionId: string | null, options?: { limit?: number }) { + const snapshots = useAppStore((state) => + sessionId ? state.gitSnapshots.bySessionId[sessionId] : undefined + ); + const latestSnapshot = useAppStore((state) => + sessionId ? state.gitSnapshots.latestBySessionId[sessionId] : undefined + ); + const loading = useAppStore((state) => + sessionId ? state.gitSnapshots.loading[sessionId] : false + ); + const setGitSnapshots = useAppStore((state) => state.setGitSnapshots); + const setGitSnapshotsLoading = useAppStore((state) => state.setGitSnapshotsLoading); + + const fetchSnapshots = useCallback(async () => { + if (!sessionId) return; + + const client = getWebSocketClient(); + if (!client) return; + + setGitSnapshotsLoading(sessionId, true); + try { + const response = await client.request<{ snapshots?: GitSnapshot[] }>( + 'session.git.snapshots', + { + session_id: sessionId, + limit: options?.limit ?? 0, + } + ); + + if (response?.snapshots) { + setGitSnapshots(sessionId, response.snapshots); + } + } catch (error) { + console.error('Failed to fetch git snapshots:', error); + } finally { + setGitSnapshotsLoading(sessionId, false); + } + }, [sessionId, options?.limit, setGitSnapshots, setGitSnapshotsLoading]); + + // Fetch snapshots on mount + useEffect(() => { + if (sessionId && !snapshots) { + fetchSnapshots(); + } + }, [sessionId, snapshots, fetchSnapshots]); + + return { + snapshots: snapshots ?? [], + latestSnapshot: latestSnapshot ?? null, + loading, + refetch: fetchSnapshots, + }; +} + diff --git a/apps/web/hooks/domains/session/use-session-git-status.ts b/apps/web/hooks/domains/session/use-session-git-status.ts index 4fc18af21d..9d1143e271 100644 --- a/apps/web/hooks/domains/session/use-session-git-status.ts +++ b/apps/web/hooks/domains/session/use-session-git-status.ts @@ -1,47 +1,18 @@ import { useEffect } from 'react'; import { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; -import type { GitStatusEntry } from '@/lib/state/store'; +/** + * Hook to get the current git status for a session. + * Git status is populated via WebSocket from git snapshot updates. + * For historical snapshots, use useSessionGitSnapshots hook. + */ export function useSessionGitStatus(sessionId: string | null) { const gitStatus = useAppStore((state) => sessionId ? state.gitStatus.bySessionId[sessionId] : undefined ); - const session = useAppStore((state) => - sessionId ? state.taskSessions.items[sessionId] : undefined - ); - const setGitStatus = useAppStore((state) => state.setGitStatus); - - // Populate git status from session metadata if not already in store - useEffect(() => { - if (!sessionId || gitStatus) return; - - // Try to extract git_status from session metadata - const metadata = session?.metadata; - if (!metadata || typeof metadata !== 'object') return; - - const storedGitStatus = metadata.git_status; - if (!storedGitStatus || typeof storedGitStatus !== 'object') return; - - // Map stored git status to GitStatusEntry - const gs = storedGitStatus as Record; - const entry: GitStatusEntry = { - branch: (gs.branch as string) ?? null, - remote_branch: (gs.remote_branch as string) ?? null, - modified: (gs.modified as string[]) ?? [], - added: (gs.added as string[]) ?? [], - deleted: (gs.deleted as string[]) ?? [], - untracked: (gs.untracked as string[]) ?? [], - renamed: (gs.renamed as string[]) ?? [], - ahead: (gs.ahead as number) ?? 0, - behind: (gs.behind as number) ?? 0, - files: (gs.files as Record) ?? {}, - timestamp: (gs.timestamp as string) ?? null, - }; - - setGitStatus(sessionId, entry); - }, [sessionId, gitStatus, session?.metadata, setGitStatus]); + // Subscribe to session updates to receive git status via WebSocket useEffect(() => { if (!sessionId) return; const client = getWebSocketClient(); diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index aafb70c80a..802585e323 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -63,6 +63,12 @@ export type { FileInfo, GitStatusEntry, GitStatusState, + SnapshotType, + GitSnapshot, + SessionCommit, + CumulativeDiff, + GitSnapshotsState, + SessionCommitsState, ContextWindowEntry, ContextWindowState, AgentState, diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index e418279ab7..e22d1de606 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -21,6 +21,8 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { devProcessBySessionId: {}, }, gitStatus: { bySessionId: {} }, + gitSnapshots: { bySessionId: {}, latestBySessionId: {}, loading: {} }, + sessionCommits: { bySessionId: {}, loading: {} }, contextWindow: { bySessionId: {} }, agents: { agents: [] }, }; @@ -90,4 +92,36 @@ export const createSessionRuntimeSlice: StateCreator< set((draft) => { draft.contextWindow.bySessionId[sessionId] = contextWindow; }), + // Git snapshot actions + setGitSnapshots: (sessionId, snapshots) => + set((draft) => { + draft.gitSnapshots.bySessionId[sessionId] = snapshots; + draft.gitSnapshots.latestBySessionId[sessionId] = snapshots.length > 0 ? snapshots[0] : null; + }), + setGitSnapshotsLoading: (sessionId, loading) => + set((draft) => { + draft.gitSnapshots.loading[sessionId] = loading; + }), + addGitSnapshot: (sessionId, snapshot) => + set((draft) => { + const existing = draft.gitSnapshots.bySessionId[sessionId] || []; + // Add to front (newest first) + draft.gitSnapshots.bySessionId[sessionId] = [snapshot, ...existing]; + draft.gitSnapshots.latestBySessionId[sessionId] = snapshot; + }), + // Session commit actions + setSessionCommits: (sessionId, commits) => + set((draft) => { + draft.sessionCommits.bySessionId[sessionId] = commits; + }), + setSessionCommitsLoading: (sessionId, loading) => + set((draft) => { + draft.sessionCommits.loading[sessionId] = loading; + }), + addSessionCommit: (sessionId, commit) => + set((draft) => { + const existing = draft.sessionCommits.bySessionId[sessionId] || []; + // Add to front (newest first) + draft.sessionCommits.bySessionId[sessionId] = [commit, ...existing]; + }), }); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 4a24669693..b9aedcf4d1 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -66,6 +66,61 @@ export type GitStatusState = { bySessionId: Record; }; +// Git Snapshot types for historical tracking +export type SnapshotType = 'status_update' | 'pre_commit' | 'post_commit' | 'pre_stage' | 'post_stage'; + +export type GitSnapshot = { + id: string; + session_id: string; + snapshot_type: SnapshotType; + branch: string; + remote_branch: string; + head_commit: string; + base_commit: string; + ahead: number; + behind: number; + files: Record; + triggered_by: string; + metadata?: Record; + created_at: string; +}; + +export type SessionCommit = { + id: string; + session_id: string; + commit_sha: string; + parent_sha: string; + author_name: string; + author_email: string; + commit_message: string; + committed_at: string; + pre_commit_snapshot_id?: string; + post_commit_snapshot_id?: string; + files_changed: number; + insertions: number; + deletions: number; + created_at: string; +}; + +export type CumulativeDiff = { + session_id: string; + base_commit: string; + head_commit: string; + total_commits: number; + files: Record; +}; + +export type GitSnapshotsState = { + bySessionId: Record; + latestBySessionId: Record; + loading: Record; +}; + +export type SessionCommitsState = { + bySessionId: Record; + loading: Record; +}; + export type ContextWindowEntry = { size: number; used: number; @@ -87,6 +142,8 @@ export type SessionRuntimeSliceState = { shell: ShellState; processes: ProcessState; gitStatus: GitStatusState; + gitSnapshots: GitSnapshotsState; + sessionCommits: SessionCommitsState; contextWindow: ContextWindowState; agents: AgentState; }; @@ -106,6 +163,14 @@ export type SessionRuntimeSliceActions = { setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void; clearGitStatus: (sessionId: string) => void; setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void; + // Git snapshot actions + setGitSnapshots: (sessionId: string, snapshots: GitSnapshot[]) => void; + setGitSnapshotsLoading: (sessionId: string, loading: boolean) => void; + addGitSnapshot: (sessionId: string, snapshot: GitSnapshot) => void; + // Session commit actions + setSessionCommits: (sessionId: string, commits: SessionCommit[]) => void; + setSessionCommitsLoading: (sessionId: string, loading: boolean) => void; + addSessionCommit: (sessionId: string, commit: SessionCommit) => void; }; export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 4f2df258c0..ad361a6d47 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -31,6 +31,8 @@ import { type ProcessStatusEntry, type Worktree, type GitStatusEntry, + type GitSnapshot, + type SessionCommit, type ContextWindowEntry, type SessionAgentctlStatus, type PreviewStage, @@ -130,6 +132,8 @@ export type AppState = { shell: typeof defaultSessionRuntimeState['shell']; processes: typeof defaultSessionRuntimeState['processes']; gitStatus: typeof defaultSessionRuntimeState['gitStatus']; + gitSnapshots: typeof defaultSessionRuntimeState['gitSnapshots']; + sessionCommits: typeof defaultSessionRuntimeState['sessionCommits']; contextWindow: typeof defaultSessionRuntimeState['contextWindow']; agents: typeof defaultSessionRuntimeState['agents']; @@ -215,6 +219,12 @@ export type AppState = { setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void; setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void; clearGitStatus: (sessionId: string) => void; + setGitSnapshots: (sessionId: string, snapshots: GitSnapshot[]) => void; + setGitSnapshotsLoading: (sessionId: string, loading: boolean) => void; + addGitSnapshot: (sessionId: string, snapshot: GitSnapshot) => void; + setSessionCommits: (sessionId: string, commits: SessionCommit[]) => void; + setSessionCommitsLoading: (sessionId: string, loading: boolean) => void; + addSessionCommit: (sessionId: string, commit: SessionCommit) => void; setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void; bumpAgentProfilesVersion: () => void; setPendingModel: (sessionId: string, modelId: string) => void; @@ -255,6 +265,8 @@ const defaultState = { shell: defaultSessionRuntimeState.shell, processes: defaultSessionRuntimeState.processes, gitStatus: defaultSessionRuntimeState.gitStatus, + gitSnapshots: defaultSessionRuntimeState.gitSnapshots, + sessionCommits: defaultSessionRuntimeState.sessionCommits, contextWindow: defaultSessionRuntimeState.contextWindow, agents: defaultSessionRuntimeState.agents, previewPanel: defaultUIState.previewPanel, @@ -300,6 +312,8 @@ function mergeInitialState(initialState?: Partial): typeof defaultStat shell: { ...defaultState.shell, ...initialState.shell }, processes: { ...defaultState.processes, ...initialState.processes }, gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus }, + gitSnapshots: { ...defaultState.gitSnapshots, ...initialState.gitSnapshots }, + sessionCommits: { ...defaultState.sessionCommits, ...initialState.sessionCommits }, contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow }, agents: { ...defaultState.agents, ...initialState.agents }, previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index c9cb6c1d68..73d5db4720 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -9,6 +9,8 @@ export type BackendMessageType = | 'terminal.output' | 'diff.update' | 'session.git.status' + | 'session.git.snapshot' + | 'session.git.commit' | 'system.error' | 'workspace.created' | 'workspace.updated' @@ -203,6 +205,47 @@ export type GitStatusPayload = { timestamp: string; }; +export type GitSnapshotPayload = { + task_id: string; + session_id: string; + snapshot: { + id: string; + session_id: string; + snapshot_type: string; + branch: string; + remote_branch: string; + head_commit: string; + base_commit: string; + ahead: number; + behind: number; + files: Record; + triggered_by: string; + metadata?: Record; + created_at: string; + }; +}; + +export type GitCommitPayload = { + task_id: string; + session_id: string; + commit: { + id: string; + session_id: string; + commit_sha: string; + parent_sha: string; + author_name: string; + author_email: string; + commit_message: string; + committed_at: string; + pre_commit_snapshot_id?: string; + post_commit_snapshot_id?: string; + files_changed: number; + insertions: number; + deletions: number; + created_at: string; + }; +}; + export type ProcessOutputPayload = { session_id: string; process_id: string; @@ -312,6 +355,8 @@ export type BackendMessageMap = { 'terminal.output': BackendMessage<'terminal.output', TerminalOutputPayload>; 'diff.update': BackendMessage<'diff.update', DiffUpdatePayload>; 'session.git.status': BackendMessage<'session.git.status', GitStatusPayload>; + 'session.git.snapshot': BackendMessage<'session.git.snapshot', GitSnapshotPayload>; + 'session.git.commit': BackendMessage<'session.git.commit', GitCommitPayload>; 'system.error': BackendMessage<'system.error', SystemErrorPayload>; 'workspace.created': BackendMessage<'workspace.created', WorkspacePayload>; 'workspace.updated': BackendMessage<'workspace.updated', WorkspacePayload>; diff --git a/apps/web/lib/ws/handlers/git-status.ts b/apps/web/lib/ws/handlers/git-status.ts index 3f6731ac16..1f480655ba 100644 --- a/apps/web/lib/ws/handlers/git-status.ts +++ b/apps/web/lib/ws/handlers/git-status.ts @@ -1,6 +1,7 @@ import type { StoreApi } from 'zustand'; import type { AppState } from '@/lib/state/store'; import type { WsHandlers } from '@/lib/ws/handlers/types'; +import type { GitSnapshot, SessionCommit } from '@/lib/state/slices/session-runtime/types'; export function registerGitStatusHandlers(store: StoreApi): WsHandlers { return { @@ -23,5 +24,19 @@ export function registerGitStatusHandlers(store: StoreApi): WsHandlers timestamp: payload.timestamp, }); }, + 'session.git.snapshot': (message) => { + const payload = message.payload; + if (!payload.session_id || !payload.snapshot) { + return; + } + store.getState().addGitSnapshot(payload.session_id, payload.snapshot as GitSnapshot); + }, + 'session.git.commit': (message) => { + const payload = message.payload; + if (!payload.session_id || !payload.commit) { + return; + } + store.getState().addSessionCommit(payload.session_id, payload.commit as SessionCommit); + }, }; }