mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-09 23:46:20 +02:00
* 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>
263 lines
6.5 KiB
Go
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
|
|
}
|