From 61e5d5cce9bc2d57722de6181784e63d1992cb80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 7 Apr 2026 09:12:17 +0100 Subject: [PATCH] feat(web): redesign task sidebar with repo-grouped layout and diff stats (#550) --- .agents/skills/feature/SKILL.md | 38 +- .agents/skills/fix/SKILL.md | 16 + .agents/skills/pr-fixup/SKILL.md | 14 +- apps/backend/cmd/kandev/helpers.go | 44 +-- .../internal/agent/lifecycle/event_types.go | 26 +- .../internal/agent/lifecycle/events.go | 26 +- apps/backend/internal/agentctl/client/git.go | 8 +- .../internal/agentctl/server/api/git.go | 14 +- .../server/process/workspace_git_diff.go | 47 ++- .../server/process/workspace_git_status.go | 5 +- .../server/process/workspace_tracker.go | 5 +- .../internal/agentctl/types/streams/git.go | 8 + .../internal/orchestrator/event_handlers.go | 1 + .../orchestrator/event_handlers_agent.go | 4 + .../internal/orchestrator/task_operations.go | 50 +++ apps/web/components/task/task-item.tsx | 292 ++++++++------- .../components/task/task-session-sidebar.tsx | 175 +++++++-- apps/web/components/task/task-switcher.tsx | 332 ++++++++++------- apps/web/e2e/pages/session-page.ts | 27 +- .../web/e2e/tests/task/sidebar-layout.spec.ts | 335 ++++++++++++++++++ .../workflow/workflow-automation.spec.ts | 5 +- .../lib/state/slices/session-runtime/types.ts | 2 + apps/web/lib/types/git-events.ts | 2 + apps/web/lib/utils/session-info.ts | 40 ++- apps/web/lib/ws/handlers/git-status.ts | 2 + 25 files changed, 1127 insertions(+), 391 deletions(-) create mode 100644 apps/web/e2e/tests/task/sidebar-layout.spec.ts diff --git a/.agents/skills/feature/SKILL.md b/.agents/skills/feature/SKILL.md index 6bbd6a0e5a..5161f8af21 100644 --- a/.agents/skills/feature/SKILL.md +++ b/.agents/skills/feature/SKILL.md @@ -13,10 +13,10 @@ The following skills and subagents are available in this repo to delegate work t - **`/tdd`** — Implement changes using Test-Driven Development (Red-Green-Refactor). Delegate implementation tasks to this. - **`/e2e`** — Write and run Playwright E2E tests using TDD. Use for the final wave when the full feature needs end-to-end coverage. -- **`qa` subagent** — Verify a feature works after implementation. Actively tries to break it — edge cases, error paths, integration wiring. +- **`code-review` subagent** — Review changed code for quality, security, and architecture compliance. Also verifies the feature works: traces wiring, tests edge cases, and writes tests for gaps. - **`verify` subagent** — Run fmt, typecheck, test, and lint across the monorepo, then fix any issues found. - **`simplify` subagent** — Simplify recently changed code — inline one-off abstractions, remove speculative code, reduce nesting. -- **`code-review` subagent** — Review changed code for quality, security, and architecture compliance. +- **`/record`** — Record architectural decisions or save implementation plans for future reference. --- @@ -30,8 +30,8 @@ Create these tasks immediately (use your task/todo tracking tool if available): 2. **Explore the codebase** — Find similar patterns, relevant architecture, integration points 3. **Design the solution** — Propose approaches with trade-offs, get user approval before implementing 4. **Implement with TDD** — Break into waves, implement test-first, delegate where possible -5. **QA** — Verify the feature works end-to-end, try to break it -6. **Review and finalize** — Simplify, verify, code review, summarize +5. **Code review and QA** — Review code quality, verify the feature works, simplify +6. **Record** — Save any architectural decisions or insights for future sessions Then start with task 1. Mark each task in_progress when you begin it and completed when you finish it. Do not skip ahead — each phase produces context that the next phase needs. Designing without exploring leads to solutions that fight the codebase. Implementing without design approval wastes time on the wrong approach. @@ -153,30 +153,32 @@ Mark task 4 as completed when all implementation sub-tasks pass their quality ga --- -## Phase 5: QA +## Phase 5: Code review and QA Mark task 5 as in_progress. -Delegate to the `qa` subagent to verify the feature works end-to-end. It will: -- Trace the wiring (exports used, APIs called, data flows) -- Test the happy path -- Try to break it (boundary values, error paths, concurrency) -- Write tests for any gaps found +1. Delegate to the `code-review` subagent to review changes and verify the feature works. It will: + - Review changed code for quality, security, and architecture compliance + - Trace the wiring (exports used, APIs called, data flows) + - Try to break it (boundary values, error paths, concurrency) + - Write tests for any gaps found +2. Delegate to the `simplify` subagent to clean up the implementation +3. Delegate to the `verify` subagent to run fmt, typecheck, test, and lint +4. Fix any blockers, present suggestions to the user +5. Summarize: what was built, key decisions, files modified, suggested next steps Mark task 5 as completed. --- -## Phase 6: Review and finalize +## Phase 6: Record Mark task 6 as in_progress. -1. Delegate to the `simplify` subagent to clean up the implementation -2. Delegate to the `verify` subagent to run fmt, typecheck, test, and lint -3. Delegate to the `code-review` subagent to review changes for quality, security, and architecture compliance -4. Fix any blockers, present suggestions to the user -5. Summarize: what was built, key decisions, files modified, suggested next steps -6. If significant architectural decisions were made, record them -7. Save the feature design to `docs/plans/` for permanent reference +Check if any decisions or insights from this feature should be recorded for future sessions: + +1. If significant architectural decisions were made, run `/record decision` to create an ADR +2. If the implementation plan has reusable patterns or non-obvious context, run `/record plan` to save it +3. If nothing worth recording, skip this phase Mark task 6 as completed. diff --git a/.agents/skills/fix/SKILL.md b/.agents/skills/fix/SKILL.md index d7eeb9eb25..b7b165d9c3 100644 --- a/.agents/skills/fix/SKILL.md +++ b/.agents/skills/fix/SKILL.md @@ -12,6 +12,7 @@ Systematic bug fixing: reproduce the problem, find the root cause, apply a minim - **`/tdd`** — Use for implementing the fix with a regression test (Red-Green-Refactor). - **`/e2e`** — Use when the bug is in a user-facing flow and needs a Playwright regression test. - **`/verify`** — Run after fixing to ensure nothing else broke. +- **`/record`** — Record architectural decisions or insights discovered during the fix. --- @@ -23,6 +24,7 @@ Create these tasks immediately (use your task/todo tracking tool if available): 2. **Find the root cause** — Trace the code path, narrow the scope, state the cause clearly 3. **Fix with TDD** — Minimal fix with regression test, no surrounding refactors 4. **Verify** — Run full verification, check for similar patterns elsewhere +5. **Record** — Save any architectural decisions or insights for future sessions Then start with task 1. Mark each task in_progress when you begin it and completed when you finish it. Do not skip ahead — fixing without reproducing leads to patches that don't address the real problem. Fixing without understanding the root cause leads to whack-a-mole. @@ -94,6 +96,20 @@ Mark task 4 as completed. --- +## Phase 5: Record + +Mark task 5 as in_progress. + +Check if the fix revealed any insights worth recording for future sessions: + +1. If the root cause exposed an architectural gap or non-obvious constraint, run `/record decision` +2. If you discovered a pattern that would help debug similar issues, save it +3. If nothing worth recording, skip this phase + +Mark task 5 as completed. + +--- + ## Stop conditions - **3 failed fix attempts:** Stop fixing and question the architecture. The bug may be a symptom of a deeper design issue. diff --git a/.agents/skills/pr-fixup/SKILL.md b/.agents/skills/pr-fixup/SKILL.md index 822c47bc90..3bf459dc6e 100644 --- a/.agents/skills/pr-fixup/SKILL.md +++ b/.agents/skills/pr-fixup/SKILL.md @@ -1,6 +1,6 @@ --- name: pr-fixup -description: Wait for CI checks and automated reviews (CodeRabbit, Greptile) on a PR, fix failures and address comments, then push. +description: Wait for CI checks and automated reviews (CodeRabbit, Greptile, Claude) on a PR, fix failures and address comments, then push. --- # PR Fixup @@ -73,9 +73,9 @@ Mark task 2 as completed. Mark task 3 as in_progress. -Check if CodeRabbit and Greptile have posted or are generating reviews. +Check if CodeRabbit, Greptile, and Claude have posted or are generating reviews. -**Bot usernames:** `coderabbitai`, `greptile-apps[bot]` +**Bot usernames:** `coderabbitai`, `greptile-apps[bot]`, `claude[bot]` **CodeRabbit — stop waiting if:** - A comment contains `` — rate-limited, won't review. @@ -84,6 +84,10 @@ Check if CodeRabbit and Greptile have posted or are generating reviews. **Greptile — stop waiting if:** - A review from `greptile-apps[bot]` exists (posts via the GitHub review API, not issue comments). +**Claude — stop waiting if:** +- A review from `claude[bot]` exists (posts via the GitHub review API with inline review comments). +- The `claude-review` check in `gh pr checks` has completed (regardless of conclusion). + **Keep polling if:** - A bot hasn't commented yet AND `gh pr checks` shows its check is still `pending`. @@ -93,6 +97,8 @@ Poll every **30 seconds**, cap at **10 minutes**. Fetch both issue comments and gh pr view --json comments --jq '.comments[] | select(.author.login == "coderabbitai") | {author: .author.login, body: .body}' # Greptile posts reviews (with inline review comments) gh api repos/:owner/:repo/pulls//reviews --jq '.[] | select(.user.login == "greptile-apps[bot]") | {user: .user.login, state: .state}' +# Claude posts reviews (with inline review comments) +gh api repos/:owner/:repo/pulls//reviews --jq '.[] | select(.user.login == "claude[bot]") | {user: .user.login, state: .state}' ``` Mark task 3 as completed. @@ -131,7 +137,7 @@ Mark task 4 as completed. Mark task 5 as in_progress. -Fetch all review comments — human reviewers, CodeRabbit, and Greptile: +Fetch all review comments — human reviewers, CodeRabbit, Greptile, and Claude: ```bash gh pr view --json reviews,comments diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 5dc7c6e87e..b38ad26bce 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -149,16 +149,18 @@ func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, s "session_id": sessionID, "timestamp": status.Timestamp, "status": map[string]interface{}{ - "branch": status.Branch, - "remote_branch": status.RemoteBranch, - "ahead": status.Ahead, - "behind": status.Behind, - "files": status.Files, - "modified": status.Modified, - "added": status.Added, - "deleted": status.Deleted, - "untracked": status.Untracked, - "renamed": status.Renamed, + "branch": status.Branch, + "remote_branch": status.RemoteBranch, + "ahead": status.Ahead, + "behind": status.Behind, + "files": status.Files, + "modified": status.Modified, + "added": status.Added, + "deleted": status.Deleted, + "untracked": status.Untracked, + "renamed": status.Renamed, + "branch_additions": status.BranchAdditions, + "branch_deletions": status.BranchDeletions, }, } notification, err := ws.NewNotification(ws.ActionSessionGitEvent, gitEventData) @@ -192,16 +194,18 @@ func appendDBSnapshotGitStatus(ctx context.Context, taskRepo *sqliterepo.Reposit "session_id": sessionID, "timestamp": metadata["timestamp"], "status": map[string]interface{}{ - "branch": latestSnapshot.Branch, - "remote_branch": latestSnapshot.RemoteBranch, - "ahead": latestSnapshot.Ahead, - "behind": latestSnapshot.Behind, - "files": latestSnapshot.Files, - "modified": metadata["modified"], - "added": metadata["added"], - "deleted": metadata["deleted"], - "untracked": metadata["untracked"], - "renamed": metadata["renamed"], + "branch": latestSnapshot.Branch, + "remote_branch": latestSnapshot.RemoteBranch, + "ahead": latestSnapshot.Ahead, + "behind": latestSnapshot.Behind, + "files": latestSnapshot.Files, + "modified": metadata["modified"], + "added": metadata["added"], + "deleted": metadata["deleted"], + "untracked": metadata["untracked"], + "renamed": metadata["renamed"], + "branch_additions": metadata["branch_additions"], + "branch_deletions": metadata["branch_deletions"], }, } notification, err := ws.NewNotification(ws.ActionSessionGitEvent, gitEventData) diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index c835374e36..a4f92c449f 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -208,18 +208,20 @@ func (p GitEventPayload) GetSessionID() string { } type GitStatusData struct { - 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"` - Untracked []string `json:"untracked"` - Renamed []string `json:"renamed"` - Ahead int `json:"ahead"` - Behind int `json:"behind"` - Files interface{} `json:"files,omitempty"` + 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"` + Untracked []string `json:"untracked"` + Renamed []string `json:"renamed"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Files interface{} `json:"files,omitempty"` + BranchAdditions int `json:"branch_additions,omitempty"` + BranchDeletions int `json:"branch_deletions,omitempty"` } type GitCommitData struct { diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index ea8f036a21..60b54997d6 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -233,18 +233,20 @@ func (p *EventPublisher) PublishGitStatus(execution *AgentExecution, update *age AgentID: execution.ID, Timestamp: update.Timestamp.Format(time.RFC3339Nano), Status: &GitStatusData{ - Branch: update.Branch, - RemoteBranch: update.RemoteBranch, - HeadCommit: update.HeadCommit, - BaseCommit: update.BaseCommit, - Modified: update.Modified, - Added: update.Added, - Deleted: update.Deleted, - Untracked: update.Untracked, - Renamed: update.Renamed, - Ahead: update.Ahead, - Behind: update.Behind, - Files: update.Files, + Branch: update.Branch, + RemoteBranch: update.RemoteBranch, + HeadCommit: update.HeadCommit, + BaseCommit: update.BaseCommit, + Modified: update.Modified, + Added: update.Added, + Deleted: update.Deleted, + Untracked: update.Untracked, + Renamed: update.Renamed, + Ahead: update.Ahead, + Behind: update.Behind, + Files: update.Files, + BranchAdditions: update.BranchAdditions, + BranchDeletions: update.BranchDeletions, }, }) } diff --git a/apps/backend/internal/agentctl/client/git.go b/apps/backend/internal/agentctl/client/git.go index d0ebe83dda..da591b79c8 100644 --- a/apps/backend/internal/agentctl/client/git.go +++ b/apps/backend/internal/agentctl/client/git.go @@ -415,9 +415,11 @@ type GitStatusResult struct { Deleted []string `json:"deleted"` Untracked []string `json:"untracked"` Renamed []string `json:"renamed"` - Files map[string]interface{} `json:"files"` - Timestamp string `json:"timestamp"` - Error string `json:"error,omitempty"` + Files map[string]interface{} `json:"files"` + Timestamp string `json:"timestamp"` + BranchAdditions int `json:"branch_additions,omitempty"` + BranchDeletions int `json:"branch_deletions,omitempty"` + Error string `json:"error,omitempty"` } // GetGitStatus gets the current git status from the workspace. diff --git a/apps/backend/internal/agentctl/server/api/git.go b/apps/backend/internal/agentctl/server/api/git.go index 2fe8207d9d..a83286a3ed 100644 --- a/apps/backend/internal/agentctl/server/api/git.go +++ b/apps/backend/internal/agentctl/server/api/git.go @@ -593,9 +593,11 @@ type GitStatusResult struct { Deleted []string `json:"deleted"` Untracked []string `json:"untracked"` Renamed []string `json:"renamed"` - Files map[string]interface{} `json:"files"` - Timestamp string `json:"timestamp"` - Error string `json:"error,omitempty"` + Files map[string]interface{} `json:"files"` + Timestamp string `json:"timestamp"` + BranchAdditions int `json:"branch_additions,omitempty"` + BranchDeletions int `json:"branch_deletions,omitempty"` + Error string `json:"error,omitempty"` } // handleGitStatus handles GET /api/v1/git/status @@ -638,8 +640,10 @@ func (s *Server) handleGitStatus(c *gin.Context) { Deleted: status.Deleted, Untracked: status.Untracked, Renamed: status.Renamed, - Files: filesMap, - Timestamp: status.Timestamp.Format("2006-01-02T15:04:05.000Z07:00"), + Files: filesMap, + Timestamp: status.Timestamp.Format("2006-01-02T15:04:05.000Z07:00"), + BranchAdditions: status.BranchAdditions, + BranchDeletions: status.BranchDeletions, }) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go index d068ad17b0..5732123418 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go @@ -20,6 +20,51 @@ func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *type wt.enrichUntrackedFileDiffs(ctx, update) } +// enrichWithBranchDiff computes the total additions/deletions for the entire branch +// vs the merge-base, covering committed + staged + unstaged changes in one pass. +// Untracked file line counts (already computed) are added on top. +// The result is stored in BranchAdditions/BranchDeletions for the sidebar display. +func (wt *WorkspaceTracker) enrichWithBranchDiff(ctx context.Context, update *types.GitStatusUpdate) { + if update.BaseCommit == "" { + return + } + + // git diff --numstat covers committed + staged + unstaged changes. + numstatCmd := exec.CommandContext(ctx, "git", "diff", "--numstat", update.BaseCommit) + numstatCmd.Dir = wt.workDir + numstatOut, err := numstatCmd.Output() + if err != nil { + wt.logger.Debug("enrichWithBranchDiff: numstat failed", zap.Error(err)) + return + } + + var additions, deletions int + for _, line := range strings.Split(string(numstatOut), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + a, _ := strconv.Atoi(parts[0]) + d, _ := strconv.Atoi(parts[1]) + additions += a + deletions += d + } + + // Add untracked file line counts (not included in git diff output). + for _, fileInfo := range update.Files { + if fileInfo.Status == fileStatusUntracked { + additions += fileInfo.Additions + } + } + + update.BranchAdditions = additions + update.BranchDeletions = deletions +} + // enrichWithUnstagedDiff populates additions/deletions and diff content for files // with unstaged changes by comparing the worktree against baseRef. func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update *types.GitStatusUpdate, baseRef string) { @@ -122,7 +167,7 @@ func (wt *WorkspaceTracker) enrichWithStagedDiff(ctx context.Context, update *ty // lines as additions, so the diff viewer can display their full content. func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update *types.GitStatusUpdate) { for filePath, fileInfo := range update.Files { - if fileInfo.Status != "untracked" { + if fileInfo.Status != fileStatusUntracked { continue } catCmd := exec.CommandContext(ctx, "cat", filePath) diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_status.go b/apps/backend/internal/agentctl/server/process/workspace_git_status.go index b3a750f36c..2cfac6208f 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_status.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_status.go @@ -89,6 +89,9 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUp // Enrich file info with diff data (additions, deletions, and actual diff content) wt.enrichWithDiffData(ctx, &update) + // Compute full branch totals vs merge-base (committed + staged + unstaged + untracked) + wt.enrichWithBranchDiff(ctx, &update) + return update, nil } @@ -228,7 +231,7 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta // Prioritize worktree changes as they represent the current state. switch { case indexStatus == '?' && workTreeStatus == '?': - fileInfo.Status = "untracked" + fileInfo.Status = fileStatusUntracked fileInfo.Staged = false update.Untracked = append(update.Untracked, filePath) case workTreeStatus == 'D': diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index d47a119e83..7853ba0096 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -19,8 +19,9 @@ const DefaultGitPollInterval = 3 * time.Second // fileStatus constants for FileInfo.Status values. const ( - fileStatusDeleted = "deleted" - fileStatusModified = "modified" + fileStatusDeleted = "deleted" + fileStatusModified = "modified" + fileStatusUntracked = "untracked" ) // WorkspaceTracker monitors workspace changes and provides real-time updates. diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index b9201ae972..1ee829afb1 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -45,6 +45,14 @@ type GitStatusUpdate struct { // Files contains detailed information about each changed file. Files map[string]FileInfo `json:"files,omitempty"` + + // BranchAdditions is the total number of added lines across all changes on + // this branch compared to the merge-base (committed + staged + unstaged + untracked). + BranchAdditions int `json:"branch_additions,omitempty"` + + // BranchDeletions is the total number of deleted lines across all changes on + // this branch compared to the merge-base (committed + staged + unstaged). + BranchDeletions int `json:"branch_deletions,omitempty"` } // FileInfo represents detailed information about a file's git status. diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 838ad76082..0aa8b00779 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -32,6 +32,7 @@ func buildTaskEventPayload(task *models.Task) map[string]interface{} { "state": string(task.State), "priority": task.Priority, "position": task.Position, + "created_at": task.CreatedAt.Format(time.RFC3339Nano), "updated_at": task.UpdatedAt.Format(time.RFC3339Nano), "is_ephemeral": task.IsEphemeral, } diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index 1c2e2a12db..8c614665d0 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -322,6 +322,10 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv } } + // Capture a git status snapshot before cleanup so it can be served + // when clients subscribe to this session later (sidebar diff stats, etc.). + s.captureGitStatusSnapshot(ctx, data.SessionID) + // Clean up the agent execution (stop agentctl, release port) go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID) } diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 9a4a1cd252..07559c08fa 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -1242,6 +1242,56 @@ func (s *Service) captureArchiveCommits(ctx context.Context, sessionID, baseComm return true } +// captureGitStatusSnapshot fetches the current git status from agentctl and saves it +// as a DB snapshot. Called when a session completes so the status is available when +// clients subscribe later (e.g. sidebar showing diff stats for inactive sessions). +func (s *Service) captureGitStatusSnapshot(ctx context.Context, sessionID string) { + status, err := s.agentManager.GetGitStatus(ctx, sessionID) + if err != nil { + s.logger.Debug("failed to capture git status snapshot", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + if status == nil || !status.Success { + return + } + + metadata := map[string]interface{}{ + "timestamp": status.Timestamp, + "modified": status.Modified, + "added": status.Added, + "deleted": status.Deleted, + "untracked": status.Untracked, + "renamed": status.Renamed, + "branch_additions": status.BranchAdditions, + "branch_deletions": status.BranchDeletions, + } + + if err := s.repo.CreateGitSnapshot(ctx, &models.GitSnapshot{ + SessionID: sessionID, + SnapshotType: models.SnapshotTypeStatusUpdate, + Branch: status.Branch, + RemoteBranch: status.RemoteBranch, + HeadCommit: status.HeadCommit, + BaseCommit: status.BaseCommit, + Ahead: status.Ahead, + Behind: status.Behind, + Files: status.Files, + TriggeredBy: "agent_completed", + Metadata: metadata, + }); err != nil { + s.logger.Warn("failed to save git status snapshot", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + + s.logger.Debug("saved git status snapshot on agent completion", + zap.String("session_id", sessionID), + zap.String("branch", status.Branch)) +} + // captureArchiveDiff fetches and saves the cumulative diff from baseCommit to the working tree // (including uncommitted/unstaged changes). func (s *Service) captureArchiveDiff(ctx context.Context, sessionID, baseCommit string) { diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index e305837774..b53fc25096 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -1,11 +1,18 @@ "use client"; import { memo } from "react"; -import { IconDots, IconLoader2, IconSubtask } from "@tabler/icons-react"; +import { + IconCircleCheck, + IconCircleDashed, + IconDots, + IconGitPullRequest, +} from "@tabler/icons-react"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; +import { useAppStore } from "@/components/state-provider"; import { cn } from "@/lib/utils"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { RemoteCloudTooltip } from "./remote-cloud-tooltip"; +import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; type DiffStats = { additions: number; @@ -14,8 +21,6 @@ type DiffStats = { type TaskItemProps = { title: string; - description?: string; - stepName?: string; state?: TaskState; sessionState?: TaskSessionState; isArchived?: boolean; @@ -31,9 +36,11 @@ type TaskItemProps = { taskId?: string; primarySessionId?: string | null; parentTaskTitle?: string; + isSubTask?: boolean; + repositories?: string[]; + prInfo?: { number: number; state: string }; }; -// Helper to format relative time function formatRelativeTime(dateString: string): string { const date = new Date(dateString); const now = new Date(); @@ -56,88 +63,158 @@ function handleTaskItemKeyDown(e: React.KeyboardEvent, onClick?: onClick?.(); } -function TaskItemTitle({ - title, - description, - taskId, - parentTaskTitle, +function TaskStateIcon({ + sessionState, + state, + isInProgress, }: { - title: string; - description?: string; - taskId?: string; - parentTaskTitle?: string; + sessionState?: TaskSessionState; + state?: TaskState; + isInProgress: boolean; }) { + if (isInProgress) { + return ( + + ); + } + const isReview = + sessionState === "WAITING_FOR_INPUT" || + sessionState === "COMPLETED" || + sessionState === "FAILED" || + sessionState === "CANCELLED" || + state === "REVIEW" || + state === "COMPLETED"; + if (isReview) { + return ( + + ); + } return ( -
- - {title} - {taskId && } - - {parentTaskTitle && ( - - - {parentTaskTitle} - + + ); +} + +function TaskItemStatsRow({ + updatedAt, + prInfo, +}: { + updatedAt?: string; + prInfo?: { number: number; state: string }; +}) { + if (!updatedAt && !prInfo) return null; + return ( + + {updatedAt && ( + {formatRelativeTime(updatedAt)} )} - {description && ( - {description} + {prInfo && #{prInfo.number}} + + ); +} + +function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpen: boolean }) { + return ( +
+ +{diffStats.additions}{" "} + -{diffStats.deletions}
); } -function TaskItemMeta({ - effectiveMenuOpen, - isRemoteExecutor, +/** Shows PR icon from store (real data) or from prInfo prop (prototype/mock). */ +function TaskPRIcon({ taskId, - primarySessionId, - remoteExecutorName, - remoteExecutorType, - isArchived, - stepName, - isInProgress, - diffStats, - updatedAt, + prInfo, }: { - effectiveMenuOpen: boolean; - isRemoteExecutor?: boolean; taskId?: string; - primarySessionId?: string | null; - remoteExecutorName?: string; + prInfo?: { number: number; state: string }; +}) { + const storePr = useAppStore((s) => (taskId ? (s.taskPRs.byTaskId[taskId] ?? null) : null)); + if (storePr) return ; + if (!prInfo) return null; + const state = prInfo.state.toLowerCase(); + let color = "text-muted-foreground"; + if (state === "merged") color = "text-purple-500"; + else if (state === "closed") color = "text-red-500"; + return ( + + + + ); +} + +function TaskItemContent({ + title, + taskId, + isRemoteExecutor, + remoteExecutorType, + remoteExecutorName, + primarySessionId, + isArchived, + repositories, + updatedAt, + prInfo, + reserveMenuSpace, +}: { + title: string; + taskId?: string; + isRemoteExecutor?: boolean; remoteExecutorType?: string; + remoteExecutorName?: string; + primarySessionId?: string | null; isArchived?: boolean; - stepName?: string; - isInProgress: boolean; - diffStats?: DiffStats; + repositories?: string[]; updatedAt?: string; + prInfo?: { number: number; state: string }; + reserveMenuSpace: boolean; }) { return (
-
+ + {title} + {isRemoteExecutor && ( )} - -
- + {isArchived && ( + + Archived + + )} + + {repositories && repositories.length > 1 && ( + + {repositories.join(" · ")} + + )} +
); } export const TaskItem = memo(function TaskItem({ title, - description, - stepName, state, sessionState, isArchived, @@ -152,7 +229,9 @@ export const TaskItem = memo(function TaskItem({ isDeleting, taskId, primarySessionId, - parentTaskTitle, + isSubTask, + repositories, + prInfo, }: TaskItemProps) { const effectiveMenuOpen = menuOpen || isDeleting === true; const isInProgress = @@ -160,65 +239,58 @@ export const TaskItem = memo(function TaskItem({ state === "SCHEDULING" || sessionState === "STARTING" || sessionState === "RUNNING"; - const hasDiffStats = diffStats && (diffStats.additions > 0 || diffStats.deletions > 0); + const hasDiffStats = !!diffStats && (diffStats.additions > 0 || diffStats.deletions > 0); return (
handleTaskItemKeyDown(e, onClick)} className={cn( - "group relative flex w-full items-center gap-2 px-3 py-2 text-left text-sm outline-none cursor-pointer", - "transition-colors duration-75", - "hover:bg-foreground/[0.05]", + "group relative flex w-full items-start gap-2 py-2 pr-3 text-left text-sm outline-none cursor-pointer", + "transition-colors duration-75 hover:bg-foreground/[0.05]", isSelected && "bg-primary/10", + isSubTask ? "pl-8" : "pl-3", )} > - {/* Selection indicator */}
- - {/* Content */} - + ↳ + + )} + + - - {/* Right side: step name + meta, or 3-dot button on hover */} -
- - - -
+ {hasDiffStats && } +
); }); -/** 3-dot button that triggers the wrapping ContextMenu */ function TaskMenuButton({ visible }: { visible: boolean }) { return (
@@ -247,51 +319,3 @@ function TaskMenuButton({ visible }: { visible: boolean }) {
); } - -/** Step badge or "Archived" badge */ -function TaskItemStepBadge({ isArchived, stepName }: { isArchived?: boolean; stepName?: string }) { - if (isArchived) { - return ( - - Archived - - ); - } - if (stepName) { - return ( - - {stepName} - - ); - } - return null; -} - -/** Right-side meta: spinner, diff stats, or relative time */ -function TaskItemRightMeta({ - isInProgress, - diffStats, - updatedAt, -}: { - isInProgress: boolean; - diffStats?: DiffStats; - updatedAt?: string; -}) { - if (isInProgress) { - return ; - } - if (diffStats) { - return ( - - +{diffStats.additions}{" "} - -{diffStats.deletions} - - ); - } - if (updatedAt) { - return ( - {formatRelativeTime(updatedAt)} - ); - } - return null; -} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index d5c3f7c93d..063f1753fe 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -1,10 +1,12 @@ "use client"; -import { useCallback, useMemo, useState, memo } from "react"; +import { useCallback, useEffect, useMemo, useState, memo } from "react"; import type { TaskState, TaskSession, TaskSessionState, Repository, Task } from "@/lib/types/http"; +import type { TaskPR } from "@/lib/types/github"; import type { KanbanState } from "@/lib/state/slices"; import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; import { TaskSwitcher } from "./task-switcher"; +import type { TaskSwitcherItem } from "./task-switcher"; import { TaskRenameDialog } from "./task-rename-dialog"; import { Button } from "@kandev/ui/button"; import { PanelRoot, PanelBody } from "./panel-primitives"; @@ -23,7 +25,33 @@ import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; import { getSessionInfoForTask } from "@/lib/utils/session-info"; +import { getWebSocketClient } from "@/lib/ws/connection"; import { useArchivedTaskState } from "./task-archived-context"; +import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; +import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; + +// Set to true to render mock data covering all sidebar edge cases (prototype mode) +const MOCK_SIDEBAR = false; + +const MOCK_REPO = "kdlbs/kandev"; +const n = Date.now(); +const mins = (m: number) => new Date(n - m * 60 * 1000).toISOString(); +const hrs = (h: number) => new Date(n - h * 60 * 60 * 1000).toISOString(); +const base = { primarySessionId: null as null, isArchived: false } as const; + +/* prettier-ignore */ +const MOCK_ITEMS: TaskSwitcherItem[] = [ + { ...base, id: "mock-1", title: "Full stack authentication migration", state: "IN_PROGRESS", sessionState: "RUNNING", repositories: [MOCK_REPO, "kdlbs/kandev-web", "kdlbs/infra"], diffStats: { additions: 88, deletions: 12 }, updatedAt: mins(2), createdAt: hrs(3) }, + { ...base, id: "mock-1a", title: "Migrate auth endpoints to new provider", state: "IN_PROGRESS", sessionState: "RUNNING", diffStats: { additions: 24, deletions: 8 }, parentTaskId: "mock-1", updatedAt: mins(1), createdAt: hrs(2) }, + { ...base, id: "mock-1b", title: "Update frontend auth flows", parentTaskId: "mock-1", createdAt: hrs(1) }, + { ...base, id: "mock-2", title: "Fix task sidebar layout", state: "IN_PROGRESS", sessionState: "WAITING_FOR_INPUT", repositoryPath: MOCK_REPO, diffStats: { additions: 3, deletions: 1 }, updatedAt: mins(5), createdAt: hrs(4), prInfo: { number: 547, state: "Open" } }, + { ...base, id: "mock-2a", title: "Extract RepoGroupHeader component", sessionState: "WAITING_FOR_INPUT", repositoryPath: MOCK_REPO, parentTaskId: "mock-2", diffStats: { additions: 45, deletions: 3 }, updatedAt: mins(10), createdAt: hrs(3) }, + { ...base, id: "mock-3", title: "Refactor token usage in CLI", repositoryPath: MOCK_REPO, createdAt: hrs(5) }, + { ...base, id: "mock-4", title: "Update dependencies", state: "COMPLETED", sessionState: "COMPLETED", repositoryPath: MOCK_REPO, diffStats: { additions: 466, deletions: 124 }, updatedAt: hrs(2), createdAt: hrs(6), prInfo: { number: 138, state: "Merged" } }, + { ...base, id: "mock-5", title: "Implement feature X with full test coverage", state: "IN_PROGRESS", sessionState: "RUNNING", repositoryPath: "myorg/other-repo", diffStats: { additions: 11, deletions: 3 }, updatedAt: mins(0.5), createdAt: hrs(1) }, + { ...base, id: "mock-5a", title: "Add unit tests", repositoryPath: "myorg/other-repo", parentTaskId: "mock-5", createdAt: mins(30) }, + { ...base, id: "mock-6", title: "Draft task — no repo assigned yet", createdAt: hrs(7) }, +]; /** Find a task across all workflow snapshots */ function findTaskInSnapshots( @@ -109,6 +137,39 @@ export const NewTaskButton = memo(function NewTaskButton({ ); }); +/** Look up git status directly via primarySessionId, bypassing the session list. */ +function getGitStatusForTask( + task: { primarySessionId?: string | null }, + envIdBySessionId: Record, + gitStatusByEnvId: Record, +): GitStatusEntry | undefined { + if (!task.primarySessionId) return undefined; + const envKey = envIdBySessionId[task.primarySessionId] ?? task.primarySessionId; + return gitStatusByEnvId[envKey]; +} + +/** Resolve diff stats for a task, falling back to direct git status when sessions aren't loaded. */ +function resolveDiffStats( + sessionDiffStats: { additions: number; deletions: number } | undefined, + task: { primarySessionId?: string | null }, + envIdBySessionId: Record, + gitStatusByEnvId: Record, +): { additions: number; deletions: number } | undefined { + if (sessionDiffStats) return sessionDiffStats; + if (!task.primarySessionId) return undefined; + const gs = getGitStatusForTask(task, envIdBySessionId, gitStatusByEnvId); + if (!gs) return undefined; + const a = gs.branch_additions ?? 0; + const d = gs.branch_deletions ?? 0; + return a > 0 || d > 0 ? { additions: a, deletions: d } : undefined; +} + +/** Format PR info for display, capitalising the state. */ +function toPrInfo(pr: TaskPR | undefined): { number: number; state: string } | undefined { + if (!pr?.state) return undefined; + return { number: pr.pr_number, state: pr.state[0].toUpperCase() + pr.state.slice(1) }; +} + /** Map a kanban task to a sidebar item with session info and repository metadata. */ function toSidebarItem( task: KanbanState["tasks"][number] & { _workflowId: string }, @@ -117,7 +178,7 @@ function toSidebarItem( gitStatusByEnvId: Record; envIdBySessionId: Record; repositorySlugById: Map; - taskPRsByTaskId: Record; + taskPRsByTaskId: Record; titleById: Map; }, ) { @@ -131,6 +192,17 @@ function toSidebarItem( sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined); const repoSlug = task.repositoryId ? ctx.repositorySlugById.get(task.repositoryId) : undefined; const pr = ctx.taskPRsByTaskId[task.id]; + + // Diff stats: prefer session-based computation, fall back to direct git status lookup + // via primarySessionId. Sessions may not be loaded yet, but git status arrives via + // the bulk WS subscription using primarySessionId from the kanban task. + const diffStats = resolveDiffStats( + sessionInfo.diffStats, + task, + ctx.envIdBySessionId, + ctx.gitStatusByEnvId, + ); + return { id: task.id, title: task.title, @@ -140,15 +212,17 @@ function toSidebarItem( workflowId: task._workflowId, workflowStepId: task.workflowStepId as string | undefined, repositoryPath: pr ? `${pr.owner}/${pr.repo}` : repoSlug, - diffStats: sessionInfo.diffStats, + diffStats, isRemoteExecutor: task.isRemoteExecutor, remoteExecutorType: task.primaryExecutorType ?? undefined, remoteExecutorName: task.primaryExecutorName ?? undefined, primarySessionId: task.primarySessionId ?? null, - updatedAt: sessionInfo.updatedAt ?? task.updatedAt, + updatedAt: sessionInfo.updatedAt ?? task.updatedAt ?? task.createdAt, createdAt: task.createdAt, isArchived: false as boolean, parentTaskTitle: task.parentTaskId ? ctx.titleById.get(task.parentTaskId) : undefined, + parentTaskId: task.parentTaskId ?? undefined, + prInfo: toPrInfo(pr), }; } @@ -158,6 +232,31 @@ type TaskSessionSidebarProps = { }; type StepInfo = { id: string; title: string; color: string; position: number }; +type SidebarItem = Omit, "workflowId"> & { workflowId?: string }; + +function buildArchivedItem(s: ReturnType): SidebarItem { + return { + id: s.archivedTaskId!, + title: s.archivedTaskTitle ?? "Archived task", + state: undefined, + sessionState: undefined, + description: undefined, + workflowId: undefined, + workflowStepId: undefined, + repositoryPath: s.archivedTaskRepositoryPath, + diffStats: undefined, + isRemoteExecutor: false, + remoteExecutorType: undefined, + remoteExecutorName: undefined, + primarySessionId: null, + updatedAt: s.archivedTaskUpdatedAt, + createdAt: undefined, + isArchived: true, + parentTaskTitle: undefined, + parentTaskId: undefined, + prInfo: undefined, + }; +} function useSidebarData(workspaceId: string | null) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); @@ -208,40 +307,16 @@ function useSidebarData(workspaceId: string | null) { gitStatusByEnvId, envIdBySessionId, repositorySlugById, - taskPRsByTaskId: taskPRsByTaskId as Record< - string, - { owner: string; repo: string } | undefined - >, + taskPRsByTaskId: taskPRsByTaskId as Record, titleById, }; - type SidebarItem = Omit, "workflowId"> & { - workflowId?: string; - }; const items: SidebarItem[] = allTasks.map((task) => toSidebarItem(task, mapCtx)); if ( archivedState.isArchived && archivedState.archivedTaskId && !items.some((t) => t.id === archivedState.archivedTaskId) ) { - items.unshift({ - id: archivedState.archivedTaskId, - title: archivedState.archivedTaskTitle ?? "Archived task", - state: undefined, - sessionState: undefined, - description: undefined, - workflowId: undefined, - workflowStepId: undefined, - repositoryPath: archivedState.archivedTaskRepositoryPath, - diffStats: undefined, - isRemoteExecutor: false, - remoteExecutorType: undefined, - remoteExecutorName: undefined, - primarySessionId: null, - updatedAt: archivedState.archivedTaskUpdatedAt, - createdAt: undefined, - isArchived: true, - parentTaskTitle: undefined, - }); + items.unshift(buildArchivedItem(archivedState)); } return items; }, [ @@ -255,6 +330,13 @@ function useSidebarData(workspaceId: string | null) { archivedState, ]); + // Stable list of primary session IDs for the bulk-subscribe effect. + // Derived from kanban tasks (always available) rather than sessionsByTaskId (loaded on-demand). + const primarySessionIds = useMemo( + () => allTasks.map((t) => t.primarySessionId).filter((id): id is string => id != null), + [allTasks], + ); + return { activeTaskId, selectedTaskId, @@ -262,6 +344,7 @@ function useSidebarData(workspaceId: string | null) { stepsByWorkflowId, isLoadingWorkflow, tasksWithRepositories, + primarySessionIds, }; } @@ -483,6 +566,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ }: TaskSessionSidebarProps) { const store = useAppStoreApi(); useAllWorkflowSnapshots(workspaceId); + useRepositories(workspaceId); + useWorkspacePRs(workspaceId); const { activeTaskId, @@ -491,7 +576,20 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ stepsByWorkflowId, isLoadingWorkflow, tasksWithRepositories, + primarySessionIds, } = useSidebarData(workspaceId); + + // Subscribe to all primary sessions when connected so the backend sends an immediate + // git status snapshot for each — this makes diff stats visible without clicking into a task. + const connectionStatus = useAppStore((state) => state.connection.status); + useEffect(() => { + if (connectionStatus !== "connected" || primarySessionIds.length === 0) return; + const client = getWebSocketClient(); + if (!client) return; + const unsubscribes = primarySessionIds.map((id) => client.subscribeSession(id)); + return () => unsubscribes.forEach((u) => u()); + }, [primarySessionIds, connectionStatus]); + const { deletingTaskId, preparingTaskId, @@ -505,15 +603,14 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ handleRenameSubmit, } = useSidebarActions(store); - const displayTasks = useMemo( - () => - preparingTaskId - ? tasksWithRepositories.map((t) => - t.id === preparingTaskId ? { ...t, sessionState: "STARTING" as TaskSessionState } : t, - ) - : tasksWithRepositories, - [tasksWithRepositories, preparingTaskId], - ); + const displayTasks = useMemo(() => { + if (MOCK_SIDEBAR) return MOCK_ITEMS; + return preparingTaskId + ? tasksWithRepositories.map((t) => + t.id === preparingTaskId ? { ...t, sessionState: "STARTING" as TaskSessionState } : t, + ) + : tasksWithRepositories; + }, [tasksWithRepositories, preparingTaskId]); return ( diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 904b14710e..00137124d8 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -1,11 +1,8 @@ "use client"; import { cloneElement, isValidElement, memo, useMemo, useState } from "react"; -import type { ComponentType } from "react"; import { - IconCircleCheck, - IconCircleDashed, - IconProgress, + IconChevronDown, IconArrowRight, IconPencil, IconCopy, @@ -24,24 +21,10 @@ import { ContextMenuTrigger, } from "@kandev/ui/context-menu"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; -import { cn, truncateRepoPath } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { TaskItem } from "./task-item"; -const SECTION_ICONS: Record< - string, - { Icon: ComponentType<{ className?: string }>; className: string } -> = { - "Turn Finished": { Icon: IconCircleCheck, className: "text-green-500" }, - Running: { Icon: IconProgress, className: "text-yellow-500" }, - Backlog: { Icon: IconCircleDashed, className: "text-muted-foreground" }, -}; - -type DiffStats = { - additions: number; - deletions: number; -}; - -type TaskSwitcherItem = { +export type TaskSwitcherItem = { id: string; title: string; state?: TaskState; @@ -50,7 +33,8 @@ type TaskSwitcherItem = { workflowId?: string; workflowStepId?: string; repositoryPath?: string; - diffStats?: DiffStats; + repositories?: string[]; + diffStats?: { additions: number; deletions: number }; isRemoteExecutor?: boolean; remoteExecutorType?: string; remoteExecutorName?: string; @@ -59,13 +43,15 @@ type TaskSwitcherItem = { isArchived?: boolean; primarySessionId?: string | null; parentTaskTitle?: string; + parentTaskId?: string; + prInfo?: { number: number; state: string }; }; type StepDef = { id: string; title: string; color?: string }; type TaskSwitcherProps = { tasks: TaskSwitcherItem[]; - steps: StepDef[]; + steps?: StepDef[]; stepsByWorkflowId?: Record; activeTaskId: string | null; selectedTaskId: string | null; @@ -78,8 +64,10 @@ type TaskSwitcherProps = { isLoading?: boolean; }; -type Section = { +type RepoGroup = { + key: string; label: string; + isMultiRepo?: boolean; tasks: TaskSwitcherItem[]; }; @@ -90,8 +78,6 @@ const REVIEW_STATES = new Set([ "CANCELLED", ]); const IN_PROGRESS_STATES = new Set(["RUNNING"]); -const BACKLOG_STATES = new Set(["CREATED", "STARTING"]); - const TASK_STATE_REVIEW = new Set(["REVIEW", "COMPLETED"]); const TASK_STATE_IN_PROGRESS = new Set(["IN_PROGRESS"]); @@ -99,9 +85,11 @@ function classifyTask( sessionState: TaskSessionState | undefined, taskState?: TaskState, ): "review" | "in_progress" | "backlog" { - if (!sessionState) return "backlog"; - // STARTING is transient (e.g., resume after backend restart). Use the - // task-level state so the sidebar position stays stable. + if (!sessionState) { + if (TASK_STATE_REVIEW.has(taskState)) return "review"; + if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress"; + return "backlog"; + } if (sessionState === "STARTING" && taskState) { if (TASK_STATE_REVIEW.has(taskState)) return "review"; if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress"; @@ -109,10 +97,23 @@ function classifyTask( } if (REVIEW_STATES.has(sessionState)) return "review"; if (IN_PROGRESS_STATES.has(sessionState)) return "in_progress"; - if (BACKLOG_STATES.has(sessionState)) return "backlog"; return "backlog"; } +function statePriority(task: TaskSwitcherItem): number { + const bucket = classifyTask(task.sessionState, task.state); + if (bucket === "in_progress") return 0; + if (bucket === "review") return 1; + return 2; +} + +function sortByStateThenCreated(a: TaskSwitcherItem, b: TaskSwitcherItem): number { + // State first, then newest createdAt first (descending) + return ( + statePriority(a) - statePriority(b) || (b.createdAt ?? "").localeCompare(a.createdAt ?? "") + ); +} + function TaskSwitcherSkeleton() { return (
@@ -124,26 +125,42 @@ function TaskSwitcherSkeleton() { ); } -function SectionHeader({ label, count }: { label: string; count: number }) { - const icon = SECTION_ICONS[label]; +function RepoGroupHeader({ + label, + count, + isCollapsed, + onToggle, +}: { + label: string; + count: number; + isCollapsed: boolean; + onToggle: () => void; +}) { return ( -
- - {icon && } + {label} - {count} -
+ {count} + + ); } -function TaskSwitcherSection({ - section, +function RepoGroupSection({ + group, + subTasksByParentId, stepsByWorkflowId, - stepNameById, activeTaskId, selectedTaskId, onSelectTask, @@ -153,9 +170,9 @@ function TaskSwitcherSection({ onMoveToStep, deletingTaskId, }: { - section: Section; + group: RepoGroup; + subTasksByParentId: Map; stepsByWorkflowId?: Record; - stepNameById: Map; activeTaskId: string | null; selectedTaskId: string | null; onSelectTask: (taskId: string) => void; @@ -165,48 +182,64 @@ function TaskSwitcherSection({ onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; deletingTaskId?: string | null; }) { - if (section.tasks.length === 0) return null; + const [isCollapsed, setIsCollapsed] = useState(false); + const totalCount = group.tasks.reduce( + (sum, t) => sum + 1 + (subTasksByParentId.get(t.id)?.length ?? 0), + 0, + ); + + function renderItem(task: TaskSwitcherItem, isSubTask?: boolean) { + const isSelected = task.id === selectedTaskId || task.id === activeTaskId; + const taskSteps = task.workflowId ? stepsByWorkflowId?.[task.workflowId] : undefined; + return ( + + onSelectTask(task.id)} + isDeleting={deletingTaskId === task.id} + /> + + ); + } + return (
- - {section.tasks.map((task) => { - const isActive = task.id === activeTaskId; - const isSelected = task.id === selectedTaskId || isActive; - const repoLabel = task.repositoryPath ? truncateRepoPath(task.repositoryPath) : undefined; - const stepName = task.workflowStepId ? stepNameById.get(task.workflowStepId) : undefined; - return ( - - onSelectTask(task.id)} - isDeleting={deletingTaskId === task.id} - /> - - ); - })} + setIsCollapsed((v) => !v)} + /> + {!isCollapsed && + group.tasks.map((task) => ( +
+ {renderItem(task)} + {subTasksByParentId.get(task.id)?.map((sub) => renderItem(sub, true))} +
+ ))}
); } @@ -340,9 +373,95 @@ function cloneWithMenuOpen( return children; } +/** Partition root tasks into multi-repo, per-repo, and unassigned buckets. */ +function partitionRootTasks(rootTasks: TaskSwitcherItem[]): { + multiRepoTasks: TaskSwitcherItem[]; + byRepo: Map; + unassigned: TaskSwitcherItem[]; +} { + const multiRepoTasks: TaskSwitcherItem[] = []; + const byRepo = new Map(); + const unassigned: TaskSwitcherItem[] = []; + for (const task of rootTasks) { + if (task.repositories && task.repositories.length > 1) { + multiRepoTasks.push(task); + } else if (task.repositoryPath) { + const arr = byRepo.get(task.repositoryPath) ?? []; + arr.push(task); + byRepo.set(task.repositoryPath, arr); + } else { + unassigned.push(task); + } + } + return { multiRepoTasks, byRepo, unassigned }; +} + +/** Build sorted repo groups from partitioned task buckets. */ +function buildRepoGroups( + multiRepoTasks: TaskSwitcherItem[], + byRepo: Map, + unassigned: TaskSwitcherItem[], +): RepoGroup[] { + const result: RepoGroup[] = []; + if (multiRepoTasks.length > 0) { + result.push({ + key: "multi-repo", + label: "Multi-repo", + isMultiRepo: true, + tasks: [...multiRepoTasks].sort(sortByStateThenCreated), + }); + } + for (const [slug, groupTasks] of byRepo) { + result.push({ key: slug, label: slug, tasks: [...groupTasks].sort(sortByStateThenCreated) }); + } + if (unassigned.length > 0) { + // In single-repo workspaces, merge unassigned tasks into the repo group + // so every task appears under a meaningful label instead of "Unassigned". + const singleRepoGroup = + byRepo.size === 1 && multiRepoTasks.length === 0 + ? result.find((g) => g.key !== "multi-repo") + : undefined; + if (singleRepoGroup) { + singleRepoGroup.tasks = [...singleRepoGroup.tasks, ...unassigned].sort( + sortByStateThenCreated, + ); + } else { + result.push({ + key: "unassigned", + label: "Unassigned", + tasks: [...unassigned].sort(sortByStateThenCreated), + }); + } + } + return result; +} + +/** Group tasks by repository, separating sub-tasks from root tasks. */ +function groupTasksIntoRepos(tasks: TaskSwitcherItem[]): { + groups: RepoGroup[]; + subTasksByParentId: Map; +} { + const allTaskIds = new Set(tasks.map((t) => t.id)); + const subMap = new Map(); + const rootTasks: TaskSwitcherItem[] = []; + for (const t of tasks) { + if (t.parentTaskId && allTaskIds.has(t.parentTaskId)) { + const arr = subMap.get(t.parentTaskId) ?? []; + arr.push(t); + subMap.set(t.parentTaskId, arr); + } else { + rootTasks.push(t); + } + } + const { multiRepoTasks, byRepo, unassigned } = partitionRootTasks(rootTasks); + return { + groups: buildRepoGroups(multiRepoTasks, byRepo, unassigned), + subTasksByParentId: subMap, + }; +} + export const TaskSwitcher = memo(function TaskSwitcher({ tasks, - steps, stepsByWorkflowId, activeTaskId, selectedTaskId, @@ -354,52 +473,21 @@ export const TaskSwitcher = memo(function TaskSwitcher({ deletingTaskId, isLoading = false, }: TaskSwitcherProps) { - const stepNameById = useMemo(() => { - const map = new Map(); - for (const col of steps) { - map.set(col.id, col.title); - } - return map; - }, [steps]); - - const sections = useMemo(() => { - const review: TaskSwitcherItem[] = []; - const inProgress: TaskSwitcherItem[] = []; - const backlog: TaskSwitcherItem[] = []; - - for (const task of tasks) { - const bucket = classifyTask(task.sessionState, task.state); - if (bucket === "review") review.push(task); - else if (bucket === "in_progress") inProgress.push(task); - else backlog.push(task); - } - - const byCreatedAt = (a: TaskSwitcherItem, b: TaskSwitcherItem) => - (a.createdAt ?? "").localeCompare(b.createdAt ?? ""); - - return [ - { label: "Turn Finished", tasks: review.sort(byCreatedAt) }, - { label: "Running", tasks: inProgress.sort(byCreatedAt) }, - { label: "Backlog", tasks: backlog.sort(byCreatedAt) }, - ] satisfies Section[]; - }, [tasks]); - - if (isLoading) { - return ; - } + const { groups, subTasksByParentId } = useMemo(() => groupTasksIntoRepos(tasks), [tasks]); + if (isLoading) return ; if (tasks.length === 0) { return
No tasks yet.
; } return (
- {sections.map((section) => ( - ( + { + test("tasks grouped by repository with header showing label and count", async ({ + testPage, + apiClient, + seedData, + }) => { + // Create two tasks in the seeded repo so there is one group + await apiClient.createTask(seedData.workspaceId, "Sidebar Group Task A", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await apiClient.createTask(seedData.workspaceId, "Sidebar Group Task B", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + // Navigate to any task to open the session page which shows the sidebar + const task = await apiClient.createTask(seedData.workspaceId, "Sidebar Nav Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Sidebar is visible + await expect(session.sidebar).toBeVisible({ timeout: 10_000 }); + + // At least one repo group header should be visible. + // The group label for a local path repo will be its local_path — we can + // match without knowing the exact slug by checking for any group header. + const anyGroupHeader = session.sidebar.locator("[data-testid^='sidebar-repo-group-']"); + await expect(anyGroupHeader.first()).toBeVisible({ timeout: 10_000 }); + + // A group header should contain a task count greater than zero + const countText = anyGroupHeader.first().locator("span").nth(1); + const count = await countText.innerText(); + expect(Number(count)).toBeGreaterThan(0); + }); + + test("repo group header is collapsible — clicking hides and restores tasks", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.createTask(seedData.workspaceId, "Collapsible Task Alpha", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + const task = await apiClient.createTask(seedData.workspaceId, "Collapsible Nav Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // In single-repo workspaces, unassigned tasks merge into the repo group, + // so both tasks should be in the same group. + const groupHeader = session.sidebar.locator("[data-testid^='sidebar-repo-group-']").first(); + await expect(groupHeader).toBeVisible({ timeout: 10_000 }); + + // Both tasks should be visible before collapsing + await expect(session.sidebar.getByText("Collapsible Task Alpha")).toBeVisible({ + timeout: 10_000, + }); + await expect(session.sidebar.getByText("Collapsible Nav Task")).toBeVisible({ + timeout: 10_000, + }); + + // Collapse the group + await groupHeader.click(); + + // After collapsing, both tasks should be hidden + await expect(session.sidebar.getByText("Collapsible Task Alpha")).not.toBeVisible({ + timeout: 5_000, + }); + await expect(session.sidebar.getByText("Collapsible Nav Task")).not.toBeVisible({ + timeout: 5_000, + }); + + // Expand again + await groupHeader.click(); + await expect(session.sidebar.getByText("Collapsible Task Alpha")).toBeVisible({ + timeout: 5_000, + }); + }); + + test("tasks without repository merge into single repo group instead of Unassigned", async ({ + testPage, + apiClient, + seedData, + }) => { + // Create a task with the repo and one without + await apiClient.createTask(seedData.workspaceId, "Repo Task One", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await apiClient.createTask(seedData.workspaceId, "No Repo Task One", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + // no repository_ids + }); + + const task = await apiClient.createTask(seedData.workspaceId, "No Repo Nav Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // In a single-repo workspace, no "Unassigned" group should appear + const unassignedGroup = session.sidebar.getByTestId("sidebar-repo-group-Unassigned"); + await expect(unassignedGroup).not.toBeVisible({ timeout: 5_000 }); + + // Both tasks should be visible in the sidebar (under the repo group) + await expect(session.sidebar.getByText("Repo Task One", { exact: true })).toBeVisible({ + timeout: 10_000, + }); + await expect(session.sidebar.getByText("No Repo Task One", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + }); +}); + +test.describe("Sidebar layout — subtasks", () => { + test("subtask appears nested under parent with arrow indicator", async ({ + testPage, + apiClient, + seedData, + }) => { + // Create parent task + const parent = await apiClient.createTask(seedData.workspaceId, "Parent Task Sidebar", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + // Create subtask with parent_id + await apiClient.createTask(seedData.workspaceId, "Child Subtask Sidebar", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + parent_id: parent.id, + }); + + await testPage.goto(`/t/${parent.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Both titles are visible in the sidebar + await expect(session.sidebar.getByText("Parent Task Sidebar")).toBeVisible({ timeout: 10_000 }); + await expect(session.sidebar.getByText("Child Subtask Sidebar")).toBeVisible({ + timeout: 10_000, + }); + + // The subtask row has the isSubTask styling — it uses pl-8 (subtask indent) + // and renders the ↳ arrow indicator + const subtaskRow = session.sidebar + .locator("[data-testid='sidebar-task-item']") + .filter({ hasText: "Child Subtask Sidebar" }); + await expect(subtaskRow).toBeVisible({ timeout: 5_000 }); + + // Arrow indicator ↳ is present inside the subtask row + await expect(subtaskRow.getByText("↳")).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Sidebar layout — context menu", () => { + test("right-clicking a task shows Rename, Archive, and Delete options", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.createTask(seedData.workspaceId, "Context Menu Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + const task = await apiClient.createTask(seedData.workspaceId, "Context Menu Nav Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Right-click on the task row + const taskRow = session.sidebar + .locator("[data-testid='sidebar-task-item']") + .filter({ hasText: "Context Menu Task" }); + await expect(taskRow).toBeVisible({ timeout: 10_000 }); + await taskRow.click({ button: "right" }); + + // Context menu items should be visible + await expect(testPage.getByRole("menuitem", { name: "Rename" })).toBeVisible({ + timeout: 5_000, + }); + await expect(testPage.getByRole("menuitem", { name: "Archive" })).toBeVisible({ + timeout: 5_000, + }); + await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible({ + timeout: 5_000, + }); + + // Dismiss + await testPage.keyboard.press("Escape"); + }); +}); + +test.describe("Sidebar layout — task timestamps and sorting", () => { + test("every task row shows a relative timestamp on second line", async ({ + testPage, + apiClient, + seedData, + }) => { + // Create three tasks + await apiClient.createTask(seedData.workspaceId, "Timestamp Task A", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await apiClient.createTask(seedData.workspaceId, "Timestamp Task B", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const navTask = await apiClient.createTask(seedData.workspaceId, "Timestamp Task C", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + await testPage.goto(`/t/${navTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Every sidebar task row should contain a relative time string ("now", "ago", etc.) + const taskRows = session.sidebar.locator("[data-testid='sidebar-task-item']"); + await expect(taskRows).toHaveCount(3, { timeout: 10_000 }); + + for (let i = 0; i < 3; i++) { + const text = await taskRows.nth(i).innerText(); + // Should contain "now" or "ago" — formatRelativeTime always produces one of these + expect(text, `task row ${i} text=${JSON.stringify(text)}`).toMatch(/now|ago/); + } + }); + + test("tasks are sorted by createdAt descending (newest first) within state bucket", async ({ + testPage, + apiClient, + seedData, + }) => { + // Create three tasks in order; each should appear above the previous + const taskOldest = await apiClient.createTask(seedData.workspaceId, "Sort Task Oldest", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + // Small delay so timestamps differ + await new Promise((r) => setTimeout(r, 50)); + await apiClient.createTask(seedData.workspaceId, "Sort Task Middle", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await new Promise((r) => setTimeout(r, 50)); + await apiClient.createTask(seedData.workspaceId, "Sort Task Newest", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + await testPage.goto(`/t/${taskOldest.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const taskRows = session.sidebar.locator("[data-testid='sidebar-task-item']"); + await expect(taskRows).toHaveCount(3, { timeout: 10_000 }); + + // Within the same state bucket (all backlog), newest should be first + const titles = await Promise.all( + [0, 1, 2].map(async (i) => (await taskRows.nth(i).innerText()).split("\n")[0]), + ); + const newestIdx = titles.findIndex((t) => t.includes("Newest")); + const middleIdx = titles.findIndex((t) => t.includes("Middle")); + const oldestIdx = titles.findIndex((t) => t.includes("Oldest")); + expect(newestIdx).toBeLessThan(middleIdx); + expect(middleIdx).toBeLessThan(oldestIdx); + }); +}); + +test.describe("Sidebar layout — active task highlight", () => { + test("selected task has visual selection state", async ({ testPage, apiClient, seedData }) => { + const taskA = await apiClient.createTask(seedData.workspaceId, "Highlight Task A", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + await testPage.goto(`/t/${taskA.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // The task row for the active task should have the selected styling + // TaskItem applies bg-primary/10 and a left border when isSelected=true + const activeRow = session.sidebar + .locator("[data-testid='sidebar-task-item']") + .filter({ hasText: "Highlight Task A" }); + await expect(activeRow).toBeVisible({ timeout: 10_000 }); + + // The selected row has a specific class pattern from isSelected — check for it + // isSelected applies `bg-primary/10` which becomes part of the element's class + await expect(activeRow).toHaveClass(/bg-primary/, { timeout: 5_000 }); + }); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-automation.spec.ts b/apps/web/e2e/tests/workflow/workflow-automation.spec.ts index d708bf6b35..cdd9df13ae 100644 --- a/apps/web/e2e/tests/workflow/workflow-automation.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-automation.spec.ts @@ -617,8 +617,9 @@ test.describe("Workflow automation", () => { expect(sessions.total).toBe(1); expect(sessions.sessions[0].state).toBe("CREATED"); - // The chat should show the empty state — no agent messages - await expect(testPage.getByText("No messages yet")).toBeVisible(); + // The agent was NOT started — the "Start agent" button should be visible, + // confirming the auto-start was blocked for this non-auto-start step. + await expect(testPage.getByTestId("task-description-start-button")).toBeVisible(); // Stepper shows Waiting as current step (task didn't move) await expect(session.stepperStep("Waiting")).toHaveAttribute("aria-current", "step", { diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index a5e4a68b3a..49a57c40ff 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -62,6 +62,8 @@ export type GitStatusEntry = { behind: number; files: Record; timestamp: string | null; + branch_additions?: number; + branch_deletions?: number; }; export type GitStatusState = { diff --git a/apps/web/lib/types/git-events.ts b/apps/web/lib/types/git-events.ts index 0bd664791f..9468a06e48 100644 --- a/apps/web/lib/types/git-events.ts +++ b/apps/web/lib/types/git-events.ts @@ -22,6 +22,8 @@ export type GitStatusData = { ahead: number; behind: number; files: Record; + branch_additions?: number; + branch_deletions?: number; }; // Git commit data diff --git a/apps/web/lib/utils/session-info.ts b/apps/web/lib/utils/session-info.ts index 8f536b2012..e9e6c016d2 100644 --- a/apps/web/lib/utils/session-info.ts +++ b/apps/web/lib/utils/session-info.ts @@ -8,9 +8,32 @@ export type SessionInfo = { type GitStatusMap = Record< string, - { files?: Record } + { + files?: Record; + branch_additions?: number; + branch_deletions?: number; + } >; +function computeDiffStats( + gitStatus: GitStatusMap[string], +): { additions: number; deletions: number } | undefined { + let additions: number; + let deletions: number; + if (gitStatus.branch_additions !== undefined || gitStatus.branch_deletions !== undefined) { + additions = gitStatus.branch_additions ?? 0; + deletions = gitStatus.branch_deletions ?? 0; + } else { + additions = 0; + deletions = 0; + for (const file of Object.values(gitStatus.files ?? {})) { + additions += file.additions ?? 0; + deletions += file.deletions ?? 0; + } + } + return additions === 0 && deletions === 0 ? undefined : { additions, deletions }; +} + export function getSessionInfoForTask( taskId: string, sessionsByTaskId: Record, @@ -26,17 +49,14 @@ export function getSessionInfoForTask( if (!latestSession) { return { diffStats: undefined, updatedAt: undefined, sessionState: undefined }; } - const updatedAt = latestSession.updated_at; + // Empty string means the session was created from a WS event without timestamps; + // return undefined so callers fall through to task.updatedAt/createdAt instead. + const updatedAt = latestSession.updated_at || undefined; const sessionState = latestSession.state as TaskSessionState | undefined; const envKey = environmentIdBySessionId?.[latestSession.id] ?? latestSession.id; const gitStatus = gitStatusByEnvId[envKey]; - if (!gitStatus?.files) return { diffStats: undefined, updatedAt, sessionState }; - let additions = 0; - let deletions = 0; - for (const file of Object.values(gitStatus.files)) { - additions += file.additions ?? 0; - deletions += file.deletions ?? 0; - } - const diffStats = additions === 0 && deletions === 0 ? undefined : { additions, deletions }; + if (!gitStatus) return { diffStats: undefined, updatedAt, sessionState }; + + const diffStats = computeDiffStats(gitStatus); return { diffStats, updatedAt, sessionState }; } diff --git a/apps/web/lib/ws/handlers/git-status.ts b/apps/web/lib/ws/handlers/git-status.ts index f08f75edfb..7ebfec25ba 100644 --- a/apps/web/lib/ws/handlers/git-status.ts +++ b/apps/web/lib/ws/handlers/git-status.ts @@ -32,6 +32,8 @@ const gitEventHandlers: GitEventHandlers = { behind: event.status.behind, files: event.status.files, timestamp: event.timestamp, + branch_additions: event.status.branch_additions, + branch_deletions: event.status.branch_deletions, }); // Invalidate cumulative diff cache when files change invalidateCumulativeDiffCache(event.session_id);