feat(sidebar): add last activity task sorting (#2762)

This commit is contained in:
Carlos Florêncio
2026-08-18 10:15:42 +01:00
committed by GitHub
parent 270ddbff8f
commit 704f652f30
74 changed files with 2525 additions and 162 deletions
@@ -295,6 +295,17 @@ func provideGateway(
projector := statussummary.NewProjector(statussummary.ProjectorConfig{
Store: taskRepo,
EventBus: eventBus,
LoadTaskActivity: func(ctx context.Context, taskID string) (*time.Time, error) {
byTask, err := taskRepo.LoadTaskLastActivity(ctx, []string{taskID})
if err != nil {
return nil, err
}
activityAt, ok := byTask[taskID]
if !ok {
return nil, nil
}
return &activityAt, nil
},
LoadSessionObservations: func(ctx context.Context, taskID string) (statussummary.SessionObservationSnapshot, error) {
return loadTaskSessionObservations(ctx, taskRepo, activityProvider, taskID)
},
@@ -110,6 +110,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
Reviews: repos.Task,
ResourceCleanups: repos.Task,
StatusSummaries: repos.Task,
TaskActivity: repos.Task,
SubagentContexts: repos.Task,
},
eventBus,
+18 -1
View File
@@ -426,7 +426,7 @@ func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int
reviewState := summarizeReviewState(raw.Reviews.Nodes)
checksState := ""
if len(raw.Commits.Nodes) > 0 && raw.Commits.Nodes[0].Commit.StatusCheckRollup != nil {
checksState = strings.ToLower(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State)
checksState = normalizeGraphQLCheckRollupState(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State)
}
unresolved := 0
for _, t := range raw.ReviewThreads.Nodes {
@@ -447,6 +447,23 @@ func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int
}
}
// normalizeGraphQLCheckRollupState converts GitHub's GraphQL status-rollup
// enum to the smaller contract shared by REST and TaskPR persistence.
func normalizeGraphQLCheckRollupState(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return ""
case checkStatusSuccess:
return checkStatusSuccess
case checkConclusionFail, "error":
return checkConclusionFail
case "expected", checkStatusPending:
return checkStatusPending
default:
return ""
}
}
// reviewNodesToSamples converts the GraphQL reviewNode shape to the shared
// reviewSample slice used by the dedup helpers in client_helpers.go.
func reviewNodesToSamples(nodes []reviewNode) []reviewSample {
@@ -220,6 +220,29 @@ func TestRunBatchedPRQuery_DecodesAliasesBackToRefs(t *testing.T) {
}
}
func TestNormalizeGraphQLCheckRollupState_CheckRollup(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "success", raw: "SUCCESS", want: checkStatusSuccess},
{name: "failure", raw: "FAILURE", want: checkConclusionFail},
{name: "error", raw: "ERROR", want: checkConclusionFail},
{name: "pending", raw: "PENDING", want: checkStatusPending},
{name: "expected", raw: "EXPECTED", want: checkStatusPending},
{name: "empty", raw: "", want: ""},
{name: "unknown", raw: "NEUTRAL", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeGraphQLCheckRollupState(tt.raw); got != tt.want {
t.Fatalf("normalizeGraphQLCheckRollupState(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
func TestRunBatchedPRQuery_PaginatesResolvedReviewThreads(t *testing.T) {
exec := &stubGraphQLExecutor{responses: []string{
batchedPRReviewThreadsResponse(t, 102, resolvedReviewThreadNodes(100), true, "cursor-1"),
@@ -797,6 +797,49 @@ func (s *Service) prepareTaskPRSyncState(ctx context.Context, tp *TaskPR, status
}
}
func taskPRChangedFields(tp *TaskPR, status *PRStatus, next taskPRSyncState) []string {
if tp == nil || status == nil || status.PR == nil {
return nil
}
changed := make([]string, 0, 16)
changed = appendChangedField(changed, "state", tp.State != next.state)
changed = appendChangedField(changed, "pr_title", tp.PRTitle != status.PR.Title)
changed = appendChangedField(changed, "additions", tp.Additions != status.PR.Additions)
changed = appendChangedField(changed, "deletions", tp.Deletions != status.PR.Deletions)
changed = appendChangedField(changed, "review_state", tp.ReviewState != status.ReviewState)
changed = appendChangedField(changed, "checks_state", tp.ChecksState != status.ChecksState)
changed = appendChangedField(changed, "mergeable_state", tp.MergeableState != next.mergeableState)
changed = appendChangedField(changed, "review_count", tp.ReviewCount != next.reviewCount)
changed = appendChangedField(
changed,
"pending_review_count",
tp.PendingReviewCount != next.pendingReviewCount,
)
changed = appendChangedField(
changed,
"required_reviews",
!intPtrEqual(tp.RequiredReviews, next.requiredReviews),
)
changed = appendChangedField(changed, "checks_total", tp.ChecksTotal != next.checksTotal)
changed = appendChangedField(changed, "checks_passing", tp.ChecksPassing != next.checksPassing)
changed = appendChangedField(
changed,
"unresolved_review_threads",
tp.UnresolvedReviewThreads != next.unresolved,
)
changed = appendChangedField(changed, "base_branch", tp.BaseBranch != next.baseBranch)
changed = appendChangedField(changed, "merged_at", !timeEqual(tp.MergedAt, next.mergedAt))
changed = appendChangedField(changed, "closed_at", !timeEqual(tp.ClosedAt, next.closedAt))
return changed
}
func appendChangedField(changed []string, field string, isChanged bool) []string {
if !isChanged {
return changed
}
return append(changed, field)
}
// SyncTaskPR updates a TaskPR record with the latest PR status. Multi-repo:
// the row is found by (task_id, owner, repo, pr_number) since the same
// task can have several PRs; the legacy GetTaskPR(taskID) "first match"
@@ -815,22 +858,7 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu
}
next := s.prepareTaskPRSyncState(ctx, tp, status)
changed := tp.State != next.state ||
tp.PRTitle != status.PR.Title ||
tp.Additions != status.PR.Additions ||
tp.Deletions != status.PR.Deletions ||
tp.ReviewState != status.ReviewState ||
tp.ChecksState != status.ChecksState ||
tp.MergeableState != next.mergeableState ||
tp.ReviewCount != next.reviewCount ||
tp.PendingReviewCount != next.pendingReviewCount ||
!intPtrEqual(tp.RequiredReviews, next.requiredReviews) ||
tp.ChecksTotal != next.checksTotal ||
tp.ChecksPassing != next.checksPassing ||
tp.UnresolvedReviewThreads != next.unresolved ||
tp.BaseBranch != next.baseBranch ||
!timeEqual(tp.MergedAt, next.mergedAt) ||
!timeEqual(tp.ClosedAt, next.closedAt)
changedFields := taskPRChangedFields(tp, status, next)
tp.State = next.state
tp.PRTitle = status.PR.Title
@@ -856,7 +884,18 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu
return fmt.Errorf("update task PR: %w", err)
}
if changed && s.eventBus != nil {
if len(changedFields) > 0 {
if s.logger != nil {
s.logger.Debug("task PR semantic state changed",
zap.String("task_id", taskID),
zap.String("pr_owner", tp.Owner),
zap.String("pr_repo", tp.Repo),
zap.Int("pr_number", tp.PRNumber),
zap.Strings("changed_fields", changedFields),
)
}
}
if len(changedFields) > 0 && s.eventBus != nil {
event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp)
if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil {
s.logger.Debug("failed to publish task PR updated event", zap.Error(err))
@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
@@ -952,6 +953,121 @@ func TestSyncTaskPR_SecondIdenticalSyncNoEvent(t *testing.T) {
}
}
func TestSyncTaskPR_EquivalentRESTAndGraphQLStatusNoSecondEvent(t *testing.T) {
svc, store, eb := setupSyncTest(t)
ctx := context.Background()
createdAt := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC)
updatedAt := createdAt.Add(time.Hour)
pr := &PR{
Number: 1,
Title: "Same status",
URL: "https://github.com/owner/repo/pull/1",
HTMLURL: "https://github.com/owner/repo/pull/1",
State: "open",
HeadBranch: "feat",
HeadSHA: "abc",
BaseBranch: "main",
AuthorLogin: "alice",
RepoOwner: "owner",
RepoName: "repo",
Mergeable: true,
MergeableState: "clean",
Additions: 3,
Deletions: 1,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
restStatus := newPRStatus(
pr,
[]PRReview{{Author: "alice", State: reviewStateApproved, CreatedAt: updatedAt}},
[]CheckRun{{Status: checkStatusCompleted, Conclusion: checkConclusionSuccess}},
)
raw := &batchedPRResult{
State: "OPEN",
Title: pr.Title,
URL: pr.URL,
HeadRefName: pr.HeadBranch,
BaseRefName: pr.BaseBranch,
HeadRefOid: pr.HeadSHA,
Additions: pr.Additions,
Deletions: pr.Deletions,
CreatedAt: pr.CreatedAt,
UpdatedAt: pr.UpdatedAt,
}
raw.Author.Login = pr.AuthorLogin
raw.Mergeable = "MERGEABLE"
raw.MergeStatus = "CLEAN"
raw.Reviews.Nodes = []reviewNode{{State: "APPROVED"}}
raw.Reviews.Nodes[0].Author.Login = "alice"
raw.Reviews.Nodes[0].SubmittedAt = updatedAt
raw.Commits.Nodes = []struct {
Commit struct {
StatusCheckRollup *struct {
State string `json:"state"`
} `json:"statusCheckRollup"`
} `json:"commit"`
}{{}}
raw.Commits.Nodes[0].Commit.StatusCheckRollup = &struct {
State string `json:"state"`
}{State: "SUCCESS"}
graphqlStatus := convertBatchedPRResult(raw, "owner", "repo", 1)
if graphqlStatus.ChecksState != restStatus.ChecksState {
t.Fatalf("GraphQL checks state = %q, REST = %q", graphqlStatus.ChecksState, restStatus.ChecksState)
}
if graphqlStatus.ReviewState != restStatus.ReviewState || graphqlStatus.ReviewCount != restStatus.ReviewCount {
t.Fatalf("GraphQL review summary = (%q, %d), REST = (%q, %d)", graphqlStatus.ReviewState, graphqlStatus.ReviewCount, restStatus.ReviewState, restStatus.ReviewCount)
}
if err := store.CreateTaskPR(ctx, &TaskPR{
TaskID: "t1",
Owner: "owner",
Repo: "repo",
PRNumber: 1,
PRURL: pr.URL,
PRTitle: "Old title",
HeadBranch: pr.HeadBranch,
BaseBranch: pr.BaseBranch,
State: "open",
}); err != nil {
t.Fatalf("create task PR: %v", err)
}
if err := svc.SyncTaskPR(ctx, "t1", restStatus); err != nil {
t.Fatalf("REST sync: %v", err)
}
if err := svc.SyncTaskPR(ctx, "t1", graphqlStatus); err != nil {
t.Fatalf("GraphQL sync: %v", err)
}
if got := eb.publishedCount(); got != 1 {
t.Fatalf("equivalent REST and GraphQL snapshots published %d events, want 1", got)
}
}
func TestSyncTaskPR_ChangedFields(t *testing.T) {
taskPR := &TaskPR{
State: "open",
PRTitle: "before",
Additions: 1,
Deletions: 2,
ReviewState: "pending",
ChecksState: "pending",
MergeableState: "blocked",
ReviewCount: 1,
PendingReviewCount: 2,
ChecksTotal: 3,
ChecksPassing: 1,
BaseBranch: "main",
}
status := &PRStatus{PR: &PR{Title: "after", State: "merged", Additions: 4, Deletions: 5, BaseBranch: "release", MergeableState: "clean"}, ReviewState: "approved", ChecksState: "success", ReviewCount: 2, PendingReviewCount: 0, ChecksTotal: 4, ChecksPassing: 4}
next := taskPRSyncState{
state: "merged", mergeableState: "clean", reviewCount: 2, pendingReviewCount: 0,
checksTotal: 4, checksPassing: 4, baseBranch: "release",
}
want := []string{"state", "pr_title", "additions", "deletions", "review_state", "checks_state", "mergeable_state", "review_count", "pending_review_count", "checks_total", "checks_passing", "base_branch"}
got := taskPRChangedFields(taskPR, status, next)
if !reflect.DeepEqual(got, want) {
t.Fatalf("taskPRChangedFields() = %#v, want %#v", got, want)
}
}
func TestSyncTaskPR_PublishesEventOnMergeableStateChange(t *testing.T) {
svc, store, eb := setupSyncTest(t)
ctx := context.Background()
@@ -253,7 +253,7 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue message", nil)
}
h.publishStatus(ctx, req.SessionID)
h.publishStatus(ctx, req.SessionID, queued)
return ws.NewResponse(msg.ID, msg.Action, queued)
}
@@ -954,7 +954,7 @@ func queueAccessDeniedResponse(msg *ws.Message) *ws.Message {
// publishStatus emits the latest QueueStatus on the event bus so the frontend
// updates its store after every mutation.
func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string) {
func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string, admitted ...*messagequeue.QueuedMessage) {
if h.eventBus == nil {
return
}
@@ -966,6 +966,10 @@ func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string) {
fieldMax: status.Max,
"merge_enabled": status.MergeEnabled,
}
if len(admitted) > 0 && admitted[0] != nil && admitted[0].QueuedBy != "" && !messagequeue.IsReservedQueuedBy(admitted[0].QueuedBy) {
eventData["queued_by"] = admitted[0].QueuedBy
eventData["queued_at"] = admitted[0].QueuedAt
}
if h.sessionTaskResolver != nil {
if taskID, err := h.sessionTaskResolver(ctx, sessionID); err != nil {
h.logger.Warn("resolve session task for queue status event",
@@ -9,6 +9,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/stretchr/testify/require"
)
@@ -73,6 +74,28 @@ func TestPublishStatusIncludesTaskIDWhenResolvable(t *testing.T) {
require.Equal(t, "s1", events.lastData["session_id"])
}
func TestWsQueueMessagePublishesUserPromptActivity(t *testing.T) {
handlers, _, events := setupQueueHandlersWithResolver(t, func(_ context.Context, sessionID string) (string, error) {
if sessionID == "s1" {
return "task-1", nil
}
return "", nil
})
response, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{
"session_id": "s1",
"task_id": "task-1",
"content": "queued prompt",
"user_id": "user-1",
}))
require.NoError(t, err)
require.NotNil(t, response)
require.NotNil(t, events.lastData)
require.Equal(t, "user-1", events.lastData["queued_by"])
require.IsType(t, time.Time{}, events.lastData["queued_at"])
require.False(t, events.lastData["queued_at"].(time.Time).IsZero())
}
func TestPublishStatusOmitsTaskIDWhenResolutionFails(t *testing.T) {
handlers, svc, events := setupQueueHandlersWithResolver(t, func(context.Context, string) (string, error) {
return "", errors.New("session gone")
@@ -3,6 +3,7 @@ package messagequeue
import (
"bytes"
"encoding/json"
"time"
"github.com/kandev/kandev/internal/entityrefs"
"github.com/kandev/kandev/internal/messageconstraints"
@@ -13,6 +14,7 @@ type autoMergedValues struct {
content string
attachments []MessageAttachment
metadata map[string]interface{}
queuedAt time.Time
}
func buildAutoMergedEntry(target, source *QueuedMessage) (*autoMergedValues, bool) {
@@ -31,9 +33,17 @@ func buildAutoMergedEntry(target, source *QueuedMessage) (*autoMergedValues, boo
content: joinAutoMergeContent(target.Content, source.Content),
attachments: attachments,
metadata: metadata,
queuedAt: latestQueuedAt(target.QueuedAt, source.QueuedAt),
}, true
}
func latestQueuedAt(first, second time.Time) time.Time {
if second.After(first) {
return second
}
return first
}
func autoMergeAllowed(target, source *QueuedMessage) bool {
if target == nil || source == nil || target.IsDurableLifecycle() || source.IsDurableLifecycle() {
return false
@@ -99,6 +99,9 @@ func TestRepository_AutoMergeIntoAbove_PreservesIdentityAndCombinesAdditiveData(
},
})
targetIdentity := autoMergeIdentity(*target)
// The surviving row carries the newest admission timestamp so durable
// activity reconstruction does not lose the folded user prompt.
targetIdentity.QueuedAt = source.QueuedAt.UTC().Truncate(time.Microsecond)
merged, didMerge, err := autoRepo.AutoMergeIntoAbove(context.Background(), "session", source.ID)
if err != nil || !didMerge {
@@ -650,6 +650,7 @@ func (r *memoryRepository) MergeIntoAbove(_ context.Context, sessionID, sourceID
target.Content = content
target.Attachments = attachments
target.Metadata = metadata
target.QueuedAt = latestQueuedAt(target.QueuedAt, source.QueuedAt)
r.entries[sessionID] = append(list[:sourceIndex], list[sourceIndex+1:]...)
if len(r.entries[sessionID]) == 0 {
delete(r.entries, sessionID)
@@ -694,6 +695,7 @@ func (r *memoryRepository) AutoMergeIntoAbove(_ context.Context, sessionID, sour
target.Content = values.content
target.Attachments = values.attachments
target.Metadata = values.metadata
target.QueuedAt = values.queuedAt
r.entries[sessionID] = append(list[:sourceIndex], list[sourceIndex+1:]...)
return cloneQueuedMessage(target), true, nil
}
@@ -722,6 +724,7 @@ func (r *memoryRepository) AutoMergeCandidateIntoAbove(_ context.Context, candid
target.Content = values.content
target.Attachments = values.attachments
target.Metadata = values.metadata
target.QueuedAt = values.queuedAt
return cloneQueuedMessage(target), true, nil
}
@@ -756,9 +756,17 @@ func TestPostgresRepository_MergeDrainOrdering_DrainWins(t *testing.T) {
func TestPostgresRepository_MergeDrainOrdering_MergeWins(t *testing.T) {
repo := newTestPostgresRepo(t)
ctx := context.Background()
targetAt := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
sourceAt := targetAt.Add(time.Minute)
target := insertTestEntry(t, repo, "s1", "t1", "first", "user", nil, nil)
source := insertTestEntry(t, repo, "s1", "t1", "second", "user", nil, nil)
sqlRepo := repo.(*sqliteRepository)
for id, queuedAt := range map[string]time.Time{target.ID: targetAt, source.ID: sourceAt} {
if _, err := sqlRepo.db.ExecContext(ctx, sqlRepo.db.Rebind(`UPDATE queued_messages SET queued_at = ? WHERE id = ?`), queuedAt, id); err != nil {
t.Fatalf("set queued_at for %s: %v", id, err)
}
}
merged, err := repo.MergeIntoAbove(ctx, "s1", source.ID, "user")
if err != nil {
@@ -774,6 +782,9 @@ func TestPostgresRepository_MergeDrainOrdering_MergeWins(t *testing.T) {
if drained.Content != "first\n\nsecond" {
t.Errorf("drained content = %q, want combined content", drained.Content)
}
if !merged.QueuedAt.Equal(sourceAt) || !drained.QueuedAt.Equal(sourceAt) {
t.Errorf("merged queued_at = %s, drained queued_at = %s, want %s", merged.QueuedAt, drained.QueuedAt, sourceAt)
}
}
// TestPostgresRepository_MergeIntoAbove_ReferenceOverflow asserts the overflow
@@ -152,6 +152,7 @@ func (r *sqliteRepository) initSchema() error {
queued_by TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_queued_messages_session_position ON queued_messages(session_id, position);
CREATE INDEX IF NOT EXISTS idx_queued_messages_task_activity ON queued_messages(task_id, queued_by, queued_at);
CREATE TABLE IF NOT EXISTS lifecycle_queue_generations (
task_id TEXT PRIMARY KEY,
@@ -1562,6 +1563,7 @@ func (r *sqliteRepository) MergeIntoAbove(ctx context.Context, sessionID, source
merged.Content = content
merged.Attachments = attachments
merged.Metadata = metadata
merged.QueuedAt = latestQueuedAt(target.QueuedAt, source.QueuedAt)
return &merged, nil
}
@@ -1611,6 +1613,7 @@ func (r *sqliteRepository) AutoMergeIntoAbove(ctx context.Context, sessionID, so
target.Content = values.content
target.Attachments = values.attachments
target.Metadata = values.metadata
target.QueuedAt = values.queuedAt
return target, true, nil
}
@@ -1675,15 +1678,16 @@ func (r *sqliteRepository) AutoMergeCandidateIntoAbove(ctx context.Context, cand
// the head), violating FIFO drain order.
res, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE queued_messages
SET content = ?, attachments_json = ?, metadata_json = ?
SET content = ?, attachments_json = ?, metadata_json = ?, queued_at = ?
WHERE id = ? AND session_id = ?
AND position = ?
AND content = ?
AND attachments_json = ?
AND metadata_json = ?
`), values.content, attachmentsJSON, metadataJSON,
AND queued_at = ?
`), values.content, attachmentsJSON, metadataJSON, values.queuedAt,
target.ID, candidate.SessionID, target.Position,
storedContent, storedAttachmentsJSON, storedMetadataJSON)
storedContent, storedAttachmentsJSON, storedMetadataJSON, target.QueuedAt)
if err != nil {
return nil, false, fmt.Errorf("update automatic candidate merge target: %w", err)
}
@@ -1704,6 +1708,7 @@ func (r *sqliteRepository) AutoMergeCandidateIntoAbove(ctx context.Context, cand
target.Content = values.content
target.Attachments = values.attachments
target.Metadata = values.metadata
target.QueuedAt = values.queuedAt
return target, true, nil
}
@@ -1869,11 +1874,12 @@ func applyMergeWrites(ctx context.Context, r *sqliteRepository, tx *sqlx.Tx, tar
if err != nil {
return err
}
queuedAt := latestQueuedAt(target.QueuedAt, source.QueuedAt)
res, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE queued_messages
SET content = ?, attachments_json = ?, metadata_json = ?
SET content = ?, attachments_json = ?, metadata_json = ?, queued_at = ?
WHERE id = ? AND session_id = ?
`), content, attachmentsJSON, metadataJSON, target.ID, sessionID)
`), content, attachmentsJSON, metadataJSON, queuedAt, target.ID, sessionID)
if err != nil {
return fmt.Errorf("update merge target: %w", err)
}
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"testing"
"time"
"github.com/kandev/kandev/internal/entityrefs"
apiv1 "github.com/kandev/kandev/pkg/api/v1"
@@ -200,6 +201,8 @@ func TestSQLiteRepository_MergeDrainOrdering_MergeWins(t *testing.T) {
func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) {
repo := newTestSQLiteRepo(t)
ctx := context.Background()
targetAt := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
sourceAt := targetAt.Add(time.Minute)
target := insertTestEntry(t, repo, "s1", "t1", "first", "user",
[]MessageAttachment{{Type: "image", Data: "a", MimeType: "image/png"}},
@@ -211,6 +214,12 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) {
map[string]interface{}{
MetadataEntityReferences: entityRefs("1", "2"),
})
sqliteRepo := repo.(*sqliteRepository)
for id, queuedAt := range map[string]time.Time{target.ID: targetAt, source.ID: sourceAt} {
if _, err := sqliteRepo.db.ExecContext(ctx, `UPDATE queued_messages SET queued_at = ? WHERE id = ?`, queuedAt, id); err != nil {
t.Fatalf("set queued_at for %s: %v", id, err)
}
}
merged, err := repo.MergeIntoAbove(ctx, "s1", source.ID, "user")
if err != nil {
@@ -222,6 +231,9 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) {
if merged.ID != target.ID {
t.Errorf("merged id = %s, want target id %s", merged.ID, target.ID)
}
if !merged.QueuedAt.Equal(sourceAt) {
t.Errorf("merged queued_at = %s, want %s", merged.QueuedAt, sourceAt)
}
if merged.Content != "first\n\nsecond" {
t.Errorf("merged content = %q, want %q", merged.Content, "first\n\nsecond")
}
@@ -235,6 +247,13 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) {
if merged.QueuedBy != "user" {
t.Errorf("merged queued_by = %q, want user", merged.QueuedBy)
}
entries, err := repo.ListBySession(ctx, "s1")
if err != nil {
t.Fatalf("list merged row: %v", err)
}
if len(entries) != 1 || !entries[0].QueuedAt.Equal(sourceAt) {
t.Fatalf("persisted merged queued_at = %#v, want %s", entries, sourceAt)
}
count, _ := repo.CountBySession(ctx, "s1")
if count != 1 {
t.Errorf("count after merge = %d, want 1", count)
@@ -83,6 +83,31 @@ func TestSQLiteRepository_InsertList(t *testing.T) {
}
}
func TestSQLiteRepository_HasTaskActivityIndex(t *testing.T) {
repo := newTestSQLiteRepo(t)
sqliteRepo := repo.(*sqliteRepository)
rows, err := sqliteRepo.db.Queryx(`PRAGMA index_info('idx_queued_messages_task_activity')`)
if err != nil {
t.Fatalf("inspect task activity index: %v", err)
}
defer func() { _ = rows.Close() }()
wantColumns := []string{"task_id", "queued_by", "queued_at"}
for index, want := range wantColumns {
if !rows.Next() {
t.Fatalf("task activity index ended at column %d, want %q", index, want)
}
var seqno, columnID int
var column string
if err := rows.Scan(&seqno, &columnID, &column); err != nil {
t.Fatalf("scan task activity index column %d: %v", index, err)
}
if column != want {
t.Fatalf("task activity index column %d = %q, want %q", index, column, want)
}
}
}
func TestSQLiteRepository_InsertRejectsOverflow(t *testing.T) {
repo := newTestSQLiteRepo(t)
ctx := context.Background()
@@ -15,6 +15,7 @@ import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
@@ -195,6 +196,7 @@ func (s *Service) queueMessageWithMetadataAdmission(ctx context.Context, session
PlanMode: planMode,
Attachments: attachments,
Metadata: copyMessageMetadata(metadata, 0),
QueuedAt: time.Now().UTC(),
QueuedBy: userID,
}
merged, didMerge, mergeErr := s.repo.AutoMergeCandidateIntoAbove(admittedCtx, candidate)
@@ -991,12 +991,14 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) {
target := &QueuedMessage{
SessionID: "s1", TaskID: "t1", Content: "first", QueuedBy: "alice",
QueuedAt: time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC),
Attachments: []MessageAttachment{{Type: "image", Data: "a", MimeType: "image/png"}},
Metadata: map[string]interface{}{MetadataEntityReferences: []apiv1.EntityReference{ref}},
}
require.NoError(t, repo.Insert(ctx, target, 0))
source := &QueuedMessage{
SessionID: "s1", TaskID: "t1", Content: "second", QueuedBy: "alice",
QueuedAt: target.QueuedAt.Add(time.Minute),
Attachments: []MessageAttachment{{Type: "file", Data: "b", MimeType: "text/plain"}},
Metadata: map[string]interface{}{MetadataEntityReferences: []apiv1.EntityReference{ref}},
}
@@ -1006,6 +1008,7 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, target.ID, merged.ID)
assert.Equal(t, "first\n\nsecond", merged.Content)
assert.Equal(t, source.QueuedAt, merged.QueuedAt)
assert.Len(t, merged.Attachments, 2)
assert.Len(t, entityrefs.NormalizePersisted(merged.Metadata[MetadataEntityReferences]), 1)
@@ -1013,6 +1016,7 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) {
require.NoError(t, err)
assert.Len(t, entries, 1)
assert.Equal(t, "first\n\nsecond", entries[0].Content)
assert.Equal(t, source.QueuedAt, entries[0].QueuedAt)
}
// TestMemoryRepository_MergeIntoAbove_MixedKindsRejected covers the memory repo
@@ -158,6 +158,12 @@ type TaskStatusSummaryRepository interface {
DeleteTaskStatusSummary(ctx context.Context, taskID string) error
}
// TaskActivityRepository reconstructs the bounded task activity timestamp
// from authoritative task, prompt, and turn rows.
type TaskActivityRepository interface {
LoadTaskLastActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, error)
}
// TaskRepoRepository handles the task↔repository junction table (models.TaskRepository rows).
// Named TaskRepoRepository to reduce reader confusion with the TaskRepository sub-interface above.
type TaskRepoRepository interface {
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/task/statussummary"
@@ -67,6 +68,116 @@ func (r *Repository) LoadTaskStatusSummaries(ctx context.Context, taskIDs []stri
return result, nil
}
// LoadTaskLastActivity returns the latest bounded activity timestamp for each
// requested task. Every source is filtered by task ID inside its union branch
// so the batch remains scoped and does not scan unrelated task history.
func (r *Repository) LoadTaskLastActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, error) {
result := make(map[string]time.Time, len(taskIDs))
if len(taskIDs) == 0 {
return result, nil
}
for _, chunk := range chunkIDs(taskIDs, sqliteMaxHostParams/6) {
placeholders, ids := buildInPlaceholders(chunk)
query := `
WITH activity AS (
SELECT id AS task_id, created_at AS activity_at
FROM tasks
WHERE id IN (` + placeholders + `)
UNION ALL
SELECT id AS task_id, updated_at AS activity_at
FROM tasks
WHERE id IN (` + placeholders + `)
UNION ALL
SELECT task_id, created_at AS activity_at
FROM task_session_messages
WHERE task_id IN (` + placeholders + `)
AND author_type = 'user'
UNION ALL
SELECT task_id, queued_at AS activity_at
FROM queued_messages
WHERE task_id IN (` + placeholders + `)
AND queued_by NOT IN ('agent', 'workflow', 'server')
UNION ALL
SELECT task_id, started_at AS activity_at
FROM task_session_turns
WHERE task_id IN (` + placeholders + `)
AND started_at IS NOT NULL
UNION ALL
SELECT task_id, completed_at AS activity_at
FROM task_session_turns
WHERE task_id IN (` + placeholders + `)
AND completed_at IS NOT NULL
)
SELECT task_id, MAX(activity_at) AS activity_at
FROM activity
GROUP BY task_id`
args := make([]interface{}, 0, len(ids)*6)
for range 6 {
args = append(args, ids...)
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, fmt.Errorf("load task last activity: %w", err)
}
for rows.Next() {
var (
taskID string
rawActivityAt interface{}
)
if err := rows.Scan(&taskID, &rawActivityAt); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("scan task last activity: %w", err)
}
activityAt, err := parseTaskActivityTime(rawActivityAt)
if err != nil {
_ = rows.Close()
return nil, fmt.Errorf("parse task last activity %q: %w", taskID, err)
}
result[taskID] = activityAt
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("read task last activity: %w", err)
}
_ = rows.Close()
}
return result, nil
}
func parseTaskActivityTime(value interface{}) (time.Time, error) {
switch value := value.(type) {
case time.Time:
return value, nil
case string:
return parseTaskActivityTimeString(value)
case []byte:
return parseTaskActivityTimeString(string(value))
default:
return time.Time{}, fmt.Errorf("unsupported timestamp type %T", value)
}
}
func parseTaskActivityTimeString(value string) (time.Time, error) {
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
return parsed, nil
}
if normalized := strings.Replace(value, " ", "T", 1); normalized != value {
if parsed, err := time.Parse(time.RFC3339Nano, normalized); err == nil {
return parsed, nil
}
}
for _, layout := range []string{
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
} {
if parsed, err := time.ParseInLocation(layout, value, time.UTC); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("invalid timestamp %q", value)
}
// CompareAndUpdateTaskStatusSummary inserts a new row or replaces an older
// semantic value. The revision and semantic comparison live in the same SQL
// statement, so concurrent source observations cannot overwrite a newer row.
@@ -5,6 +5,8 @@ import (
"testing"
"time"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/statussummary"
"github.com/kandev/kandev/internal/testutil"
)
@@ -53,3 +55,76 @@ func TestPostgresTaskStatusSummarySchemaReplay(t *testing.T) {
t.Fatalf("postgres summary = %#v", loaded["task-summary-postgres"])
}
}
func TestPostgresTaskLastActivityBatch(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("initialize postgres schema: %v", err)
}
ctx := context.Background()
base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC)
if _, err := db.Exec(db.Rebind(`
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`), "task-activity-postgres", "workspace-activity-postgres", "Postgres activity", base, base.Add(time.Hour)); err != nil {
t.Fatalf("seed postgres task: %v", err)
}
if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-activity-postgres", TaskID: "task-activity-postgres"}); err != nil {
t.Fatalf("create postgres session: %v", err)
}
completedAt := base.Add(3 * time.Hour)
if err := repo.CreateTurn(ctx, &models.Turn{
ID: "turn-activity-postgres", TaskSessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
StartedAt: base.Add(2 * time.Hour), CompletedAt: &completedAt,
}); err != nil {
t.Fatalf("create postgres turn: %v", err)
}
if err := repo.CreateMessage(ctx, &models.Message{
ID: "message-activity-postgres", TaskSessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
TurnID: "turn-activity-postgres", AuthorType: models.MessageAuthorUser, Content: "prompt",
CreatedAt: base.Add(4 * time.Hour),
}); err != nil {
t.Fatalf("create postgres message: %v", err)
}
queueRepo, err := messagequeue.NewSQLiteRepository(db, db)
if err != nil {
t.Fatalf("initialize postgres queue repository: %v", err)
}
queuedAt := base.Add(5 * time.Hour)
queuedTarget := &messagequeue.QueuedMessage{
ID: "queued-activity-postgres-user-target", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
Content: "queued target", QueuedAt: queuedAt.Add(-time.Minute), QueuedBy: "user-1",
}
if err := queueRepo.Insert(ctx, queuedTarget, 10); err != nil {
t.Fatalf("insert postgres queued target: %v", err)
}
queuedSource := &messagequeue.QueuedMessage{
ID: "queued-activity-postgres-user", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
Content: "queued prompt", QueuedAt: queuedAt, QueuedBy: "user-1",
}
if err := queueRepo.Insert(ctx, queuedSource, 10); err != nil {
t.Fatalf("insert postgres queued prompt: %v", err)
}
merged, err := queueRepo.MergeIntoAbove(ctx, queuedSource.SessionID, queuedSource.ID, queuedSource.QueuedBy)
if err != nil {
t.Fatalf("merge postgres queued prompts: %v", err)
}
if merged == nil || !merged.QueuedAt.Equal(queuedAt) {
t.Fatalf("merged postgres queued activity = %#v, want %s", merged, queuedAt)
}
if err := queueRepo.Insert(ctx, &messagequeue.QueuedMessage{
ID: "queued-activity-postgres-agent", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
Content: "queued agent prompt", QueuedAt: base.Add(6 * time.Hour), QueuedBy: messagequeue.QueuedByAgent,
}, 10); err != nil {
t.Fatalf("insert postgres queued agent prompt: %v", err)
}
got, err := repo.LoadTaskLastActivity(ctx, []string{"task-activity-postgres"})
if err != nil {
t.Fatalf("load postgres task activity: %v", err)
}
if gotAt, ok := got["task-activity-postgres"]; !ok || !gotAt.Equal(queuedAt) {
t.Fatalf("postgres activity = %v, %v; want %v", gotAt, ok, queuedAt)
}
}
@@ -10,6 +10,8 @@ import (
"github.com/jmoiron/sqlx"
dbutil "github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/statussummary"
)
@@ -151,6 +153,145 @@ func TestTaskStatusSummaryBatchCompareAndUpdate(t *testing.T) {
}
}
func TestTaskLastActivityBatch(t *testing.T) {
repo, db := newTaskStatusSummaryTestRepo(t)
ctx := context.Background()
base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC)
seedTaskAt := func(taskID string, createdAt, updatedAt time.Time) {
t.Helper()
if _, err := db.Exec(db.Rebind(`
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`), taskID, "workspace-activity", taskID, createdAt, updatedAt); err != nil {
t.Fatalf("seed task %q: %v", taskID, err)
}
}
seedTaskAt("task-activity-turn", base, base.Add(time.Hour))
seedTaskAt("task-activity-active", base.Add(2*time.Hour), base.Add(3*time.Hour))
seedTaskAt("task-activity-no-session", base.Add(4*time.Hour), base.Add(5*time.Hour))
seedTaskAt("task-activity-queued", base.Add(4*time.Hour), base.Add(5*time.Hour))
queueRepo, err := messagequeue.NewSQLiteRepository(db, db)
if err != nil {
t.Fatalf("initialize queue repository: %v", err)
}
queuedAt := base.Add(9 * time.Hour)
queuedTargetAt := queuedAt.Add(-time.Minute)
queuedTarget := &messagequeue.QueuedMessage{
ID: "queued-activity-user-target", SessionID: "session-activity-queued", TaskID: "task-activity-queued",
Content: "queued target", QueuedAt: queuedTargetAt, QueuedBy: "user-1",
}
if err := queueRepo.Insert(ctx, queuedTarget, 10); err != nil {
t.Fatalf("insert queued user target: %v", err)
}
queuedSource := &messagequeue.QueuedMessage{
ID: "queued-activity-user", SessionID: "session-activity-queued", TaskID: "task-activity-queued",
Content: "queued prompt", QueuedAt: queuedAt, QueuedBy: "user-1",
}
if err := queueRepo.Insert(ctx, queuedSource, 10); err != nil {
t.Fatalf("insert queued user prompt: %v", err)
}
merged, err := queueRepo.MergeIntoAbove(ctx, queuedSource.SessionID, queuedSource.ID, queuedSource.QueuedBy)
if err != nil {
t.Fatalf("merge queued user prompts: %v", err)
}
if merged == nil || !merged.QueuedAt.Equal(queuedAt) {
t.Fatalf("merged queued activity = %#v, want %s", merged, queuedAt)
}
if err := queueRepo.Insert(ctx, &messagequeue.QueuedMessage{
ID: "queued-activity-agent", SessionID: "session-activity-queued", TaskID: "task-activity-queued",
Content: "queued agent prompt", QueuedAt: base.Add(12 * time.Hour), QueuedBy: messagequeue.QueuedByAgent,
}, 10); err != nil {
t.Fatalf("insert queued agent prompt: %v", err)
}
for _, session := range []*models.TaskSession{
{ID: "session-activity-turn", TaskID: "task-activity-turn"},
{ID: "session-activity-active", TaskID: "task-activity-active"},
} {
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("create session %q: %v", session.ID, err)
}
}
completedAt := base.Add(8 * time.Hour)
if err := repo.CreateTurn(ctx, &models.Turn{
ID: "turn-activity-completed",
TaskSessionID: "session-activity-turn",
TaskID: "task-activity-turn",
StartedAt: base.Add(6 * time.Hour),
CompletedAt: &completedAt,
}); err != nil {
t.Fatalf("create completed turn: %v", err)
}
if err := repo.CreateMessage(ctx, &models.Message{
ID: "message-activity-user",
TaskSessionID: "session-activity-turn",
TaskID: "task-activity-turn",
TurnID: "turn-activity-completed",
AuthorType: models.MessageAuthorUser,
Content: "user prompt",
CreatedAt: base.Add(7 * time.Hour),
}); err != nil {
t.Fatalf("create user message: %v", err)
}
if err := repo.CreateMessage(ctx, &models.Message{
ID: "message-activity-agent",
TaskSessionID: "session-activity-turn",
TaskID: "task-activity-turn",
TurnID: "turn-activity-completed",
AuthorType: models.MessageAuthorAgent,
Content: "agent output",
CreatedAt: base.Add(12 * time.Hour),
}); err != nil {
t.Fatalf("create agent message: %v", err)
}
if err := repo.CreateTurn(ctx, &models.Turn{
ID: "turn-activity-active",
TaskSessionID: "session-activity-active",
TaskID: "task-activity-active",
StartedAt: base.Add(10 * time.Hour),
}); err != nil {
t.Fatalf("create active turn: %v", err)
}
if err := repo.CreateMessage(ctx, &models.Message{
ID: "message-activity-agent-active",
TaskSessionID: "session-activity-active",
TaskID: "task-activity-active",
TurnID: "turn-activity-active",
AuthorType: models.MessageAuthorAgent,
Content: "streamed output",
CreatedAt: base.Add(11 * time.Hour),
}); err != nil {
t.Fatalf("create active agent message: %v", err)
}
got, err := repo.LoadTaskLastActivity(ctx, []string{
"task-activity-turn",
"task-activity-active",
"task-activity-no-session",
"task-activity-queued",
"missing-task",
})
if err != nil {
t.Fatalf("load task activity: %v", err)
}
want := map[string]time.Time{
"task-activity-turn": completedAt,
"task-activity-active": base.Add(10 * time.Hour),
"task-activity-no-session": base.Add(5 * time.Hour),
"task-activity-queued": queuedAt,
}
if len(got) != len(want) {
t.Fatalf("activity rows = %#v, want %#v", got, want)
}
for taskID, wantAt := range want {
gotAt, ok := got[taskID]
if !ok || !gotAt.Equal(wantAt) {
t.Errorf("activity[%q] = %v, %v; want %v", taskID, gotAt, ok, wantAt)
}
}
}
func TestTaskStatusSummaryConcurrentUpdatesRemainMonotonic(t *testing.T) {
repo, db := newTaskStatusSummaryTestRepo(t)
ctx := context.Background()
@@ -278,6 +278,7 @@ type Repos struct {
Reviews repository.ReviewRepository
ResourceCleanups repository.TaskResourceCleanupRepository
StatusSummaries repository.TaskStatusSummaryRepository
TaskActivity repository.TaskActivityRepository
SubagentContexts repository.SubagentContextRepository
}
@@ -301,6 +302,7 @@ type Service struct {
reviews repository.ReviewRepository
resourceCleanups repository.TaskResourceCleanupRepository
statusSummaries repository.TaskStatusSummaryRepository
taskActivity repository.TaskActivityRepository
subagentContexts repository.SubagentContextRepository
attachmentSvc *AttachmentService
statusSummaryPRs TaskStatusSummaryPRReader
@@ -440,6 +442,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover
reviews: repos.Reviews,
resourceCleanups: repos.ResourceCleanups,
statusSummaries: repos.StatusSummaries,
taskActivity: repos.TaskActivity,
subagentContexts: repos.SubagentContexts,
eventBus: eventBus,
logger: log,
@@ -378,8 +378,8 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas
"priority": task.Priority,
"position": task.Position,
"wip_admitted": task.WIPAdmitted,
"created_at": task.CreatedAt.Format(time.RFC3339),
"updated_at": task.UpdatedAt.Format(time.RFC3339),
"created_at": task.CreatedAt.Format(time.RFC3339Nano),
"updated_at": task.UpdatedAt.Format(time.RFC3339Nano),
"is_ephemeral": task.IsEphemeral,
"autopilot": task.Autopilot,
// Consumers that restore quick-chat tabs filter on origin, so it has to
@@ -388,7 +388,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas
}
data["queued_for_step_id"] = task.QueuedForStepID
if task.QueuedAt != nil {
data["queued_at"] = task.QueuedAt.Format(time.RFC3339)
data["queued_at"] = task.QueuedAt.Format(time.RFC3339Nano)
} else {
data["queued_at"] = nil
}
@@ -407,7 +407,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas
}
data["archived_at"] = nil
if task.ArchivedAt != nil {
data["archived_at"] = task.ArchivedAt.Format(time.RFC3339)
data["archived_at"] = task.ArchivedAt.Format(time.RFC3339Nano)
}
// Orchestrator-originated events fetch the task via the raw repo.GetTask,
// which does not populate Repositories. Load on demand so the payload
@@ -122,6 +122,42 @@ func TestTaskPublication_ActivityRefreshDoesNotOvertakeOrdinaryUpdate(t *testing
<-activityDone
}
func TestTaskPublication_PreservesSameSecondActivityOrdering(t *testing.T) {
svc, eventBus, _ := createTestService(t)
messageAt := time.Date(2026, 8, 18, 10, 20, 0, 100_000_000, time.UTC)
taskUpdatedAt := messageAt.Add(800 * time.Millisecond)
svc.publishTaskEventNow(context.Background(), events.TaskUpdated, &models.Task{
ID: "task-precision",
WorkspaceID: "ws-1",
CreatedAt: messageAt.Add(-time.Minute),
UpdatedAt: taskUpdatedAt,
}, nil, nil, nil, nil)
published := eventBus.GetPublishedEvents()
if len(published) != 1 {
t.Fatalf("published events = %d, want 1", len(published))
}
data, ok := published[0].Data.(map[string]interface{})
if !ok {
t.Fatalf("event data type = %T, want map[string]interface{}", published[0].Data)
}
updatedAt, ok := data["updated_at"].(string)
if !ok {
t.Fatalf("updated_at type = %T, want string", data["updated_at"])
}
parsed, err := time.Parse(time.RFC3339Nano, updatedAt)
if err != nil {
t.Fatalf("parse updated_at %q: %v", updatedAt, err)
}
if !parsed.Equal(taskUpdatedAt) {
t.Fatalf("published updated_at = %s, want %s", parsed, taskUpdatedAt)
}
if !parsed.After(messageAt) {
t.Fatalf("task mutation at %s was not ordered after same-second message at %s", parsed, messageAt)
}
}
func TestTaskPublication_QueuedActivityOutlivesCallerCancellation(t *testing.T) {
svc, eventBus, repo := createTestServiceWithSessionsRepo(t, func(repo *sqliterepo.Repository) repository.SessionRepository {
return cancellationAwareSessionRepository{SessionRepository: repo}
@@ -53,8 +53,9 @@ func (s *Service) ReconcileTaskStatusSummaries(
if s == nil || s.statusSummaries == nil || len(tasks) == 0 {
return summaries, nil
}
activityByTask, activityObserved := s.loadSummaryActivity(ctx, taskIDs(tasks))
failedTaskIDs, reconcileErr := s.reconcileExistingSummaries(
ctx, tasks, sessionsByTask, pendingBySession, summaries,
ctx, tasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved,
)
rebuildTasks := tasks
if len(failedTaskIDs) > 0 {
@@ -69,7 +70,7 @@ func (s *Service) ReconcileTaskStatusSummaries(
}
}
summaries = s.rebuildMissingSummaries(
ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries,
ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved,
)
return summaries, reconcileErr
}
@@ -80,6 +81,8 @@ func (s *Service) reconcileExistingSummaries(
sessionsByTask map[string][]*models.TaskSession,
pendingBySession map[string]models.TaskPendingAction,
summaries map[string]*statussummary.TaskStatusSummary,
activityByTask map[string]time.Time,
activityObserved bool,
) (map[string]struct{}, error) {
var reconcileErr error
failedTaskIDs := make(map[string]struct{})
@@ -94,6 +97,8 @@ func (s *Service) reconcileExistingSummaries(
task,
summaries[task.ID],
action,
activityByTask[task.ID],
activityObserved,
)
if err != nil {
if reconciled != nil {
@@ -127,6 +132,8 @@ func (s *Service) rebuildMissingSummaries(
sessionsByTask map[string][]*models.TaskSession,
pendingBySession map[string]models.TaskPendingAction,
summaries map[string]*statussummary.TaskStatusSummary,
activityByTask map[string]time.Time,
activityObserved bool,
) map[string]*statussummary.TaskStatusSummary {
missing := missingSummaryTasks(tasks, summaries)
if len(missing) == 0 {
@@ -135,8 +142,10 @@ func (s *Service) rebuildMissingSummaries(
prByTask, prObserved := s.loadSummaryPRs(ctx, taskIDs(missing))
gitBySession, gitObserved := s.loadSummaryGit(ctx, sessionIDsForTasks(missing, sessionsByTask))
queuedByTask := s.loadQueuedSummaryCounts(ctx, taskIDs(missing))
activityAtByTask := activityByTask
now := time.Now().UTC()
for _, task := range missing {
activityAt := activityAtByTask[task.ID]
s.rebuildMissingSummary(ctx, task, summaries, s.rebuildInput(
sessionsByTask[task.ID],
pendingBySession,
@@ -145,6 +154,8 @@ func (s *Service) rebuildMissingSummaries(
prByTask[task.ID],
prObserved,
queuedByTask[task.ID],
activityAt,
activityObserved,
now,
))
}
@@ -162,6 +173,20 @@ func (s *Service) loadQueuedSummaryCounts(ctx context.Context, taskIDs []string)
return map[string]int{}
}
func (s *Service) loadSummaryActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, bool) {
if s.taskActivity == nil || len(taskIDs) == 0 {
return nil, false
}
activityByTask, err := s.taskActivity.LoadTaskLastActivity(ctx, taskIDs)
if err != nil {
if s.logger != nil {
s.logger.Warn("failed to load task activity for status summary repair", zap.Error(err))
}
return nil, false
}
return activityByTask, true
}
func (s *Service) rebuildMissingSummary(
ctx context.Context,
task *models.Task,
@@ -211,9 +236,14 @@ func (s *Service) reconcileExistingSummary(
task *models.Task,
current *statussummary.TaskStatusSummary,
pendingAction string,
authoritativeActivity time.Time,
activityObserved bool,
) (*statussummary.TaskStatusSummary, error) {
for attempt := 0; attempt < maxSummaryReconcileAttempts && current != nil; attempt++ {
if current.PendingAction == pendingAction {
activityNeedsRepair := activityObserved &&
(authoritativeActivity.After(time.Time{}) &&
(current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt)))
if current.PendingAction == pendingAction && !activityNeedsRepair {
return current, nil
}
if err := prepareSummaryReconcileAttempt(ctx, attempt, current.Revision); err != nil {
@@ -221,6 +251,9 @@ func (s *Service) reconcileExistingSummary(
}
next := *current
next.PendingAction = pendingAction
if activityObserved && authoritativeActivity.After(time.Time{}) {
next.LastActivityAt = maxSummaryActivity(current.LastActivityAt, authoritativeActivity)
}
next.Revision = current.Revision + 1
next.UpdatedAt = advancedSummaryTime(current.UpdatedAt, time.Now().UTC())
if err := next.Validate(); err != nil {
@@ -259,13 +292,32 @@ func (s *Service) reconcileExistingSummary(
}
pendingAction = pendingActionForTask(refreshedSessions, pendingBySession)
}
if current != nil && current.PendingAction == pendingAction {
activityNeedsRepair := activityObserved &&
(authoritativeActivity.After(time.Time{}) &&
(current != nil && (current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt))))
if current != nil && current.PendingAction == pendingAction && !activityNeedsRepair {
return current, nil
}
s.logSummaryReconcileExhaustion(task.ID, current)
return nil, errors.New("exhausted compare-and-set retries")
}
func maxSummaryActivity(current *time.Time, candidate time.Time) *time.Time {
if candidate.IsZero() {
if current == nil {
return nil
}
copy := current.UTC()
return &copy
}
if current != nil && !candidate.After(*current) {
copy := current.UTC()
return &copy
}
copy := candidate.UTC()
return &copy
}
func (s *Service) logSummaryReconcileExhaustion(
taskID string,
current *statussummary.TaskStatusSummary,
@@ -447,18 +499,25 @@ func (s *Service) rebuildInput(
prs []statussummary.PullRequestInput,
prObserved bool,
queuedPromptCount int,
lastActivityAt time.Time,
activityObserved bool,
now time.Time,
) statussummary.RebuildInput {
input := statussummary.RebuildInput{
Sessions: make([]statussummary.RebuildSession, 0, len(sessions)),
PendingActions: make(map[string]string),
ActivityObserved: s.foregroundActivity != nil,
LastActivityAt: nil,
PullRequests: prs,
PRObserved: prObserved,
GitObserved: gitObserved,
QueuedPromptCount: maxInt(queuedPromptCount, 0),
Now: now,
}
if activityObserved && !lastActivityAt.IsZero() {
activityCopy := lastActivityAt.UTC()
input.LastActivityAt = &activityCopy
}
countProvider, hasCountProvider := s.foregroundActivity.(activeSubagentCountProvider)
for _, session := range sessions {
if session == nil || session.ID == "" {
@@ -6,12 +6,14 @@ import (
"testing"
"time"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
commonlogger "github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/task/statussummary"
@@ -415,6 +417,68 @@ func TestReconcileTaskStatusSummariesRepairsExistingPendingOnly(t *testing.T) {
}
}
func TestStatusSummaryActivityRebuildBackfillsAndPreservesNewerStoredValue(t *testing.T) {
svc, _, repo := createTestService(t)
svc.statusSummaries = repo
svc.taskActivity = repo
queueDB := sqlx.NewDb(repo.DB(), "sqlite3")
if _, err := messagequeue.NewSQLiteRepository(queueDB, queueDB); err != nil {
t.Fatalf("initialize queue repository: %v", err)
}
ctx := context.Background()
createTaskWithoutRepositories(t, ctx, repo)
base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC)
expected := base.Add(3 * time.Hour)
if _, err := repo.DB().Exec(
`UPDATE tasks SET created_at = ?, updated_at = ? WHERE id = ?`,
base, expected, "task-1",
); err != nil {
t.Fatalf("set task timestamps: %v", err)
}
tasks := []*models.Task{{ID: "task-1", WorkspaceID: "ws-1"}}
got, err := svc.ReconcileTaskStatusSummaries(
ctx,
tasks,
map[string][]*models.TaskSession{},
map[string]models.TaskPendingAction{},
map[string]*statussummary.TaskStatusSummary{},
)
if err != nil {
t.Fatalf("rebuild missing status summary: %v", err)
}
backfilled := got["task-1"]
if backfilled == nil || backfilled.LastActivityAt == nil || !backfilled.LastActivityAt.Equal(expected) {
t.Fatalf("backfilled activity = %+v, want %v", backfilled, expected)
}
newer := expected.Add(time.Hour)
stored := statussummary.TaskStatusSummary{
Revision: 9,
LastActivityAt: &newer,
}
accepted, err := repo.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{
TaskID: "task-1", WorkspaceID: "ws-1", Summary: stored,
})
if err != nil || !accepted {
t.Fatalf("seed newer summary: accepted=%v err=%v", accepted, err)
}
got, err = svc.ReconcileTaskStatusSummaries(
ctx,
tasks,
map[string][]*models.TaskSession{},
map[string]models.TaskPendingAction{},
map[string]*statussummary.TaskStatusSummary{"task-1": &stored},
)
if err != nil {
t.Fatalf("reconcile newer status summary: %v", err)
}
preserved := got["task-1"]
if preserved == nil || preserved.Revision != stored.Revision || preserved.LastActivityAt == nil || !preserved.LastActivityAt.Equal(newer) {
t.Fatalf("newer stored activity = %+v, want revision %d at %v", preserved, stored.Revision, newer)
}
}
func TestReconcileTaskStatusSummariesInvalidatesStaleSummaryOnRepairFailure(t *testing.T) {
svc, _, repo := createTestService(t)
repairErr := errors.New("status summary write failed")
@@ -584,6 +648,8 @@ func TestReconcileExistingSummaryStopsBeforeRetryWhenContextIsCanceled(t *testin
&models.Task{ID: "task-1", WorkspaceID: "ws-1"},
stored,
"",
time.Time{},
false,
)
if !errors.Is(err, context.Canceled) {
t.Fatalf("reconcileExistingSummary error = %v, want context canceled", err)
@@ -8,6 +8,7 @@ const (
messageTypeClarificationRequest = "clarification_request"
messageTypePermissionRequest = "permission_request"
messageTypeUser = "user"
messageTypeError = "error"
messageTypeStatus = "status"
messageTypeAgent = "agent"
@@ -28,6 +28,7 @@ const (
type TaskStatusSummary struct {
Revision uint64 `json:"revision"`
UpdatedAt time.Time `json:"updated_at"`
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
PrimarySession *PrimarySessionSummary `json:"primary_session,omitempty"`
ForegroundActivity string `json:"foreground_activity,omitempty"`
ActiveSubagentCount int `json:"active_subagent_count,omitempty"`
@@ -186,6 +187,7 @@ func (s TaskStatusSummary) SemanticJSON() ([]byte, error) {
return nil, err
}
return json.Marshal(semanticPayload{
LastActivityAt: s.LastActivityAt,
PrimarySession: s.PrimarySession,
ForegroundActivity: s.ForegroundActivity,
ActiveSubagentCount: s.ActiveSubagentCount,
@@ -198,6 +200,7 @@ func (s TaskStatusSummary) SemanticJSON() ([]byte, error) {
}
type semanticPayload struct {
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
PrimarySession *PrimarySessionSummary `json:"primary_session,omitempty"`
ForegroundActivity string `json:"foreground_activity,omitempty"`
ActiveSubagentCount int `json:"active_subagent_count,omitempty"`
@@ -29,6 +29,34 @@ func TestTaskStatusSummarySemanticEqualityIgnoresTransportMetadata(t *testing.T)
}
}
func TestTaskStatusSummaryLastActivityIsSemanticAndBackwardCompatible(t *testing.T) {
activity := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.UTC)
first := TaskStatusSummary{LastActivityAt: &activity}
second := first
if first.SemanticEqual(second) == false {
t.Fatal("equal last activity timestamps should compare equal")
}
later := activity.Add(time.Minute)
second.LastActivityAt = &later
if first.SemanticEqual(second) {
t.Fatal("different last activity timestamps must not compare equal")
}
payload, err := first.SemanticJSON()
if err != nil {
t.Fatalf("last activity semantic JSON: %v", err)
}
if !strings.Contains(string(payload), `"last_activity_at"`) {
t.Fatalf("last activity missing from semantic JSON: %s", payload)
}
var decoded TaskStatusSummary
if err := json.Unmarshal([]byte(`{"revision": 4, "updated_at": "2026-08-17T10:00:00Z"}`), &decoded); err != nil {
t.Fatalf("decode legacy summary: %v", err)
}
if decoded.LastActivityAt != nil {
t.Fatalf("legacy summary activity = %v, want nil", decoded.LastActivityAt)
}
}
func TestTaskStatusSummarySemanticJSONIsBoundedAndOmitsTransportMetadata(t *testing.T) {
summary := TaskStatusSummary{
Revision: 4,
@@ -54,6 +54,10 @@ type SessionObservationSnapshot struct {
// observations for one task.
type SessionObservationLoader func(context.Context, string) (SessionObservationSnapshot, error)
// TaskActivityLoader rehydrates the durable maximum activity timestamp for a
// task when a projector starts with a legacy or incomplete summary row.
type TaskActivityLoader func(context.Context, string) (*time.Time, error)
// PullRequestLoader rehydrates the keyed PR observations needed to preserve
// sibling pull requests across projector restarts and CAS rebases.
type PullRequestLoader func(context.Context, string) ([]PullRequestInput, error)
@@ -78,6 +82,7 @@ type ProjectorConfig struct {
LoadGitObservations GitObservationLoader
LoadPendingActions PendingActionLoader
LoadSessionObservations SessionObservationLoader
LoadTaskActivity TaskActivityLoader
LoadPullRequests PullRequestLoader
// CountQueuedPrompts returns the number of prompts currently en-queued for
// a task across all of its sessions (pending semantics identical to
@@ -99,6 +104,7 @@ type Projector struct {
loadGitObservations GitObservationLoader
loadPendingActions PendingActionLoader
loadSessionObservations SessionObservationLoader
loadTaskActivity TaskActivityLoader
loadPullRequests PullRequestLoader
countQueuedPrompts func(context.Context, string) (int, error)
logger *logger.Logger
@@ -121,6 +127,7 @@ type projectionState struct {
revision uint64
current *TaskStatusSummary
queuedCount int
lastActivityAt *time.Time
sessions map[string]sessionObservation
pending map[string]string
pendingRequests map[string]pendingRequestIdentity
@@ -184,6 +191,7 @@ func NewProjector(cfg ProjectorConfig) *Projector {
loadGitObservations: cfg.LoadGitObservations,
loadPendingActions: cfg.LoadPendingActions,
loadSessionObservations: cfg.LoadSessionObservations,
loadTaskActivity: cfg.LoadTaskActivity,
loadPullRequests: cfg.LoadPullRequests,
countQueuedPrompts: cfg.CountQueuedPrompts,
logger: log.WithFields(zap.String("component", "task-status-summary-projector")),
@@ -201,13 +209,17 @@ func (p *Projector) Start(ctx context.Context) error {
return nil
}
patterns := []string{
events.TaskCreated,
events.TaskUpdated,
events.TaskStateChanged,
events.TaskSessionStateChanged,
events.TaskSessionActivityChanged,
events.TaskSessionErrorChanged,
events.MessageAdded,
events.MessageUpdated,
events.MessageDeleted,
events.TurnStarted,
events.TurnCompleted,
events.ClarificationAnswered,
events.ClarificationPrimaryAnswered,
events.ClarificationCancelled,
@@ -304,16 +316,16 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error {
}
if event.Type == events.MessageQueueStatusChanged {
activityChanged := applyTaskActivityEventLocked(state, event.Type, data)
pendingChanged := false
if p.loadPendingActions != nil && !state.pendingObserved {
pendingChanged, refreshErr := p.refreshPendingLocked(ctx, taskID, state)
var refreshErr error
pendingChanged, refreshErr = p.refreshPendingLocked(ctx, taskID, state)
if refreshErr != nil {
return refreshErr
}
if err := p.persistPendingRefreshLocked(ctx, taskID, state, pendingChanged, "", nil); err != nil {
return err
}
}
return p.applyQueueStatusEvent(ctx, state, taskID)
return p.applyQueueStatusEvent(ctx, state, taskID, pendingChanged || activityChanged, event.Type, data)
}
refreshPending := p.loadPendingActions != nil &&
@@ -391,46 +403,36 @@ const maxQueueCountPersistAttempts = 3
// authoritative queue store. The event payload's per-session count is not
// reused: the badge is per-task across all sessions, and the queue may have
// changed between the status snapshot and this projection.
func (p *Projector) applyQueueStatusEvent(ctx context.Context, state *projectionState, taskID string) error {
if p.countQueuedPrompts == nil {
return nil
}
func (p *Projector) applyQueueStatusEvent(
ctx context.Context,
state *projectionState,
taskID string,
sourceChanged bool,
eventType string,
eventData map[string]interface{},
) error {
for attempt := 0; attempt < maxQueueCountPersistAttempts; attempt++ {
count, err := p.countQueuedPrompts(ctx, taskID)
count, err := p.loadQueueCount(ctx, taskID, state)
if err != nil {
return fmt.Errorf("count queued prompts for task %q: %w", taskID, err)
if persistErr := p.persistQueueSourceOnCountError(ctx, taskID, state, sourceChanged); persistErr != nil {
return persistErr
}
return err
}
if count == state.queuedCount {
if !sourceChanged && count == state.queuedCount {
return nil
}
previousCount := state.queuedCount
state.queuedCount = count
accepted, err := p.persistAndPublishLocked(ctx, taskID, state)
accepted, err := p.persistQueueStatus(ctx, taskID, state, count)
if err != nil {
// Keep in-memory state aligned with the last persisted value so a
// later zero-count event can retry instead of short-circuiting.
state.queuedCount = previousCount
// Delete cascades the summary row (FK) before the post-commit
// queue-status event. With warm state the recount hits persist
// rather than resolveWorkspace. Only suppress a verified gone-task
// failure; transient DB errors must propagate.
if count == 0 && isGoneTaskPersistErr(err) {
p.logger.Debug("skipping queue status persist for gone task",
zap.String("task_id", taskID),
zap.Error(err))
return nil
}
return err
}
if accepted {
return nil
}
// A competing writer may have changed any summary domain. Rebuild every
// keyed source before recounting so the queue retry cannot overwrite the
// winner with stale in-memory observations.
if err := p.rebaseProjectionStateFromCurrent(ctx, taskID, state); err != nil {
if err := p.rebaseQueueStatusEvent(ctx, taskID, state, eventType, eventData); err != nil {
return err
}
sourceChanged = true
}
// The count self-corrects on the next queue event or list load, but a
// sustained contention run is worth surfacing so a repeated rejector is not
@@ -441,6 +443,75 @@ func (p *Projector) applyQueueStatusEvent(ctx context.Context, state *projection
return nil
}
func (p *Projector) loadQueueCount(ctx context.Context, taskID string, state *projectionState) (int, error) {
if p.countQueuedPrompts == nil {
return state.queuedCount, nil
}
count, err := p.countQueuedPrompts(ctx, taskID)
if err != nil {
return 0, fmt.Errorf("count queued prompts for task %q: %w", taskID, err)
}
return count, nil
}
func (p *Projector) persistQueueSourceOnCountError(
ctx context.Context,
taskID string,
state *projectionState,
sourceChanged bool,
) error {
if !sourceChanged {
return nil
}
_, err := p.persistAndPublishLocked(ctx, taskID, state)
return err
}
func (p *Projector) persistQueueStatus(ctx context.Context, taskID string, state *projectionState, count int) (bool, error) {
previousCount := state.queuedCount
state.queuedCount = count
accepted, err := p.persistAndPublishLocked(ctx, taskID, state)
if err == nil {
return accepted, nil
}
// Keep in-memory state aligned with the last persisted value so a later
// zero-count event can retry instead of short-circuiting.
state.queuedCount = previousCount
// Delete cascades the summary row (FK) before the post-commit queue-status
// event. With warm state the recount hits persist rather than resolveWorkspace.
// Only suppress a verified gone-task failure; transient DB errors propagate.
if count == 0 && isGoneTaskPersistErr(err) {
p.logger.Debug("skipping queue status persist for gone task",
zap.String("task_id", taskID),
zap.Error(err))
return true, nil
}
return false, err
}
func (p *Projector) rebaseQueueStatusEvent(
ctx context.Context,
taskID string,
state *projectionState,
eventType string,
eventData map[string]interface{},
) error {
// A competing writer may have changed any summary domain. Rebuild every
// keyed source before recounting so the queue retry cannot overwrite the
// winner with stale in-memory observations.
if err := p.rebaseProjectionStateFromCurrent(ctx, taskID, state); err != nil {
return err
}
if eventType != "" {
applyTaskActivityEventLocked(state, eventType, eventData)
}
if p.loadPendingActions == nil {
return nil
}
_, err := p.refreshPendingLocked(ctx, taskID, state)
return err
}
func (p *Projector) lockTask(taskID string) func() {
p.taskLockMu.Lock()
lock := p.taskLocks[taskID]
@@ -504,6 +575,7 @@ func (p *Projector) persistAndPublishLocked(ctx context.Context, taskID string,
state.current = cloneSummary(stored)
state.revision = stored.Revision
state.queuedCount = stored.QueuedPromptCount
state.lastActivityAt = maxTimePtr(state.lastActivityAt, stored.LastActivityAt)
}
return false, nil
}
@@ -15,6 +15,7 @@ func deriveSummary(state *projectionState) TaskStatusSummary {
Git: deriveGitSummary(state),
PullRequest: derivePullRequestSummary(state),
QueuedPromptCount: state.queuedCount,
LastActivityAt: cloneTimePtr(state.lastActivityAt),
}
}
@@ -4,52 +4,111 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/events"
)
func (p *Projector) applySourceEventLocked(state *projectionState, eventType string, data map[string]interface{}) bool {
activityChanged := applyTaskActivityEventLocked(state, eventType, data)
var sourceChanged bool
switch eventType {
case events.TaskCreated, events.TaskStateChanged:
// These lifecycle events have no additional bounded status fields. Their
// persisted timestamps are handled by applyTaskActivityEventLocked.
sourceChanged = false
case events.TaskUpdated:
return p.applyTaskUpdatedEventLocked(state, data)
sourceChanged = p.applyTaskUpdatedEventLocked(state, data)
case events.TaskSessionStateChanged:
return p.applySessionEventLocked(state, data)
sourceChanged = p.applySessionEventLocked(state, data)
case events.TaskSessionActivityChanged:
return p.applyActivityEventLocked(state, data)
sourceChanged = p.applyActivityEventLocked(state, data)
case events.TaskSessionErrorChanged:
return p.applyErrorEventLocked(state, data)
sourceChanged = p.applyErrorEventLocked(state, data)
case events.MessageAdded, events.MessageUpdated, events.MessageDeleted:
return p.applyMessageEventLocked(state, eventType, data)
sourceChanged = p.applyMessageEventLocked(state, eventType, data)
case events.TurnStarted, events.TurnCompleted:
// Turn timestamps are the bounded activity source. The turn payload does
// not contribute another task-row status field.
sourceChanged = false
case events.ClarificationAnswered, events.ClarificationPrimaryAnswered,
events.ClarificationCancelled:
if p.loadPendingActions != nil {
return false
sourceChanged = false
break
}
return p.clearPendingLocked(state, stringField(data, "session_id"))
sourceChanged = p.clearPendingLocked(state, stringField(data, "session_id"))
case events.ClarificationStaleDismissed:
if p.loadPendingActions != nil {
return false
sourceChanged = false
break
}
sessionID := stringField(data, "session_id")
dismissedID := stringField(data, "pending_id")
if dismissedID != "" {
if current, ok := state.pendingRequests[sessionID]; ok && current.pendingID != dismissedID {
return false
sourceChanged = false
break
}
}
return p.clearPendingLocked(state, sessionID)
sourceChanged = p.clearPendingLocked(state, sessionID)
case events.PermissionRequestReceived:
if p.loadPendingActions != nil {
sourceChanged = false
break
}
sourceChanged = applyPermissionEventLocked(state, data)
case events.GitEvent:
sourceChanged = p.applyGitEventLocked(state, data)
case events.GitHubTaskPRUpdated:
sourceChanged = p.applyPREventLocked(state, data)
}
return activityChanged || sourceChanged
}
func applyTaskActivityEventLocked(state *projectionState, eventType string, data map[string]interface{}) bool {
var candidate time.Time
switch eventType {
case events.TaskCreated, events.TaskUpdated, events.TaskStateChanged:
candidate = timeValue(data["updated_at"])
if candidate.IsZero() {
candidate = timeValue(data["created_at"])
}
case events.MessageAdded:
if stringField(data, "author_type") != messageTypeUser {
return false
}
return applyPermissionEventLocked(state, data)
case events.GitEvent:
return p.applyGitEventLocked(state, data)
case events.GitHubTaskPRUpdated:
return p.applyPREventLocked(state, data)
candidate = timeValue(data["created_at"])
case events.MessageQueueStatusChanged:
if !isUserQueuedPrompt(data) {
return false
}
candidate = timeValue(data["queued_at"])
case events.TurnStarted:
candidate = timeValue(data["started_at"])
case events.TurnCompleted:
candidate = timeValue(data["completed_at"])
default:
return false
}
return advanceTaskActivity(state, candidate)
}
func isUserQueuedPrompt(data map[string]interface{}) bool {
queuedBy := stringField(data, "queued_by")
return queuedBy != "" && queuedBy != "agent" && queuedBy != "workflow" && queuedBy != "server"
}
func advanceTaskActivity(state *projectionState, candidate time.Time) bool {
if candidate.IsZero() {
return false
}
candidate = candidate.UTC()
if state.lastActivityAt != nil && !candidate.After(*state.lastActivityAt) {
return false
}
state.lastActivityAt = &candidate
return true
}
func applyPermissionEventLocked(state *projectionState, data map[string]interface{}) bool {
@@ -218,18 +277,20 @@ func (p *Projector) ensureState(ctx context.Context, taskID string) (*projection
}
func (p *Projector) restorePersistedState(ctx context.Context, taskID string, state *projectionState) error {
if p.store == nil {
return nil
if p.store != nil {
rows, err := p.store.LoadTaskStatusSummaries(ctx, []string{taskID})
if err != nil {
return fmt.Errorf("load task status summary %q: %w", taskID, err)
}
summary := rows[taskID]
if summary != nil {
state.current = cloneSummary(summary)
state.revision = summary.Revision
applySummaryBaseline(state, summary)
}
}
rows, err := p.store.LoadTaskStatusSummaries(ctx, []string{taskID})
if err != nil {
return fmt.Errorf("load task status summary %q: %w", taskID, err)
}
summary := rows[taskID]
if summary != nil {
state.current = cloneSummary(summary)
state.revision = summary.Revision
applySummaryBaseline(state, summary)
if err := p.restoreTaskActivity(ctx, taskID, state); err != nil {
return err
}
if err := p.restoreSessionObservations(ctx, taskID, state); err != nil {
return err
@@ -246,6 +307,7 @@ func (p *Projector) restorePersistedState(ctx context.Context, taskID string, st
func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) {
state.queuedCount = summary.QueuedPromptCount
state.taskPending = summary.PendingAction
state.lastActivityAt = maxTimePtr(state.lastActivityAt, summary.LastActivityAt)
if summary.PrimarySession != nil && summary.PrimarySession.ID != "" {
state.sessions[summary.PrimarySession.ID] = sessionObservation{
id: summary.PrimarySession.ID,
@@ -267,6 +329,18 @@ func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) {
}
}
func (p *Projector) restoreTaskActivity(ctx context.Context, taskID string, state *projectionState) error {
if p.loadTaskActivity == nil || state.lastActivityAt != nil {
return nil
}
activityAt, err := p.loadTaskActivity(ctx, taskID)
if err != nil {
return fmt.Errorf("load task activity for task status summary %q: %w", taskID, err)
}
state.lastActivityAt = maxTimePtr(state.lastActivityAt, activityAt)
return nil
}
// rebaseProjectionStateFromCurrent rebuilds all derived source state from the
// summary that won a rejected compare-and-set. Pending authority and the
// triggering source event are refreshed by the caller before retrying.
@@ -279,8 +353,10 @@ func (p *Projector) rebaseProjectionStateFromCurrent(
return nil
}
current := state.current
previousLastActivityAt := cloneTimePtr(state.lastActivityAt)
state.sessions = make(map[string]sessionObservation)
state.activityObserved = false
state.lastActivityAt = previousLastActivityAt
state.pending = make(map[string]string)
state.pendingRequests = make(map[string]pendingRequestIdentity)
state.pendingObserved = false
@@ -293,6 +369,9 @@ func (p *Projector) rebaseProjectionStateFromCurrent(
state.prBaseline = nil
state.prObserved = false
applySummaryBaseline(state, current)
if err := p.restoreTaskActivity(ctx, taskID, state); err != nil {
return err
}
if err := p.restoreSessionObservations(ctx, taskID, state); err != nil {
return err
}
@@ -282,6 +282,10 @@ func timeValue(value interface{}) time.Time {
switch value := value.(type) {
case time.Time:
return value
case *time.Time:
if value != nil {
return *value
}
case string:
parsed, err := time.Parse(time.RFC3339Nano, value)
if err == nil {
@@ -291,6 +295,26 @@ func timeValue(value interface{}) time.Time {
return time.Time{}
}
func cloneTimePtr(value *time.Time) *time.Time {
if value == nil || value.IsZero() {
return nil
}
copy := value.UTC()
return &copy
}
func maxTimePtr(left, right *time.Time) *time.Time {
left = cloneTimePtr(left)
right = cloneTimePtr(right)
if left == nil {
return right
}
if right == nil || !right.After(*left) {
return left
}
return right
}
func maxInt(value, minimum int) int {
if value < minimum {
return minimum
@@ -39,6 +39,40 @@ func TestProjectorQueueEventUpdatesQueuedPromptCount(t *testing.T) {
}
}
func TestProjectorQueueEventTracksUserPromptActivity(t *testing.T) {
projector, store, eventBus, updates, _ := newProjectorTest(t)
const taskID = "task-queued-activity"
queuedAt := time.Date(2026, 8, 1, 19, 0, 0, 0, time.UTC)
projector.countQueuedPrompts = func(context.Context, string) (int, error) {
return 1, nil
}
publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{
"task_id": taskID,
"queued_by": "user-1",
"queued_at": queuedAt,
})
summary := store.summary(taskID)
if summary == nil || summary.LastActivityAt == nil || !summary.LastActivityAt.Equal(queuedAt) {
t.Fatalf("queued user activity = %+v, want %s", summary, queuedAt)
}
if got := updates.Load(); got != 1 {
t.Fatalf("queued user admission published %d summary updates, want 1", got)
}
// Queue status events without a user-owned admission must remain count-only
// bookkeeping, even when they carry a newer timestamp-shaped value.
publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{
"task_id": taskID,
"queued_by": "agent",
"queued_at": queuedAt.Add(time.Hour),
})
if got := store.summary(taskID).LastActivityAt; got == nil || !got.Equal(queuedAt) {
t.Fatalf("agent queue activity changed last activity to %v, want %s", got, queuedAt)
}
}
func TestProjectorQueueEventWithUnchangedCountDoesNotRepublish(t *testing.T) {
projector, store, eventBus, updates, _ := newProjectorTest(t)
const taskID = "task-queued-unchanged"
@@ -334,6 +334,170 @@ func TestProjectorDerivesBoundedStatusAcrossSources(t *testing.T) {
}
}
func TestProjectorLastActivityTracksBoundedSourcesMonotonically(t *testing.T) {
_, store, eventBus, _, _ := newProjectorTest(t)
const taskID = "task-last-activity-projector"
const sessionID = "session-last-activity-projector"
publishProjectorEvent(t, eventBus, events.TaskCreated, events.TaskCreated, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"created_at": "2026-08-01T18:01:00Z",
"updated_at": "2026-08-01T18:01:00Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z")
// Focus and subscription changes are operational state, not task activity.
publishProjectorEvent(t, eventBus, events.TaskSessionActivityChanged, events.TaskSessionActivityChanged, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"session_id": sessionID,
"foreground_activity": "background",
"updated_at": "2026-08-01T18:02:00Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z")
// Provider polling can refresh a PR summary without making the task active.
publishProjectorEvent(t, eventBus, events.GitHubTaskPRUpdated, events.GitHubTaskPRUpdated, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"repository_id": "repo-last-activity",
"state": "open",
"pr_number": 12,
"pr_url": "https://example.test/pr/12",
"updated_at": "2026-08-01T18:03:00Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z")
// Agent-authored output and streamed/message-update bookkeeping do not move
// activity. The user-authored message below is the first message source that
// should move it.
publishProjectorEvent(t, eventBus, events.MessageAdded, events.MessageAdded, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"session_id": sessionID,
"author_type": "agent",
"type": "text",
"created_at": "2026-08-01T18:04:00Z",
})
publishProjectorEvent(t, eventBus, events.MessageUpdated, events.MessageUpdated, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"session_id": sessionID,
"author_type": "agent",
"type": "text",
"updated_at": "2026-08-01T18:04:30Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z")
publishProjectorEvent(t, eventBus, events.MessageAdded, events.MessageAdded, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"session_id": sessionID,
"author_type": "user",
"type": "text",
"created_at": "2026-08-01T18:05:00Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:05:00Z")
publishProjectorEvent(t, eventBus, events.TurnStarted, events.TurnStarted, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"session_id": sessionID,
"started_at": "2026-08-01T18:06:00Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:06:00Z")
publishProjectorEvent(t, eventBus, events.TurnCompleted, events.TurnCompleted, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"session_id": sessionID,
"completed_at": "2026-08-01T18:07:00Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:07:00Z")
// State-only transitions are activity even when they carry no other
// bounded-summary field.
publishProjectorEvent(t, eventBus, events.TaskStateChanged, events.TaskStateChanged, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"new_state": "in_progress",
"updated_at": "2026-08-01T18:08:00Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:08:00Z")
// Queue bookkeeping is deliberately ignored, and an older persisted update
// cannot move the monotonic maximum backwards.
publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"updated_at": "2026-08-01T18:09:00Z",
})
publishProjectorEvent(t, eventBus, events.TaskUpdated, events.TaskUpdated, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"updated_at": "2026-08-01T18:07:30Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:08:00Z")
publishProjectorEvent(t, eventBus, events.TaskUpdated, events.TaskUpdated, map[string]interface{}{
"task_id": taskID,
"workspace_id": "workspace-1",
"updated_at": "2026-08-01T18:10:00Z",
})
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:10:00Z")
}
func TestProjectorLastActivityRehydratesFromDurableLoader(t *testing.T) {
const taskID = "task-last-activity-rehydrate"
want := time.Date(2026, 8, 1, 18, 11, 0, 0, time.UTC)
store := newProjectorTestStore()
store.rows[taskID] = &StoredTaskStatusSummary{
TaskID: taskID,
WorkspaceID: "workspace-1",
Summary: TaskStatusSummary{
Revision: 4,
UpdatedAt: want.Add(-time.Minute),
},
}
var calls atomic.Int64
projector := NewProjector(ProjectorConfig{
Store: store,
ResolveWorkspace: func(context.Context, string) (string, error) {
return "workspace-1", nil
},
LoadTaskActivity: func(context.Context, string) (*time.Time, error) {
calls.Add(1)
return &want, nil
},
})
state, err := projector.ensureState(context.Background(), taskID)
if err != nil {
t.Fatalf("restore projector state: %v", err)
}
if state.lastActivityAt == nil || !state.lastActivityAt.Equal(want) {
t.Fatalf("rehydrated last activity = %v, want %v", state.lastActivityAt, want)
}
if calls.Load() != 1 {
t.Fatalf("durable activity loader calls = %d, want 1", calls.Load())
}
}
func assertProjectedLastActivity(t *testing.T, summary *TaskStatusSummary, want string) {
t.Helper()
if summary == nil || summary.LastActivityAt == nil {
t.Fatalf("projected last activity = %+v, want %s", summary, want)
}
expected, err := time.Parse(time.RFC3339, want)
if err != nil {
t.Fatalf("parse expected activity %q: %v", want, err)
}
if !summary.LastActivityAt.Equal(expected) {
t.Fatalf("projected last activity = %s, want %s", summary.LastActivityAt.Format(time.RFC3339), want)
}
}
// Session events carry a `session_metadata` snapshot taken when the publisher
// read the session, so one taken before the error was cleared must not re-arm an
// error this projection already cleared.
@@ -50,6 +50,7 @@ type RebuildInput struct {
Sessions []RebuildSession
PendingActions map[string]string
ActivityObserved bool
LastActivityAt *time.Time
Git []RebuildGit
GitObserved bool
PullRequests []PullRequestInput
@@ -112,7 +113,12 @@ func BuildFromAuthoritative(input RebuildInput) TaskStatusSummary {
}
applyPullRequestInputs(state, input.PullRequests)
state.queuedCount = maxInt(input.QueuedPromptCount, 0)
return deriveSummary(state)
summary := deriveSummary(state)
if input.LastActivityAt != nil {
lastActivityAt := input.LastActivityAt.UTC()
summary.LastActivityAt = &lastActivityAt
}
return summary
}
func normalizeRebuildError(input *ActiveErrorSummary, now time.Time) *ActiveErrorSummary {
@@ -0,0 +1,56 @@
import type { TaskSwitcherProps } from "../task-switcher";
import type { MobileTaskListProps } from "./session-task-switcher-sheet";
/** Maps mobile view state and actions to the shared task switcher rows. */
export function buildMobileTaskSwitcherProps(
props: MobileTaskListProps,
helpers: {
grouped: TaskSwitcherProps["grouped"];
collapsedGroupKeys: string[];
onToggleGroup: (groupKey: string) => void;
collapsedSubtaskParentIds: string[];
onToggleSubtasks: (parentTaskId: string) => void;
onTogglePin: (taskId: string) => void;
onReorderGroup: (groupTaskIds: string[]) => void;
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
pinnedTaskIds: string[];
showActivityTime: boolean;
},
): TaskSwitcherProps {
return {
grouped: helpers.grouped,
workflows: props.workflows,
stepsByWorkflowId: props.stepsByWorkflowId,
activeTaskId: props.activeTaskId,
selectedTaskId: props.selectedTaskId,
collapsedGroupKeys: helpers.collapsedGroupKeys,
showActivityTime: helpers.showActivityTime,
onToggleGroup: helpers.onToggleGroup,
collapsedSubtaskParentIds: helpers.collapsedSubtaskParentIds,
onToggleSubtasks: helpers.onToggleSubtasks,
onSelectTask: props.onSelectTask,
onEditTask: props.onEditTask,
onRenameTask: props.onRenameTask,
onCreateSubtask: props.onCreateSubtask,
onArchiveTask: props.onArchiveTask,
onDeleteTask: props.onDeleteTask,
onDetachTask: props.onDetachTask,
onNestTask: props.onNestTask,
onLinkPullRequest: props.onLinkPullRequest,
onLinkIssue: props.onLinkIssue,
onLinkMergeRequest: props.onLinkMergeRequest,
onLinkJiraTicket: props.onLinkJiraTicket,
onLinkLinearIssue: props.onLinkLinearIssue,
onLinkSentryIssue: props.onLinkSentryIssue,
onTogglePin: helpers.onTogglePin,
onReorderGroup: helpers.onReorderGroup,
onReorderSubtasks: helpers.onReorderSubtasks,
pinnedTaskIds: helpers.pinnedTaskIds,
deletingTaskId: props.deletingTaskId,
isLoading: props.isLoading,
loadError: props.loadError,
onRetryLoad: props.onRetryLoad,
retryLabel: props.retryLabel,
totalTaskCount: props.tasks.length,
};
}
@@ -8,7 +8,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/dra
import { Button } from "@kandev/ui/button";
import { QuickChatSheetButton } from "./quick-chat-sheet-button";
import { TaskSwitcher } from "../task-switcher";
import type { TaskSwitcherItem, TaskSwitcherProps } from "../task-switcher";
import type { TaskSwitcherItem } from "../task-switcher";
import { SidebarFilterBar } from "../sidebar-filter/sidebar-filter-bar";
import type { StepDef } from "../task-switcher-context-menu";
import type { TaskMoveWorkflow } from "../task-move-context-menu";
@@ -39,7 +39,7 @@ import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { useMobileTaskRename } from "./use-mobile-task-rename";
import { SidebarTaskEditDialog, useSidebarTaskEdit } from "../task-session-sidebar-edit";
import { usePortForwardingVisibility } from "../port-forwarding-visibility-provider";
import { buildMobileTaskSwitcherProps } from "./session-task-switcher-sheet-props";
type SessionTaskSwitcherSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -47,7 +47,6 @@ type SessionTaskSwitcherSheetProps = {
workflowId: string | null;
presentation?: "sheet" | "drawer";
};
export function useTaskSheetSelectionController() {
const [selectionController] = useState(createTaskSheetSelectionController);
useEffect(
@@ -58,7 +57,6 @@ export function useTaskSheetSelectionController() {
);
return selectionController;
}
function useMobileTaskLinking(workspaceId: string | null) {
const store = useAppStoreApi();
const actions = useSidebarLinkActions(store);
@@ -80,7 +78,7 @@ function useSidebarGroupToggle(viewId: string) {
);
}
type MobileTaskListProps = {
export type MobileTaskListProps = {
tasks: TaskSwitcherItem[];
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, StepDef[]>;
@@ -107,62 +105,6 @@ type MobileTaskListProps = {
retryLabel?: string;
};
/**
* Assembles the TaskSwitcher props for the mobile task list, mirroring the
* desktop `buildTaskSwitcherProps` so the prop-forwarding surface stays a
* thin mapping layer.
*/
function buildMobileTaskSwitcherProps(
props: MobileTaskListProps,
helpers: {
grouped: TaskSwitcherProps["grouped"];
collapsedGroupKeys: string[];
onToggleGroup: (groupKey: string) => void;
collapsedSubtaskParentIds: string[];
onToggleSubtasks: (parentTaskId: string) => void;
onTogglePin: (taskId: string) => void;
onReorderGroup: (groupTaskIds: string[]) => void;
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
pinnedTaskIds: string[];
},
): TaskSwitcherProps {
return {
grouped: helpers.grouped,
workflows: props.workflows,
stepsByWorkflowId: props.stepsByWorkflowId,
activeTaskId: props.activeTaskId,
selectedTaskId: props.selectedTaskId,
collapsedGroupKeys: helpers.collapsedGroupKeys,
onToggleGroup: helpers.onToggleGroup,
collapsedSubtaskParentIds: helpers.collapsedSubtaskParentIds,
onToggleSubtasks: helpers.onToggleSubtasks,
onSelectTask: props.onSelectTask,
onEditTask: props.onEditTask,
onRenameTask: props.onRenameTask,
onCreateSubtask: props.onCreateSubtask,
onArchiveTask: props.onArchiveTask,
onDeleteTask: props.onDeleteTask,
onDetachTask: props.onDetachTask,
onNestTask: props.onNestTask,
onLinkPullRequest: props.onLinkPullRequest,
onLinkIssue: props.onLinkIssue,
onLinkMergeRequest: props.onLinkMergeRequest,
onLinkJiraTicket: props.onLinkJiraTicket,
onLinkLinearIssue: props.onLinkLinearIssue,
onLinkSentryIssue: props.onLinkSentryIssue,
onTogglePin: helpers.onTogglePin,
onReorderGroup: helpers.onReorderGroup,
onReorderSubtasks: helpers.onReorderSubtasks,
pinnedTaskIds: helpers.pinnedTaskIds,
deletingTaskId: props.deletingTaskId,
isLoading: props.isLoading,
loadError: props.loadError,
onRetryLoad: props.onRetryLoad,
retryLabel: props.retryLabel,
totalTaskCount: props.tasks.length,
};
}
/**
* The mobile task tree surface: renders the shared TaskSwitcher with the
* mobile drawer's view state (grouping, ordering, collapse, reorder, nest).
@@ -201,6 +143,7 @@ export function MobileTaskList(props: MobileTaskListProps) {
onReorderGroup: handleReorderGroup,
onReorderSubtasks: handleReorderSubtasks,
pinnedTaskIds,
showActivityTime: view.sort.key === "lastActivityAt",
});
return <TaskSwitcher {...switcherProps} />;
}
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [
{ key: "state", labelKey: "task:sortStatus" },
{ key: "updatedAt", labelKey: "task:sortUpdated" },
{ key: "lastActivityAt", labelKey: "task:sortLastActivity" },
{ key: "createdAt", labelKey: "task:sortCreated" },
{ key: "title", labelKey: "task:sortTitle" },
{ key: "custom", labelKey: "task:sortCustom" },
@@ -64,7 +64,13 @@ export function TaskItemStatsRow({
return (
<span className="flex items-center gap-1.5 text-[11px]">
{updatedAt && (
<span className="text-muted-foreground/50">{formatRelativeTime(updatedAt)}</span>
<span
data-testid="sidebar-task-time"
data-time-value={updatedAt}
className="text-muted-foreground/50"
>
{formatRelativeTime(updatedAt)}
</span>
)}
{prInfo && <span className="text-muted-foreground/50">#{prInfo.number}</span>}
{queuedCount ? (
@@ -7,6 +7,7 @@ import type { TaskPR } from "@/lib/types/github";
import type { TaskMR } from "@/lib/types/gitlab";
import { TaskItem } from "./task-item";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { formatRelativeTime } from "@/lib/utils";
const TURN_FINISHED_ICON_TEST_ID = "task-state-turn-finished";
const WORKFLOW_COMPLETE_ICON_TEST_ID = "task-state-workflow-complete";
@@ -577,6 +578,37 @@ describe("TaskItem queued prompt count badge", () => {
});
});
describe("TaskItem activity timestamp", () => {
it("shows activity time only when the active view sorts by activity", () => {
const activityAt = "2026-07-20T00:00:00Z";
const updatedAt = "2026-07-24T00:00:00Z";
const { rerender } = renderTaskItem({
updatedAt,
lastActivityAt: activityAt,
showActivityTime: true,
});
expect(screen.getByTestId("sidebar-task-time").textContent).toBe(
formatRelativeTime(activityAt),
);
rerender(
<StateProvider>
<TooltipProvider>
<TaskItem
title="Needs answer"
state="REVIEW"
updatedAt={updatedAt}
lastActivityAt={activityAt}
showActivityTime={false}
/>
</TooltipProvider>
</StateProvider>,
);
expect(screen.getByTestId("sidebar-task-time").textContent).toBe(formatRelativeTime(updatedAt));
});
});
describe("TaskItem contribution badges", () => {
it("renders the PR badge before the MR badge when the task has both (AC2)", () => {
renderTaskItem(
+11 -1
View File
@@ -68,6 +68,8 @@ type TaskItemProps = {
remoteExecutorType?: string;
remoteExecutorName?: string;
updatedAt?: string;
lastActivityAt?: string;
showActivityTime?: boolean;
menuOpen?: boolean;
isDeleting?: boolean;
taskId?: string;
@@ -324,6 +326,8 @@ function TaskItemContent({
isPinned,
repositories,
updatedAt,
lastActivityAt,
showActivityTime,
prInfo,
queuedCount,
wipQueue,
@@ -341,6 +345,8 @@ function TaskItemContent({
isPinned?: boolean;
repositories?: string[];
updatedAt?: string;
lastActivityAt?: string;
showActivityTime?: boolean;
prInfo?: { number: number; state: string; aggregateState?: string };
queuedCount?: number;
wipQueue?: WipQueueStatus;
@@ -384,7 +390,7 @@ function TaskItemContent({
</span>
)}
<TaskItemStatsRow
updatedAt={updatedAt}
updatedAt={showActivityTime ? (lastActivityAt ?? updatedAt) : updatedAt}
prInfo={prInfo}
primarySessionId={primarySessionId}
queuedCount={queuedCount}
@@ -434,6 +440,8 @@ export const TaskItem = memo(function TaskItem({
remoteExecutorType,
remoteExecutorName,
updatedAt,
lastActivityAt,
showActivityTime = false,
menuOpen = false,
isDeleting,
taskId,
@@ -496,6 +504,8 @@ export const TaskItem = memo(function TaskItem({
isPinned={isPinned}
repositories={repositories}
updatedAt={updatedAt}
lastActivityAt={lastActivityAt}
showActivityTime={showActivityTime}
prInfo={prInfo}
queuedCount={queuedCount}
wipQueue={wipQueue}
@@ -4,6 +4,9 @@ import { buildSidebarItem } from "./task-session-sidebar-item";
type SidebarTask = Parameters<typeof buildSidebarItem>[0];
type SidebarContext = Parameters<typeof buildSidebarItem>[1];
const UPDATED_AT = "2026-07-22T00:00:00Z";
const ACTIVITY_AT = "2026-07-24T00:00:00Z";
const CREATED_AT = "2026-07-20T00:00:00Z";
const SUMMARY_UPDATED_AT = "2026-07-25T00:00:00Z";
function emptyContext(): SidebarContext {
return {
@@ -122,3 +125,41 @@ describe("buildSidebarItem", () => {
expect(item.queuedCount).toBeUndefined();
});
});
describe("buildSidebarItem activity", () => {
it("maps summary activity and falls back through task update to creation", () => {
const projected = buildSidebarItem(
task({
updatedAt: UPDATED_AT,
createdAt: CREATED_AT,
statusSummary: {
revision: 6,
updated_at: SUMMARY_UPDATED_AT,
last_activity_at: ACTIVITY_AT,
},
}),
emptyContext(),
);
expect(projected.updatedAt).toBe(SUMMARY_UPDATED_AT);
expect(projected.lastActivityAt).toBe(ACTIVITY_AT);
const updatedFallback = buildSidebarItem(
task({
updatedAt: UPDATED_AT,
createdAt: CREATED_AT,
statusSummary: { revision: 7, updated_at: SUMMARY_UPDATED_AT },
}),
emptyContext(),
);
expect(updatedFallback.lastActivityAt).toBe(UPDATED_AT);
const createdFallback = buildSidebarItem(
task({
createdAt: CREATED_AT,
statusSummary: { revision: 8, updated_at: SUMMARY_UPDATED_AT },
}),
emptyContext(),
);
expect(createdFallback.lastActivityAt).toBe(CREATED_AT);
});
});
@@ -68,6 +68,13 @@ function issueInfoForTask(task: KanbanState["tasks"][number]) {
return { url: task.issueUrl, number: task.issueNumber };
}
function sidebarLastActivityAt(
summary: TaskStatusSummary | null | undefined,
task: KanbanState["tasks"][number],
) {
return summary?.last_activity_at ?? task.updatedAt ?? task.createdAt;
}
function sidebarSessionStatus(
summary: TaskStatusSummary | null | undefined,
task: KanbanState["tasks"][number],
@@ -85,6 +92,7 @@ function sidebarSessionStatus(
foregroundActivity: hasSummary ? summary?.foreground_activity : task.foregroundActivity,
primarySessionId: hasSummary ? (primarySession?.id ?? null) : (task.primarySessionId ?? null),
updatedAt: hasSummary ? summary?.updated_at : (task.updatedAt ?? task.createdAt),
lastActivityAt: sidebarLastActivityAt(summary, task),
};
}
@@ -17,7 +17,10 @@ export function buildTaskSwitcherProps(args: {
stepsByWorkflowId: TaskSwitcherComponentProps["stepsByWorkflowId"];
highlightedTaskId: string | null;
highlightedSelectedTaskId: string | null;
effectiveView: { collapsedGroups: TaskSwitcherComponentProps["collapsedGroupKeys"] };
effectiveView: {
collapsedGroups: TaskSwitcherComponentProps["collapsedGroupKeys"];
sort: { key: string };
};
handleToggleGroup: TaskSwitcherComponentProps["onToggleGroup"];
collapsedSubtaskParents: TaskSwitcherComponentProps["collapsedSubtaskParentIds"];
toggleSubtaskCollapsed: TaskSwitcherComponentProps["onToggleSubtasks"];
@@ -43,6 +46,7 @@ export function buildTaskSwitcherProps(args: {
activeTaskId: args.highlightedTaskId,
selectedTaskId: args.highlightedSelectedTaskId,
collapsedGroupKeys: args.effectiveView.collapsedGroups,
showActivityTime: args.effectiveView.sort.key === "lastActivityAt",
onToggleGroup: args.handleToggleGroup,
collapsedSubtaskParentIds: args.collapsedSubtaskParents,
onToggleSubtasks: args.toggleSubtaskCollapsed,
@@ -21,6 +21,7 @@ export type TaskRowProps = {
stepsByWorkflowId?: Record<string, StepDef[]>;
activeTaskId: string | null;
selectedTaskId: string | null;
showActivityTime?: boolean;
onSelectTask: (taskId: string) => void;
onEditTask?: (task: TaskSwitcherItem) => void;
onRenameTask?: (taskId: string, currentTitle: string) => void;
@@ -91,6 +92,7 @@ type TaskRowItemProps = Pick<
| "subtaskToggle"
| "activeTaskId"
| "selectedTaskId"
| "showActivityTime"
| "onSelectTask"
| "selectedTaskIds"
| "onToggleSelectTask"
@@ -107,6 +109,7 @@ function TaskRowItem({
subtaskToggle,
activeTaskId,
selectedTaskId,
showActivityTime,
onSelectTask,
selectedTaskIds,
onToggleSelectTask,
@@ -154,6 +157,8 @@ function TaskRowItem({
hasPendingClarification={task.hasPendingClarification}
hasPendingPermission={task.hasPendingPermission}
updatedAt={task.updatedAt}
lastActivityAt={task.lastActivityAt}
showActivityTime={showActivityTime}
repositories={task.repositories}
prInfo={task.prInfo}
queuedCount={task.queuedCount}
@@ -36,6 +36,7 @@ export type TaskSwitcherItem = {
remoteExecutorType?: string;
remoteExecutorName?: string;
updatedAt?: string;
lastActivityAt?: string;
createdAt?: string;
isArchived?: boolean;
primarySessionId?: string | null;
@@ -91,6 +92,7 @@ export type TaskSwitcherProps = {
onRetryLoad?: () => void;
retryLabel?: string;
totalTaskCount?: number;
showActivityTime?: boolean;
// Multi-select (cmd/shift click). When the selection is non-empty, plain
// clicks toggle instead of navigating; the context menu acts on the selection.
selectedTaskIds?: Set<string>;
@@ -20,6 +20,7 @@ function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps {
stepsByWorkflowId: props.stepsByWorkflowId,
activeTaskId: props.activeTaskId,
selectedTaskId: props.selectedTaskId,
showActivityTime: props.showActivityTime,
onSelectTask: props.onSelectTask,
onEditTask: props.onEditTask,
onRenameTask: props.onRenameTask,
@@ -55,6 +55,16 @@ async function addTitleFilter(testPage: Page, sheet: Locator, value: string): Pr
await popover.getByTestId("filter-value-input").fill(value);
}
async function taskRowOrder(sheet: Locator, taskIds: string[]) {
const rows = await sheet.locator("[data-task-row-id]").all();
const order: string[] = [];
for (const row of rows) {
const id = await row.getAttribute("data-task-row-id");
if (id && taskIds.includes(id)) order.push(id);
}
return order;
}
test.describe("Mobile sidebar — view system", () => {
test("direct creation stays touch-reachable, viewport-safe, and persists", async ({
testPage,
@@ -244,6 +254,63 @@ test.describe("Mobile sidebar — view system", () => {
await expect(sheet.getByText("Fix auth bug")).toBeVisible();
});
test("mobile last activity sort is touch reachable, persistent, and contained", async ({
testPage,
apiClient,
seedData,
}) => {
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [
"Mobile activity old",
"Mobile activity new",
]);
const listed = await apiClient.listTasks(seedData.workspaceId);
const oldTask = listed.tasks.find((task) => task.title === "Mobile activity old");
const newTask = listed.tasks.find((task) => task.title === "Mobile activity new");
expect(oldTask?.id).toBeTruthy();
expect(newTask?.id).toBeTruthy();
await apiClient.updateTaskTitle(oldTask!.id, "Mobile activity old touched");
const gear = sheet.getByTestId("sidebar-filter-gear");
await gear.tap();
const popover = testPage.getByTestId("sidebar-filter-popover");
await expect(popover).toBeVisible();
await popover.getByTestId("sort-key-select").tap();
await testPage.getByRole("option", { name: "Last activity", exact: true }).tap();
const direction = popover.getByTestId("sort-direction-toggle");
if ((await direction.getAttribute("data-direction")) !== "desc") await direction.tap();
await popover.getByTestId("view-save-as-button").tap();
await popover.getByTestId("view-save-as-name-input").fill("Mobile last activity");
await popover.getByTestId("view-save-as-confirm").tap();
await testPage.keyboard.press("Escape");
const taskIds = [oldTask!.id, newTask!.id];
await expect.poll(() => taskRowOrder(sheet, taskIds)).toEqual(taskIds);
const viewport = testPage.viewportSize();
const list = sheet.getByTestId("mobile-task-switcher-list");
const sheetBox = await sheet.boundingBox();
const listBox = await list.boundingBox();
expect(viewport).not.toBeNull();
expect(sheetBox).not.toBeNull();
expect(listBox).not.toBeNull();
expect(listBox!.x).toBeGreaterThanOrEqual(sheetBox!.x);
expect(listBox!.x + listBox!.width).toBeLessThanOrEqual(sheetBox!.x + sheetBox!.width);
await expect(list).toHaveCSS("overflow-y", "auto");
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await testPage.reload();
await new SessionPage(testPage).waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" });
await expect(
reloadedSheet.getByTestId("sidebar-view-chip").filter({ hasText: "Mobile last activity" }),
).toHaveAttribute("data-active", "true");
await expect.poll(() => taskRowOrder(reloadedSheet, taskIds)).toEqual(taskIds);
});
test("many saved views scroll without covering fixed actions", async ({
testPage,
apiClient,
@@ -75,6 +75,25 @@ async function saveTitleView(
await filters.close();
}
async function taskRowOrder(surface: import("@playwright/test").Locator, taskIds: string[]) {
const rows = await surface.locator("[data-task-row-id]").all();
const order: string[] = [];
for (const row of rows) {
const id = await row.getAttribute("data-task-row-id");
if (id && taskIds.includes(id)) order.push(id);
}
return order;
}
async function taskActivityAt(
apiClient: import("../../helpers/api-client").ApiClient,
workspaceId: string,
taskId: string,
): Promise<string | null> {
const result = await apiClient.listTasks(workspaceId);
return result.tasks.find((task) => task.id === taskId)?.status_summary?.last_activity_at ?? null;
}
test.describe("Sidebar filter bar — popover basics", () => {
test("gear opens popover; ESC closes it", async ({ testPage, apiClient, seedData }) => {
const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Basics Task"]);
@@ -306,6 +325,106 @@ test.describe("Sidebar filter — group + sort", () => {
const flipped = await toggle.getAttribute("data-direction");
expect(flipped).not.toBe(initial);
});
test("sorts by last activity, persists, and ignores provider-only refresh", async ({
testPage,
apiClient,
seedData,
}) => {
const oldTask = await apiClient.createTask(seedData.workspaceId, "Activity old", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const newTask = await apiClient.createTask(seedData.workspaceId, "Activity new", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.updateTaskTitle(oldTask.id, "Activity old touched");
const navTask = await apiClient.createTask(seedData.workspaceId, "Activity sort nav", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${navTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const filters = new SidebarFilterPopoverPage(testPage);
await filters.open();
await filters.setGroup("None");
await filters.setSort("Last activity", "desc");
await filters.saveAs("Last activity view");
await filters.close();
await expect
.poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id]))
.toEqual([oldTask.id, newTask.id]);
let activityAt: string | null = null;
await expect
.poll(async () => {
activityAt = await taskActivityAt(apiClient, seedData.workspaceId, oldTask.id);
return activityAt;
})
.toBeTruthy();
const oldRow = session.sidebar.locator(`[data-task-row-id="${oldTask.id}"]`);
await expect(oldRow.getByTestId("sidebar-task-time")).toHaveAttribute(
"data-time-value",
activityAt!,
);
await testPage.reload();
await new SessionPage(testPage).waitForLoad();
const reloadedFilters = new SidebarFilterPopoverPage(testPage);
await reloadedFilters.expectActiveViewChip("Last activity view");
await expect
.poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id]))
.toEqual([oldTask.id, newTask.id]);
await apiClient.mockGitHubAssociateTaskPR({
task_id: newTask.id,
workspace_id: seedData.workspaceId,
repository_id: seedData.repositoryId,
owner: "activity-owner",
repo: "activity-repo",
pr_number: 7,
pr_url: "https://github.com/activity-owner/activity-repo/pull/7",
pr_title: "Activity PR",
head_branch: "activity",
base_branch: "main",
author_login: "activity-owner",
checks_state: "failure",
});
await expect
.poll(async () => {
const tasks = await apiClient.listTasks(seedData.workspaceId);
return tasks.tasks.find((task) => task.id === newTask.id)?.status_summary?.pull_request
?.aggregate_state;
})
.toBe("failure");
await apiClient.mockGitHubAssociateTaskPR({
task_id: newTask.id,
workspace_id: seedData.workspaceId,
repository_id: seedData.repositoryId,
owner: "activity-owner",
repo: "activity-repo",
pr_number: 7,
pr_url: "https://github.com/activity-owner/activity-repo/pull/7",
pr_title: "Activity PR refreshed",
head_branch: "activity",
base_branch: "main",
author_login: "activity-owner",
checks_state: "success",
});
await expect
.poll(async () => {
const tasks = await apiClient.listTasks(seedData.workspaceId);
return tasks.tasks.find((task) => task.id === newTask.id)?.status_summary?.pull_request
?.aggregate_state;
})
.toBe("passing");
await expect
.poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id]))
.toEqual([oldTask.id, newTask.id]);
});
});
test.describe("Sidebar filter — saved views CRUD", () => {
+31 -3
View File
@@ -219,9 +219,12 @@ describe("applyFilters — titleMatch + combos", () => {
});
describe("applySort", () => {
const a = task({ id: "a", state: "REVIEW", updatedAt: "2026-04-01", title: "Zeta" });
const b = task({ id: "b", sessionState: "RUNNING", updatedAt: "2026-04-05", title: "Alpha" });
const c = task({ id: "c", updatedAt: "2026-04-03", title: "Mu" });
const early = "2026-04-01";
const middle = "2026-04-03";
const late = "2026-04-05";
const a = task({ id: "a", state: "REVIEW", updatedAt: early, title: "Zeta" });
const b = task({ id: "b", sessionState: "RUNNING", updatedAt: late, title: "Alpha" });
const c = task({ id: "c", updatedAt: middle, title: "Mu" });
it("sorts by state bucket asc (review, in_progress, backlog)", () => {
const out = applySort([c, b, a], { key: "state", direction: "asc" });
@@ -238,6 +241,31 @@ describe("applySort", () => {
expect(out.map((t) => t.id)).toEqual(["b", "c", "a"]);
});
it("sorts by lastActivityAt with update and creation fallbacks", () => {
const out = applySort(
[
task({ id: "created-fallback", createdAt: early }),
task({ id: "updated-fallback", updatedAt: middle, createdAt: early }),
task({
id: "activity",
lastActivityAt: late,
updatedAt: "2026-04-02",
createdAt: early,
}),
],
{ key: "lastActivityAt", direction: "desc" },
);
expect(out.map((t) => t.id)).toEqual(["activity", "updated-fallback", "created-fallback"]);
});
it("keeps lastActivityAt stable for equal timestamps", () => {
const out = applySort(
[task({ id: "first", lastActivityAt: late }), task({ id: "second", lastActivityAt: late })],
{ key: "lastActivityAt", direction: "asc" },
);
expect(out.map((t) => t.id)).toEqual(["first", "second"]);
});
it("sorts by title asc", () => {
const out = applySort([a, b, c], { key: "title", direction: "asc" });
expect(out.map((t) => t.id)).toEqual(["b", "c", "a"]);
+5
View File
@@ -117,6 +117,10 @@ export function applyFilters(
type SortComparator = (a: TaskSwitcherItem, b: TaskSwitcherItem) => number;
function lastActivitySortValue(task: TaskSwitcherItem): string {
return task.lastActivityAt ?? task.updatedAt ?? task.createdAt ?? "";
}
const SORT_COMPARATORS: Record<Exclude<SortKey, "custom">, SortComparator> = {
state: (a, b) => {
const bucket = STATE_BUCKET_ORDER[getStateBucket(a)] - STATE_BUCKET_ORDER[getStateBucket(b)];
@@ -125,6 +129,7 @@ const SORT_COMPARATORS: Record<Exclude<SortKey, "custom">, SortComparator> = {
return (b.createdAt ?? "").localeCompare(a.createdAt ?? "");
},
updatedAt: (a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? ""),
lastActivityAt: (a, b) => lastActivitySortValue(a).localeCompare(lastActivitySortValue(b)),
createdAt: (a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""),
title: (a, b) => (a.title ?? "").localeCompare(b.title ?? ""),
};
@@ -22,7 +22,7 @@ export type FilterClause = {
value: FilterValue;
};
export type SortKey = "state" | "updatedAt" | "createdAt" | "title" | "custom";
export type SortKey = "state" | "updatedAt" | "lastActivityAt" | "createdAt" | "title" | "custom";
export type SortDirection = "asc" | "desc";
export type SortSpec = { key: SortKey; direction: SortDirection };
@@ -10,7 +10,7 @@ const view: SidebarView = {
{ id: "c2", dimension: "state", op: "in", value: ["review", "in_progress"] },
{ id: "c3", dimension: "titleMatch", op: "matches", value: "fix " },
],
sort: { key: "updatedAt", direction: "desc" },
sort: { key: "lastActivityAt", direction: "desc" },
group: "workflow",
collapsedGroups: ["backlog", "review"],
};
@@ -84,3 +84,15 @@ describe("migrateSidebarViewDraft archived compatibility", () => {
expect(migrateSidebarViewDraft(draft)).toEqual(draft);
});
});
describe("migrate sidebar activity sort", () => {
it("preserves lastActivityAt on saved views and drafts", () => {
const view = makeSidebarView("view-activity", "Recent activity");
view.sort = { key: "lastActivityAt", direction: "desc" };
const draft = makeSidebarDraft("view-activity");
draft.sort = { key: "lastActivityAt", direction: "asc" };
expect(migrateView(view).sort).toEqual(view.sort);
expect(migrateSidebarViewDraft(draft).sort).toEqual(draft.sort);
});
});
+1
View File
@@ -46,6 +46,7 @@ export const KNOWN_DIMENSIONS = new Set<string>([
export const KNOWN_SORT_KEYS = new Set<string>([
"state",
"updatedAt",
"lastActivityAt",
"createdAt",
"title",
"custom",
@@ -3,6 +3,8 @@ import type { ForegroundActivity, TaskPendingAction, TaskSessionState } from "./
export type TaskStatusSummary = {
revision: number;
updated_at: string;
/** Semantic task activity, separate from summary projection freshness. */
last_activity_at?: string;
primary_session?: {
id: string;
state: TaskSessionState;
+1
View File
@@ -1261,6 +1261,7 @@
"sortCreated": "Created",
"sortCustom": "Custom",
"sortDirection": "Sort direction {{direction}}",
"sortLastActivity": "Last activity",
"sortStatus": "Status",
"sortTitle": "Title",
"sortUpdated": "Updated",
+1
View File
@@ -1261,6 +1261,7 @@
"sortCreated": "Ćŕēàţēď",
"sortCustom": "Ćũśţōḿ",
"sortDirection": "Śōŕţ ďĩŕēćţĩōń {{direction}}",
"sortLastActivity": "Ĺàśţ àćţĩṿĩţŷ",
"sortStatus": "Śţàţũś",
"sortTitle": "Ţĩţĺē",
"sortUpdated": "Ũƥďàţēď",
+1
View File
@@ -1224,6 +1224,7 @@
"sortCreated": "Criação",
"sortCustom": "Personalizada",
"sortDirection": "Sentido da ordenação {{direction}}",
"sortLastActivity": "Última atividade",
"sortStatus": "Estado",
"sortTitle": "Título",
"sortUpdated": "Atualização",
+1
View File
@@ -1223,6 +1223,7 @@
"sortCreated": "创建时间",
"sortCustom": "自定义",
"sortDirection": "排序方向 {{direction}}",
"sortLastActivity": "最后活动",
"sortStatus": "状态",
"sortTitle": "标题",
"sortUpdated": "更新时间",
+1
View File
@@ -1223,6 +1223,7 @@
"sortCreated": "建立時間",
"sortCustom": "自訂",
"sortDirection": "排序方向 {{direction}}",
"sortLastActivity": "最後活動",
"sortStatus": "狀態",
"sortTitle": "標題",
"sortUpdated": "更新時間",
+1
View File
@@ -1223,6 +1223,7 @@
"sortCreated": "建立時間",
"sortCustom": "自訂",
"sortDirection": "排序方向 {{direction}}",
"sortLastActivity": "最後活動",
"sortStatus": "狀態",
"sortTitle": "標題",
"sortUpdated": "更新時間",
@@ -0,0 +1,67 @@
# ADR-2026-08-17-separate-task-activity-from-summary-freshness: Separate Task Activity From Summary Freshness
**Status:** accepted
**Date:** 2026-08-17
**Area:** backend, frontend, protocol
## Context
Task sidebar rows currently use `TaskStatusSummary.updated_at` as their visible
time and saved-view sort value. That field records when any projected status
changed. A pull-request poll can therefore make many idle tasks look recently
active even when no user opened them and no agent ran.
Users need both concepts. Projection freshness is useful for status diagnostics,
while task activity is useful for finding recent work.
## Decision
Kandev will keep `TaskStatusSummary.updated_at` as transport and projection
freshness. It will add semantic field `last_activity_at` to the same bounded,
rebuildable summary.
`last_activity_at` is the latest durable time from these sources:
- task creation or a persisted task mutation
- a user-authored prompt, including a prompt that enters the queue
- an agent turn start or completion
The timestamp does not advance for task focus, session subscription, Git or
pull-request polling, queue bookkeeping, status-summary repair, or session
metadata maintenance. Live projection uses source timestamps and applies a
monotonic maximum. Rebuilds batch the same durable task, message, and turn
sources and preserve a newer stored value.
The saved-view wire key is `lastActivityAt`. The existing `updatedAt` key keeps
its behavior, so stored views remain compatible and users can choose either
meaning.
This decision extends
[ADR-2026-08-01-separate-task-summary-session-stream-traffic](2026-08-01-separate-task-summary-session-stream-traffic.md).
Task-list consumers continue to use the bounded task summary and do not load
background session streams.
## Consequences
Idle tasks stay stable when background provider status changes. Desktop and
mobile saved views can sort by meaningful work without opening task sessions.
The backend needs a batched activity query and more bounded projector inputs.
Turn milestones prevent per-chunk agent output from producing task-list update
traffic. A running turn becomes recent when it starts and advances again when
it completes.
Older summaries need a one-time semantic repair. During partial rollout, the
frontend falls back to the task update or creation time when
`last_activity_at` is absent.
## Alternatives Considered
- **Reuse summary `updated_at`.** Rejected because provider and Git status
changes are not user or agent activity.
- **Use only `tasks.updated_at`.** Rejected because prompts and agent turns do
not always mutate the task row.
- **Derive activity in each browser.** Rejected because background session
history is not loaded and task switchers must not subscribe to it.
- **Write every activity to the task row.** Rejected because message and turn
domains gain extra task writes and mix activity with task edits.
+1
View File
@@ -172,4 +172,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-12-task-bound-fork-destinations | [Bind Fork Push Destinations to Tasks](2026-08-12-task-bound-fork-destinations.md) | accepted (amended 2026-08-13) | backend, frontend, workflow, security, GitHub | 2026-08-12 |
| 2026-08-13-hard-delete-task-contribution-links | [Hard delete owns task contribution links](2026-08-13-hard-delete-task-contribution-links.md) | accepted | backend | 2026-08-13 |
| 2026-08-15-office-mode-follows-active-workspace | [Office Mode Follows the Active Workspace](2026-08-15-office-mode-follows-active-workspace.md) | accepted | frontend, backend | 2026-08-15 |
| 2026-08-17-separate-task-activity-from-summary-freshness | [Separate Task Activity From Summary Freshness](2026-08-17-separate-task-activity-from-summary-freshness.md) | accepted | backend, frontend, protocol | 2026-08-17 |
| 2026-08-17-release-pr-ruleset-bypass | [Give Stable Release PRs an Administrator Token Bypass](2026-08-17-release-pr-ruleset-bypass.md) | accepted | infra, workflow, security | 2026-08-17 |
@@ -0,0 +1,210 @@
---
spec: docs/specs/ui/sidebar-last-activity-sort.md
created: 2026-08-17
status: completed
---
# Implementation Plan: Sidebar Last Activity Sort
## Overview
Add a durable task activity timestamp to the bounded task-status summary. Keep
summary freshness separate, then expose the activity timestamp through a new
saved-view sort on desktop and mobile.
The work also closes the observed GitHub refresh loop. REST and batched GraphQL
status snapshots must converge on one semantic task-PR value, and diagnostics
must name fields that cause a real update.
## Confirmed current behavior
- `apps/web/components/task/task-session-sidebar-item.ts` maps summary
`updated_at` to `TaskSwitcherItem.updatedAt`.
- `apps/web/lib/sidebar/apply-view.ts` uses `updatedAt` for the saved-view
`updatedAt` comparator.
- `apps/web/components/task/task-item-stats-row.tsx` renders the same value as
the row's relative time.
- `apps/backend/internal/task/statussummary/projector.go` stamps
`TaskStatusSummary.UpdatedAt` when any semantic summary field changes.
- The projector subscribes to `github.task_pr.updated`, so provider status can
refresh the visible time of an idle task.
- Sidebar views persist arbitrary sort strings through portable user settings.
Frontend `KNOWN_SORT_KEYS` controls which keys survive migration.
- Desktop and phone surfaces share `SortPicker`, `applySort`, saved-view state,
and user-settings synchronization.
## Architecture
### Timestamp ownership
Extend `TaskStatusSummary` with semantic `LastActivityAt`. Keep `UpdatedAt` as
transport and projection freshness.
Add a narrow `TaskActivityRepository` to the task service. Its batch query
returns the maximum of:
- `tasks.created_at` and `tasks.updated_at`
- user-authored `task_session_messages.created_at`
- non-reserved user-owned `queued_messages.queued_at`
- `task_session_turns.started_at` and non-null `completed_at`
The query accepts task IDs, returns one map, and uses the existing task/message
and turn indexes. Add an index only if query-plan evidence shows a missing
leading task key on a production-sized fixture.
### Live projection and repair
Add `lastActivityAt` to projector state and persisted semantic JSON. Rehydrate
it from the stored summary and a narrow activity loader.
Subscribe to `task.created`, `task.state_changed`, `turn.started`, and
`turn.completed` in addition to the existing task, message, and queue events.
Apply timestamps as follows:
- `task.created`, `task.updated`, and `task.state_changed`: payload
`updated_at`, with `created_at` as the creation fallback
- `message.added`: `created_at` only for `author_type=user`
- successful user-owned queue admission: `queued_at`
- `turn.started`: `started_at`
- `turn.completed`: `completed_at`
Use a monotonic maximum. Foreground-activity task events carry the task row's
unchanged timestamp and therefore cannot advance task activity.
Task-list reconciliation batch-loads activity for all requested tasks. It
repairs missing fields and preserves a newer stored value during compare-and-set
retries. The complete replacement summary continues to use the existing event.
### GitHub refresh stability
Normalize GraphQL check-rollup states to the documented `success`, `failure`,
`pending`, or empty contract before `SyncTaskPR` compares fields. Exercise the
same semantic fixture through REST feedback and batched GraphQL conversion.
Refactor the `SyncTaskPR` comparison into a helper that returns changed field
names. Publish only when that list is non-empty. Log the names at debug level
with task and PR identity, without old or new values.
### Saved-view and row behavior
Add `lastActivityAt` to `SortKey`, `KNOWN_SORT_KEYS`, `SORT_OPTIONS`, and the
comparator table. Thread `lastActivityAt` from `status_summary` into
`TaskSwitcherItem`, with task update and creation fallbacks.
When the effective view uses this key, rows display activity time. Other views
retain the existing time selection. Keep stable input order for equal values.
Add localized **Last activity** copy to English, Portuguese, Simplified
Chinese, Hong Kong Chinese, Taiwan Chinese, and pseudo catalogs. Run the
Traditional Chinese generator instead of translating both variants by hand.
## Mobile design contract
- **Outcome and entry:** desktop opens the Tasks section view editor. Phones
open `SessionTaskSwitcherSheet`, then the existing filter gear.
- **Exemplar:** keep
`apps/web/components/task/mobile/session-task-switcher-sheet.tsx` and the
shared `SidebarFilterPopover` composition.
- **Hierarchy and presentation:** the sort remains a short temporary choice in
the existing selector. No route, drawer, or nested overlay is added.
- **Interaction:** the existing selector row is the touch target. Selection
updates the shared draft and live task order.
- **Scroll and geometry:** the task list remains the only vertical scroll body.
The existing drawer and portaled selector remain inside the Pixel 5 viewport
with no document horizontal overflow.
- **Shared state:** saved-view state, activity mapping, comparator, direction,
persistence, and rollback are shared between desktop and mobile.
## Tests
Use Red-Green-Refactor for each implementation task. Run each focused test
before production edits and record the expected failure. Run the same test
after the smallest implementation, then refactor while it remains green.
### Backend
- GitHub conversion tests cover every GraphQL rollup state and prove an
equivalent REST/GraphQL sequence emits no second task-PR update.
- Activity repository tests cover tasks without sessions, queued user prompts,
active turns, completed turns, multiple sessions, and batched task IDs.
- Summary model tests cover semantic JSON, equality, compatibility with absent
activity, and monotonic timestamps.
- Projector tests cover every included source, including state-only task
transitions. They prove focus-adjacent status, PR, Git, queue, agent-message,
and older replay events do not advance activity.
- Rebuild tests cover field backfill, no N+1 reads, compare-and-set retry, and
preservation of a newer stored timestamp.
### Frontend
- Comparator tests cover ascending, descending, equal times, and fallback.
- Migration, wire, hydration, and live user-settings tests preserve
`lastActivityAt` in saved views and drafts.
- Sidebar mapping and rendered-row tests select activity time only for the new
sort.
- Locale checks cover the new label in every required catalog.
### E2E
- Desktop creates a view, selects **Last activity**, verifies order and row
time, saves it, reloads, and verifies persistence.
- The backend regression proves a provider status refresh does not change the
activity timestamp. The desktop flow verifies that resulting sort order.
- Mobile selects the same option from the task-switcher drawer and verifies
order, persistence, touch reachability, viewport containment, scroll
ownership, and zero horizontal overflow.
## Verification commands
```bash
make -C apps/backend test lint build
cd apps && pnpm install --frozen-lockfile
cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/apply-view.test.ts lib/state/slices/ui/ui-slice-migration.test.ts lib/state/slices/ui/sidebar-view-wire.test.ts components/task/task-session-sidebar-item.test.ts
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web lint
cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check && pnpm run i18n:ratchet
cd apps/web && pnpm e2e:run tests/task/sidebar-filter.spec.ts -- --grep "last activity"
cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "last activity"
```
## Implementation waves
- [Task 01: Stabilize GitHub task-PR refreshes](task-01-stabilize-github-pr-refresh.md), wave 1
- [Task 02: Add durable activity reconstruction](task-02-add-activity-reconstruction.md), wave 1
- [Task 03: Project task activity live](task-03-project-live-task-activity.md), wave 2, after Task 02
- [Task 04: Add the saved-view sort](task-04-add-sidebar-sort.md), wave 3, after Task 03
- [Task 05: Prove desktop and mobile behavior](task-05-prove-sidebar-activity-sort.md), wave 4, after Tasks 01 and 04
This package does not authorize implementation subagents. Implementation stays
in the user-controlled primary session.
## Implementation result
All five tasks are complete. The implementation includes durable activity
reconstruction, live bounded projection, GitHub refresh normalization, the
desktop/mobile saved-view sort, localized catalogs, and production-build
browser coverage.
Targeted checks passed during implementation:
- Backend focused GitHub, activity repository, summary, rebuild, projector, and
backend-app tests.
- Frontend focused tests, typecheck, lint, i18n completeness, and i18n ratchet.
- One desktop Chromium E2E test and one mobile Chromium E2E test, both through
the managed production-build runner.
The full backend test, lint, and build matrix passed as the final pre-PR gate.
## Risks and boundaries
- `updated_at` and `last_activity_at` must remain separate in semantic equality,
persistence, DTOs, and UI mapping.
- Event timestamps can arrive out of order. Every live and rebuild path needs a
monotonic maximum.
- Counting all agent messages recreates high-frequency task-list traffic.
Turn start and completion are the bounded agent milestones.
- Existing saved views and default view creation must not change.
- The GitHub normalization test must exercise both real conversion paths. A
handcrafted `PRStatus` pair does not detect future adapter drift.
- Public documentation does not describe sidebar sort choices today. No public
documentation update is required.
@@ -0,0 +1,54 @@
---
id: "01-stabilize-github-pr-refresh"
title: "Stabilize GitHub task-PR refreshes"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/sidebar-last-activity-sort.md"
---
# Task 01: Stabilize GitHub task-PR refreshes
Make REST feedback and batched GraphQL polling converge on one task-PR status.
Add safe evidence for any future semantic update.
## Acceptance
- GraphQL check-rollup values map to the documented task-PR check states.
- Equivalent REST and GraphQL snapshots do not persist or publish a second
semantic task-PR update.
- A real update logs only changed field names with task and PR identity, then
publishes the existing event once.
## Verification
```bash
cd apps/backend && go test ./internal/github -run 'CheckRollup|Equivalent.*Status|SyncTaskPR.*ChangedFields'
```
## Files likely touched
- `apps/backend/internal/github/graphql.go`
- `apps/backend/internal/github/graphql_test.go`
- `apps/backend/internal/github/service_pr_watch.go`
- `apps/backend/internal/github/service_pr_feedback_sync_test.go`
- `apps/backend/internal/github/service_pr_watch_batched_budget_test.go`
## Dependencies
None.
## Risks
- The regression must call both conversion paths before `SyncTaskPR`.
- Logs must not include provider field values, comments, titles, or tokens.
- Normalization must preserve the existing empty state for absent checks.
## Result
- RED: the focused tests failed because the normalization and changed-field
helper were not present.
- GREEN: `go test ./internal/github -run
'CheckRollup|Equivalent.*Status|SyncTaskPR.*ChangedFields'` passed (10 tests).
- Regression: `go test ./internal/github` passed (1572 tests).
@@ -0,0 +1,60 @@
---
id: "02-add-activity-reconstruction"
title: "Add durable activity reconstruction"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/sidebar-last-activity-sort.md"
---
# Task 02: Add durable activity reconstruction
Create the authoritative batched read that reconstructs task activity from
task, user-message, queued-prompt, and turn records.
## Acceptance
- One repository call returns the latest activity for every requested task ID.
- The result includes task creation and mutation, user prompts including
non-reserved queued prompts, and turn start or completion. It excludes agent
messages, reserved queue entries, and session metadata timestamps.
- SQLite and Postgres behavior is covered without an N+1 query.
## Verification
```bash
cd apps/backend && go test ./internal/task/repository/sqlite -run 'TaskLastActivity'
cd apps/backend && go test ./internal/task/service -run 'StatusSummary.*Activity.*Rebuild'
```
## Files likely touched
- `apps/backend/internal/task/repository/interface.go`
- `apps/backend/internal/task/repository/sqlite/task_status_summary.go`
- `apps/backend/internal/task/repository/sqlite/task_status_summary_test.go`
- `apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go`
- `apps/backend/internal/task/service/service.go`
- `apps/backend/internal/backendapp/services.go`
## Dependencies
None.
## Risks
- The task-ID filter must apply inside every union branch.
- Null completion times must not erase a turn start.
- Add an index only after query-plan evidence shows the existing indexes are
insufficient.
## Result
- RED: the repository test failed before `LoadTaskLastActivity` existed.
- GREEN: `go test ./internal/task/repository/sqlite -run 'TaskLastActivity'`
passed (1 SQLite test). The Postgres counterpart is environment-gated.
- GREEN: `go test ./internal/task/service -run
'StatusSummary.*Activity.*Rebuild'` passed (1 rebuild test).
- GREEN: queued user prompts are included in the same batched max query while
agent-owned queued rows remain excluded; affected repository, projector, and
handler packages passed 1,031 tests.
@@ -0,0 +1,78 @@
---
id: "03-project-live-task-activity"
title: "Project task activity live"
status: completed
wave: 2
depends_on: ["02-add-activity-reconstruction"]
plan: "plan.md"
spec: "../../specs/ui/sidebar-last-activity-sort.md"
---
# Task 03: Project task activity live
Extend the bounded task summary with a monotonic activity time and repair older
summary rows from the new batched source.
## Acceptance
- Summary JSON, equality, persistence, DTOs, and replacement events carry
optional `last_activity_at` without changing `updated_at`.
- Task creation, task update, state-only task transition, user-message, user
queue admission, and turn events advance activity by source time. Queue
bookkeeping, excluded background status, and older replay events do not
advance it.
- Initial hydration repairs missing values in batch and preserves newer stored
activity through compare-and-set retries.
## Verification
```bash
cd apps/backend && go test ./internal/task/statussummary -run 'LastActivity'
cd apps/backend && go test ./internal/task/service -run 'StatusSummary.*LastActivity'
cd apps/backend && go test ./internal/backendapp -run 'StatusSummary'
```
## Files likely touched
- `apps/backend/internal/task/statussummary/model.go`
- `apps/backend/internal/task/statussummary/model_test.go`
- `apps/backend/internal/task/statussummary/projector.go`
- `apps/backend/internal/task/statussummary/projector_events.go`
- `apps/backend/internal/task/statussummary/projector_derive.go`
- `apps/backend/internal/task/statussummary/projector_test.go`
- `apps/backend/internal/task/statussummary/projector_restart_rehydration_test.go`
- `apps/backend/internal/task/statussummary/rebuild.go`
- `apps/backend/internal/task/statussummary/rebuild_test.go`
- `apps/backend/internal/task/service/service_status_summary_rebuild.go`
- `apps/backend/internal/task/service/service_status_summary_rebuild_test.go`
- `apps/backend/internal/backendapp/gateway.go`
## Dependencies
Task 02.
## Risks
- A status-only event must not copy projection time into activity time.
- State transitions publish `task.state_changed` without a matching
`task.updated` on several service paths, so the projector must consume both.
- Message events count only user-authored additions.
- Repair and live projection must share the same monotonic rule.
## Result
- Added optional semantic `last_activity_at` to live summary replacement
payloads and projector state, with durable loader rehydration.
- Subscribed to task creation, state-only transitions, user queue admission,
and turn start and completion. User-authored message additions and queued
admissions use their persisted source time; background status, provider,
queue bookkeeping, agent-message, and older replay events do not advance the
monotonic maximum. Queue merges retain the newest admission time.
- Focused verification passed:
- `go test ./internal/task/statussummary -run 'LastActivity'` (3 tests)
- `go test ./internal/task/service -run 'StatusSummary.*LastActivity'` (no matching tests)
- `go test ./internal/backendapp -run 'StatusSummary'` (7 tests)
- `go test ./internal/task/statussummary` (64 tests)
- Review fixup verification: affected backend packages passed 1,031 tests,
including live queued-prompt projection and SQLite/Postgres reconstruction
coverage.
@@ -0,0 +1,75 @@
---
id: "04-add-sidebar-sort"
title: "Add the sidebar activity sort"
status: completed
wave: 3
depends_on: ["03-project-live-task-activity"]
plan: "plan.md"
spec: "../../specs/ui/sidebar-last-activity-sort.md"
---
# Task 04: Add the sidebar activity sort
Expose `lastActivityAt` through the shared saved-view picker, comparator, row
time, portable settings, and required locales.
## Acceptance
- Desktop and mobile view editors offer localized **Last activity** with both
directions. The option updates the live order and row time.
- Saved views and drafts retain `lastActivityAt` through wire conversion,
migration, hydration, settings events, sync failure rollback, and reload.
- Existing **Updated** views and new-view defaults remain unchanged. Missing
activity uses task update, then creation, as fallback.
## Verification
```bash
cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/apply-view.test.ts lib/state/slices/ui/ui-slice-migration.test.ts lib/state/slices/ui/sidebar-view-wire.test.ts components/task/task-session-sidebar-item.test.ts
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web lint
cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check && pnpm run i18n:ratchet
```
## Files likely touched
- `apps/web/lib/types/task-status-summary.ts`
- `apps/web/lib/state/slices/ui/sidebar-view-types.ts`
- `apps/web/lib/state/slices/ui/ui-slice.ts`
- `apps/web/components/task/sidebar-filter/sort-picker.tsx`
- `apps/web/lib/sidebar/apply-view.ts`
- `apps/web/lib/sidebar/apply-view.test.ts`
- `apps/web/components/task/task-switcher-types.ts`
- `apps/web/components/task/task-session-sidebar-item.ts`
- `apps/web/components/task/task-session-sidebar-item.test.ts`
- `apps/web/components/task/task-item-stats-row.tsx`
- `apps/web/lib/state/slices/ui/ui-slice-migration.test.ts`
- `apps/web/lib/state/slices/ui/sidebar-view-wire.test.ts`
- `apps/web/lib/state/hydration/hydrator.test.ts`
- `apps/web/lib/ws/handlers/users.test.ts`
- `apps/web/src/locales/*/task.json`
## Dependencies
Task 03.
## Risks
- Do not translate or rename the persisted key.
- Do not call `t()` at module scope in the option registry.
- The effective draft controls both sorting and displayed row time.
## Result
- Added the `lastActivityAt` sort key, migration allowlist entry, shared
comparator with update/creation fallbacks, and wire round-trip coverage.
- Threaded `last_activity_at` from summary payloads into desktop and mobile
task rows. Relative time uses activity only when the effective view selects
the new sort; existing Updated behavior and defaults remain unchanged.
- Added the localized label to English, Portuguese, Simplified Chinese,
Traditional Chinese, Hong Kong Chinese, and pseudo catalogs.
- Verification passed:
- focused Vitest suite: 119 tests
- `pnpm run typecheck`
- `pnpm --filter @kandev/web lint`
- `pnpm run i18n:zh-hant`, `i18n:check`, and `i18n:ratchet`
@@ -0,0 +1,67 @@
---
id: "05-prove-sidebar-activity-sort"
title: "Prove sidebar activity sorting"
status: completed
wave: 4
depends_on: ["01-stabilize-github-pr-refresh", "04-add-sidebar-sort"]
plan: "plan.md"
spec: "../../specs/ui/sidebar-last-activity-sort.md"
---
# Task 05: Prove sidebar activity sorting
Add production-build browser regressions for the saved option, stable activity
order, persistence, and phone geometry. Finish the change-aware verification
gates.
## Acceptance
- Desktop proves **Last activity** order and row time survive save and reload.
A provider-only refresh does not reorder activity.
- Mobile proves the same saved option and order from the task-switcher drawer.
- Mobile assertions cover touch interaction, viewport containment, the task-list
scroll owner, and zero document horizontal overflow.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile
cd apps/web && pnpm e2e:run tests/task/sidebar-filter.spec.ts -- --grep "last activity"
cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "last activity"
make -C apps/backend test lint build
cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet
cd apps && pnpm --filter @kandev/web lint
```
## Files likely touched
- `apps/web/e2e/helpers/api-client.ts`
- `apps/web/e2e/pages/sidebar-filter-popover.ts`
- `apps/web/e2e/tests/task/sidebar-filter.spec.ts`
- `apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts`
- `apps/web/components/task/task-item-stats-row.tsx`
## Dependencies
Tasks 01 and 04.
## Risks
- Seed distinct activity milestones instead of relying on fixed sleeps.
- Scope portaled selectors to the active desktop or mobile surface.
- Confirm Playwright discovers one desktop test and one mobile test before
treating the runs as evidence.
## Result
- Desktop discovery found one targeted test, then the managed production E2E
run passed. It verified Last activity ordering, row timestamp mapping, saved
view persistence after reload, and stable order after two provider-only PR
refreshes.
- Mobile discovery found one targeted test, then the managed mobile-chrome E2E
run passed. It verified touch selection, saved-view persistence, drawer
containment, the task-list scroll owner, and zero document horizontal
overflow.
- The desktop fixture explicitly uses the existing None group so provider
association does not intentionally change repository grouping while the test
measures activity ordering.
+1
View File
@@ -233,6 +233,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [external-vcs-file-links](ui/external-vcs-file-links.md) | shipped |
| [task-listing-display-preferences](ui/task-listing-display-preferences.md) | shipped |
| [sidebar-archived-filter](ui/sidebar-archived-filter.md) | draft |
| [sidebar-last-activity-sort](ui/sidebar-last-activity-sort.md) | draft |
| [task-workspace-content-search](ui/task-workspace-content-search.md) | shipped |
| [file-tree-chat-context](ui/file-tree-chat-context.md) | shipped |
| [task-review-shortcut](ui/task-review-shortcut.md) | approved |
@@ -1,7 +1,7 @@
---
status: approved
created: 2026-08-01
updated: 2026-08-15
updated: 2026-08-17
owner: kandev
---
@@ -52,6 +52,7 @@ The initial contract is:
| Field | Meaning | Bound |
| ---------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ |
| `revision`, `updated_at` | Monotonic task-local version and projection time | Constant |
| `last_activity_at` | Latest durable task, user-prompt, or turn milestone | Constant |
| `primary_session` | Primary session ID and lifecycle state | One session |
| `foreground_activity`, `active_subagent_count` | Existing task-level busy aggregate | Constant |
| `pending_action` | `permission`, `clarification`, or absent | Constant |
@@ -115,6 +116,14 @@ remain during migration, but switchers use the summary when present.
- A semantic no-op does not increment `revision` or emit an update.
- Clients ignore a summary delta whose revision is not newer than the stored
revision.
- `last_activity_at` is separate from projection freshness. Task creation,
persisted task mutations, user-authored prompts, and turn start or completion
advance it by source time. Focus, subscriptions, Git or pull-request polling,
queue bookkeeping, summary repair, and streamed chunks do not advance it.
- Missing and older summaries rebuild `last_activity_at` in one batch from
task, user-message, and turn records. Live and rebuilt values use a monotonic
maximum, so replay and repair cannot move activity backward. See
[ADR-2026-08-17-separate-task-activity-from-summary-freshness](../../decisions/2026-08-17-separate-task-activity-from-summary-freshness.md).
## API and event surface
@@ -254,6 +263,9 @@ intermediate replacement.
changes its Git tree, or receives a PR update, **WHEN** its summary revision
arrives, **THEN** desktop and mobile rows update without a session
subscription.
- **GIVEN** an idle task receives Git or pull-request summary changes, **WHEN**
its replacement summary arrives, **THEN** `updated_at` can advance while
`last_activity_at` remains unchanged.
- **GIVEN** a recoverable error is dismissed or followed by a newer agent
response, **WHEN** the projector processes that occurrence, **THEN** the
independent error indicator clears on both task switchers.
+163
View File
@@ -0,0 +1,163 @@
---
status: draft
created: 2026-08-17
updated: 2026-08-17
owner: kandev
---
# Sidebar Last Activity Sort
## Why
Saved sidebar views can sort tasks by **Updated**. That value currently follows
task-status summary freshness. Background pull-request updates can refresh many
idle rows together, so this order does not answer which tasks a user or agent
worked on most recently.
Users need a separate **Last activity** option that remains stable during
background status refreshes.
## What
- Desktop and mobile sidebar view editors offer **Last activity** as a sort
option.
- The saved-view wire key is `lastActivityAt`. The key persists in saved views
and in-flight drafts through user settings, boot hydration, and live settings
updates.
- Ascending order places the oldest activity first. Descending order places the
newest activity first.
- Equal timestamps retain the existing input order. Sorting does not introduce
row movement for a tie.
- The existing `updatedAt` key and **Updated** label remain valid. Existing
saved views keep their current behavior.
- The built-in view and new-view defaults do not change.
- When a view sorts by **Last activity**, each row displays that activity time.
Other sort modes retain the existing row time behavior.
Decision:
[ADR-2026-08-17-separate-task-activity-from-summary-freshness](../../decisions/2026-08-17-separate-task-activity-from-summary-freshness.md).
## Activity definition
`last_activity_at` is the greatest known timestamp from these durable actions:
| Source | Activity time |
|---|---|
| Task creation | `tasks.created_at` |
| Persisted task mutation | `tasks.updated_at` |
| User-authored prompt, including a queued prompt | message `created_at` |
| Agent turn starts | turn `started_at` |
| Agent turn completes | turn `completed_at` |
The following events do not advance activity:
- opening, focusing, or subscribing to a task or session
- Git or pull-request polling and reconciliation
- queued-prompt count changes and other queue bookkeeping
- status-summary rebuilds or transport delivery
- session metadata maintenance without a user prompt or agent turn
- streamed agent chunks inside an already-started turn
A task-state or workflow-placement change counts because it is a persisted task
mutation. A foreground-activity publication that carries an unchanged task
timestamp does not advance activity.
## Data model and derivation
- `TaskStatusSummary` gains optional semantic field
`last_activity_at: RFC3339 timestamp`.
- `revision` and `updated_at` keep their existing meanings: task-local summary
version and projection freshness.
- A change to `last_activity_at` is semantic. It advances the summary revision
and uses the existing complete-replacement event.
- Live projection consumes both `task.updated` and `task.state_changed`, because
some persisted state transitions publish only the latter. It takes the
monotonic maximum of the stored value and a source event timestamp. An older
replay cannot move activity backward.
- Initial load and repair use one batched query for all requested task IDs. The
query combines task, user-message, and turn timestamps without one query per
task.
- Repair updates older summaries that lack the field. It preserves a stored
activity time that is newer than the rebuilt durable maximum.
- A missing summary or an older backend falls back to task `updated_at`, then
task `created_at`. The row remains sortable during partial recovery.
## API and persistence
The existing task surfaces carry the additive field:
- boot state and task-list snapshots use `status_summary.last_activity_at`
- `task.status_summary.updated` uses the same complete summary payload
- sidebar views and drafts persist sort key `lastActivityAt` through the
existing user-settings JSON contract
No new endpoint or WebSocket action is required. The summary remains bounded
and contains no transcript, file, or provider payload.
## Provider refresh stability
Equivalent GitHub status snapshots from the REST feedback path and the batched
GraphQL poller must produce one canonical task-PR state. A semantic no-op does
not emit `github.task_pr.updated`.
GitHub sync diagnostics record the names of changed semantic fields without
recording field values or provider content. This evidence makes future
cross-path oscillation visible without exposing sensitive data.
Provider changes can advance summary `updated_at`. They never advance
`last_activity_at`.
## Responsive behavior
Desktop uses the existing Tasks section view editor. Phones use the existing
task-switcher drawer and its shared view editor. Both surfaces use the same
sort key, comparator, draft, and saved setting.
The option stays inside the existing sort selector. It adds no mobile route,
drawer, popover, or scroll owner. The current task list remains the only
vertical scroll body.
## Failure modes
- If activity backfill fails, Kandev keeps the last valid summary and uses the
frontend fallback. A later list load or source event retries convergence.
- If a summary event is dropped, normal task-list hydration restores the
complete value.
- If user-settings sync fails, the existing saved-view rollback and error
treatment restores the previous view and draft.
- An unknown stored sort key keeps the existing migration fallback to Status.
`lastActivityAt` is added to the known-key set before it is offered in the
editor.
## Scenarios
- **GIVEN** several idle tasks receive pull-request status refreshes, **WHEN** a
view sorts by **Last activity**, **THEN** their order and displayed activity
times do not change.
- **GIVEN** a task with an older summary refresh but a newer user prompt,
**WHEN** the view sorts descending by **Last activity**, **THEN** that task
appears before a task with newer provider status but older activity.
- **GIVEN** a user sends a queued prompt, **WHEN** the prompt is stored,
**THEN** the task activity advances before the agent begins that turn.
- **GIVEN** an agent begins and completes a turn, **WHEN** each milestone is
projected, **THEN** activity advances at start and again at completion but
not for every streamed chunk.
- **GIVEN** an existing saved view sorted by **Updated**, **WHEN** Kandev is
upgraded, **THEN** the view keeps that sort and its current order.
- **GIVEN** a saved view sorted by **Last activity**, **WHEN** the page reloads,
**THEN** the selection, direction, order, and row timestamps persist.
- **GIVEN** a phone viewport, **WHEN** the user selects **Last activity** in the
task-switcher drawer, **THEN** the same order is applied and the selector
remains touch-reachable and viewport-contained.
## Out of scope
- Replacing or renaming the existing **Updated** sort.
- Changing the built-in view or new-view defaults.
- Sorting the Kanban board, Office task list, command panel, or quick-chat tabs.
- Treating passive focus, provider polling, or raw stream chunks as activity.
- Adding a general task activity feed or audit log.
## Implementation plan
- [Sidebar Last Activity Sort](../../plans/sidebar-last-activity-sort/plan.md)