Files
multica/server/pkg/db/generated/activity.sql.go
Naiyuan Qing ba147708a6 fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968) (#2128)
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)

Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.

This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.

API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
  + ?limit (default 50, max 100); response wraps entries with next/prev
  cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
  pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
  the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
  every row unbounded; the unbounded ListComments / ListActivities sqlc
  queries are deleted.

Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
  fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
  prepends to pages[0] only when the user is reading the live tail;
  otherwise it just bumps a counter so the UI offers a "Jump to latest"
  affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
  helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
  core/issues/timeline-cache.ts) and use setQueriesData so all open
  windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
  during the brief load window plus subtle text-link load-more buttons
  matching the existing Subscribe affordance (no Button chrome). Top
  uses a divider for boundary clarity; bottom shows
  "Jump to latest · N new" weighted slightly heavier when there's
  unread state.
- highlightCommentId now flows into the hook's around parameter so
  Inbox jumps fetch the surrounding 50 entries directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(agent): default comment list to 50 + prompt hint about long issues

The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.

The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:27:06 +08:00

263 lines
6.5 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: activity.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countAssigneeChangesByActor = `-- name: CountAssigneeChangesByActor :many
SELECT
details->>'to_type' as assignee_type,
details->>'to_id' as assignee_id,
COUNT(*)::bigint as frequency
FROM activity_log
WHERE workspace_id = $1
AND actor_id = $2
AND actor_type = 'member'
AND action = 'assignee_changed'
AND details->>'to_type' IS NOT NULL
AND details->>'to_id' IS NOT NULL
GROUP BY details->>'to_type', details->>'to_id'
`
type CountAssigneeChangesByActorParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ActorID pgtype.UUID `json:"actor_id"`
}
type CountAssigneeChangesByActorRow struct {
AssigneeType interface{} `json:"assignee_type"`
AssigneeID interface{} `json:"assignee_id"`
Frequency int64 `json:"frequency"`
}
// Count how many times a user assigned each target via assignee_changed activities.
func (q *Queries) CountAssigneeChangesByActor(ctx context.Context, arg CountAssigneeChangesByActorParams) ([]CountAssigneeChangesByActorRow, error) {
rows, err := q.db.Query(ctx, countAssigneeChangesByActor, arg.WorkspaceID, arg.ActorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CountAssigneeChangesByActorRow{}
for rows.Next() {
var i CountAssigneeChangesByActorRow
if err := rows.Scan(&i.AssigneeType, &i.AssigneeID, &i.Frequency); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const createActivity = `-- name: CreateActivity :one
INSERT INTO activity_log (
workspace_id, issue_id, actor_type, actor_id, action, details
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at
`
type CreateActivityParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
ActorType pgtype.Text `json:"actor_type"`
ActorID pgtype.UUID `json:"actor_id"`
Action string `json:"action"`
Details []byte `json:"details"`
}
func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) (ActivityLog, error) {
row := q.db.QueryRow(ctx, createActivity,
arg.WorkspaceID,
arg.IssueID,
arg.ActorType,
arg.ActorID,
arg.Action,
arg.Details,
)
var i ActivityLog
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
)
return i, err
}
const getActivity = `-- name: GetActivity :one
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE id = $1
`
// Used by the around-id mode of ListTimeline to resolve an entry to its
// (created_at, id) cursor when the entry is an activity.
func (q *Queries) GetActivity(ctx context.Context, id pgtype.UUID) (ActivityLog, error) {
row := q.db.QueryRow(ctx, getActivity, id)
var i ActivityLog
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
)
return i, err
}
const listActivitiesAfter = `-- name: ListActivitiesAfter :many
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE issue_id = $1
AND (created_at, id) > ($2::timestamptz, $3::uuid)
ORDER BY created_at ASC, id ASC
LIMIT $4
`
type ListActivitiesAfterParams struct {
IssueID pgtype.UUID `json:"issue_id"`
Column2 pgtype.Timestamptz `json:"column_2"`
Column3 pgtype.UUID `json:"column_3"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListActivitiesAfter(ctx context.Context, arg ListActivitiesAfterParams) ([]ActivityLog, error) {
rows, err := q.db.Query(ctx, listActivitiesAfter,
arg.IssueID,
arg.Column2,
arg.Column3,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ActivityLog{}
for rows.Next() {
var i ActivityLog
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listActivitiesBefore = `-- name: ListActivitiesBefore :many
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE issue_id = $1
AND (created_at, id) < ($2::timestamptz, $3::uuid)
ORDER BY created_at DESC, id DESC
LIMIT $4
`
type ListActivitiesBeforeParams struct {
IssueID pgtype.UUID `json:"issue_id"`
Column2 pgtype.Timestamptz `json:"column_2"`
Column3 pgtype.UUID `json:"column_3"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListActivitiesBefore(ctx context.Context, arg ListActivitiesBeforeParams) ([]ActivityLog, error) {
rows, err := q.db.Query(ctx, listActivitiesBefore,
arg.IssueID,
arg.Column2,
arg.Column3,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ActivityLog{}
for rows.Next() {
var i ActivityLog
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listActivitiesLatest = `-- name: ListActivitiesLatest :many
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE issue_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2
`
type ListActivitiesLatestParams struct {
IssueID pgtype.UUID `json:"issue_id"`
Limit int32 `json:"limit"`
}
// Top N activities for an issue, newest first. Used by the cursor-paginated
// timeline endpoint to assemble the latest page.
func (q *Queries) ListActivitiesLatest(ctx context.Context, arg ListActivitiesLatestParams) ([]ActivityLog, error) {
rows, err := q.db.Query(ctx, listActivitiesLatest, arg.IssueID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ActivityLog{}
for rows.Next() {
var i ActivityLog
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}