feat(web): redesign task sidebar with repo-grouped layout and diff stats (#550)

This commit is contained in:
Carlos Florêncio
2026-04-07 09:12:17 +01:00
committed by GitHub
parent df9a9bf309
commit 61e5d5cce9
25 changed files with 1127 additions and 391 deletions
+20 -18
View File
@@ -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.
+16
View File
@@ -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.
+10 -4
View File
@@ -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
+24 -20
View File
@@ -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 {
+14 -12
View File
@@ -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,
},
})
}
+5 -3
View File
@@ -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) {
+158 -134
View File
@@ -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;
}
+136 -39
View File
@@ -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">
+210 -122
View File
@@ -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}
+21 -6
View File
@@ -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 = {
+2
View File
@@ -22,6 +22,8 @@ export type GitStatusData = {
ahead: number;
behind: number;
files: Record<string, FileInfo>;
branch_additions?: number;
branch_deletions?: number;
};
// Git commit data
+30 -10
View File
@@ -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 };
}
+2
View File
@@ -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);