Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
8b2ecdb637 refactor(issues): bump trailing activity block visible limit from 6 to 8 (MUL-2628)
User feedback on the original PR: 6 felt slightly too tight. Bumped the
trailing-block truncation threshold to 8 entries to give the "most recent
activity" view a bit more headroom before older entries fold behind the
"Show N more activities" toggle.

Test count is unchanged; the existing trailing-block / non-trailing-block
truncation cases were adjusted to exercise the new 8-entry boundary
(10-entry trailing block → 2 hidden; 8-entry trailing block → none
hidden; 10-entry non-trailing block → all visible after expand).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 18:42:44 +08:00
Jiang Bohan
8f95b1ff24 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>
2026-05-25 17:26:58 +08:00
4 changed files with 191 additions and 3 deletions

View File

@@ -844,6 +844,140 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByText(/changed priority/i)).toBeInTheDocument();
});
it("truncates the trailing activity block to the most recent 8 entries with a show-more toggle", async () => {
// 10 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 10 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: "priority_changed", details: { from: "urgent", to: "low" }, created_at: "2026-01-18T00:07:00Z" },
{ type: "activity", id: "act-9", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "blocked", to: "todo" }, created_at: "2026-01-18T00:08:00Z" },
{ type: "activity", id: "act-10", actor_type: "member", actor_id: "user-1", action: "due_date_changed", details: { to: "2026-02-01T00:00:00Z" }, created_at: "2026-01-18T00:09:00Z" },
] as TimelineEntry[];
mockApiObj.listTimeline.mockResolvedValue(trailingBlock);
renderIssueDetail();
// The block header reports the full count.
await waitFor(() => {
expect(screen.getByText("10 activities")).toBeInTheDocument();
});
// Only the 8 most recent entries (act-3..act-10) 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-10
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 8 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: "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: "high", to: "urgent" }, 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: "urgent", to: "low" }, 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();
await waitFor(() => {
expect(screen.getByText("8 activities")).toBeInTheDocument();
});
// Every one of the 8 entries should be visible — the trailing block fits
// exactly within the limit, so no "Show N more activities" line appears.
expect(screen.getByText(/from Todo to In Progress/i)).toBeInTheDocument();
expect(screen.getByText(/from Low to High/i)).toBeInTheDocument();
expect(screen.getByText(/from In Progress to In Review/i)).toBeInTheDocument();
expect(screen.getByText(/from High to Urgent/i)).toBeInTheDocument();
expect(screen.getByText(/from In Review to Done/i)).toBeInTheDocument();
expect(screen.getByText(/from Urgent to Low/i)).toBeInTheDocument();
expect(screen.getByText(/from Done to Blocked/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 (10 activities) + comment + trailing block (1 activity).
// Manually expanding the older block must reveal all 10 entries — the
// truncate-to-8 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: "activity", id: "old-9", actor_type: "member", actor_id: "user-1", action: "status_changed", details: { from: "done", to: "blocked" }, created_at: "2026-01-16T00:08:00Z" },
{ type: "activity", id: "old-10", actor_type: "member", actor_id: "user-1", action: "priority_changed", details: { from: "urgent", to: "low" }, created_at: "2026-01-16T00:09: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 10.
await waitFor(() => {
expect(screen.getByText("10 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("10 activities"));
// Every one of the 10 entries should now be visible — even though the
// block has more than 8 entries, the truncate-to-8 rule does not apply
// to non-trailing blocks, so no "Show N more activities" line appears.
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.getByText(/from Done to Blocked/i)).toBeInTheDocument();
expect(screen.getByText(/from Urgent to Low/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");

View File

@@ -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 = 8;
// 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-8) 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}

View File

@@ -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",

View File

@@ -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": "删除评论",