feat(activity): unified activity timeline with comment reply support

Replace the comment-only list with a Linear-style unified timeline that
interleaves field changes and comments chronologically.

Backend:
- activity_listeners.go: records field changes (status, assignee, description,
  task completed/failed) to activity_log table on domain events
- Timeline API: GET /api/issues/{id}/timeline merges activity_log + comments
  sorted by created_at
- Comment reply: parent_id column + handler support for threading

Frontend:
- Unified timeline replaces comment list: activity entries as compact muted
  lines, comments as Card components with reply threading
- Filter toggle (All / Comments / Activity)
- Reply UI: inline editor under comments with Cancel/Reply buttons
- Real-time sync for activity:created + comment events
- 10 new Go tests, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-28 21:53:08 +08:00
parent 3bb79564ed
commit e7fe6ea79b
21 changed files with 1307 additions and 132 deletions

View file

@ -30,6 +30,7 @@ import type {
CreatePersonalAccessTokenResponse,
RuntimeUsage,
RuntimePing,
TimelineEntry,
} from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
@ -183,13 +184,21 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/comments`);
}
async createComment(issueId: string, content: string, type?: string): Promise<Comment> {
async createComment(issueId: string, content: string, type?: string, parentId?: string): Promise<Comment> {
return this.fetch(`/api/issues/${issueId}/comments`, {
method: "POST",
body: JSON.stringify({ content, type: type ?? "comment" }),
body: JSON.stringify({
content,
type: type ?? "comment",
...(parentId ? { parent_id: parentId } : {}),
}),
});
}
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
return this.fetch(`/api/issues/${issueId}/timeline`);
}
async updateComment(commentId: string, content: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",

View file

@ -0,0 +1,15 @@
export interface TimelineEntry {
type: "activity" | "comment";
id: string;
actor_type: string;
actor_id: string;
created_at: string;
// Activity fields
action?: string;
details?: Record<string, unknown>;
// Comment fields
content?: string;
parent_id?: string | null;
updated_at?: string;
comment_type?: string;
}

View file

@ -9,6 +9,7 @@ export interface Comment {
author_id: string;
content: string;
type: CommentType;
parent_id: string | null;
created_at: string;
updated_at: string;
}

View file

@ -2,6 +2,7 @@ import type { Issue } from "./issue";
import type { Agent } from "./agent";
import type { InboxItem } from "./inbox";
import type { Comment } from "./comment";
import type { TimelineEntry } from "./activity";
import type { Workspace, MemberWithUser } from "./workspace";
// WebSocket event types (matching Go server protocol/events.go)
@ -35,7 +36,8 @@ export type WSEventType =
| "skill:updated"
| "skill:deleted"
| "subscriber:added"
| "subscriber:removed";
| "subscriber:removed"
| "activity:created";
export interface WSMessage<T = unknown> {
type: WSEventType;
@ -139,3 +141,8 @@ export interface SubscriberRemovedPayload {
user_type: string;
user_id: string;
}
export interface ActivityCreatedPayload {
issue_id: string;
entry: TimelineEntry;
}

View file

@ -24,6 +24,7 @@ export type {
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { Comment, CommentType, CommentAuthorType } from "./comment";
export type { TimelineEntry } from "./activity";
export type { IssueSubscriber } from "./subscriber";
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon";
export type * from "./events";