feat: Git status tracking refactor with persistent snapshots and commit history (#86)

This commit is contained in:
João Salavisa
2026-01-24 00:41:43 +00:00
committed by GitHub
parent 7a7927925e
commit 39b6e89244
45 changed files with 2260 additions and 348 deletions
+34 -17
View File
@@ -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 {
@@ -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)
@@ -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"`
@@ -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 {
@@ -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.
@@ -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)
@@ -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)
@@ -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)
@@ -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
}
@@ -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) {
@@ -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)
}
}
@@ -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
}
@@ -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
}
@@ -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()
@@ -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"`
}
+16 -2
View File
@@ -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{
+33
View File
@@ -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
@@ -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)
@@ -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)
}
}
}()
}
@@ -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)
@@ -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 {
@@ -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)
}
@@ -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",
@@ -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,
})
}
+64
View File
@@ -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
}
@@ -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)
@@ -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
@@ -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
}
@@ -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
}
+7 -1
View File
@@ -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"
+14 -12
View File
@@ -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<string | null>(null);
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
const [notes, setNotes] = useState('');
const [openFileTabs, setOpenFileTabs] = useState<OpenFileTab[]>([]);
// 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({
<TabsContent value="changes" className="flex-1 min-h-0">
<TaskChangesPanel
selectedDiffPath={selectedDiffPath}
onClearSelected={() => setSelectedDiffPath(null)}
selectedDiff={selectedDiff}
onClearSelected={() => setSelectedDiff(null)}
/>
</TabsContent>
+195 -69
View File
@@ -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<FileInfo[]>(() => {
const selectedDiffPath = selectedDiff?.path ?? null;
// Use provided diff content (from commit or snapshot) or look up from current git status
const hasProvidedContent = Boolean(selectedDiff?.content);
// Convert git status files to array for display (uncommitted changes)
const uncommittedFiles = useMemo<FileInfo[]>(() => {
if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) {
return [];
}
return Object.values(gitStatus.files) as FileInfo[];
}, [gitStatus]);
const diffTargets = useMemo(
() => (selectedDiffPath ? [selectedDiffPath] : changedFiles.map((file: FileInfo) => file.path)),
[selectedDiffPath, changedFiles]
);
// Convert cumulative diff files to array (committed changes)
const committedFiles = useMemo<Array<{ path: string; diff: string; additions: number; deletions: number }>>(() => {
if (!cumulativeDiff?.files || Object.keys(cumulativeDiff.files).length === 0) {
return [];
}
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<string, string> = {
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 (
<div className="flex flex-col gap-2 h-full">
{/* Header toolbar */}
<div className="flex items-center justify-between gap-3">
<Badge variant="secondary" className="rounded-full text-xs">
{selectedDiffPath ?? 'All files'}
</Badge>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="rounded-full text-xs">
{selectedDiffPath ?? 'All files'}
</Badge>
</div>
<div className="flex items-center gap-1.5">
{selectedDiffPath && (
<Tooltip>
@@ -66,10 +151,10 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
className="h-7 px-2 text-xs cursor-pointer"
onClick={onClearSelected}
>
All changes
Clear
</Button>
</TooltipTrigger>
<TooltipContent>Show all changes</TooltipContent>
<TooltipContent>Show all uncommitted changes</TooltipContent>
</Tooltip>
)}
<Tooltip>
@@ -90,7 +175,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Copy file contents</TooltipContent>
<TooltipContent>Copy diff</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -156,62 +241,103 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
</div>
</div>
</div>
{/* Diff content */}
<SessionPanelContent>
{changedFiles.length === 0 ? (
{isSingleDiffSelected && selectedDiffContent ? (
// Render single file diff (from commit, snapshot, or uncommitted)
<div className="space-y-4">
<DiffView
data={{
hunks: parseDiffIntoHunks(selectedDiffContent, selectedDiffPath!),
oldFile: { fileName: selectedDiffPath!, fileLang: getFileLang(selectedDiffPath!) },
newFile: { fileName: selectedDiffPath!, fileLang: getFileLang(selectedDiffPath!) },
}}
diffViewMode={diffModeEnum}
diffViewTheme={diffTheme}
/>
</div>
) : cumulativeLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No changes detected
Loading changes...
</div>
) : !hasAnyChanges ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No changes
</div>
) : (
<div className="space-y-4">
{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<string, string> = {
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 (
<div key={path} className="space-y-2">
{!selectedDiffPath && (
<div className="flex items-center justify-between">
<Badge variant="secondary" className="rounded-full text-xs">
{path}
</Badge>
</div>
)}
<DiffView
data={{
hunks: [file.diff],
oldFile: { fileName: path, fileLang },
newFile: { fileName: path, fileLang },
}}
diffViewMode={diffModeEnum}
diffViewTheme={diffTheme}
/>
<div className="space-y-6">
{/* Uncommitted changes section */}
{hasUncommitted && (
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Uncommitted ({uncommittedFiles.length})
</div>
);
})}
{uncommittedFiles.map((file) => {
if (!file.diff) return null;
const fileLang = getFileLang(file.path);
return (
<div key={`uncommitted-${file.path}`} className="space-y-2">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="rounded-full text-xs">
{file.path}
</Badge>
<span className="text-xs text-muted-foreground">
<span className="text-emerald-500">+{file.additions}</span>
{' / '}
<span className="text-rose-500">-{file.deletions}</span>
</span>
</div>
<DiffView
data={{
hunks: parseDiffIntoHunks(file.diff, file.path),
oldFile: { fileName: file.path, fileLang },
newFile: { fileName: file.path, fileLang },
}}
diffViewMode={diffModeEnum}
diffViewTheme={diffTheme}
/>
</div>
);
})}
</div>
)}
{/* Committed changes section */}
{hasCommitted && (
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Committed ({committedFiles.length})
</div>
{committedFiles.map((file) => {
if (!file.diff) return null;
const fileLang = getFileLang(file.path);
return (
<div key={`committed-${file.path}`} className="space-y-2">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="rounded-full text-xs">
{file.path}
</Badge>
<span className="text-xs text-muted-foreground">
<span className="text-emerald-500">+{file.additions}</span>
{' / '}
<span className="text-rose-500">-{file.deletions}</span>
</span>
</div>
<DiffView
data={{
hunks: parseDiffIntoHunks(file.diff, file.path),
oldFile: { fileName: file.path, fileLang },
newFile: { fileName: file.path, fileLang },
}}
diffViewMode={diffModeEnum}
diffViewTheme={diffTheme}
/>
</div>
);
})}
</div>
)}
</div>
)}
</SessionPanelContent>
+226 -97
View File
@@ -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<string | null>(null);
const [commitDiffs, setCommitDiffs] = useState<Record<string, Record<string, FileInfo>>>({});
const [loadingCommitSha, setLoadingCommitSha] = useState<string | null>(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<string, FileInfo> }>(
'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
>
<TabsContent value="diff" className="flex-1 min-h-0">
<SessionPanelContent>
{changedFiles.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No changes detected
</div>
) : (
<ul className="space-y-2">
{changedFiles.map((file) => {
const { folder, file: name } = splitPath(file.path);
return (
<li
key={file.path}
className="group flex items-center justify-between gap-3 text-sm rounded-md px-1 py-0.5 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => onSelectDiffPath(file.path)}
>
<div className="flex items-center gap-2 min-w-0">
{/* Staged indicator */}
{file.staged ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center h-4 w-4 rounded bg-emerald-500/20 text-emerald-600">
<IconCheck className="h-3 w-3" />
</div>
</TooltipTrigger>
<TooltipContent>Staged for commit</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center h-4 w-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10"
onClick={(event) => {
event.stopPropagation();
void gitOps.stage([file.path]);
}}
>
<IconPlus className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Stage file</TooltipContent>
</Tooltip>
)}
<button type="button" className="min-w-0 text-left cursor-pointer">
<p className="truncate text-foreground">
<span className="text-foreground/60">{folder}/</span>
<span className="font-medium text-foreground">{name}</span>
</p>
{file.oldPath && (
<p className="truncate text-xs text-muted-foreground">
Renamed from: {file.oldPath}
</p>
<div className="space-y-4">
{/* Uncommitted changes section */}
{changedFiles.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
Uncommitted ({changedFiles.length})
</div>
<ul className="space-y-1">
{changedFiles.map((file) => {
const { folder, file: name } = splitPath(file.path);
return (
<li
key={file.path}
className="group flex items-center justify-between gap-2 text-sm rounded-md px-1 py-0.5 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => onSelectDiff(file.path)}
>
<div className="flex items-center gap-2 min-w-0">
{file.staged ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center h-4 w-4 rounded bg-emerald-500/20 text-emerald-600">
<IconCheck className="h-3 w-3" />
</div>
</TooltipTrigger>
<TooltipContent>Staged for commit</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center h-4 w-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10"
onClick={(event) => {
event.stopPropagation();
void gitOps.stage([file.path]);
}}
>
<IconPlus className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Stage file</TooltipContent>
</Tooltip>
)}
<button type="button" className="min-w-0 text-left cursor-pointer">
<p className="truncate text-foreground text-xs">
<span className="text-foreground/60">{folder}/</span>
<span className="font-medium text-foreground">{name}</span>
</p>
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Discard changes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
if (!activeSessionId) return;
void openEditor.open({ filePath: file.path });
}}
>
<IconExternalLink className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Open in editor</TooltipContent>
</Tooltip>
</div>
<LineStat added={file.plus} removed={file.minus} />
<StatusIcon status={file.status} />
</div>
</li>
);
})}
</ul>
</div>
)}
{/* Commits section */}
{commits.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
Commits ({commits.length})
</div>
<ul className="space-y-1">
{commits.map((commit) => {
const isExpanded = expandedCommit === commit.commit_sha;
const files = commitDiffs[commit.commit_sha];
const isLoading = loadingCommitSha === commit.commit_sha;
return (
<li key={commit.commit_sha}>
<button
type="button"
className="w-full flex items-center gap-2 text-left rounded-md px-1 py-1 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => toggleCommit(commit.commit_sha)}
>
<IconChevronRight
className={cn(
'h-3 w-3 text-muted-foreground transition-transform shrink-0',
isExpanded && 'rotate-90'
)}
/>
<IconGitCommit className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs truncate">
<code className="font-mono text-muted-foreground">{commit.commit_sha.slice(0, 7)}</code>
{' '}
<span className="text-foreground">{commit.commit_message}</span>
</p>
</div>
<span className="text-xs shrink-0">
<span className="text-emerald-500">+{commit.insertions}</span>
{'/'}
<span className="text-rose-500">-{commit.deletions}</span>
</span>
{isLoading && (
<IconLoader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</button>
{isExpanded && files && (
<ul className="ml-6 mt-1 space-y-0.5">
{Object.entries(files).map(([path, file]) => {
const { folder, file: fileName } = splitPath(path);
return (
<li
key={path}
className="flex items-center gap-2 text-xs rounded px-1 py-0.5 hover:bg-muted/50 cursor-pointer"
onClick={() => onSelectDiff(path, file.diff)}
>
<StatusIcon status={file.status} />
<span className="truncate flex-1">
<span className="text-foreground/60">{folder}/</span>
<span className="text-foreground">{fileName}</span>
</span>
<span className="shrink-0 text-xs">
<span className="text-emerald-500">+{file.additions ?? 0}</span>
{'/'}
<span className="text-rose-500">-{file.deletions ?? 0}</span>
</span>
</li>
);
})}
</ul>
)}
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Discard changes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
if (!activeSessionId) return;
void openEditor.open({ filePath: file.path });
}}
>
<IconExternalLink className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Open in editor</TooltipContent>
</Tooltip>
</div>
<LineStat added={file.plus} removed={file.minus} />
<StatusIcon status={file.status} />
</div>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</div>
)}
{/* Empty state */}
{changedFiles.length === 0 && commits.length === 0 && (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No changes detected
</div>
)}
</div>
</SessionPanelContent>
</TabsContent>
<TabsContent value="files" className="mt-3 flex-1 min-h-0">
+13 -8
View File
@@ -34,6 +34,11 @@ type TaskLayoutProps = {
defaultLayouts?: Record<string, Layout>;
};
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<string | null>(null);
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
const [openFileRequest, setOpenFileRequest] = useState<OpenFileTab | null>(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 = (
<TaskFilesPanel
onSelectDiffPath={handleSelectDiffPath}
onSelectDiff={handleSelectDiff}
onOpenFile={handleOpenFile}
/>
);
@@ -127,9 +132,9 @@ export const TaskLayout = memo(function TaskLayout({
>
<Panel id="chat" minSize="400px" className="min-h-0 min-w-0">
<TaskCenterPanel
selectedDiffPath={selectedDiffPath}
selectedDiff={selectedDiff}
openFileRequest={openFileRequest}
onDiffPathHandled={() => setSelectedDiffPath(null)}
onDiffHandled={() => setSelectedDiff(null)}
onFileOpenHandled={() => setOpenFileRequest(null)}
/>
</Panel>
@@ -141,9 +146,9 @@ export const TaskLayout = memo(function TaskLayout({
) : (
<Panel id="chat" minSize="400px" className="min-h-0 min-w-0">
<TaskCenterPanel
selectedDiffPath={selectedDiffPath}
selectedDiff={selectedDiff}
openFileRequest={openFileRequest}
onDiffPathHandled={() => setSelectedDiffPath(null)}
onDiffHandled={() => setSelectedDiff(null)}
onFileOpenHandled={() => setOpenFileRequest(null)}
sessionId={sessionId}
/>
+26 -15
View File
@@ -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({
</div>
)}
{/* Git Status: Lines Changed */}
{/* Git Status: Total Lines Changed (uncommitted + commits) */}
{(totalAdditions > 0 || totalDeletions > 0) && (
<Tooltip>
<TooltipTrigger asChild>
@@ -366,7 +374,9 @@ const TaskTopBar = memo(function TaskTopBar({
<LineStat added={totalAdditions} removed={totalDeletions} />
</span>
</TooltipTrigger>
<TooltipContent>Lines changed</TooltipContent>
<TooltipContent>
Total changes{commits.length > 0 ? ` (${commits.length} commit${commits.length !== 1 ? 's' : ''}${uncommittedAdditions > 0 || uncommittedDeletions > 0 ? ' + uncommitted' : ''})` : ''}
</TooltipContent>
</Tooltip>
)}
@@ -494,17 +504,18 @@ const TaskTopBar = memo(function TaskTopBar({
<IconCopy className="h-4 w-4" />
Copy workspace path
</DropdownMenuItem>
{/* TODO: Implement open workspace folder
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
// TODO: Implement open workspace folder
console.log('Open workspace folder:', worktreePath);
// Open workspace folder in system file manager
}}
disabled={!worktreePath}
>
<IconFolderOpen className="h-4 w-4" />
Open workspace folder
</DropdownMenuItem>
*/}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -523,11 +534,11 @@ const TaskTopBar = memo(function TaskTopBar({
{gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? (
<span>
<span className="font-medium text-foreground">{Object.keys(gitStatus.files).length}</span> file{Object.keys(gitStatus.files).length !== 1 ? 's' : ''} changed
{(totalAdditions > 0 || totalDeletions > 0) && (
{(uncommittedAdditions > 0 || uncommittedDeletions > 0) && (
<span className="ml-2">
(<span className="text-green-600">+{totalAdditions}</span>
(<span className="text-green-600">+{uncommittedAdditions}</span>
{' / '}
<span className="text-red-600">-{totalDeletions}</span>)
<span className="text-red-600">-{uncommittedDeletions}</span>)
</span>
)}
</span>
@@ -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<string, CumulativeDiff | null> = {};
const loadingState: Record<string, boolean> = {};
/**
* 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<CumulativeDiff | null>(
sessionId ? cumulativeDiffCache[sessionId] ?? null : null
);
const [loading, setLoading] = useState(
sessionId ? loadingState[sessionId] ?? false : false
);
const [error, setError] = useState<string | null>(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,
};
}
@@ -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,
};
}
@@ -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,
};
}
@@ -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<string, unknown>;
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<string, GitStatusEntry['files'][string]>) ?? {},
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();
+6
View File
@@ -63,6 +63,12 @@ export type {
FileInfo,
GitStatusEntry,
GitStatusState,
SnapshotType,
GitSnapshot,
SessionCommit,
CumulativeDiff,
GitSnapshotsState,
SessionCommitsState,
ContextWindowEntry,
ContextWindowState,
AgentState,
@@ -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];
}),
});
@@ -66,6 +66,61 @@ export type GitStatusState = {
bySessionId: Record<string, GitStatusEntry>;
};
// 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<string, FileInfo>;
triggered_by: string;
metadata?: Record<string, unknown>;
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<string, FileInfo>;
};
export type GitSnapshotsState = {
bySessionId: Record<string, GitSnapshot[]>;
latestBySessionId: Record<string, GitSnapshot | null>;
loading: Record<string, boolean>;
};
export type SessionCommitsState = {
bySessionId: Record<string, SessionCommit[]>;
loading: Record<string, boolean>;
};
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;
+14
View File
@@ -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<AppState>): 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 },
+45
View File
@@ -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<string, FileInfo>;
triggered_by: string;
metadata?: Record<string, unknown>;
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>;
+15
View File
@@ -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<AppState>): WsHandlers {
return {
@@ -23,5 +24,19 @@ export function registerGitStatusHandlers(store: StoreApi<AppState>): 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);
},
};
}