Compare commits

...

2 Commits

Author SHA1 Message Date
J
70c856eb7c Merge branch 'main' into feat/timeline-activity-collapse
Re-integrate the activity-block collapse feature on top of the
Virtuoso-based timeline rewrite that landed on main:

- Drop the inline ActivityBlock that lived next to the old PropRow
  helper (PropRow now has its own file); reintroduce ActivityBlock as
  a presentational component that takes (expanded, onToggle) so its
  state can survive Virtuoso's mount/unmount on scroll.
- Track per-session expansion overrides with two Sets in IssueDetail
  so a user-collapsed older block stays collapsed even when a newer
  block appends and shifts the "trailing block" default.
- Wire ActivityBlock into the renderItem activity-group branch.
- i18n: add activity_count_one/_other for the "N activities" summary.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 14:35:50 +08:00
Jiang Bohan
63cd5e8742 feat(views): collapse activity blocks in issue timeline
Each consecutive run of activities renders as a single "N activities"
summary by default. Clicking expands the block in place. Comments are
unaffected; the most recent activity block stays expanded so users see
"what just happened" without a click.

Refs MUL-2188

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 13:39:12 +08:00
4 changed files with 226 additions and 52 deletions

View File

@@ -620,6 +620,73 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByText("I can help with this")).toBeInTheDocument();
});
it("collapses non-trailing activity blocks and expands the last one by default", async () => {
// Timeline shape:
// [activities: status_changed, priority_changed] ← block A (older)
// [comment-1]
// [activities: due_date_changed] ← block B (latest)
// Block A should be collapsed; block B should be expanded.
mockApiObj.listTimeline.mockResolvedValue([
{
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-16T00: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-16T01:00:00Z",
},
{
type: "comment",
id: "comment-1",
actor_type: "member",
actor_id: "user-1",
content: "Talking it through",
parent_id: null,
created_at: "2026-01-17T00:00:00Z",
updated_at: "2026-01-17T00:00:00Z",
comment_type: "comment",
},
{
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:00:00Z",
},
] as TimelineEntry[]);
renderIssueDetail();
// Latest block (single activity) is expanded — its rendered text is visible.
await waitFor(() => {
expect(screen.getByText(/set due date to/i)).toBeInTheDocument();
});
// Older block is collapsed: shows the summary, hides the individual entries.
expect(screen.getByText("2 activities")).toBeInTheDocument();
expect(screen.queryByText(/changed status/i)).not.toBeInTheDocument();
expect(screen.queryByText(/changed priority/i)).not.toBeInTheDocument();
// Clicking the summary expands the older block.
fireEvent.click(screen.getByText("2 activities"));
await waitFor(() => {
expect(screen.getByText(/changed status/i)).toBeInTheDocument();
});
expect(screen.getByText(/changed priority/i)).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

@@ -336,6 +336,101 @@ function TimelineSkeleton() {
);
}
// 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
// usually answers "what just happened?". Expansion state is owned by the
// parent so it survives Virtuoso's mount/unmount on scroll.
function ActivityBlock({
entries,
expanded,
onToggle,
getActorName,
t,
}: {
entries: TimelineEntry[];
expanded: boolean;
onToggle: () => void;
getActorName: (type: string, id: string) => string;
t: ActivityT;
}) {
if (!expanded) {
const count = entries.length;
return (
<div className="pb-3 px-4">
<button
type="button"
onClick={onToggle}
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.activity_count, { count })}</span>
</button>
</div>
);
}
return (
<div className="pb-3 px-4 flex flex-col gap-3">
<button
type="button"
onClick={onToggle}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronDown className="h-3 w-3 shrink-0" />
<span>{t(($) => $.activity.activity_count, { count: entries.length })}</span>
</button>
{entries.map((entry) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, t, getActorName)}</span>
{(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed" && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })}
</span>
)}
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// SubIssueRow — sub-issue list item with inline status & assignee editing
// ---------------------------------------------------------------------------
@@ -524,6 +619,43 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
return next;
});
}, []);
// Per-session activity-block expansion overrides. The default rule is
// "only the trailing block is expanded" (computed from timelineView.groups
// below); these two sets capture user clicks that diverge from the default.
// Two sets are needed because "default" can flip when a new activity block
// appends — without an explicit collapse override, a manually-collapsed
// older block would re-expand when it stops being the trailing one (or vice
// 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());
const toggleActivityBlock = useCallback((id: string, currentlyExpanded: boolean) => {
if (currentlyExpanded) {
setCollapsedActivityIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
setExpandedActivityIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
} else {
setExpandedActivityIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
setCollapsedActivityIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
}
}, []);
const didHighlightRef = useRef<string | null>(null);
// Issue data from TQ — uses detail query, seeded from list cache if available.
@@ -686,6 +818,15 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
[timelineView.groups, expandedResolved],
);
// ID of the trailing activity block — the only one expanded by default.
const lastActivityGroupId = useMemo(() => {
for (let i = timelineView.groups.length - 1; i >= 0; i--) {
const g = timelineView.groups[i]!;
if (g.type === "activities") return g.entries[0]!.id;
}
return null;
}, [timelineView.groups]);
// Map of reply-comment id → root-comment id, so a deep-link to a reply
// (which lives inside a CommentCard, not in the flat items array) can fall
// back to scrolling the root thread into view. Without this, an inbox
@@ -1106,57 +1247,19 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
);
}
// activity-group
const expanded = expandedActivityIds.has(item.id)
? true
: collapsedActivityIds.has(item.id)
? false
: item.id === lastActivityGroupId;
return (
<div className="pb-3 px-4 flex flex-col gap-3">
{item.entries.map((entry) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, t, getActorName)}</span>
{(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed" && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })}
</span>
)}
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
<ActivityBlock
entries={item.entries}
expanded={expanded}
onToggle={() => toggleActivityBlock(item.id, expanded)}
getActorName={getActorName}
t={t}
/>
);
};

View File

@@ -191,7 +191,9 @@
"squad_leader_no_action_reason": "evaluated: no action needed ({{reason}})",
"squad_leader_failed": "evaluation failed",
"squad_leader_failed_reason": "evaluation failed: {{reason}}",
"coalesced_badge": "×{{count}}"
"coalesced_badge": "×{{count}}",
"activity_count_one": "{{count}} activity",
"activity_count_other": "{{count}} activities"
},
"comment": {
"delete_title": "Delete comment",

View File

@@ -190,7 +190,9 @@
"squad_leader_no_action_reason": "已评估:无需操作({{reason}}",
"squad_leader_failed": "评估失败",
"squad_leader_failed_reason": "评估失败:{{reason}}",
"coalesced_badge": "×{{count}}"
"coalesced_badge": "×{{count}}",
"activity_count_one": "{{count}} 条动态",
"activity_count_other": "{{count}} 条动态"
},
"comment": {
"delete_title": "删除评论",