feat(web): redesign task sidebar with repo-grouped layout and diff stats (#550)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 by coderabbit.ai -->` — 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 <number> --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/<number>/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/<number>/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 <number> --json reviews,comments
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <merge-base> 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)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<HTMLDivElement>, 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 (
|
||||
<IconCircleDashed
|
||||
data-testid="task-state-running"
|
||||
className="mt-[1px] h-3.5 w-3.5 shrink-0 text-yellow-500 animate-spin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isReview =
|
||||
sessionState === "WAITING_FOR_INPUT" ||
|
||||
sessionState === "COMPLETED" ||
|
||||
sessionState === "FAILED" ||
|
||||
sessionState === "CANCELLED" ||
|
||||
state === "REVIEW" ||
|
||||
state === "COMPLETED";
|
||||
if (isReview) {
|
||||
return (
|
||||
<IconCircleCheck
|
||||
data-testid="task-state-review"
|
||||
className="mt-[1px] h-3.5 w-3.5 shrink-0 text-green-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-1 line-clamp-1 min-w-0 text-[13px] font-medium text-foreground">
|
||||
{title}
|
||||
{taskId && <PRTaskIcon taskId={taskId} />}
|
||||
</span>
|
||||
{parentTaskTitle && (
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate flex items-center gap-0.5">
|
||||
<IconSubtask className="h-2.5 w-2.5 shrink-0" />
|
||||
{parentTaskTitle}
|
||||
</span>
|
||||
<IconCircleDashed
|
||||
data-testid="task-state-backlog"
|
||||
className="mt-[1px] h-3.5 w-3.5 shrink-0 text-muted-foreground/40"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskItemStatsRow({
|
||||
updatedAt,
|
||||
prInfo,
|
||||
}: {
|
||||
updatedAt?: string;
|
||||
prInfo?: { number: number; state: string };
|
||||
}) {
|
||||
if (!updatedAt && !prInfo) return null;
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-[11px]">
|
||||
{updatedAt && (
|
||||
<span className="text-muted-foreground/50">{formatRelativeTime(updatedAt)}</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">{description}</span>
|
||||
{prInfo && <span className="text-muted-foreground/50">#{prInfo.number}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpen: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 self-center font-mono text-[11px] transition-opacity duration-100",
|
||||
menuOpen ? "opacity-0" : "group-hover:opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className="text-emerald-500">+{diffStats.additions}</span>{" "}
|
||||
<span className="text-rose-500">-{diffStats.deletions}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <PRTaskIcon taskId={taskId!} />;
|
||||
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 (
|
||||
<span className={cn("inline-flex items-center shrink-0", color)}>
|
||||
<IconGitPullRequest className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-end gap-0.5 transition-opacity duration-100",
|
||||
effectiveMenuOpen ? "opacity-0" : "group-hover:opacity-0",
|
||||
)}
|
||||
className={cn("flex min-w-0 flex-1 flex-col gap-0.5", reserveMenuSpace && "group-hover:pr-5")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-1 min-w-0 text-[13px] font-medium text-foreground leading-tight">
|
||||
<ScrollOnOverflow className="min-w-0">{title}</ScrollOnOverflow>
|
||||
<TaskPRIcon taskId={taskId} prInfo={prInfo} />
|
||||
{isRemoteExecutor && (
|
||||
<RemoteCloudTooltip
|
||||
taskId={taskId ?? ""}
|
||||
sessionId={primarySessionId ?? null}
|
||||
fallbackName={remoteExecutorName ?? remoteExecutorType}
|
||||
iconClassName="h-3.5 w-3.5 text-muted-foreground/80"
|
||||
iconClassName="h-3 w-3 text-muted-foreground/60"
|
||||
/>
|
||||
)}
|
||||
<TaskItemStepBadge isArchived={isArchived} stepName={stepName} />
|
||||
</div>
|
||||
<TaskItemRightMeta isInProgress={isInProgress} diffStats={diffStats} updatedAt={updatedAt} />
|
||||
{isArchived && (
|
||||
<span className="rounded px-1 py-px text-[10px] bg-amber-500/15 text-amber-500">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{repositories && repositories.length > 1 && (
|
||||
<span className="truncate text-[11px] text-muted-foreground/50">
|
||||
{repositories.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<TaskItemStatsRow updatedAt={updatedAt} prInfo={prInfo} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="sidebar-task-item"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => 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 */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 bottom-0 w-[2px] transition-opacity",
|
||||
isSelected ? "bg-primary opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<TaskItemTitle
|
||||
{isSubTask && (
|
||||
<span className="absolute left-3.5 top-[10px] select-none text-[11px] text-muted-foreground/30">
|
||||
↳
|
||||
</span>
|
||||
)}
|
||||
<TaskStateIcon sessionState={sessionState} state={state} isInProgress={isInProgress} />
|
||||
<TaskItemContent
|
||||
title={title}
|
||||
description={description}
|
||||
taskId={taskId}
|
||||
parentTaskTitle={parentTaskTitle}
|
||||
isRemoteExecutor={isRemoteExecutor}
|
||||
remoteExecutorType={remoteExecutorType}
|
||||
remoteExecutorName={remoteExecutorName}
|
||||
primarySessionId={primarySessionId}
|
||||
isArchived={isArchived}
|
||||
repositories={repositories}
|
||||
updatedAt={updatedAt}
|
||||
prInfo={prInfo}
|
||||
reserveMenuSpace={!hasDiffStats}
|
||||
/>
|
||||
|
||||
{/* Right side: step name + meta, or 3-dot button on hover */}
|
||||
<div className="relative flex items-center shrink-0">
|
||||
<TaskItemMeta
|
||||
effectiveMenuOpen={effectiveMenuOpen}
|
||||
isRemoteExecutor={isRemoteExecutor}
|
||||
taskId={taskId}
|
||||
primarySessionId={primarySessionId}
|
||||
remoteExecutorName={remoteExecutorName}
|
||||
remoteExecutorType={remoteExecutorType}
|
||||
isArchived={isArchived}
|
||||
stepName={stepName}
|
||||
isInProgress={isInProgress}
|
||||
diffStats={hasDiffStats ? diffStats : undefined}
|
||||
updatedAt={updatedAt}
|
||||
/>
|
||||
|
||||
<TaskMenuButton visible={effectiveMenuOpen} />
|
||||
</div>
|
||||
{hasDiffStats && <DiffStatsRight diffStats={diffStats!} menuOpen={effectiveMenuOpen} />}
|
||||
<TaskMenuButton visible={effectiveMenuOpen} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** 3-dot button that triggers the wrapping ContextMenu */
|
||||
function TaskMenuButton({ visible }: { visible: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 flex items-center gap-0.5 transition-opacity duration-100",
|
||||
"absolute right-1 inset-y-0 flex items-center gap-0.5 transition-opacity duration-100",
|
||||
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
@@ -247,51 +319,3 @@ function TaskMenuButton({ visible }: { visible: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step badge or "Archived" badge */
|
||||
function TaskItemStepBadge({ isArchived, stepName }: { isArchived?: boolean; stepName?: string }) {
|
||||
if (isArchived) {
|
||||
return (
|
||||
<span className="text-[10px] text-muted-foreground/70 bg-amber-500/15 text-amber-500 px-1.5 py-px rounded-[6px]">
|
||||
Archived
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (stepName) {
|
||||
return (
|
||||
<span className="text-[10px] text-muted-foreground/70 bg-foreground/[0.06] px-1.5 py-px rounded-[6px]">
|
||||
{stepName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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 <IconLoader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
|
||||
}
|
||||
if (diffStats) {
|
||||
return (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
<span className="text-emerald-500">+{diffStats.additions}</span>{" "}
|
||||
<span className="text-rose-500">-{diffStats.deletions}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (updatedAt) {
|
||||
return (
|
||||
<span className="text-[11px] text-muted-foreground/60">{formatRelativeTime(updatedAt)}</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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<string, string>,
|
||||
gitStatusByEnvId: Record<string, GitStatusEntry>,
|
||||
): 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<string, string>,
|
||||
gitStatusByEnvId: Record<string, GitStatusEntry>,
|
||||
): { 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<string, GitStatusEntry>;
|
||||
envIdBySessionId: Record<string, string>;
|
||||
repositorySlugById: Map<string, string | undefined>;
|
||||
taskPRsByTaskId: Record<string, { owner: string; repo: string } | undefined>;
|
||||
taskPRsByTaskId: Record<string, TaskPR | undefined>;
|
||||
titleById: Map<string, string>;
|
||||
},
|
||||
) {
|
||||
@@ -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<ReturnType<typeof toSidebarItem>, "workflowId"> & { workflowId?: string };
|
||||
|
||||
function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): 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<string, TaskPR | undefined>,
|
||||
titleById,
|
||||
};
|
||||
type SidebarItem = Omit<ReturnType<typeof toSidebarItem>, "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 (
|
||||
<PanelRoot data-testid="task-sidebar">
|
||||
|
||||
@@ -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<string, StepDef[]>;
|
||||
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<TaskSessionState>([
|
||||
"CANCELLED",
|
||||
]);
|
||||
const IN_PROGRESS_STATES = new Set<TaskSessionState>(["RUNNING"]);
|
||||
const BACKLOG_STATES = new Set<TaskSessionState>(["CREATED", "STARTING"]);
|
||||
|
||||
const TASK_STATE_REVIEW = new Set<TaskState | undefined>(["REVIEW", "COMPLETED"]);
|
||||
const TASK_STATE_IN_PROGRESS = new Set<TaskState | undefined>(["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 (
|
||||
<div className="animate-pulse">
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid={`sidebar-section-${label}`}
|
||||
className="flex items-center justify-between px-3 py-1.5 bg-background"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
data-testid={`sidebar-repo-group-${label}`}
|
||||
className="flex w-full items-center gap-2 bg-background px-3 py-1.5 cursor-pointer hover:bg-foreground/[0.03]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{icon && <icon.Icon className={`h-3 w-3 ${icon.className}`} />}
|
||||
<span className="flex-1 truncate text-left text-[12px] font-medium text-foreground/80">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/60">{count}</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground/50">{count}</span>
|
||||
<IconChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 text-muted-foreground/40 transition-transform",
|
||||
isCollapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, TaskSwitcherItem[]>;
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
stepNameById: Map<string, string>;
|
||||
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 (
|
||||
<TaskItemWithContextMenu
|
||||
key={task.id}
|
||||
task={task}
|
||||
steps={taskSteps}
|
||||
onRenameTask={onRenameTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onMoveToStep={onMoveToStep}
|
||||
isDeleting={deletingTaskId === task.id}
|
||||
>
|
||||
<TaskItem
|
||||
title={task.title}
|
||||
state={task.state}
|
||||
sessionState={task.sessionState}
|
||||
isArchived={task.isArchived}
|
||||
isSelected={isSelected}
|
||||
diffStats={task.diffStats}
|
||||
isRemoteExecutor={task.isRemoteExecutor}
|
||||
remoteExecutorType={task.remoteExecutorType}
|
||||
remoteExecutorName={task.remoteExecutorName}
|
||||
taskId={task.id}
|
||||
primarySessionId={task.primarySessionId ?? null}
|
||||
updatedAt={task.updatedAt}
|
||||
repositories={task.repositories}
|
||||
prInfo={task.prInfo}
|
||||
isSubTask={isSubTask}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
isDeleting={deletingTaskId === task.id}
|
||||
/>
|
||||
</TaskItemWithContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader label={section.label} count={section.tasks.length} />
|
||||
{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 (
|
||||
<TaskItemWithContextMenu
|
||||
key={task.id}
|
||||
task={task}
|
||||
steps={task.workflowId ? stepsByWorkflowId?.[task.workflowId] : undefined}
|
||||
onRenameTask={onRenameTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onMoveToStep={onMoveToStep}
|
||||
isDeleting={deletingTaskId === task.id}
|
||||
>
|
||||
<TaskItem
|
||||
title={task.title}
|
||||
description={repoLabel}
|
||||
stepName={stepName}
|
||||
state={task.state}
|
||||
sessionState={task.sessionState}
|
||||
isArchived={task.isArchived}
|
||||
isSelected={isSelected}
|
||||
diffStats={task.diffStats}
|
||||
isRemoteExecutor={task.isRemoteExecutor}
|
||||
remoteExecutorType={task.remoteExecutorType}
|
||||
remoteExecutorName={task.remoteExecutorName}
|
||||
taskId={task.id}
|
||||
primarySessionId={task.primarySessionId ?? null}
|
||||
updatedAt={task.updatedAt}
|
||||
parentTaskTitle={task.parentTaskTitle}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
isDeleting={deletingTaskId === task.id}
|
||||
/>
|
||||
</TaskItemWithContextMenu>
|
||||
);
|
||||
})}
|
||||
<RepoGroupHeader
|
||||
label={group.label}
|
||||
count={totalCount}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => setIsCollapsed((v) => !v)}
|
||||
/>
|
||||
{!isCollapsed &&
|
||||
group.tasks.map((task) => (
|
||||
<div key={task.id}>
|
||||
{renderItem(task)}
|
||||
{subTasksByParentId.get(task.id)?.map((sub) => renderItem(sub, true))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, TaskSwitcherItem[]>;
|
||||
unassigned: TaskSwitcherItem[];
|
||||
} {
|
||||
const multiRepoTasks: TaskSwitcherItem[] = [];
|
||||
const byRepo = new Map<string, TaskSwitcherItem[]>();
|
||||
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<string, TaskSwitcherItem[]>,
|
||||
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<string, TaskSwitcherItem[]>;
|
||||
} {
|
||||
const allTaskIds = new Set(tasks.map((t) => t.id));
|
||||
const subMap = new Map<string, TaskSwitcherItem[]>();
|
||||
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<string, string>();
|
||||
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 <TaskSwitcherSkeleton />;
|
||||
}
|
||||
const { groups, subTasksByParentId } = useMemo(() => groupTasksIntoRepos(tasks), [tasks]);
|
||||
|
||||
if (isLoading) return <TaskSwitcherSkeleton />;
|
||||
if (tasks.length === 0) {
|
||||
return <div className="px-3 py-3 text-xs text-muted-foreground">No tasks yet.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sections.map((section) => (
|
||||
<TaskSwitcherSection
|
||||
key={section.label}
|
||||
section={section}
|
||||
{groups.map((group) => (
|
||||
<RepoGroupSection
|
||||
key={group.key}
|
||||
group={group}
|
||||
subTasksByParentId={subTasksByParentId}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
stepNameById={stepNameById}
|
||||
activeTaskId={activeTaskId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={onSelectTask}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { type Locator, type Page, expect } from "@playwright/test";
|
||||
|
||||
/** Maps old state-section labels to the new per-task state icon data-testid. */
|
||||
function sectionLabelToStateTestId(label: string): string {
|
||||
if (label === "Running") return "task-state-running";
|
||||
if (label === "Turn Finished") return "task-state-review";
|
||||
return "task-state-backlog";
|
||||
}
|
||||
|
||||
export class SessionPage {
|
||||
readonly chat: Locator;
|
||||
readonly sidebar: Locator;
|
||||
@@ -131,17 +138,25 @@ export class SessionPage {
|
||||
return this.sidebar.getByText(title, { exact: false });
|
||||
}
|
||||
|
||||
/** Sidebar section header (e.g. "Turn Finished", "Running", "Backlog"). */
|
||||
/**
|
||||
* Sidebar state indicator — returns the first icon matching the given state label.
|
||||
* Accepts "Turn Finished" (review/completed), "Running" (in-progress), or "Backlog".
|
||||
*/
|
||||
sidebarSection(label: string): Locator {
|
||||
return this.sidebar.getByTestId(`sidebar-section-${label}`);
|
||||
const testId = sectionLabelToStateTestId(label);
|
||||
return this.sidebar.getByTestId(testId).first();
|
||||
}
|
||||
|
||||
/** Task title scoped to a specific sidebar section (Turn Finished, Running, Backlog). */
|
||||
/**
|
||||
* Task item in the sidebar matching both a title and a state label.
|
||||
* Accepts "Turn Finished" (review/completed), "Running" (in-progress), or "Backlog".
|
||||
*/
|
||||
taskInSection(title: string, sectionLabel: string): Locator {
|
||||
const testId = sectionLabelToStateTestId(sectionLabel);
|
||||
return this.sidebar
|
||||
.getByTestId(`sidebar-section-${sectionLabel}`)
|
||||
.locator("..")
|
||||
.getByText(title, { exact: false });
|
||||
.getByTestId("sidebar-task-item")
|
||||
.filter({ has: this.page.getByText(title, { exact: false }) })
|
||||
.filter({ has: this.page.getByTestId(testId) });
|
||||
}
|
||||
|
||||
/** Agent STARTING or RUNNING status indicator. */
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* E2E tests for the repo-grouped task sidebar layout.
|
||||
*
|
||||
* Covers:
|
||||
* - Repo group headers: letter avatar, task count, collapsible
|
||||
* - Subtasks (parent_id): nested under parent with arrow indicator
|
||||
* - Unassigned group: tasks with no repo
|
||||
* - Context menu: Rename, Archive, Delete options via right-click
|
||||
* - Active task highlight: selected task has aria/visual selection state
|
||||
*/
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
test.describe("Sidebar layout — repo groups", () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
@@ -62,6 +62,8 @@ export type GitStatusEntry = {
|
||||
behind: number;
|
||||
files: Record<string, FileInfo>;
|
||||
timestamp: string | null;
|
||||
branch_additions?: number;
|
||||
branch_deletions?: number;
|
||||
};
|
||||
|
||||
export type GitStatusState = {
|
||||
|
||||
@@ -22,6 +22,8 @@ export type GitStatusData = {
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: Record<string, FileInfo>;
|
||||
branch_additions?: number;
|
||||
branch_deletions?: number;
|
||||
};
|
||||
|
||||
// Git commit data
|
||||
|
||||
@@ -8,9 +8,32 @@ export type SessionInfo = {
|
||||
|
||||
type GitStatusMap = Record<
|
||||
string,
|
||||
{ files?: Record<string, { additions?: number; deletions?: number }> }
|
||||
{
|
||||
files?: Record<string, { additions?: number; deletions?: number }>;
|
||||
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<string, TaskSession[]>,
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user