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 (
- {entries.map((entry) => { + {hiddenOlderCount > 0 && ( + + )} + {visibleEntries.map((entry) => { const details = (entry.details ?? {}) as Record; const isStatusChange = entry.action === "status_changed"; const isPriorityChange = entry.action === "priority_changed"; @@ -712,6 +743,12 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr // versa). Not persisted, matches the resolved-thread behaviour above. const [expandedActivityIds, setExpandedActivityIds] = useState>(() => new Set()); const [collapsedActivityIds, setCollapsedActivityIds] = useState>(() => new Set()); + // Block IDs where the user has explicitly chosen to also reveal the older + // (pre-last-6) entries within the trailing block. Kept independent of the + // expanded/collapsed sets so collapsing then re-expanding preserves the + // "show all" choice, and so the choice survives the block losing its + // trailing position when a new comment lands after it. + const [showOlderActivityIds, setShowOlderActivityIds] = useState>(() => new Set()); const toggleActivityBlock = useCallback((id: string, currentlyExpanded: boolean) => { if (currentlyExpanded) { setCollapsedActivityIds((prev) => { @@ -739,6 +776,14 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr }); } }, []); + const showOlderActivities = useCallback((id: string) => { + setShowOlderActivityIds((prev) => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + }, []); const didHighlightRef = useRef(null); // Issue data from TQ — uses detail query, seeded from list cache if available. @@ -1527,11 +1572,16 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr : collapsedActivityIds.has(item.id) ? false : item.id === lastActivityGroupId; + const truncateOlder = item.id === lastActivityGroupId; + const showOlder = showOlderActivityIds.has(item.id); return ( toggleActivityBlock(item.id, expanded)} + truncateOlder={truncateOlder} + showOlder={showOlder} + onToggleShowOlder={() => showOlderActivities(item.id)} getActorName={getActorName} t={t} timeAgo={timeAgo} diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index 4dcbef4b9..4368e5564 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -236,7 +236,9 @@ "squad_leader_failed_reason": "evaluation failed: {{reason}}", "coalesced_badge": "×{{count}}", "activity_count_one": "{{count}} activity", - "activity_count_other": "{{count}} activities" + "activity_count_other": "{{count}} activities", + "show_more_activities_one": "Show {{count}} more activity", + "show_more_activities_other": "Show {{count}} more activities" }, "comment": { "delete_title": "Delete comment", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 8756b6716..b217840ed 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -235,7 +235,9 @@ "squad_leader_failed_reason": "评估失败:{{reason}}", "coalesced_badge": "×{{count}}", "activity_count_one": "{{count}} 条动态", - "activity_count_other": "{{count}} 条动态" + "activity_count_other": "{{count}} 条动态", + "show_more_activities_one": "展开更早 {{count}} 条动态", + "show_more_activities_other": "展开更早 {{count}} 条动态" }, "comment": { "delete_title": "删除评论",