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
@@ -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)