diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts index e9e3b90e8a..9f4d41fbd5 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts @@ -74,6 +74,40 @@ describe("toSheetItem repository projection", () => { }); }); +describe("toSheetItem activity projection", () => { + it("preserves task activity and its task-local fallbacks on the phone row", () => { + const item = toSheetItem( + task({ + updatedAt: "task-updated", + createdAt: "task-created", + statusSummary: { + revision: 3, + updated_at: "summary-refreshed", + last_activity_at: "task-activity", + }, + }), + emptyCtx(), + ); + + expect(item.lastActivityAt).toBe("task-activity"); + expect(item.updatedAt).toBe("summary-refreshed"); + expect(item.createdAt).toBe("task-created"); + }); + + it("falls back to the task update time instead of summary freshness", () => { + const item = toSheetItem( + task({ + updatedAt: "task-updated", + createdAt: "task-created", + statusSummary: { revision: 3, updated_at: "summary-refreshed" }, + }), + emptyCtx(), + ); + + expect(item.lastActivityAt).toBe("task-updated"); + }); +}); + describe("toSheetItem remote executor projection", () => { it("carries the exact remote executor scope onto the mobile sheet row", () => { const item = toSheetItem( diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts index 0c7459ae97..aa07b8f191 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts @@ -58,6 +58,10 @@ function sheetLaunchQueue(task: KanbanState["tasks"][number]) { return task.statusSummary?.launch_queue; } +function sheetLastActivity(task: KanbanState["tasks"][number]) { + return task.statusSummary?.last_activity_at ?? task.updatedAt ?? task.createdAt; +} + function sheetStatus(task: KanbanState["tasks"][number], ctx: SheetItemCtx) { const summary = task.statusSummary; const hasSummary = summary != null; @@ -104,6 +108,8 @@ export function toSheetItem( title: task.title, autopilot: task.autopilot, priority: task.priority, + createdAt: task.createdAt, + lastActivityAt: sheetLastActivity(task), parentTaskId: task.parentTaskId ?? undefined, workspaceMode: task.workspaceMode, state: task.state as TaskState | undefined, diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-tree-activity-sort.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-tree-activity-sort.spec.ts new file mode 100644 index 0000000000..9e300671ff --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-tree-activity-sort.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { seedTaskTreeActivityScenario } from "./sidebar-task-tree-activity-sort-helpers"; + +// @covers AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1, .2, .5, .6 +test("phone task drawer ranks a parent by its active child and navigates to that child", async ({ + testPage, + apiClient, + seedData, +}) => { + const { parent, child, peer, navigationTaskId } = await seedTaskTreeActivityScenario( + apiClient, + seedData, + "Phone tree sort", + ); + await testPage.goto(`/t/${navigationTaskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + await testPage.getByTestId("mobile-task-picker-trigger").tap(); + const sheet = testPage.getByRole("dialog", { name: "Tasks" }); + await expect(sheet).toBeVisible(); + + const treeAndPeerIds = [parent.id, peer.id]; + await expect + .poll(async () => { + const rows = await sheet + .locator("[data-task-row-id]") + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-task-row-id")), + ); + return rows.filter((id): id is string => id !== null && treeAndPeerIds.includes(id)); + }) + .toEqual([parent.id, peer.id]); + + const parentTime = await sheet + .locator(`[data-task-row-id="${parent.id}"]`) + .getByTestId("sidebar-task-time") + .getAttribute("data-time-value"); + const childTime = await sheet + .locator(`[data-task-row-id="${child.id}"]`) + .getByTestId("sidebar-task-time") + .getAttribute("data-time-value"); + expect(parentTime).toBeTruthy(); + expect(childTime).toBeTruthy(); + expect(Date.parse(childTime!)).toBeGreaterThan(Date.parse(parentTime!)); + + await sheet.locator(`[data-task-row-id="${child.id}"]`).tap(); + await expect(testPage).toHaveURL((url) => url.pathname === `/t/${child.id}`); +}); diff --git a/apps/web/e2e/tests/task/sidebar-task-tree-activity-sort-helpers.ts b/apps/web/e2e/tests/task/sidebar-task-tree-activity-sort-helpers.ts new file mode 100644 index 0000000000..0cc5594489 --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-task-tree-activity-sort-helpers.ts @@ -0,0 +1,48 @@ +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; + +export async function seedTaskTreeActivityScenario( + apiClient: ApiClient, + seedData: SeedData, + prefix: string, +) { + const taskOptions = { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }; + const parent = await apiClient.createTask(seedData.workspaceId, `${prefix} Parent`, taskOptions); + const child = await apiClient.createTask(seedData.workspaceId, `${prefix} Child`, { + ...taskOptions, + parent_id: parent.id, + }); + const peer = await apiClient.createTask(seedData.workspaceId, `${prefix} Peer`, taskOptions); + await apiClient.updateTaskTitle(child.id, `${prefix} Child active`); + + const navigationTask = await apiClient.seedTask(seedData.workspaceId, `${prefix} Navigation`, { + ...taskOptions, + }); + const viewId = `tree-activity-${prefix.toLowerCase().replaceAll(" ", "-")}`; + await apiClient.saveUserSettings({ + sidebar_view_state: { + workspace_id: seedData.workspaceId, + views: [ + { + id: viewId, + name: `${prefix} activity view`, + filters: [], + sort: { key: "lastActivityAt", direction: "desc" }, + group: "none", + collapsed_groups: [], + }, + ], + active_view_id: viewId, + }, + }); + + return { + parent, + child, + peer, + navigationTaskId: navigationTask.task_id, + }; +} diff --git a/apps/web/e2e/tests/task/sidebar-task-tree-activity-sort.spec.ts b/apps/web/e2e/tests/task/sidebar-task-tree-activity-sort.spec.ts new file mode 100644 index 0000000000..af2244242c --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-task-tree-activity-sort.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { seedTaskTreeActivityScenario } from "./sidebar-task-tree-activity-sort-helpers"; + +// @covers AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1, .2, .5, .6 +test("desktop sidebar ranks a parent by its active child and keeps row-local times", async ({ + testPage, + apiClient, + seedData, +}) => { + const { parent, child, peer, navigationTaskId } = await seedTaskTreeActivityScenario( + apiClient, + seedData, + "Desktop tree sort", + ); + await testPage.goto(`/t/${navigationTaskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const treeAndPeerIds = [parent.id, peer.id]; + await expect + .poll(async () => { + const rows = await session.sidebar + .locator("[data-task-row-id]") + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-task-row-id")), + ); + return rows.filter((id): id is string => id !== null && treeAndPeerIds.includes(id)); + }) + .toEqual([parent.id, peer.id]); + + const parentTime = await session.sidebar + .locator(`[data-task-row-id="${parent.id}"]`) + .getByTestId("sidebar-task-time") + .getAttribute("data-time-value"); + const childTime = await session.sidebar + .locator(`[data-task-row-id="${child.id}"]`) + .getByTestId("sidebar-task-time") + .getAttribute("data-time-value"); + expect(parentTime).toBeTruthy(); + expect(childTime).toBeTruthy(); + expect(Date.parse(childTime!)).toBeGreaterThan(Date.parse(parentTime!)); +}); diff --git a/apps/web/lib/sidebar/apply-view.test.ts b/apps/web/lib/sidebar/apply-view.test.ts index 8c9be699f6..08104f8046 100644 --- a/apps/web/lib/sidebar/apply-view.test.ts +++ b/apps/web/lib/sidebar/apply-view.test.ts @@ -24,6 +24,8 @@ function task(overrides: Partial): TaskSwitcherItem { } const C = (c: Omit): FilterClause => ({ id: "c1", ...c }); +const LAST_ACTIVITY_SORT_KEY = "lastActivityAt"; +const TEST_DATE_EARLY = "2026-04-01"; describe("applyFilters — basics", () => { it("returns all when no clauses", () => { @@ -238,7 +240,7 @@ describe("applyFilters — titleMatch + combos", () => { }); describe("applySort", () => { - const early = "2026-04-01"; + const early = TEST_DATE_EARLY; const middle = "2026-04-03"; const late = "2026-04-05"; const a = task({ id: "a", state: "REVIEW", updatedAt: early, title: "Zeta" }); @@ -272,7 +274,7 @@ describe("applySort", () => { createdAt: early, }), ], - { key: "lastActivityAt", direction: "desc" }, + { key: LAST_ACTIVITY_SORT_KEY, direction: "desc" }, ); expect(out.map((t) => t.id)).toEqual(["activity", "updated-fallback", "created-fallback"]); }); @@ -280,7 +282,7 @@ describe("applySort", () => { it("keeps lastActivityAt stable for equal timestamps", () => { const out = applySort( [task({ id: "first", lastActivityAt: late }), task({ id: "second", lastActivityAt: late })], - { key: "lastActivityAt", direction: "asc" }, + { key: LAST_ACTIVITY_SORT_KEY, direction: "asc" }, ); expect(out.map((t) => t.id)).toEqual(["first", "second"]); }); @@ -708,7 +710,7 @@ describe("applyView — custom sort", () => { it("places tasks not in orderedTaskIds after listed ones, newest createdAt first", () => { const tasks = [ task({ id: "a", title: "Alpha", createdAt: "2026-01-01" }), - task({ id: "b", title: "Beta", createdAt: "2026-04-01" }), + task({ id: "b", title: "Beta", createdAt: TEST_DATE_EARLY }), task({ id: "c", title: "Gamma", createdAt: "2026-02-01" }), ]; const out = applyView(tasks, customView, { pinnedTaskIds: [], orderedTaskIds: ["c"] }); @@ -849,6 +851,52 @@ describe("applyView — subtaskOrderByParentId", () => { }); }); +describe("applyView — Last activity tree overrides", () => { + it("keeps pin and manual subtask order ahead of tree sorting without changing Updated", () => { + const lastActivityView: SidebarView = { + id: "activity", + name: "Activity", + filters: [], + sort: { key: LAST_ACTIVITY_SORT_KEY, direction: "desc" }, + group: "none", + collapsedGroups: [], + }; + const tasks = [ + task({ id: "parent", lastActivityAt: TEST_DATE_EARLY, updatedAt: TEST_DATE_EARLY }), + task({ + id: "child", + parentTaskId: "parent", + lastActivityAt: "2026-04-10", + updatedAt: "2026-04-03", + }), + task({ + id: "sibling", + parentTaskId: "parent", + lastActivityAt: "2026-04-02", + updatedAt: "2026-04-08", + }), + task({ id: "peer", lastActivityAt: "2026-04-04", updatedAt: "2026-04-05" }), + ]; + + const activity = applyView(tasks, lastActivityView, { + pinnedTaskIds: ["peer"], + orderedTaskIds: [], + subtaskOrderByParentId: { parent: ["sibling", "child"] }, + }); + expect(activity.groups[0].tasks.map((item) => item.id)).toEqual(["peer", "parent"]); + expect(activity.subTasksByParentId.get("parent")?.map((item) => item.id)).toEqual([ + "sibling", + "child", + ]); + + const updated = applyView(tasks, { + ...lastActivityView, + sort: { key: "updatedAt", direction: "desc" }, + }); + expect(updated.groups[0].tasks.map((item) => item.id)).toEqual(["peer", "parent"]); + }); +}); + describe("mergeGroupOrder", () => { it("appends when no group items are in current", () => { expect(mergeGroupOrder([], ["a", "b"])).toEqual(["a", "b"]); diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index 95c4fcf295..68553ec00d 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -19,6 +19,11 @@ import { STATE_GROUP_ORDER, type EffectiveTaskTreeState, } from "./effective-task-tree-state"; +import { + compareActivityTimestamps, + resolveTaskTreeActivity, + taskActivitySortValue, +} from "./task-tree-activity"; export type SidebarGroup = { key: string; @@ -122,10 +127,6 @@ 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, SortComparator> = { state: (a, b) => { const bucket = STATE_BUCKET_ORDER[getStateBucket(a)] - STATE_BUCKET_ORDER[getStateBucket(b)]; @@ -134,7 +135,8 @@ const SORT_COMPARATORS: Record, SortComparator> = { return (b.createdAt ?? "").localeCompare(a.createdAt ?? ""); }, updatedAt: (a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? ""), - lastActivityAt: (a, b) => lastActivitySortValue(a).localeCompare(lastActivitySortValue(b)), + lastActivityAt: (a, b) => + compareActivityTimestamps(taskActivitySortValue(a), taskActivitySortValue(b)), createdAt: (a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""), title: (a, b) => (a.title ?? "").localeCompare(b.title ?? ""), }; @@ -176,6 +178,13 @@ export function applySort( if (bucket !== 0) return bucket; return (b.createdAt ?? "").localeCompare(a.createdAt ?? ""); }; + } else if (spec.key === "lastActivityAt" && subTasksByParentId) { + const latestActivityByTaskId = resolveTaskTreeActivity(tasks, subTasksByParentId); + cmp = (a, b) => + compareActivityTimestamps( + latestActivityByTaskId.get(a.id) ?? taskActivitySortValue(a), + latestActivityByTaskId.get(b.id) ?? taskActivitySortValue(b), + ); } else { cmp = spec.key === "custom" ? customComparator(orderedTaskIds) : SORT_COMPARATORS[spec.key]; } diff --git a/apps/web/lib/sidebar/task-tree-activity.test.ts b/apps/web/lib/sidebar/task-tree-activity.test.ts new file mode 100644 index 0000000000..f07ba02344 --- /dev/null +++ b/apps/web/lib/sidebar/task-tree-activity.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "vitest"; +import type { TaskSwitcherItem } from "@/components/task/task-switcher"; +import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types"; +import { applySort, applyView, flattenVisibleTaskIds } from "./apply-view"; + +const ACTIVITY = { + early: "2026-04-01", + second: "2026-04-02", + third: "2026-04-03", + fourth: "2026-04-04", + latest: "2026-04-05", + farFuture: "2026-04-10", + fractional120: "2026-04-01T10:00:00.12Z", + fractional121: "2026-04-01T10:00:00.121Z", + fractional120Equivalent: "2026-04-01T11:00:00.120+01:00", + fractional123: "2026-04-01T10:00:00.123Z", + fractional125: "2026-04-01T10:00:00.125Z", + normalizedInvalid: "2026-02-30T10:00:00Z", + createdParent: "2026-03-01", + createdChild: "2026-03-02", +} as const; +const SHORT_FRACTION_TASK_ID = "short-fraction"; + +function task(overrides: Partial): TaskSwitcherItem { + return { + id: overrides.id ?? "task", + title: overrides.title ?? "Task", + ...overrides, + }; +} + +function lastActivityView( + direction: "asc" | "desc" = "desc", + filters: SidebarView["filters"] = [], +): SidebarView { + return { + id: "last-activity", + name: "Last activity", + filters, + sort: { key: "lastActivityAt", direction }, + group: "none", + collapsedGroups: [], + }; +} + +// @covers AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1, .2, .4, .5 +describe("task tree activity root and child order", () => { + it("ranks roots and child subtrees by their newest included descendant in either direction", () => { + const tasks = [ + task({ id: "peer", lastActivityAt: ACTIVITY.fourth }), + task({ id: "parent", lastActivityAt: ACTIVITY.early }), + task({ id: "sibling", parentTaskId: "parent", lastActivityAt: ACTIVITY.third }), + task({ id: "child", parentTaskId: "parent", lastActivityAt: ACTIVITY.second }), + task({ id: "grandchild", parentTaskId: "child", lastActivityAt: ACTIVITY.latest }), + ]; + + const descending = applyView(tasks, lastActivityView("desc")); + expect(descending.groups[0].tasks.map((item) => item.id)).toEqual(["parent", "peer"]); + expect(descending.subTasksByParentId.get("parent")?.map((item) => item.id)).toEqual([ + "child", + "sibling", + ]); + + const ascending = applyView(tasks, lastActivityView("asc")); + expect(ascending.groups[0].tasks.map((item) => item.id)).toEqual(["peer", "parent"]); + expect(ascending.subTasksByParentId.get("parent")?.map((item) => item.id)).toEqual([ + "sibling", + "child", + ]); + + expect(tasks.find((item) => item.id === "parent")?.lastActivityAt).toBe(ACTIVITY.early); + }); + + it("ignores filtered-out descendants and promotes a child when its parent is filtered out", () => { + const parent = task({ + id: "parent", + title: "Visible parent", + lastActivityAt: ACTIVITY.early, + }); + const includedChild = task({ + id: "included-child", + title: "Visible child", + parentTaskId: "parent", + lastActivityAt: ACTIVITY.latest, + }); + const excludedChild = task({ + id: "excluded-child", + title: "Hidden child", + parentTaskId: "parent", + isArchived: true, + lastActivityAt: ACTIVITY.farFuture, + }); + const peer = task({ + id: "peer", + title: "Visible peer", + lastActivityAt: ACTIVITY.fourth, + }); + + const visible = applyView( + [parent, includedChild, excludedChild, peer], + lastActivityView("desc", [ + { id: "active-only", dimension: "archived", op: "is", value: false }, + ]), + ); + expect(visible.groups[0].tasks.map((item) => item.id)).toEqual(["parent", "peer"]); + expect(visible.subTasksByParentId.get("parent")?.map((item) => item.id)).toEqual([ + "included-child", + ]); + + const promoted = applyView( + [parent, includedChild, peer], + lastActivityView("desc", [ + { id: "visible-child", dimension: "titleMatch", op: "matches", value: "child" }, + ]), + ); + expect(promoted.groups[0].tasks.map((item) => item.id)).toEqual(["included-child"]); + expect(promoted.subTasksByParentId.has("parent")).toBe(false); + }); + + it("keeps equal tree keys stable and collapse does not change root rank", () => { + const tasks = [ + task({ id: "parent", lastActivityAt: ACTIVITY.early }), + task({ id: "child", parentTaskId: "parent", lastActivityAt: ACTIVITY.latest }), + task({ id: "peer", lastActivityAt: ACTIVITY.latest }), + ]; + const grouped = applyView(tasks, lastActivityView()); + + expect(grouped.groups[0].tasks.map((item) => item.id)).toEqual(["parent", "peer"]); + expect(flattenVisibleTaskIds(grouped, [], ["parent"])).toEqual(["parent", "peer"]); + expect(grouped.groups[0].tasks.map((item) => item.id)).toEqual(["parent", "peer"]); + }); +}); + +describe("task activity fallback timestamps", () => { + it("uses task update and creation timestamps when Last activity is unavailable", () => { + const tasks = [ + task({ + id: "parent", + updatedAt: ACTIVITY.early, + createdAt: ACTIVITY.createdParent, + }), + task({ + id: "child", + parentTaskId: "parent", + updatedAt: ACTIVITY.latest, + createdAt: ACTIVITY.createdChild, + }), + task({ id: "peer", lastActivityAt: ACTIVITY.fourth }), + task({ id: "created-only", createdAt: ACTIVITY.third }), + ]; + + const grouped = applyView(tasks, lastActivityView()); + expect(grouped.groups[0].tasks.map((item) => item.id)).toEqual([ + "parent", + "peer", + "created-only", + ]); + }); +}); + +describe("fractional activity timestamp precision", () => { + it("orders two root tasks by their chronological instant in both directions", () => { + const tasks = [ + task({ id: SHORT_FRACTION_TASK_ID, lastActivityAt: ACTIVITY.fractional120 }), + task({ id: "long-fraction", lastActivityAt: ACTIVITY.fractional123 }), + ]; + + expect( + applyView(tasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["long-fraction", SHORT_FRACTION_TASK_ID]); + expect( + applyView(tasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id), + ).toEqual([SHORT_FRACTION_TASK_ID, "long-fraction"]); + }); + + it("aggregates a child timestamp precisely when ranking the parent tree in both directions", () => { + const tasks = [ + task({ id: "parent", lastActivityAt: ACTIVITY.fractional120 }), + task({ + id: "child", + parentTaskId: "parent", + lastActivityAt: ACTIVITY.fractional123, + }), + task({ id: "peer", lastActivityAt: ACTIVITY.fractional121 }), + ]; + + expect( + applyView(tasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["parent", "peer"]); + expect( + applyView(tasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["peer", "parent"]); + }); + + it("keeps equivalent fractional timestamps stable and missing timestamps in their prior position", () => { + const tiedTasks = [ + task({ id: SHORT_FRACTION_TASK_ID, lastActivityAt: ACTIVITY.fractional120 }), + task({ id: "padded-fraction", lastActivityAt: ACTIVITY.fractional120Equivalent }), + ]; + const missingTasks = [ + task({ id: "first-missing" }), + task({ id: "dated", lastActivityAt: ACTIVITY.fractional120 }), + task({ id: "second-missing" }), + ]; + + for (const direction of ["asc", "desc"] as const) { + expect( + applyView(tiedTasks, lastActivityView(direction)).groups[0].tasks.map((item) => item.id), + ).toEqual([SHORT_FRACTION_TASK_ID, "padded-fraction"]); + } + expect( + applyView(missingTasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["dated", "first-missing", "second-missing"]); + expect( + applyView(missingTasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["first-missing", "second-missing", "dated"]); + }); + + it("uses the existing lexical fallback for malformed or normalized wire timestamps", () => { + const tasks = [ + task({ id: "normalized-invalid", lastActivityAt: ACTIVITY.normalizedInvalid }), + task({ id: "valid", lastActivityAt: "2026-03-01T12:00:00Z" }), + ]; + + expect( + applyView(tasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["valid", "normalized-invalid"]); + expect( + applyView(tasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["normalized-invalid", "valid"]); + + const zeroTasks = [ + task({ id: "zero", lastActivityAt: "0" }), + task({ id: "older", lastActivityAt: "1999-12-31T23:59:59Z" }), + ]; + expect( + applyView(zeroTasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["older", "zero"]); + expect( + applyView(zeroTasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["zero", "older"]); + }); + + it("does not let a normalized-invalid child move its parent tree", () => { + const tasks = [ + task({ id: "parent", lastActivityAt: "2026-03-01T12:00:00Z" }), + task({ + id: "child", + parentTaskId: "parent", + lastActivityAt: ACTIVITY.normalizedInvalid, + }), + task({ id: "peer", lastActivityAt: "2026-03-02T09:00:00Z" }), + ]; + + expect( + applyView(tasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["peer", "parent"]); + expect( + applyView(tasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id), + ).toEqual(["parent", "peer"]); + }); +}); + +describe("large and malformed task trees", () => { + it("resolves activity on a deep descendant without recursive traversal", () => { + const tasks: TaskSwitcherItem[] = [task({ id: "root", lastActivityAt: ACTIVITY.early })]; + let parentTaskId = "root"; + for (let index = 0; index < 6_000; index += 1) { + const id = "child-" + index; + tasks.push( + task({ + id, + parentTaskId, + lastActivityAt: index === 5_999 ? ACTIVITY.farFuture : ACTIVITY.second, + }), + ); + parentTaskId = id; + } + tasks.push(task({ id: "peer", lastActivityAt: ACTIVITY.latest })); + + const grouped = applyView(tasks, lastActivityView()); + expect(grouped.groups[0].tasks.map((item) => item.id)).toEqual(["root", "peer"]); + }); + + it("terminates and returns a key for every task in a malformed parent cycle", () => { + const a = task({ id: "a", lastActivityAt: ACTIVITY.early }); + const b = task({ id: "b", lastActivityAt: ACTIVITY.second }); + const children = new Map([ + ["a", [b]], + ["b", [a]], + ]); + + const sorted = applySort([a, b], { key: "lastActivityAt", direction: "desc" }, [], children); + expect(sorted.map((item) => item.id)).toEqual(["a", "b"]); + }); +}); diff --git a/apps/web/lib/sidebar/task-tree-activity.ts b/apps/web/lib/sidebar/task-tree-activity.ts new file mode 100644 index 0000000000..ee7acd4950 --- /dev/null +++ b/apps/web/lib/sidebar/task-tree-activity.ts @@ -0,0 +1,196 @@ +import type { TaskSwitcherItem } from "@/components/task/task-switcher"; +import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp"; + +type IncludedTaskGraph = { + tasksById: Map; + taskIds: Set; + childrenById: Map; + parentsById: Map; +}; + +type ComponentGraph = { + children: Set[]; + parents: Set[]; +}; + +export function taskActivitySortValue(task: TaskSwitcherItem): string { + return task.lastActivityAt ?? task.updatedAt ?? task.createdAt ?? ""; +} + +function parseActivityTimestamp(value: string): bigint | null { + return parseStrictRfc3339Timestamp(value); +} + +/** Compare valid timestamps by instant, preserving lexical fallback for missing or invalid values. */ +export function compareActivityTimestamps(a: string, b: string): number { + if (a === b) return 0; + const parsedA = parseActivityTimestamp(a); + const parsedB = parseActivityTimestamp(b); + if (parsedA !== null && parsedB !== null) { + if (parsedA === parsedB) return 0; + return parsedA < parsedB ? -1 : 1; + } + return a.localeCompare(b); +} + +function laterActivity(a: string, b: string): string { + return compareActivityTimestamps(a, b) >= 0 ? a : b; +} + +function buildIncludedTaskGraph( + tasks: TaskSwitcherItem[], + subTasksByParentId: Map, +): IncludedTaskGraph { + const tasksById = new Map(tasks.map((task) => [task.id, task])); + const taskIds = new Set(tasksById.keys()); + const childrenById = new Map(); + const parentsById = new Map(); + for (const taskId of taskIds) { + childrenById.set(taskId, []); + parentsById.set(taskId, []); + } + + for (const [parentId, children] of subTasksByParentId) { + if (!taskIds.has(parentId)) continue; + const includedChildren = childrenById.get(parentId)!; + const seenChildIds = new Set(); + for (const child of children) { + if (!taskIds.has(child.id) || seenChildIds.has(child.id)) continue; + seenChildIds.add(child.id); + includedChildren.push(child.id); + parentsById.get(child.id)!.push(parentId); + } + } + + return { tasksById, taskIds, childrenById, parentsById }; +} + +function buildFinishOrder(taskIds: Set, childrenById: Map): string[] { + const visited = new Set(); + const finishOrder: string[] = []; + for (const startId of taskIds) { + if (visited.has(startId)) continue; + visited.add(startId); + const stack: Array<{ taskId: string; nextChildIndex: number }> = [ + { taskId: startId, nextChildIndex: 0 }, + ]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const children = childrenById.get(frame.taskId)!; + if (frame.nextChildIndex === children.length) { + finishOrder.push(frame.taskId); + stack.pop(); + continue; + } + + const childId = children[frame.nextChildIndex++]; + if (visited.has(childId)) continue; + visited.add(childId); + stack.push({ taskId: childId, nextChildIndex: 0 }); + } + } + return finishOrder; +} + +function buildStrongComponents( + finishOrder: string[], + parentsById: Map, +): { components: string[][]; componentByTaskId: Map } { + const componentByTaskId = new Map(); + const components: string[][] = []; + for (let index = finishOrder.length - 1; index >= 0; index -= 1) { + const startId = finishOrder[index]; + if (componentByTaskId.has(startId)) continue; + + const componentId = components.length; + const members: string[] = []; + const stack = [startId]; + componentByTaskId.set(startId, componentId); + while (stack.length > 0) { + const taskId = stack.pop()!; + members.push(taskId); + for (const parentId of parentsById.get(taskId)!) { + if (componentByTaskId.has(parentId)) continue; + componentByTaskId.set(parentId, componentId); + stack.push(parentId); + } + } + components.push(members); + } + return { components, componentByTaskId }; +} + +function buildComponentGraph( + components: string[][], + componentByTaskId: Map, + childrenById: Map, +): ComponentGraph { + const children = components.map(() => new Set()); + const parents = components.map(() => new Set()); + for (const [parentId, childIds] of childrenById) { + const parentComponent = componentByTaskId.get(parentId)!; + for (const childId of childIds) { + const childComponent = componentByTaskId.get(childId)!; + if (parentComponent === childComponent) continue; + children[parentComponent].add(childComponent); + parents[childComponent].add(parentComponent); + } + } + return { children, parents }; +} + +function resolveComponentActivity( + components: string[][], + graph: ComponentGraph, + tasksById: Map, +): string[] { + const latestByComponent = components.map((members) => + members.reduce( + (latest, taskId) => laterActivity(latest, taskActivitySortValue(tasksById.get(taskId)!)), + "", + ), + ); + const remainingChildren = graph.children.map((children) => children.size); + const ready: number[] = []; + for (let componentId = 0; componentId < remainingChildren.length; componentId += 1) { + if (remainingChildren[componentId] === 0) ready.push(componentId); + } + + for (let head = 0; head < ready.length; head += 1) { + const childComponent = ready[head]; + for (const parentComponent of graph.parents[childComponent]) { + latestByComponent[parentComponent] = laterActivity( + latestByComponent[parentComponent], + latestByComponent[childComponent], + ); + remainingChildren[parentComponent] -= 1; + if (remainingChildren[parentComponent] === 0) ready.push(parentComponent); + } + } + return latestByComponent; +} + +/** Resolve each included task to the newest activity in its reachable subtree. */ +export function resolveTaskTreeActivity( + tasks: TaskSwitcherItem[], + subTasksByParentId: Map, +): Map { + const taskGraph = buildIncludedTaskGraph(tasks, subTasksByParentId); + const finishOrder = buildFinishOrder(taskGraph.taskIds, taskGraph.childrenById); + const { components, componentByTaskId } = buildStrongComponents( + finishOrder, + taskGraph.parentsById, + ); + const componentGraph = buildComponentGraph(components, componentByTaskId, taskGraph.childrenById); + const latestByComponent = resolveComponentActivity( + components, + componentGraph, + taskGraph.tasksById, + ); + return new Map( + Array.from(componentByTaskId, ([taskId, componentId]) => [ + taskId, + latestByComponent[componentId], + ]), + ); +} diff --git a/docs/plans/sidebar-task-tree-activity-sort/plan.md b/docs/plans/sidebar-task-tree-activity-sort/plan.md new file mode 100644 index 0000000000..3e47a54a14 --- /dev/null +++ b/docs/plans/sidebar-task-tree-activity-sort/plan.md @@ -0,0 +1,81 @@ +--- +created: 2026-09-25 +status: complete +requirements: + - REQ-UI-SIDEBAR-LAST-ACTIVITY-SORT-002 +system_design: + - ../../specs/ui/system-design/sidebar-task-tree-activity-sort.md +legacy_specs: [] +--- + +# Implementation Plan: Sidebar Task Tree Activity Sort + +## Overview + +Make Last activity sort rank each visible task tree by its newest included member. The shared sort path serves both desktop and phone, so one focused work order can add the derived key and prove it through unit and rendered scenarios. + +## Scope + +### In scope + +- Apply the tree's latest activity to root and nested sibling placement for ascending and descending Last activity views. +- Respect active filters, collapse state, stable ties, and existing pin and manual-order precedence. +- Prove the shared result in the desktop sidebar and phone task-switcher drawer. + +### Out of scope + +- Changes to how a task's own activity timestamp is published or displayed. +- Changes to Updated, state, custom, or other sort keys; group-heading order; task-row presentation; saved-view storage. + +## Technical approach + +`applyView` in `apps/web/lib/sidebar/apply-view.ts` already filters before building `subTasksByParentId`, then calls `applySort`. Add a pure iterative resolver in `apps/web/lib/sidebar/task-tree-activity.ts` that uses each task's Last activity fallback and memoizes the maximum for each included subtree. The phone task-switcher projection must pass through the task's own activity timestamp instead of summary freshness. Use the derived value only in the `lastActivityAt` comparator branch of `applySort`. Keep the stable index tiebreak, sort-direction sign, `applyGroup`, pin floating, and `applySubtaskOrder` in their current order. Neither the backend nor `TaskSwitcherItem` changes. + +## ASCII UI preview + +`UI-01: Desktop Tasks sidebar`, entry point: a saved view with Last activity descending. The example rows share one existing group. Times are illustrative; each label is the task's own time. + +```text +CURRENT EXPECTED +Tasks | Last activity v Tasks | Last activity v + Standalone task 2d v Parent task 4d + v Parent task 4d Child task 10s + Child task 10s Standalone task 2d +``` + +`UI-02: Phone task-switcher drawer`, entry point: the existing Tasks picker and shared saved view. + +```text +Tasks [New] +[Last activity view v] [Filters] +--------------------------------- +v Parent task 4d + Child task 10s +Standalone task 2d +``` + +Only tree order is required to change. The existing desktop sidebar and phone drawer keep their respective headers and navigation. Their task lists remain the scroll owners. A collapsed parent keeps the same rank, with its child row hidden. No new controls or copy are required. These previews cover `AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1`, `.2`, `.4`, `.5`, and `.6`. + +## Tests + +`apps/web/lib/sidebar/task-tree-activity.test.ts` and `apply-view.test.ts` will cover `AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1` through `.5`: both directions, deep descendants, sibling subtrees, equal values, fallbacks, filtered-out descendants, orphan promotion, collapsed trees, and precedence of pin, manual order, and other sort modes. Include a deep chain to catch recursion limits. + +## E2E tests + +- `apps/web/e2e/tests/task/sidebar-task-tree-activity-sort.spec.ts` (`chromium`) covers the desktop parent-versus-standalone order and individual row time after a child activity update (`AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1`, `.5`, `.6`). +- `apps/web/e2e/tests/task/mobile-sidebar-task-tree-activity-sort.spec.ts` (`mobile-chrome`) covers the same saved-view order in the phone drawer and touch navigation to the child (`AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1`, `.6`). + +## Work orders + +- [x] [Task 01: Sort visible task trees by activity](task-01-sort-visible-task-trees.md) + +## Verification results + +Task 01 is complete. The focused unit suite passed (3 files, 83 tests), frontend typecheck and changed-file ESLint passed, including mixed fractional precision and malformed RFC3339 coverage, and focused desktop (`chromium`) and phone (`mobile-chrome`) Playwright tests passed. Specification validation and lint passed. + +## Risks + +- A recursive per-comparison tree walk would grow with list size and can overflow on deep trees; compute the map once per sort. +- A child excluded by filtering must not influence its former parent's key. The resolver must consume the filtered parent-child map, not the original task list. +- The parent row's own time may appear older than its position. Keep that established row meaning and prove the child's activity explains the tree's placement. +- The phone task drawer must use each task's activity time for both Last activity sorting and row display, with task-local fallbacks when the summary field is absent. diff --git a/docs/plans/sidebar-task-tree-activity-sort/task-01-sort-visible-task-trees.md b/docs/plans/sidebar-task-tree-activity-sort/task-01-sort-visible-task-trees.md new file mode 100644 index 0000000000..397811db1e --- /dev/null +++ b/docs/plans/sidebar-task-tree-activity-sort/task-01-sort-visible-task-trees.md @@ -0,0 +1,123 @@ +--- +id: "01-sort-visible-task-trees" +title: "Sort visible task trees by activity" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-LAST-ACTIVITY-SORT-002 +acceptance_criteria: + - AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1 + - AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.2 + - AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.3 + - AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.4 + - AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.5 + - AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.6 +system_design: + - ../../specs/ui/system-design/sidebar-task-tree-activity-sort.md +--- + +# Task 01: Sort Visible Task Trees by Activity + +## Summary + +Derive each visible task subtree's latest activity and use it for Last activity sorting. Prove that a recently active child moves its parent tree in both desktop and phone task lists while every row retains its own time. + +## In scope + +- Add a cycle-safe, iterative, memoized tree-activity resolver over the included parent-child map. +- Use the derived key only in the Last activity sort path, preserving direction, ties, filtered roots, and existing later ordering overrides. +- Preserve the phone task drawer's task-local activity field and fallback through its row projection. +- Add focused unit and desktop/phone Playwright coverage. + +## Out of scope + +- Backend generation of activity timestamps, Updated and other sort keys, group-heading sort, settings format, and visual layout changes. + +## Acceptance + +1. A direct or nested child's newer activity ranks its included parent tree ahead of an older tree in descending order, with the inverse order for ascending views; siblings follow the same subtree rule. +2. Filtered-out descendants have no effect, collapsed descendants still contribute, and ties, missing timestamps, pinning, manual order, and other sort keys retain their established behavior. +3. Desktop and phone rendered tests show the same parent-tree order, and the parent's visible time remains its own. + +## ASCII UI preview + +`UI-01: Desktop Tasks sidebar`, Last activity descending, before and after. The complete preview is in [plan.md](plan.md#ascii-ui-preview). + +```text +CURRENT EXPECTED +Standalone task 2d v Parent task 4d +v Parent task 4d Child task 10s + Child task 10s Standalone task 2d +``` + +`UI-02: Phone task-switcher drawer`, same saved view and ordering. + +```text +Tasks | Last activity view +v Parent task 4d + Child task 10s +Standalone task 2d +``` + +The times illustrate each row's own activity; the tree's rank follows the child. These views cover `AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1`, `.2`, `.5`, and `.6`. + +## Verification + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/task-tree-activity.test.ts lib/sidebar/apply-view.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm e2e:run --project chromium tests/task/sidebar-task-tree-activity-sort.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-sidebar-task-tree-activity-sort.spec.ts) +``` + +Run the new focused unit case red before production edits, then green after the resolver and sort integration. The managed E2E runner builds the current web bundle before browser assertions. + +## Files likely touched + +- `apps/web/lib/sidebar/task-tree-activity.ts` +- `apps/web/lib/sidebar/task-tree-activity.test.ts` +- `apps/web/lib/sidebar/apply-view.ts` +- `apps/web/lib/sidebar/apply-view.test.ts` +- `apps/web/components/task/mobile/session-task-switcher-sheet-item.ts` +- `apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts` +- `apps/web/e2e/tests/task/sidebar-task-tree-activity-sort.spec.ts` +- `apps/web/e2e/tests/task/mobile-sidebar-task-tree-activity-sort.spec.ts` +- `apps/web/e2e/tests/task/sidebar-task-tree-activity-sort-helpers.ts` + +## Dependencies + +None. Read [the requirement](../../specs/ui/requirements/sidebar-last-activity-sort.md) and [the system design](../../specs/ui/system-design/sidebar-task-tree-activity-sort.md) before implementation. + +## Risks + +- The tree resolver must use filtered descendants and remain bounded for deep or malformed parent graphs. +- Existing mobile task views use the same `applyView` result but a separate drawer; prove the rendered order rather than assuming shared code is sufficient. + +## Parallelism + +`sequential` + +## Inputs + +- `applyView`, `applySort`, `separateSubtasks`, and existing `lastActivitySortValue` in `apps/web/lib/sidebar/apply-view.ts`. +- The existing state-tree resolver and its tests as a traversal pattern. +- Existing desktop Last activity and phone view E2E fixtures. + +## Results + +Implemented a cycle-safe iterative resolver that aggregates the newest activity across each included subtree. Filters run before aggregation, collapse does not affect it, and each row keeps its own activity timestamp. The phone row projection now carries `lastActivityAt` with task update and creation fallbacks while preserving summary freshness in `updatedAt`. + +Activity keys compare chronological RFC3339 instants with full fractional precision and timezone offsets through the shared strict parser. Malformed or semantically invalid timestamps retain the existing lexical fallback instead of being normalized by `Date.parse`. Equal instants retain stable order, and missing timestamps keep their existing fallback order. + +Validation passed: + +- Focused Vitest suite after review: 3 files, 83 tests, including malformed calendar dates and shorthand timestamp fallback. +- Frontend typecheck. +- ESLint on all changed TypeScript files with `--max-warnings 0`. +- Desktop Playwright regression in `chromium`. +- Phone Playwright regression in `mobile-chrome`. +- Specification validation and lint. +- `git diff --check`. diff --git a/docs/specs/ui/requirements/sidebar-last-activity-sort.md b/docs/specs/ui/requirements/sidebar-last-activity-sort.md index 9d085ebf20..61d57197d0 100644 --- a/docs/specs/ui/requirements/sidebar-last-activity-sort.md +++ b/docs/specs/ui/requirements/sidebar-last-activity-sort.md @@ -2,7 +2,7 @@ status: draft system: ui created: 2026-08-17 -updated: 2026-08-29 +updated: 2026-09-25 owners: - kandev --- @@ -12,6 +12,8 @@ owners: 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. +A parent and its nested children appear as one sidebar tree. Last activity sorting must position that tree by its most recently active included member, so active child work does not remain buried under an idle parent's older timestamp. + ## Requirements ### REQ-UI-SIDEBAR-LAST-ACTIVITY-SORT-001: Sidebar Last Activity Sort @@ -29,6 +31,19 @@ Saved sidebar views can sort tasks by **Updated**. That value currently follows - **AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-001.7:** When a view sorts by **Last activity**, each row displays that activity time. Other sort modes retain the existing row time behavior. - **AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-001.8:** **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. +### REQ-UI-SIDEBAR-LAST-ACTIVITY-SORT-002: Task tree activity order + +**Intent:** A parent task and its descendants move together when any included member becomes active. + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1:** When a sidebar view sorts by **Last activity**, each task tree shall rank by the newest activity timestamp among its root and all included descendants. Descending order shall place the most recently active tree first; ascending order shall place the least recently active tree first. +- **AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.2:** Activity of a nested descendant at any depth shall affect the placement of every included ancestor. Sibling child trees shall follow the same rule within their parent. +- **AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.3:** A descendant excluded by the active filters shall not affect the visible tree's rank. A child whose parent is excluded shall rank as its own visible root. +- **AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.4:** Equal tree activity timestamps shall retain input order. Collapsing or expanding a tree shall not change its rank. +- **AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.5:** Each row shall continue to show that task's own activity time. Existing pinned-task precedence, manual subtask order, and other sort keys shall retain their behavior. +- **AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.6:** Desktop and phone task sidebars shall show the same tree order for the same view and included tasks, without adding a new control or changing the saved-view format. + ## Migrated source detail ## Why @@ -182,7 +197,9 @@ vertical scroll body. - 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. +- Changing the activity timestamp published for an individual task or the meaning of the **Updated** sort. ## Implementation plan - [Sidebar Last Activity Sort](../../../plans/sidebar-last-activity-sort/plan.md) +- [Sidebar task tree activity order](../../../plans/sidebar-task-tree-activity-sort/plan.md) diff --git a/docs/specs/ui/system-design/sidebar-task-tree-activity-sort.md b/docs/specs/ui/system-design/sidebar-task-tree-activity-sort.md new file mode 100644 index 0000000000..65cdc4ced3 --- /dev/null +++ b/docs/specs/ui/system-design/sidebar-task-tree-activity-sort.md @@ -0,0 +1,66 @@ +--- +status: draft +system: ui +requirements: + - REQ-UI-SIDEBAR-LAST-ACTIVITY-SORT-002 +--- + +# Sidebar Task Tree Activity Sort System Design + +## Purpose and boundaries + +The task system publishes each task's `last_activity_at`. The UI owns the saved-view ordering of a parent and its visible descendants as one sidebar tree. This design changes only the derived order used when the view's sort key is `lastActivityAt`. It does not change task activity publication, the saved-view wire shape, row timestamps, or other sort modes. + +## Requirement mapping + +| Requirement | Design sections | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQ-UI-SIDEBAR-LAST-ACTIVITY-SORT-002` | [Tree activity derivation](#tree-activity-derivation), [Shared sort path](#shared-sort-path), [Responsive behavior](#responsive-behavior), [Tests](#tests) | + +## Components and responsibilities + +- `TaskSwitcherItem` retains each task's own `lastActivityAt`, `updatedAt`, and `createdAt` fields. +- The phone task-switcher projection derives `lastActivityAt` from the task's status summary, falling back to that task's `updatedAt` and `createdAt`. It must not use summary freshness as activity. +- `applyView` in `apps/web/lib/sidebar/apply-view.ts` filters tasks, builds the included parent-child map with `separateSubtasks`, and passes that map to `applySort`. +- A pure tree-activity resolver under `apps/web/lib/sidebar/` derives one maximum activity timestamp per included task subtree. Keep it separate from `apply-view.ts`, which is already close to the frontend file-size limit. +- `applySort` uses the derived timestamp for `lastActivityAt` comparisons when the included map is available. The existing per-task fallback remains available for direct callers without a map. +- Desktop `useGroupedSidebarView` and phone `MobileTaskList` continue to call the same `applyView` path. + +## Tree activity derivation + +The resolver starts with the current per-task sort value: `lastActivityAt`, falling back to `updatedAt` and then `createdAt`. For each included task, it takes the maximum of its own value and the resolved values of all included children. This makes a root's key the newest activity anywhere in its visible tree and gives a child with descendants the same subtree semantics among its siblings. + +Resolve the map in one bounded traversal with memoized subtree values and an iterative walk. A visited or active-path guard prevents malformed parent cycles from hanging the sidebar. Do not mutate task records or replace the row's own timestamp with the aggregate. + +`applyView` calls `applyFilters` before `separateSubtasks`; therefore filtered-out descendants cannot contribute. A child whose parent is filtered out becomes a root through the existing `separateSubtasks` behavior. Collapse preferences only affect rendering and do not remove members from the derived map. + +## Shared sort path + +Compare valid activity values by chronological instant, including timezone offsets and all fractional-second digits. Use the shared strict RFC3339 parser because JavaScript's `Date.parse` can normalize malformed wire values into a different instant. Malformed values retain the existing lexical fallback. RFC3339Nano values can have different fractional precision, so string order does not always match time order. Semantically equal instants retain stable input order, and missing values keep their existing fallback behavior. + +The existing stable `applySort` index tiebreak handles equal aggregate values. Its direction sign gives descending and ascending order from the same maximum value. `applyGroup` then keeps roots and children attached and uses the sorted input; group headings keep their existing ordering rules. `applySubtaskOrder` and pinned-task floating still run afterward with their current precedence. Only the `lastActivityAt` comparator branch consumes the tree activity map; `updatedAt`, state, title, created, and custom order keep their existing semantics. + +The parent row still receives its original `TaskSwitcherItem`. Its relative time describes the parent's own activity, while tree placement reflects the newest included member. No new label, icon, setting, or persisted aggregate is introduced. + +## Responsive behavior + +The nearest phone exemplar is `SessionTaskSwitcherSheet`: its drawer contains `MobileTaskList` and the shared view editor. Desktop and phone share the derived ordering and view state. The change does not alter the phone drawer, task-row layout, navigation, touch targets, safe-area handling, or the existing single task-list scroll body. + +## Tests + +- A focused unit test for the resolver and `applyView` covers descending and ascending root order, deep descendants, siblings, equal timestamps, missing activity fallbacks, filtered-out children, orphan promotion, collapsed trees, and unchanged pin/manual/other-sort behavior. +- A desktop Playwright scenario selects Last activity descending and shows a parent tree moving ahead of a newer standalone root after child activity, while the parent's displayed time remains its own. +- A `mobile-chrome` Playwright scenario opens the existing task-switcher drawer, selects the same sort, and verifies the parent tree's order and child reachability by touch. No new mobile control or geometry is introduced. + +## Failure and recovery + +Missing task activity uses the existing update and creation fallbacks. A missing descendant projection cannot influence the current visible tree; the next task snapshot or event recomputes the shared `applyView` result. No separate cache or retry path is needed. + +## Persistence and security + +The aggregate exists only during sidebar view derivation. It adds no API field, database value, permission, or cross-workspace data access. It uses only tasks already included in the current sidebar view. + +## Related design + +- [Sidebar effective task tree state](sidebar-effective-task-tree-state.md) defines the corresponding state grouping and sorting pattern. +- [Activity timestamp decision](../../../decisions/2026-08-17-separate-task-activity-from-summary-freshness.md) defines the source activity value.