diff --git a/packages/views/issues/components/issue-detail.test.tsx b/packages/views/issues/components/issue-detail.test.tsx index e8735ee14..6da17e017 100644 --- a/packages/views/issues/components/issue-detail.test.tsx +++ b/packages/views/issues/components/issue-detail.test.tsx @@ -844,6 +844,121 @@ describe("IssueDetail (shared)", () => { expect(screen.getByText(/changed priority/i)).toBeInTheDocument(); }); + it("truncates the trailing activity block to the most recent 6 entries with a show-more toggle", async () => { + // 8 activities, all in the trailing block (no comment after them, so it's + // the trailing block by definition). Alternating action types so the + // 2-minute coalesce window never merges consecutive entries — we end up + // with 8 distinct rows. + const trailingBlock: TimelineEntry[] = [ + { type: "activity", id: "act-1", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "todo", to: "in_progress" }, created_at: "2026-01-18T00:00:00Z" }, + { type: "activity", id: "act-2", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "low", to: "medium" }, created_at: "2026-01-18T00:01:00Z" }, + { type: "activity", id: "act-3", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "in_progress", to: "in_review" }, created_at: "2026-01-18T00:02:00Z" }, + { type: "activity", id: "act-4", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "medium", to: "high" }, created_at: "2026-01-18T00:03:00Z" }, + { type: "activity", id: "act-5", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "in_review", to: "done" }, created_at: "2026-01-18T00:04:00Z" }, + { type: "activity", id: "act-6", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "high", to: "urgent" }, created_at: "2026-01-18T00:05:00Z" }, + { type: "activity", id: "act-7", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "done", to: "blocked" }, created_at: "2026-01-18T00:06:00Z" }, + { type: "activity", id: "act-8", actor_type: "member", actor_id: "user-1", action: "due_date_changed", details: { to: "2026-02-01T00:00:00Z" }, created_at: "2026-01-18T00:07:00Z" }, + ] as TimelineEntry[]; + mockApiObj.listTimeline.mockResolvedValue(trailingBlock); + + renderIssueDetail(); + + // The block header reports the full count. + await waitFor(() => { + expect(screen.getByText("8 activities")).toBeInTheDocument(); + }); + + // Only the 6 most recent entries (act-3..act-8) are rendered by default. + // act-1 and act-2 are folded behind the show-more line. + expect(screen.getByText(/from In Progress to In Review/i)).toBeInTheDocument(); // act-3 + expect(screen.getByText(/set due date to/i)).toBeInTheDocument(); // act-8 + expect(screen.queryByText(/from Todo to In Progress/i)).not.toBeInTheDocument(); // act-1 + expect(screen.queryByText(/from Low to Medium/i)).not.toBeInTheDocument(); // act-2 + + // The show-more toggle reports the count of hidden older entries (2). + const showMore = screen.getByText("Show 2 more activities"); + expect(showMore).toBeInTheDocument(); + + // Clicking the toggle reveals the older entries in place; the rest of the + // block stays visible. + fireEvent.click(showMore); + await waitFor(() => { + expect(screen.getByText(/from Todo to In Progress/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/from Low to Medium/i)).toBeInTheDocument(); + expect(screen.getByText(/set due date to/i)).toBeInTheDocument(); + expect(screen.queryByText(/Show \d+ more activit/i)).not.toBeInTheDocument(); + }); + + it("does not show the show-more toggle when the trailing block has 6 or fewer entries", async () => { + const trailingBlock: TimelineEntry[] = [ + { type: "activity", id: "act-1", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "todo", to: "in_progress" }, created_at: "2026-01-18T00:00:00Z" }, + { type: "activity", id: "act-2", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "low", to: "high" }, created_at: "2026-01-18T00:01:00Z" }, + { type: "activity", id: "act-3", actor_type: "member", actor_id: "user-1", action: "due_date_changed", details: { to: "2026-02-01T00:00:00Z" }, created_at: "2026-01-18T00:02:00Z" }, + ] as TimelineEntry[]; + mockApiObj.listTimeline.mockResolvedValue(trailingBlock); + + renderIssueDetail(); + + await waitFor(() => { + expect(screen.getByText("3 activities")).toBeInTheDocument(); + }); + expect(screen.getByText(/changed status/i)).toBeInTheDocument(); + expect(screen.getByText(/changed priority/i)).toBeInTheDocument(); + expect(screen.getByText(/set due date to/i)).toBeInTheDocument(); + expect(screen.queryByText(/Show \d+ more activit/i)).not.toBeInTheDocument(); + }); + + it("expanding a non-trailing block shows every entry — only the trailing block truncates older ones", async () => { + // Non-trailing block (8 activities) + comment + trailing block (1 activity). + // Manually expanding the older block must reveal all 8 entries — the + // truncate-to-6 rule applies only to the trailing block. + const timeline: TimelineEntry[] = [ + { type: "activity", id: "old-1", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "backlog", to: "todo" }, created_at: "2026-01-16T00:00:00Z" }, + { type: "activity", id: "old-2", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "none", to: "low" }, created_at: "2026-01-16T00:01:00Z" }, + { type: "activity", id: "old-3", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "todo", to: "in_progress" }, created_at: "2026-01-16T00:02:00Z" }, + { type: "activity", id: "old-4", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "low", to: "medium" }, created_at: "2026-01-16T00:03:00Z" }, + { type: "activity", id: "old-5", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "in_progress", to: "in_review" }, created_at: "2026-01-16T00:04:00Z" }, + { type: "activity", id: "old-6", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "medium", to: "high" }, created_at: "2026-01-16T00:05:00Z" }, + { type: "activity", id: "old-7", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "in_review", to: "done" }, created_at: "2026-01-16T00:06:00Z" }, + { type: "activity", id: "old-8", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "high", to: "urgent" }, created_at: "2026-01-16T00:07:00Z" }, + { + type: "comment", id: "comment-mid", actor_type: "member", actor_id: "user-1", + content: "Splitting the blocks", parent_id: null, + created_at: "2026-01-17T00:00:00Z", updated_at: "2026-01-17T00:00:00Z", + comment_type: "comment", + }, + { type: "activity", id: "last-1", actor_type: "member", actor_id: "user-1", action: "due_date_changed", details: { to: "2026-02-01T00:00:00Z" }, created_at: "2026-01-18T00:00:00Z" }, + ] as TimelineEntry[]; + mockApiObj.listTimeline.mockResolvedValue(timeline); + + renderIssueDetail(); + + // The older block defaults to collapsed; its summary reports 8. + await waitFor(() => { + expect(screen.getByText("8 activities")).toBeInTheDocument(); + }); + // None of the older entries are rendered before expansion. + expect(screen.queryByText(/from Backlog to Todo/i)).not.toBeInTheDocument(); + + // Expand the older block by clicking its summary line. + fireEvent.click(screen.getByText("8 activities")); + + // Every one of the 8 entries should now be visible — no truncation, no + // "Show N more activities" line on a non-trailing block. + await waitFor(() => { + expect(screen.getByText(/from Backlog to Todo/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/from No priority to Low/i)).toBeInTheDocument(); + expect(screen.getByText(/from Todo to In Progress/i)).toBeInTheDocument(); + expect(screen.getByText(/from Low to Medium/i)).toBeInTheDocument(); + expect(screen.getByText(/from In Progress to In Review/i)).toBeInTheDocument(); + expect(screen.getByText(/from Medium to High/i)).toBeInTheDocument(); + expect(screen.getByText(/from In Review to Done/i)).toBeInTheDocument(); + expect(screen.getByText(/from High to Urgent/i)).toBeInTheDocument(); + expect(screen.queryByText(/Show \d+ more activit/i)).not.toBeInTheDocument(); + }); + describe("highlightCommentId scroll-to-comment", () => { it("scrolls to the highlighted comment after both issue and timeline finish loading", async () => { renderIssueDetailWithHighlight("comment-2"); diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 4bf589d5d..fd1be5660 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -386,6 +386,12 @@ function TimelineSkeleton() { ); } +// When the trailing block is expanded, we still truncate its body to the most +// recent N entries — a single block of 50 status flips drowns the comment area +// as badly as N blocks of 1 would. Older entries fold behind a "Show N more +// activities" line that expands in place. +const LAST_ACTIVITY_BLOCK_VISIBLE_LIMIT = 6; + // Collapsible wrapper for an activity block. Older blocks default to a single // "N activities" summary line so the timeline isn't dominated by status / // priority / assignee churn; the trailing block stays expanded because it @@ -395,6 +401,9 @@ function ActivityBlock({ entries, expanded, onToggle, + truncateOlder, + showOlder, + onToggleShowOlder, getActorName, t, timeAgo, @@ -402,6 +411,12 @@ function ActivityBlock({ entries: TimelineEntry[]; expanded: boolean; onToggle: () => void; + // Trailing block only: when true, the body shows only the most recent + // LAST_ACTIVITY_BLOCK_VISIBLE_LIMIT entries with the older ones folded + // behind a "Show N more activities" inline toggle. + truncateOlder: boolean; + showOlder: boolean; + onToggleShowOlder: () => void; getActorName: (type: string, id: string) => string; t: ActivityT; timeAgo: (dateStr: string) => string; @@ -421,6 +436,12 @@ function ActivityBlock({ ); } + const hiddenOlderCount = + truncateOlder && !showOlder && entries.length > LAST_ACTIVITY_BLOCK_VISIBLE_LIMIT + ? entries.length - LAST_ACTIVITY_BLOCK_VISIBLE_LIMIT + : 0; + const visibleEntries = + hiddenOlderCount > 0 ? entries.slice(-LAST_ACTIVITY_BLOCK_VISIBLE_LIMIT) : entries; return (