mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
feat(issues): truncate trailing activity block to most recent 6 (MUL-2628)
The trailing activity block defaults to expanded, but a block with dozens of entries still drowns the comment area. Show only the most recent 6 by default; older entries fold behind an in-place "Show N more activities" toggle. Non-trailing blocks are unchanged — they still collapse whole. The "show older" choice is tracked per block id in a separate Set so it survives the block losing its trailing position (when a new comment lands after it) and survives a collapse/re-expand cycle. Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="pb-3 px-4 flex flex-col gap-3">
|
||||
<button
|
||||
@@ -431,7 +452,17 @@ function ActivityBlock({
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
<span>{t(($) => $.activity.activity_count, { count: entries.length })}</span>
|
||||
</button>
|
||||
{entries.map((entry) => {
|
||||
{hiddenOlderCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleShowOlder}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
<span>{t(($) => $.activity.show_more_activities, { count: hiddenOlderCount })}</span>
|
||||
</button>
|
||||
)}
|
||||
{visibleEntries.map((entry) => {
|
||||
const details = (entry.details ?? {}) as Record<string, string>;
|
||||
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<Set<string>>(() => new Set());
|
||||
const [collapsedActivityIds, setCollapsedActivityIds] = useState<Set<string>>(() => 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<Set<string>>(() => 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<string | null>(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 (
|
||||
<ActivityBlock
|
||||
entries={item.entries}
|
||||
expanded={expanded}
|
||||
onToggle={() => toggleActivityBlock(item.id, expanded)}
|
||||
truncateOlder={truncateOlder}
|
||||
showOlder={showOlder}
|
||||
onToggleShowOlder={() => showOlderActivities(item.id)}
|
||||
getActorName={getActorName}
|
||||
t={t}
|
||||
timeAgo={timeAgo}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "删除评论",
|
||||
|
||||
Reference in New Issue
Block a user